Skip to content
Open
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
15 changes: 10 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,16 @@ orchestrators call it the same way. What callers rely on:
- A runner that must hear from a long step puts a zero-argument callable at
`context.metadata[HEARTBEAT_KEY]` (`task_step/context.py`); a step that blocks for long calls
`context.heartbeat()`, a no-op without one. The key is in `_REDACTED_KEYS`, so it is never persisted.
- An episode (`EpisodeSpec`, `task/episode/`) is one `Task.run()` of a copy of the task in which
the `deploy_agent` and `prompt_agent` steps of each of its `trainer_agents` are the plain-data
`episode_agent` and `episode_turn`, which wait for the caller instead of deploying or prompting. Its
record is tagged `context.metadata["episode"]` and names the original task, so it isn't resumable
with `start_step`. The episode tears down what its run deployed, and an abandoned one is recorded
- An episode (`EpisodeSpec`, `task/episode/`) is one `Task.run()` of a copy of the task, with
namespace routing on, in which each of its `trainer_agents` is played through a stand-in agent
(the `agents` bundle's `stand-in`, `@local/agentenv-framework/agents/stand-in`, which the episode writes
from its folder as a bundle run would).
The agent's `deploy_agent` or `install_agent`, wrapped as `episode_agent`, deploys the stand-in in the
agent's place when the task uses the agent's sandbox and serves it in this process when it doesn't;
the agent's `prompt_agent` steps message it as they would the agent, and the caller plays each
message as a `Turn` through its `urn:agentenv:driver/v1`, where a turn's `bash` runs. Its record is
tagged `context.metadata["episode"]` and names the original task, so it isn't resumable with
`start_step`. The episode tears down what its run deployed, and an abandoned one is recorded
`cancelled`.

## Tests
Expand Down
25 changes: 25 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,31 @@ The answer is agent-wide rather than per caller, and like every extension route
probe carries no access check of its own, so expose an agent only to the control plane
that drives it.

`DRIVER_V1` hands an agent's turns to a driver outside its sandbox, and `StandInAgent`
(`agentenv_protocol.a2a_agent.stand_in`) serves it. The stand-in has no model: each message
it gets stays an open turn, its task working, until the driver replies. The driver's first
`POST /ext/driver/claim` (`{"token"}`, at least 22 characters) sets the token every later
call carries in its body. A claim with another token gets 409, and any other call with a
wrong one gets 401. The token is held in memory only, never in the environment a command
inherits. The other operations:

- `POST /ext/driver/next` (`{"token", "after"}`) waits up to 25 s for the turns' version to
pass `after`, then returns `{"version", "turns": [{"task_id", "context_id", "text",
"system_prompt", "skills"}]}`. A turn its sender cancels drops out of the list.
- `POST /ext/driver/reply` (`{"token", "task_id", "text"}` or `"error"` in place of
`"text"`, plus `"trajectory"` and `"tool_call_count"`) ends a turn. A text is the task's
result, an error fails it with `stand_in.driver_error`, and the trajectory's spans become
the task's trajectory.
- `POST /ext/driver/exec` (`{"token", "command"}`) starts `bash -c` in the stand-in's working
folder, as its user, and returns `{"exec_id"}`. `POST /ext/driver/output` (`{"token",
"exec_id"}`) waits up to 25 s and returns `{"done", "output"}`. The output is stdout then
stderr, or an error line after 3600 s, when the command's process group is killed.

Skills the stand-in is given are written to `~/skills/<name>/`, and a turn names each one's
folder. `StandInAgent(shell=False)` stands in for an agent that works in no sandbox: it keeps
no skills and refuses `exec`. Commands inherit the stand-in's environment, so run it with no
credentials in it.

