Skip to content
Merged
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
5 changes: 2 additions & 3 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,8 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name,
`ATTRIBUTION_PROBE_V1.probe` lets a caller check that an agent forwards attribution
upstream. Attribution is an open map of string dimensions, like AgentEnv's cost
attribution: the SDK assigns no keys, and each deployment uses whatever dimensions
its model gateway understands (the AgentEnv control plane configures `project_id`
and `task_id` on agents that declare those config fields). An agent records the
dimensions it last sent with a model request and returns them from a bodyless
its model gateway understands. An agent records the dimensions it last sent with a
model request and returns them from a bodyless
`POST /ext/attribution-probe` as an `AttributionProbeResponse`:
`{"last_seen_attribution": {<dimension>: <value>, ...}, "last_seen_at_utc": ...}`.
The SDK does not record attribution itself, because only the agent knows what it sent.
Expand Down
22 changes: 3 additions & 19 deletions packages/agentenv-protocol/tests/test_a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1359,7 +1359,7 @@ def __init__(self) -> None:
self.sent: dict[str, str] = {}

async def run(self, request: TaskRequest) -> TaskResult:
self.sent = {"project_id": "project-1", "task_id": "task-1", "team": "evals"}
self.sent = {"task_id": "task-1", "team": "evals"}
return TaskResult.text("ok")

@extension(ATTRIBUTION_PROBE_V1.probe)
Expand All @@ -1381,16 +1381,12 @@ async def probe(self):
response = client.post("/a2a", json=_message_request())
assert response.json()["result"]["status"]["state"] == "completed"
assert _operation(client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {}).json() == {
"last_seen_attribution": {
"project_id": "project-1",
"task_id": "task-1",
"team": "evals",
},
"last_seen_attribution": {"task_id": "task-1", "team": "evals"},
"last_seen_at_utc": "2026-01-01T00:00:00Z",
}
assert (
_operation(
client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {"project_id": "x"}
client, card, ATTRIBUTION_PROBE_V1.uri, "probe", {"unexpected": "x"}
).status_code
== 400
)
Expand Down Expand Up @@ -2658,16 +2654,6 @@ async def add_bundle_skill(self, request: BundleSkillRequest):
).status_code
== 400
)
assert (
_operation(
client,
card,
"urn:agentenv:agent-config/v1",
"set",
{"project_id": "project"},
).status_code
== 400
)
assert (
_operation(
client,
Expand Down Expand Up @@ -2820,7 +2806,6 @@ async def add_bundle_skill(self, request: BundleSkillRequest):
"parts": [{"kind": "text", "text": "hello"}],
"metadata": {
"trace_id": "trace-1",
"project_id": "caller-project",
},
},
"configuration": {"blocking": True},
Expand All @@ -2842,7 +2827,6 @@ async def add_bundle_skill(self, request: BundleSkillRequest):
assert seen[0].config.timeout_seconds == 600
assert seen[0].metadata == {
"trace_id": "trace-1",
"project_id": "caller-project",
"role": "auditor",
}
assert seen[0].mcp_servers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ def test_basic_example_uses_typed_config_and_framework_trajectory() -> None:
)
schema = config["params"]["methods"]["set"]["request"]["schema"]
assert schema["properties"]["provider_token"]["writeOnly"] is True
assert "project_id" not in supported
assert "task_id" not in supported

