Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agentenv/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ secret = "local" # process env vars, or an optional flat YAML file
default = "local" # envs run as containers on the host Docker daemon
agent_default = "local" # agents too

# Deployment-wide attribution defaults, any keys (values take env:NAME?default references).
# Modal puts them on its App tags; a run's own attribution, over these, goes on each sandbox's
# tags (Modal) or metadata (E2B). The local provider ignores attribution.
# [sandbox.attribution]
# group = "my-group"

[agents]
default_a2a_agent_id = "a2a-default" # what a deploy_agent step that names no agent deploys

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ and deserialized through a registry.
| `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). |
| `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. |
| `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. |
| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`; `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). |
| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`; `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, and `[sandbox.attribution]` sets attribution defaults under any keys: Modal App tags carry those defaults, and a run's attribution over them goes on its sandbox tags (Modal) or metadata (E2B). `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). |
| `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. |
| `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. |
| `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. |
Expand Down
14 changes: 12 additions & 2 deletions src/agent_env/providers/sandbox_providers/e2b/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,28 @@
from agent_env.attribution import Attribution
from agent_env.config.errors import ConfigError
from agent_env.providers.sandbox_providers.e2b.sandbox import E2B_ALL_TRAFFIC, E2BSandbox
from agent_env.providers.sandbox_providers.modal_sandbox import _build_cost_attribution_tags
from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy
from agent_env.providers.sandbox_providers.sandbox_provider import (
SANDBOX_MODE_VM,
SandboxProvider,
apply_default_attribution,
)

logger = logging.getLogger(__name__)

_EXPOSED_PORTS_METADATA_KEY = "agent_env_exposed_ports"


def _attribution_metadata(attribution: Attribution | None) -> dict[str, str]:
"""Sandbox metadata from the attribution, unset keys filled from ``[sandbox.attribution]``.
The exposed-ports key is agent-env's own, so an attribution key of that name never lands."""
return {
key: str(value)
for key, value in apply_default_attribution(dict(attribution or {})).items()
if value is not None and key != _EXPOSED_PORTS_METADATA_KEY
}


class E2BSandboxProvider(SandboxProvider):
"""Provision Docker-capable E2B sandboxes from a configured base template.

Expand Down Expand Up @@ -143,7 +153,7 @@ async def create_vm(
)
effective_policy = self.effective_network_policy(network_policy)
template = await self._resolve_template(cpu=cpu, memory=memory)
metadata = _build_cost_attribution_tags(dict(attribution or {}))
metadata = _attribution_metadata(attribution)
ports = list(exposed_ports or [])
if ports:
# E2B can derive a URL for any port, so the adapter's cache is the
Expand Down
45 changes: 20 additions & 25 deletions src/agent_env/providers/sandbox_providers/modal_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,9 @@ async def create_container(
effective = self.effective_network_policy(network_policy)
attribution = dict(attribution or {})

# Modal billing breaks cost down by App tags; sandbox-level tags don't show up in billing.
app_tags = _build_cost_attribution_tags(attribution)
app_tags = _build_app_tags()
app_name = self._app_name
app = await self._get_app(app_name, app_tags)
# Not in billing (see above); makes sandboxes filterable by the step that deployed them.
sandbox_tags = _build_sandbox_tags(attribution)

image_store = get_config().get_image_store_at(image_name)
Expand Down Expand Up @@ -401,33 +399,27 @@ async def get_sandbox(self, sandbox_id: str) -> Sandbox:
return self._sandbox_cls(sb, tunnel_urls, i6pn_address=i6pn_address)


def _build_cost_attribution_tags(attribution: Attribution) -> dict[str, str]:
"""Build the Modal App tag set used for cost attribution.
def _build_app_tags() -> dict[str, str]:
"""Modal App tags: the deployment-wide config.toml ``[sandbox.attribution]`` defaults.

Unset dimensions fall back to config.toml ``[sandbox.attribution]``; a dimension with
no value anywhere is omitted rather than emitted as a null tag. Returns a flat
``dict[str, str]`` for ``modal.App.set_tags``. (priority is a scheduling concern,
not attribution, so it is not included here.)
"""
resolved = apply_default_attribution(attribution)
return {
name: resolved[name]
for name in ("product", "customer", "team")
if resolved.get(name) is not None
}
Modal's billing report breaks cost down by App tags, and one App serves every run with its
tags set on first lookup, so per-run attribution goes on sandbox tags instead."""
return _modal_tags(apply_default_attribution({}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old App tags remain
When [sandbox.attribution] has no defaults, _build_app_tags returns {}. Both Modal providers skip set_tags for an empty dictionary, so an existing App keeps tags from earlier runs or an earlier configuration. After an upgrade or removal of defaults, Modal can keep grouping billing under those old labels. Clear the App's tags when the configured set is empty.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/modal_sandbox.py
Line: 407

Comment:
**Old App tags remain**
When `[sandbox.attribution]` has no defaults, `_build_app_tags` returns `{}`. Both Modal providers skip `set_tags` for an empty dictionary, so an existing App keeps tags from earlier runs or an earlier configuration. After an upgrade or removal of defaults, Modal can keep grouping billing under those old labels. Clear the App's tags when the configured set is empty.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex



def _build_sandbox_tags(attribution: Attribution) -> dict[str, str]:
"""Build the Modal Sandbox tag set: ``pipeline_step`` and ``run_id``, each when set.
"""Modal Sandbox tags: the run's attribution, unset keys filled from ``[sandbox.attribution]``,
including ``pipeline_step`` and ``run_id`` when the deploy step set them."""
return _modal_tags(apply_default_attribution(attribution))

Modal rejects the whole create on an invalid tag, so values are sanitized to
``[a-zA-Z0-9._-]``; past Modal's 63-char limit a value keeps a prefix plus a hash of the
full value, so steps of a long-named task still get distinct tags.
"""

def _modal_tags(attribution: Attribution) -> dict[str, str]:
"""Modal rejects a whole create on one invalid tag: keys and values are made Modal-valid and a
value that is unset or empty is left out, since Modal has no empty tag."""
return {
key: _modal_tag_value(attribution[key])
for key in (PIPELINE_STEP_KEY, RUN_ID_KEY)
if attribution.get(key)
_modal_tag_value(key): _modal_tag_value(str(value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Modal drops an attribution label
If a run has both billing team and billing/team, _modal_tag_value turns both keys into billing-team. The later value silently replaces the earlier one, so the run loses a label. Reject keys that collide or give them distinct tag names.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/modal_sandbox.py
Line: 420

Comment:
**Modal drops an attribution label**
If a run has both `billing team` and `billing/team`, `_modal_tag_value` turns both keys into `billing-team`. The later value silently replaces the earlier one, so the run loses a label. Reject keys that collide or give them distinct tag names.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

for key, value in attribution.items()
if value is not None and str(value) != ""
Comment on lines +420 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty key stops a sandbox
If a step supplies {"": "value"}, _modal_tags keeps the empty key because it filters only empty values. The key then reaches Modal even though the tag format requires at least one character, so Modal can reject sandbox creation. Reject empty keys before sending tags.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/modal_sandbox.py
Line: 420-422

Comment:
**Empty key stops a sandbox**
If a step supplies `{"": "value"}`, `_modal_tags` keeps the empty key because it filters only empty values. The key then reaches Modal even though the tag format requires at least one character, so Modal can reject sandbox creation. Reject empty keys before sending tags.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

}


Expand Down Expand Up @@ -457,7 +449,8 @@ async def _log_sandbox_started(
"modal_sandbox_id": sb.object_id,
"modal_container_id": container_id,
"modal_app_name": app_name,
**sandbox_tags,
"modal_sandbox_tags": sandbox_tags,
**{key: sandbox_tags[key] for key in (PIPELINE_STEP_KEY, RUN_ID_KEY) if key in sandbox_tags},
"cpu": cpu,
"memory_mb": memory,
"gpu": gpu,
Expand All @@ -466,6 +459,8 @@ async def _log_sandbox_started(


def _modal_tag_value(value: str) -> str:
"""``value`` within Modal's ``[a-zA-Z0-9._-]{1,63}``: other characters become ``-``, and past
63 characters a prefix plus a hash of the full value keeps long values distinct."""
slug = re.sub(r"[^a-zA-Z0-9._-]", "-", value)
if len(slug) > _MODAL_TAG_MAX_LEN:
digest = hashlib.sha256(value.encode()).hexdigest()[:8]
Expand Down
4 changes: 2 additions & 2 deletions src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from agent_env.providers.sandbox_providers.modal_sandbox import (
_ModalProcessAdapter,
_build_cost_attribution_tags,
_build_app_tags,
_build_sandbox_tags,
_log_sandbox_started,
_resolve_app_base_name,
Expand Down Expand Up @@ -308,7 +308,7 @@ async def create_vm(
# disk_size_gb: Modal has no disk-size knob on Sandbox.create; a VM sandbox's rootfs is
# fixed at the 512 GiB max. https://modal.com/docs/guide/vm-sandboxes
attribution = dict(attribution or {})
app_tags = _build_cost_attribution_tags(attribution)
app_tags = _build_app_tags()
app_name = self._app_name
app = await self._get_app(app_name, app_tags)
sandbox_tags = _build_sandbox_tags(attribution)
Expand Down
10 changes: 5 additions & 5 deletions tst/unit/attribution_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ def test_attribution_of_reads_the_reserved_metadata_key_only():


def test_attribution_of_ignores_flat_attributes_on_the_step():
step = _step(cost_center="research", metadata={"attribution": {"team": "t"}})
assert attribution_of(step) == {"team": "t"}
step = _step(cost_center="research", metadata={"attribution": {"group": "g"}})
assert attribution_of(step) == {"group": "g"}


def test_attribution_of_returns_a_copy():
metadata = {"attribution": {"team": "t"}}
attribution_of(_step(metadata=metadata))["team"] = "mutated"
assert metadata["attribution"] == {"team": "t"}
metadata = {"attribution": {"group": "g"}}
attribution_of(_step(metadata=metadata))["group"] = "mutated"
assert metadata["attribution"] == {"group": "g"}
8 changes: 4 additions & 4 deletions tst/unit/env/envs/test_mcp_server_container_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ async def test_a_deploy_hands_the_gateway_its_sizing_state_and_attribution_and_r
env, state, seen = _env(), SimpleNamespace(state_type="remote", instance_id="st-ext"), {}

record = await _deploy(env, {"gw": _sandbox("gw")}, seen, state, gateway_mode=GatewayMode.CONSISTENT, env_state_type="remote",
ttl_seconds=60, disk_size_gb=20, cpu=2.0, memory_mb=4096, priority=3, attribution={"team": "t1"})
ttl_seconds=60, disk_size_gb=20, cpu=2.0, memory_mb=4096, priority=3, attribution={"group": "g1"})

assert ([c.environment_name for c in seen["mcp_servers"]], seen["mcp_server_images"]) == (["email"], [env.docker_image_artifact])
assert {k: seen[k] for k in ("ttl_seconds", "disk_size_gb", "cpu", "memory_mb", "priority", "attribution", "gateway_mode")} == {
"ttl_seconds": 60, "disk_size_gb": 20, "cpu": 2.0, "memory_mb": 4096, "priority": 3, "attribution": {"team": "t1"},
"ttl_seconds": 60, "disk_size_gb": 20, "cpu": 2.0, "memory_mb": 4096, "priority": 3, "attribution": {"group": "g1"},
"gateway_mode": GatewayMode.CONSISTENT}
assert (seen["gateway_port"], seen["website_configs"], seen["website_images"], seen["existing_sandbox"], seen["sidecars"]) == (
18765, None, None, None, None)
Expand Down Expand Up @@ -199,9 +199,9 @@ async def test_a_deploy_without_a_gateway_records_the_servers_own_card():
async def test_a_deploy_without_a_gateway_hands_the_server_its_sizing_and_attribution():
seen = {}
await _deploy(_env("server"), {"srv": _sandbox("srv")}, seen, gateway_mode=GatewayMode.PERFORMANCE, # the deploy_env step's default
ttl_seconds=60, disk_size_gb=20, cpu=2.0, memory_mb=4096, priority=3, attribution={"team": "t1"})
ttl_seconds=60, disk_size_gb=20, cpu=2.0, memory_mb=4096, priority=3, attribution={"group": "g1"})

assert seen == {"ttl_seconds": 60, "disk_size_gb": 20, "cpu": 2.0, "memory_mb": 4096, "priority": 3, "attribution": {"team": "t1"}}
assert seen == {"ttl_seconds": 60, "disk_size_gb": 20, "cpu": 2.0, "memory_mb": 4096, "priority": 3, "attribution": {"group": "g1"}}


@pytest.mark.asyncio
Expand Down
8 changes: 4 additions & 4 deletions tst/unit/env/envs/test_mcp_server_plugin_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ def test_reading_a_stored_env_looks_up_no_environment_provider(monkeypatch):
@pytest.mark.asyncio
async def test_a_plugin_provider_gets_every_option_and_its_record_is_registered():
env = _env()
record = await _deploy(env, ttl_seconds=60, cpu=2.0, priority=1, gateway_mode=GatewayMode.CONSISTENT, attribution={"team": "t"})
record = await _deploy(env, ttl_seconds=60, cpu=2.0, priority=1, gateway_mode=GatewayMode.CONSISTENT, attribution={"group": "g"})

[(sandbox_provider, options)] = SEEN["deploys"]
assert sandbox_provider == "SANDBOXES"
assert options == {"ttl_seconds": 60, "disk_size_gb": 10, "gateway_mode": GatewayMode.CONSISTENT, "cpu": 2.0, "memory_mb": None,
"priority": 1, "env_state_type": None, "env_state_instance_id": None, "attribution": {"team": "t"}}
"priority": 1, "env_state_type": None, "env_state_instance_id": None, "attribution": {"group": "g"}}
assert (record.instance_id, env._instance_id, env._deployed, env._sandbox, env._gateway_url) == ("inst-1", "inst-1", record, None, None)


Expand Down Expand Up @@ -252,8 +252,8 @@ async def close(self):
async def test_a_plugin_that_names_its_options_gets_those_and_the_rest_left_at_their_defaults():
env = _env("plugin_named")
env._env_provider = _NamedOptionsProvider()
await _deploy(env, ttl_seconds=60, attribution={"team": "t"})
assert SEEN["deploys"] == [("SANDBOXES", {"ttl_seconds": 60, "attribution": {"team": "t"}})]
await _deploy(env, ttl_seconds=60, attribution={"group": "g"})
assert SEEN["deploys"] == [("SANDBOXES", {"ttl_seconds": 60, "attribution": {"group": "g"}})]


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1308,7 +1308,7 @@ async def test_a_chained_gateway_that_fails_everywhere_names_every_member():


@pytest.mark.asyncio
@pytest.mark.parametrize("attribution, forwarded", [({"team": "t1"}, {"team": "t1"}), (None, {})], ids=["set", "unset"])
@pytest.mark.parametrize("attribution, forwarded", [({"group": "g1"}, {"group": "g1"}), (None, {})], ids=["set", "unset"])
async def test_create_gateway_hands_the_deploy_path_every_argument(attribution, forwarded):
"""The topology entry point loses nothing on its way to the VM path; attribution arrives as a fresh dict."""
from agent_env.env.gateway import GatewayMode
Expand Down
4 changes: 2 additions & 2 deletions tst/unit/providers/env_providers/env_server_provider_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ async def test_a_deploy_returns_a_sandbox_record_of_the_servers_own_card_and_its

@pytest.mark.asyncio
async def test_a_deploy_starts_only_the_server_on_sqlite_with_its_sizing():
run = await _run_server_deploy(cpu=2.0, memory_mb=4096, priority=3, attribution={"team": "t1"})
run = await _run_server_deploy(cpu=2.0, memory_mb=4096, priority=3, attribution={"group": "g1"})
[call] = run.calls
assert (call["image_name"], call["port"], call["cpu"], call["memory"], call["timeout"], call["priority"]) == ("mcp-slack", 18765, 2.0, 4096, 60, 3)
assert call["attribution"] == {"team": "t1"} and "i6pn" not in call
assert call["attribution"] == {"group": "g1"} and "i6pn" not in call
assert call["env"] == {"ENVIRONMENT_NAME": "slack", "MCP_HOST": "0.0.0.0", "DATABASE_URL": "sqlite:////tmp/slack.sqlite"}


Expand Down
Loading
Loading