Runnable, self-contained reference agents live in [`examples/`](https://github.com/scaleapi/agentenv-framework/tree/main/packages/agentenv-protocol/examples):
the normal, streaming, and multimodal `run(request)` paths, single- and
multi-operation custom extensions, and advanced ASGI-lifespan plus
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .extensions import (
AGENT_CONFIG_V1,
ATTRIBUTION_PROBE_V1,
DRIVER_V1,
INSTALL_V1,
MCP_CONFIG_V1,
PEER_AGENTS_V1,
Expand All @@ -19,6 +20,16 @@
ContextEventsTrajectoryRequest,
ContextObjectTrajectoryRequest,
ContextTrajectoryRequest,
DriverClaimRequest,
DriverExecRequest,
DriverExecResponse,
DriverNextRequest,
DriverNextResponse,
DriverOutputRequest,
DriverOutputResponse,
DriverReplyRequest,
DriverSkill,
DriverTurn,
ExtensionActivation,
ExtensionConfiguration,
ExtensionDefinition,
Expand Down Expand Up @@ -117,6 +128,7 @@
__all__ = [
"AGENT_CONFIG_V1",
"ATTRIBUTION_PROBE_V1",
"DRIVER_V1",
"INSTALL_V1",
"MCP_CONFIG_V1",
"PEER_AGENTS_V1",
Expand All @@ -139,6 +151,16 @@
"ContextEventsTrajectoryRequest",
"ContextObjectTrajectoryRequest",
"ContextTrajectoryRequest",
"DriverClaimRequest",
"DriverExecRequest",
"DriverExecResponse",
"DriverNextRequest",
"DriverNextResponse",
"DriverOutputRequest",
"DriverOutputResponse",
"DriverReplyRequest",
"DriverSkill",
"DriverTurn",
"DataPart",
"DefaultExtensionHandlers",
"ExtensionActivation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,71 @@ class TriggerDecideRequest(ExtensionRequest):
env_triggers: dict[str, dict[str, Any]] | None = None


DRIVER_TOKEN_MIN_LENGTH = 22


class DriverClaimRequest(ExtensionRequest):
token: str = Field(min_length=DRIVER_TOKEN_MIN_LENGTH)


class DriverNextRequest(ExtensionRequest):
token: str
after: int = Field(default=0, ge=0)


class DriverSkill(ExtensionResponse):
name: str
description: str
path: str | None = None


class DriverTurn(ExtensionResponse):
task_id: str
context_id: str
text: str
system_prompt: str | None = None
skills: list[DriverSkill] = Field(default_factory=list)


class DriverNextResponse(ExtensionResponse):
version: int
turns: list[DriverTurn]


class DriverReplyRequest(ExtensionRequest):
token: str
task_id: str
text: str | None = None
error: str | None = None
trajectory: list[dict[str, Any]] = Field(default_factory=list)
tool_call_count: int = Field(default=0, ge=0)

@model_validator(mode="after")
def _text_or_error(self) -> DriverReplyRequest:
if (self.text is None) == (self.error is None):
raise ValueError("a reply carries exactly one of text and error")
return self


class DriverExecRequest(ExtensionRequest):
token: str
command: str


class DriverExecResponse(ExtensionResponse):
exec_id: str


class DriverOutputRequest(ExtensionRequest):
token: str
exec_id: str


class DriverOutputResponse(ExtensionResponse):
done: bool
output: str | None = None


def _tuple(values: Iterable[str]) -> tuple[str, ...]:
return tuple(dict.fromkeys(values))

Expand Down Expand Up @@ -1167,6 +1232,28 @@ def _configure_trajectory(*, live: bool = False) -> ExtensionConfiguration:
configuration_validator=_configure_install,
)

DRIVER_V1 = ExtensionDefinition(
uri="urn:agentenv:driver/v1",
description="Hand the agent's turns to a driver outside its sandbox, and run its commands where the agent works.",
endpoint="/ext/driver",
core_operations={
"claim": _op("claim", "POST", "/ext/driver/claim", RUNTIME, request_model=DriverClaimRequest),
"next": _op(
"next", "POST", "/ext/driver/next", RUNTIME,
request_model=DriverNextRequest, response_model=DriverNextResponse,
),
"reply": _op("reply", "POST", "/ext/driver/reply", RUNTIME, request_model=DriverReplyRequest),
"exec": _op(
"exec", "POST", "/ext/driver/exec", RUNTIME,
request_model=DriverExecRequest, response_model=DriverExecResponse,
),
"output": _op(
"output", "POST", "/ext/driver/output", RUNTIME,
request_model=DriverOutputRequest, response_model=DriverOutputResponse,
),
},
)

STANDARD_EXTENSIONS: Mapping[str, ExtensionDefinition] = MappingProxyType(
{
extension.uri: extension
Expand All @@ -1180,6 +1267,7 @@ def _configure_trajectory(*, live: bool = False) -> ExtensionConfiguration:
ATTRIBUTION_PROBE_V1,
TRIGGERS_V1,
INSTALL_V1,
DRIVER_V1,
)
}
)
Expand Down
Loading
Loading