configured = client.post(
Expand Down
25 changes: 1 addition & 24 deletions src/agent_env/a2a_agent/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,6 @@ async def validate(
VerifyA2AAgentCardStep,
VerifyA2AAgentConfigIdentityStep,
VerifyA2AAgentMCPStep,
VerifyA2ALitellmAttributionRuntimeStep,
VerifyA2ALitellmAttributionStep,
VerifyA2AModalitiesStep,
VerifyA2APeerAgentsStep,
VerifyA2ARoleStep,
Expand Down Expand Up @@ -216,14 +214,6 @@ async def validate(
protocol_prompt_id = f"{task_id}-protocol-prompt"
skill_prompt_id = f"{task_id}-skill-prompt"
skill_verifier_id = f"{task_id}-skill-rubric"
# LiteLLM attribution runtime probe: plant unique IDs on context.metadata
# so prompt_agent forwards them via /ext/agent-config; the agent records
# the attribution it emits on the resulting LLM call; the post-prompt
# verify step queries /ext/attribution-probe and asserts the planted
# values came through. Unique per validation run to avoid cross-run
# bleed when /ext/attribution-probe is stateful.
probe_project_id = f"probe-project-{uuid.uuid4().hex[:12]}"
probe_task_id = f"probe-task-{uuid.uuid4().hex[:12]}"
# Snapshot validation: plant a unique token on agent A, snapshot, deploy
# agent B with the snapshot loaded into a known target context_id, prompt
# agent B for recall, grade with a rubric.
Expand Down Expand Up @@ -268,19 +258,11 @@ async def validate(
DeployEnvTaskStep(id=f"{task_id}-deploy-env", version=None, env_id=agent.VALIDATION_ENV_ID),
DeployAgentTaskStep(id=deploy_agent_id, version=None, env_ids=[agent.VALIDATION_ENV_ID], a2a_agent_id=agent.id, a2a_agent_version=agent.version),
VerifyA2AAgentCardStep(id=f"{task_id}-card", version=None, a2a_agent_id=agent.id, a2a_agent_version=agent.version, depends_on=deploy_agent_dep, fail_task_on_error=False),
VerifyA2ALitellmAttributionStep(id=f"{task_id}-litellm-attribution", version=None, a2a_agent_id=agent.id, a2a_agent_version=agent.version, depends_on=deploy_agent_dep, fail_task_on_error=False),
VerifyA2AAgentConfigIdentityStep(id=f"{task_id}-agent-config-identity", version=None, a2a_agent_id=agent.id, a2a_agent_version=agent.version, depends_on=deploy_agent_dep, fail_task_on_error=False),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
VerifyA2ARoleStep(id=f"{task_id}-role", version=None, a2a_agent_id=agent.id, a2a_agent_version=agent.version, depends_on=deploy_agent_dep, fail_task_on_error=False),
VerifyA2ASystemPromptStep(id=f"{task_id}-system-prompt", version=None, a2a_agent_id=agent.id, a2a_agent_version=agent.version, depends_on=deploy_agent_dep, fail_task_on_error=False),
PromptAgentTaskStep(id=protocol_prompt_id, version=None, prompt="Hello, respond with OK.", prompt_id=protocol_prompt_id, timeout_seconds=120, depends_on=deploy_agent_dep, fail_task_on_error=False),
VerifyCoreA2AProtocolStep(id=f"{task_id}-protocol", version=None, a2a_agent_id=agent.id, prompt_id=protocol_prompt_id, depends_on=[TaskStepDependency(task_step_id=protocol_prompt_id)], fail_task_on_error=False),
VerifyA2ALitellmAttributionRuntimeStep(
id=f"{task_id}-litellm-attribution-runtime", version=None,
a2a_agent_id=agent.id, a2a_agent_version=agent.version,
expected_project_id=probe_project_id, expected_task_id=probe_task_id,
depends_on=[TaskStepDependency(task_step_id=protocol_prompt_id)],
fail_task_on_error=False,
),
*modality_steps,
VerifyA2AModalitiesStep(id=f"{task_id}-modalities", version=None, a2a_agent_id=agent.id, probes=grading_probes,
depends_on=[TaskStepDependency(task_step_id=sid) for sid in modality_step_ids], fail_task_on_error=False),
Expand Down Expand Up @@ -424,12 +406,7 @@ async def validate(
initial_context = TaskStepContext()
if litellm_api_key:
initial_context.metadata["user_overrides"] = {"litellm_api_key": litellm_api_key}
# Plant LiteLLM attribution probe values so prompt_agent forwards them
# via /ext/agent-config — the runtime-attribution verify step reads them
# back from /ext/attribution-probe and asserts the agent's bridge
# actually wired them through to outbound LLM calls.
initial_context.metadata["project_id"] = probe_project_id
initial_context.metadata["task_id"] = probe_task_id
initial_context.metadata["task_id"] = task_id
num_deploy_steps = 2
run_kwargs = {"on_step_start": on_start, "on_step_complete": on_complete}

Expand Down
5 changes: 1 addition & 4 deletions src/agent_env/artifact/artifacts/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,7 @@ async def put_from_github(

log("create_vm", "Creating VM...", 10)
logger.info(f"put_from_github: cloning {owner}/{repo} ref={ref} dockerfile={dockerfile_repo_path} context={context_repo_path}")
sandbox = await get_sandbox_provider().create_vm(
disk_size_gb=20, timeout=1800, exposed_ports=[],
attribution={"project_id": "agent-env-image-builder"},
)
sandbox = await get_sandbox_provider().create_vm(disk_size_gb=20, timeout=1800, exposed_ports=[])
try:
log("install_git", "Installing git...", 20)
await sandbox.exec_script("apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1")
Expand Down
6 changes: 3 additions & 3 deletions src/agent_env/attribution.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Cost-attribution dimensions carried from the caller down to a sandbox provider.

Attribution is an open ``dict[str, str]``. Core threads it without reading it; each provider
picks out the keys its billing backend understands and ignores the rest, so a deployment can
attribute on whatever dimensions and terminology it uses.
Attribution is an open ``dict[str, str]``. Core threads it without reading it, and providers
carry every key (Modal as sandbox tags, E2B as sandbox metadata), so a deployment can attribute
on whatever dimensions and terminology it uses.
"""

from __future__ import annotations
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/cli/a2a_agent/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def run_validation(agent: A2AAgent, litellm_api_key: str | None = None) -> dict:
# Re-fetch agent to get the persisted metadata
agent = A2AAgent.get(agent.id, agent.version)

for key in ("validated_agent_card", "validated_a2a_protocol", "validated_a2a_extensions", "validated_data_extensions", "validated_litellm_attribution", "validated_litellm_attribution_runtime"):
for key in ("validated_agent_card", "validated_a2a_protocol", "validated_a2a_extensions", "validated_data_extensions"):
value = agent.metadata.get(key, {})
click.echo(click.style(f"\n{key}:", bold=True))
click.echo(json.dumps(value, indent=2))
Expand Down
18 changes: 4 additions & 14 deletions src/agent_env/cli/task/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,12 @@
@click.command()
@click.argument("filepath", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--id", "task_id", required=True, help="Task id")
@click.option(
"--project-id",
"project_id",
required=True,
help=(
"Project id for LiteLLM cost attribution. Required for new "
"tasks; tasks created before this requirement landed run without "
"one and the runner falls back to manual attribution."
),
)
@click.option(
"--skip-validation",
is_flag=True,
help="Save even if preflight finds problems (they are printed either way).",
)
def create(filepath: Path, task_id: str, project_id: str, skip_validation: bool):
def create(filepath: Path, task_id: str, skip_validation: bool):
"""Create a task from a JSON file containing step definitions.

\b
Expand All @@ -48,7 +38,7 @@ def create(filepath: Path, task_id: str, project_id: str, skip_validation: bool)

built = _build_steps(steps)
_report_preflight(
Task(id=task_id, version=None, steps=[s for _, s in built], project_id=project_id),
Task(id=task_id, version=None, steps=[s for _, s in built]),
skip_validation,
)

Expand All @@ -58,8 +48,8 @@ def create(filepath: Path, task_id: str, project_id: str, skip_validation: bool)
click.echo(f" Step {i + 1}/{len(built)}: {step.type} id={saved.id} version={saved.version}")
task_steps.append(saved)

click.echo(f"Creating task '{task_id}' with {len(task_steps)} steps (project_id={project_id})...")
task = Task.put(id=task_id, steps=task_steps, project_id=project_id)
click.echo(f"Creating task '{task_id}' with {len(task_steps)} steps...")
task = Task.put(id=task_id, steps=task_steps)
click.echo(f"Created task: id={task.id} version={task.version} steps={len(task.steps)}")


Expand Down
61 changes: 2 additions & 59 deletions src/agent_env/cli/task/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,53 +311,6 @@ def _stamp_agent_env_client_metadata(context_metadata: dict, client_id: str | No
context_metadata.setdefault("agent_env_hub", {}).setdefault("caller", client_id)


# Step types whose execution path makes LiteLLM calls (directly or via a
# deployed agent that consumes LITELLM_* env vars). Used to decide whether
# the cost-attribution banner is relevant for a given task.
_LITELLM_USING_STEP_TYPES: frozenset[str] = frozenset({
"deploy_agent",
"prompt_agent",
"rubrics_verifier",
})


def _task_uses_litellm(task) -> bool:
"""True if any of the task's steps are known to make LiteLLM calls."""
return any(getattr(step, "type", None) in _LITELLM_USING_STEP_TYPES for step in task.steps)


def _warn_missing_cost_attribution(
project_id: str | None,
task,
) -> None:
"""Yellow CLI banner when `--project-id` is missing on a LiteLLM task.

Project ID is the primary attribution dimension (it becomes the
LiteLLM `user` field that spend reporting joins on to derive the
customer). Skipped when the task has no LiteLLM-using
steps. Never blocks the run.
"""
if project_id:
return
if not _task_uses_litellm(task):
return
click.echo(click.style(
"⚠ LiteLLM cost attribution is incomplete: --project-id not set.",
fg="yellow", bold=True,
))
click.echo(click.style(
" Runs will still start, but spend won't be tagged to a "
"project.",
fg="yellow",
))
click.echo(click.style(
" Pass --project-id <project-id> on your next run so the "
"cost shows up in your team's budget.",
fg="yellow",
))
click.echo()


async def _run_single(task, run_index, task_id, output_dir, agent_model=None, agent_artifact_id=None, start_step=0, context=None):
"""Execute a single parallel run."""
tag = f"[run {run_index}] "
Expand All @@ -383,8 +336,6 @@ async def _run_single(task, run_index, task_id, output_dir, agent_model=None, ag
@click.option("--a2a-agent-id", default=None, type=str, help="Override A2A agent id for deploy_agent steps")
@click.option("--agent-sandbox", default=None,
help="Override agent sandbox backend(s) for deploy_agent steps; comma-separated for fallback chain (e.g. 'modal,local')")
@click.option("--project-id", "project_id", default=None, type=str,
help="Project id for LiteLLM cost attribution. Forwarded as the `user` field on every LLM call and as `projectId:<id>` in `metadata.tags`. Optional, but spend won't be attributed to a project without it.")
@click.option("--env-sandbox", default=None,
help="Override env sandbox backend(s) for deploy_env steps; comma-separated for fallback chain (e.g. 'modal,local')")
@click.option("--gateway-env-id", default=None, type=str,
Expand All @@ -395,7 +346,7 @@ async def _run_single(task, run_index, task_id, output_dir, agent_model=None, ag
help="Override the env state type for deploy_env steps")
@click.option("--env-state-instance-id", default=None, type=str,
help="Attach env to an existing EnvStateInstance")
def run(task_id: str, task_version: int | None, output_dir: str | None, k: int, agent_model: str | None, agent_artifact_id: str | None, start_step: int, context_json: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, a2a_agent_id: str | None, agent_sandbox: str | None, project_id: str | None, env_sandbox: str | None, gateway_env_id: str | None, service_db_env_id: str | None, env_state_type: str | None, env_state_instance_id: str | None):
def run(task_id: str, task_version: int | None, output_dir: str | None, k: int, agent_model: str | None, agent_artifact_id: str | None, start_step: int, context_json: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, a2a_agent_id: str | None, agent_sandbox: str | None, env_sandbox: str | None, gateway_env_id: str | None, service_db_env_id: str | None, env_state_type: str | None, env_state_instance_id: str | None):
"""Run a task by executing its steps sequentially."""
if k < 1:
raise click.BadParameter("must be at least 1", param_hint="'--k'")
Expand Down Expand Up @@ -438,15 +389,12 @@ def run(task_id: str, task_version: int | None, output_dir: str | None, k: int,
if env_state_instance_id:
initial_context.metadata.setdefault("user_overrides", {})["env_state_instance_id"] = env_state_instance_id
initial_context.metadata["task_id"] = task_id
if project_id:
initial_context.metadata["project_id"] = project_id

click.echo(f"Fetching task: id={task_id} version={task_version or 'latest'}...")
task = Task.get(task_id, version=task_version)
click.echo(f"Found task: id={task.id} version={task.version} steps={len(task.steps)}")

print_banner()
_warn_missing_cost_attribution(project_id, task)

if k > 1:
# Parallel runs: compact output with [run N] prefixes
Expand Down Expand Up @@ -503,13 +451,11 @@ async def _run_all():
help="Override agent sandbox backend(s) for deploy_agent steps; comma-separated for fallback chain (e.g. 'modal,local')")
@click.option("--env-sandbox", default=None,
help="Override env sandbox backend(s) for deploy_env steps; comma-separated for fallback chain (e.g. 'modal,local')")
@click.option("--project-id", "project_id", default=None, type=str,
help="Project id for LiteLLM cost attribution. Forwarded as the `user` field on every LLM call and as `projectId:<id>` in `metadata.tags`. Optional, but spend won't be attributed to a project without it.")
@click.option("--env-state-type", default=None, type=str,
help="Override the env state type for deploy_env steps")
@click.option("--env-state-instance-id", default=None, type=str,
help="Attach env to an existing EnvStateInstance")
def run_batch(task_id: str, task_version: int | None, seeds: str, concurrency: int, output_dir: str | None, agent_model: str | None, agent_artifact_id: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, agent_sandbox: str | None, env_sandbox: str | None, project_id: str | None, env_state_type: str | None, env_state_instance_id: str | None):
def run_batch(task_id: str, task_version: int | None, seeds: str, concurrency: int, output_dir: str | None, agent_model: str | None, agent_artifact_id: str | None, litellm_api_key: str | None, judge_litellm_api_key: str | None, apply_trajectory_filter: bool | None, agent_sandbox: str | None, env_sandbox: str | None, env_state_type: str | None, env_state_instance_id: str | None):
"""Run a task in batch against multiple seeds from a CSV file.

Each row in the CSV becomes a seed dict passed to the task via
Expand Down Expand Up @@ -538,7 +484,6 @@ def run_batch(task_id: str, task_version: int | None, seeds: str, concurrency: i
click.echo()

print_banner()
_warn_missing_cost_attribution(project_id, task)

sem = asyncio.Semaphore(concurrency)
batch_run_group_id = uuid4().hex
Expand Down Expand Up @@ -576,8 +521,6 @@ async def _run_seed(index: int, seed: dict):
if env_state_instance_id:
ctx.metadata.setdefault("user_overrides", {})["env_state_instance_id"] = env_state_instance_id
ctx.metadata["task_id"] = task_id
if project_id:
ctx.metadata["project_id"] = project_id

click.echo(click.style(f"{tag} Starting...", fg="yellow"))
context = await task.run(
Expand Down
Loading
Loading