agentenv-framework-protocol 0.1.372__tar.gz → 0.1.373__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/PKG-INFO +18 -9
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/README.md +17 -8
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/pyproject.toml +1 -1
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/extensions.py +1 -1
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/proxy.py +125 -45
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_proxy.py +192 -15
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_peer_agents.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_preflight.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_client.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_context.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_context_fastmcp.py +0 -0
- {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_context_injection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentenv-framework-protocol
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.373
|
|
4
4
|
Summary: Open data-plane protocol and server SDK for agent environments.
|
|
5
5
|
Project-URL: Homepage, https://www.agentenvframework.com
|
|
6
6
|
Project-URL: Documentation, https://www.agentenvframework.com/docs
|
|
@@ -759,20 +759,27 @@ operations:
|
|
|
759
759
|
"skills"}]}`. A turn its sender cancels drops out of the list.
|
|
760
760
|
- `tools` (`{"token", "task_id"}`) lists what the turn can call, with input schemas: the
|
|
761
761
|
tools of each env server registered through mcp-config, and `bash`. Two servers offering
|
|
762
|
-
one name are refused with 409
|
|
762
|
+
one name are refused with 409, and servers that haven't all listed their tools within
|
|
763
|
+
600 s get 502. `record` waits for the same listing.
|
|
763
764
|
- `act` (`{"token", "task_id", "action_id", "action"}`) runs one action: `{"kind": "tool",
|
|
764
765
|
"server", "name", "arguments"}` calls an env server through `ToolSession`, with the URL
|
|
765
766
|
and headers registered for it, and `{"kind": "bash", "command"}` runs `bash -c` in the
|
|
766
|
-
proxy's folder, as its user, in a process group of its own
|
|
767
|
+
proxy's folder, as its user, in a process group of its own. Where there is no `bash`, as
|
|
768
|
+
in a minimal image, it runs `sh -c`, and the `bash` tool's description says so. A command
|
|
769
|
+
is done when its shell exits: what it started in the background keeps running, and output
|
|
770
|
+
that arrives more than a second later is read and dropped. Either takes
|
|
767
771
|
`"timeout_seconds"` (3600 for `bash`, 600 for a tool by default). The driver picks
|
|
768
|
-
`action_id`, so a retried `act` returns the action it already
|
|
769
|
-
`{"done", "result"}` at once when the action ends within the long-poll;
|
|
770
|
-
(`{"token", "action_id"}`) long-polls one that doesn't
|
|
771
|
-
`{"exit_code", "stdout", "stderr",
|
|
772
|
+
`action_id`, unique for the proxy, so a retried `act` returns the action it already
|
|
773
|
+
started. It answers `{"done", "result"}` at once when the action ends within the long-poll;
|
|
774
|
+
`result` (`{"token", "action_id"}`) long-polls one that doesn't, and gets 404 once the
|
|
775
|
+
action's turn has closed. A `bash` result is `{"exit_code", "stdout", "stderr",
|
|
776
|
+
"timed_out"}`, with no `exit_code` when the command was stopped and 126 when it couldn't
|
|
777
|
+
start; a tool's is `{"content", "is_error"}`.
|
|
772
778
|
- `record` (`{"token", "task_id", "message", "model", "usage"}`) records one model call: an
|
|
773
779
|
OpenAI chat message, an Anthropic message or a string; anything else gets 400. Once a turn
|
|
774
780
|
records a call, each action must be one the last recorded call made, by name and
|
|
775
|
-
arguments
|
|
781
|
+
arguments (a `bash` call by its command alone, a whole float equal to its int), and all of
|
|
782
|
+
them must run before the next `record` or the `reply`, else 409.
|
|
776
783
|
Calls to tools the turn doesn't offer aren't checked.
|
|
777
784
|
- `reply` (`{"token", "task_id", "parts"}`, or `"error"` in place of `"parts"`) ends the
|
|
778
785
|
turn: parts complete the task, and an error fails it as `proxy.driver_error`.
|
|
@@ -781,7 +788,9 @@ A turn's actions belong to it: when it closes, by reply or withdrawal, those sti
|
|
|
781
788
|
are stopped, their whole process groups killed, and its results forgotten; the app's
|
|
782
789
|
lifespan stops what runs when it shuts down. The proxy writes the turn's trajectory as it
|
|
783
790
|
goes, readable live, as OTel GenAI spans in format `agentenv-proxy/v1`: `chat` for each
|
|
784
|
-
recorded call, `execute_tool` for each action, and `chain` for the turn
|
|
791
|
+
recorded call, `execute_tool` for each action, and `chain` for the turn, each with an
|
|
792
|
+
OTel status: `ERROR` for an action that failed, a command that exited nonzero or timed out,
|
|
793
|
+
and a turn that ended in an error. Skills it is given
|
|
785
794
|
are written to `~/skills/<name>/`, once each, and a turn names each one's folder.
|
|
786
795
|
`ProxyAgent(shell=False)` is a proxy for an agent that works in no sandbox: it offers no
|
|
787
796
|
`bash` and keeps no skills. It holds the env servers' headers as any agent does, so run it
|
|
@@ -713,20 +713,27 @@ operations:
|
|
|
713
713
|
"skills"}]}`. A turn its sender cancels drops out of the list.
|
|
714
714
|
- `tools` (`{"token", "task_id"}`) lists what the turn can call, with input schemas: the
|
|
715
715
|
tools of each env server registered through mcp-config, and `bash`. Two servers offering
|
|
716
|
-
one name are refused with 409
|
|
716
|
+
one name are refused with 409, and servers that haven't all listed their tools within
|
|
717
|
+
600 s get 502. `record` waits for the same listing.
|
|
717
718
|
- `act` (`{"token", "task_id", "action_id", "action"}`) runs one action: `{"kind": "tool",
|
|
718
719
|
"server", "name", "arguments"}` calls an env server through `ToolSession`, with the URL
|
|
719
720
|
and headers registered for it, and `{"kind": "bash", "command"}` runs `bash -c` in the
|
|
720
|
-
proxy's folder, as its user, in a process group of its own
|
|
721
|
+
proxy's folder, as its user, in a process group of its own. Where there is no `bash`, as
|
|
722
|
+
in a minimal image, it runs `sh -c`, and the `bash` tool's description says so. A command
|
|
723
|
+
is done when its shell exits: what it started in the background keeps running, and output
|
|
724
|
+
that arrives more than a second later is read and dropped. Either takes
|
|
721
725
|
`"timeout_seconds"` (3600 for `bash`, 600 for a tool by default). The driver picks
|
|
722
|
-
`action_id`, so a retried `act` returns the action it already
|
|
723
|
-
`{"done", "result"}` at once when the action ends within the long-poll;
|
|
724
|
-
(`{"token", "action_id"}`) long-polls one that doesn't
|
|
725
|
-
`{"exit_code", "stdout", "stderr",
|
|
726
|
+
`action_id`, unique for the proxy, so a retried `act` returns the action it already
|
|
727
|
+
started. It answers `{"done", "result"}` at once when the action ends within the long-poll;
|
|
728
|
+
`result` (`{"token", "action_id"}`) long-polls one that doesn't, and gets 404 once the
|
|
729
|
+
action's turn has closed. A `bash` result is `{"exit_code", "stdout", "stderr",
|
|
730
|
+
"timed_out"}`, with no `exit_code` when the command was stopped and 126 when it couldn't
|
|
731
|
+
start; a tool's is `{"content", "is_error"}`.
|
|
726
732
|
- `record` (`{"token", "task_id", "message", "model", "usage"}`) records one model call: an
|
|
727
733
|
OpenAI chat message, an Anthropic message or a string; anything else gets 400. Once a turn
|
|
728
734
|
records a call, each action must be one the last recorded call made, by name and
|
|
729
|
-
arguments
|
|
735
|
+
arguments (a `bash` call by its command alone, a whole float equal to its int), and all of
|
|
736
|
+
them must run before the next `record` or the `reply`, else 409.
|
|
730
737
|
Calls to tools the turn doesn't offer aren't checked.
|
|
731
738
|
- `reply` (`{"token", "task_id", "parts"}`, or `"error"` in place of `"parts"`) ends the
|
|
732
739
|
turn: parts complete the task, and an error fails it as `proxy.driver_error`.
|
|
@@ -735,7 +742,9 @@ A turn's actions belong to it: when it closes, by reply or withdrawal, those sti
|
|
|
735
742
|
are stopped, their whole process groups killed, and its results forgotten; the app's
|
|
736
743
|
lifespan stops what runs when it shuts down. The proxy writes the turn's trajectory as it
|
|
737
744
|
goes, readable live, as OTel GenAI spans in format `agentenv-proxy/v1`: `chat` for each
|
|
738
|
-
recorded call, `execute_tool` for each action, and `chain` for the turn
|
|
745
|
+
recorded call, `execute_tool` for each action, and `chain` for the turn, each with an
|
|
746
|
+
OTel status: `ERROR` for an action that failed, a command that exited nonzero or timed out,
|
|
747
|
+
and a turn that ended in an error. Skills it is given
|
|
739
748
|
are written to `~/skills/<name>/`, once each, and a turn names each one's folder.
|
|
740
749
|
`ProxyAgent(shell=False)` is a proxy for an agent that works in no sandbox: it offers no
|
|
741
750
|
`bash` and keeps no skills. It holds the env servers' headers as any agent does, so run it
|
|
@@ -367,7 +367,7 @@ class ProxyResultRequest(ExtensionRequest):
|
|
|
367
367
|
|
|
368
368
|
class ProxyBashResult(ExtensionResponse):
|
|
369
369
|
kind: Literal["bash"] = "bash"
|
|
370
|
-
exit_code: int | None # none when the command was stopped
|
|
370
|
+
exit_code: int | None = None # none when the command was stopped
|
|
371
371
|
stdout: str
|
|
372
372
|
stderr: str
|
|
373
373
|
timed_out: bool
|
|
@@ -64,6 +64,8 @@ from ..types import EnvironmentTool
|
|
|
64
64
|
|
|
65
65
|
LONG_POLL_SECONDS = 25.0
|
|
66
66
|
BASH_TIMEOUT_SECONDS = 3600
|
|
67
|
+
OUTPUT_GRACE_SECONDS = 1.0 # how long a done command's output may still arrive, from what it left running
|
|
68
|
+
EXIT_POLL_SECONDS = 0.05
|
|
67
69
|
TOOL_TIMEOUT_SECONDS = 600
|
|
68
70
|
SKILLS_DIR = Path.home() / "skills"
|
|
69
71
|
TRAJECTORY_FORMAT = "agentenv-proxy/v1"
|
|
@@ -75,6 +77,11 @@ BASH_TOOL = ProxyTool(
|
|
|
75
77
|
input_schema={"type": "object", "properties": {"command": {"type": "string", "description": "The command."}},
|
|
76
78
|
"required": ["command"]},
|
|
77
79
|
)
|
|
80
|
+
BASH_TOOL_ON_SH = BASH_TOOL.model_copy(update={
|
|
81
|
+
"description": "Run a command where the agent works, as its user, in its folder, with POSIX sh: this place has "
|
|
82
|
+
"no bash, so bash-only syntax such as [[ ]], arrays or <(...) fails. Returns its stdout, then its "
|
|
83
|
+
"stderr.",
|
|
84
|
+
})
|
|
78
85
|
|
|
79
86
|
|
|
80
87
|
class ProxyConfig(AgentConfig):
|
|
@@ -127,16 +134,19 @@ EXTENSIONS = (MCP_CONFIG_V1, enable(TRAJECTORY_V1, live=True), PROXY_V1)
|
|
|
127
134
|
@a2a_agent(identity=IDENTITY, config=ProxyConfig, extensions=EXTENSIONS, lifespan=stopping_actions)
|
|
128
135
|
class ProxyAgent(AgentEnvAgent):
|
|
129
136
|
"""``shell=False`` is a proxy for an agent that works in no sandbox: it offers no ``bash`` and keeps no skills.
|
|
137
|
+
Where there is no ``bash``, its ``bash`` runs commands with ``sh``, as the tool's description says.
|
|
130
138
|
``long_poll_seconds`` bounds how long ``next``, ``act`` and ``result`` wait for something to report."""
|
|
131
139
|
|
|
132
140
|
def __init__(self, *, shell: bool = True, long_poll_seconds: float = LONG_POLL_SECONDS):
|
|
133
141
|
self._shell = shell
|
|
142
|
+
self._program, self._bash_tool = ("bash", BASH_TOOL) if shutil.which("bash") else ("sh", BASH_TOOL_ON_SH)
|
|
134
143
|
self._long_poll_seconds = long_poll_seconds
|
|
135
144
|
self._token: bytes | None = None
|
|
136
145
|
self._turns: dict[str, _Turn] = {}
|
|
137
146
|
self._version = 0
|
|
138
147
|
self._changed: asyncio.Future[None] | None = None
|
|
139
148
|
self._actions: dict[str, tuple[_Turn, asyncio.Task[ProxyBashResult | ProxyToolResult]]] = {}
|
|
149
|
+
self._draining: set[asyncio.Task[None]] = set()
|
|
140
150
|
|
|
141
151
|
async def run(self, request: TaskRequest[ProxyConfig]) -> TaskResult:
|
|
142
152
|
if any(not isinstance(part, TextPart) for part in request.parts):
|
|
@@ -164,7 +174,7 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
164
174
|
prompt = {"prompt": "\n".join(part.text for part in turn.parts), "system_prompt": turn.system_prompt}
|
|
165
175
|
if reply.error is not None:
|
|
166
176
|
turn.trajectory.append(_span("turn", "chain", turn.started, turn.model, prompt=prompt,
|
|
167
|
-
completion={"error": reply.error}))
|
|
177
|
+
completion={"error": reply.error}, failed=True))
|
|
168
178
|
return TaskResult.failure("proxy.driver_error", reply.error)
|
|
169
179
|
turn.trajectory.append(_span("turn", "chain", turn.started, turn.model, prompt=prompt, completion={
|
|
170
180
|
"content": [{"type": "text", "text": part.text} for part in reply.parts]}))
|
|
@@ -202,14 +212,14 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
202
212
|
if (known := self._actions.get(request.action_id)) is not None:
|
|
203
213
|
if known[0].task_id != request.task_id:
|
|
204
214
|
raise HTTPException(status_code=409, detail=f"action {request.action_id!r} is another turn's")
|
|
205
|
-
return await self._state(known[1])
|
|
215
|
+
return await self._state(request.action_id, known[1])
|
|
206
216
|
turn = self._open_turn(request.token, request.task_id)
|
|
207
217
|
action = request.action
|
|
208
218
|
if isinstance(action, ProxyBashAction) and not self._shell:
|
|
209
219
|
raise HTTPException(status_code=409, detail="this proxy works in no sandbox, so it runs no commands")
|
|
210
220
|
if turn.recorded:
|
|
211
|
-
call = (action.name,
|
|
212
|
-
|
|
221
|
+
call = (_call(action.name, action.arguments) if isinstance(action, ProxyToolAction)
|
|
222
|
+
else _call(BASH, {"command": action.command}, own_shell=True))
|
|
213
223
|
if call not in turn.pending:
|
|
214
224
|
raise HTTPException(status_code=409, detail=f"the last recorded model call didn't make this call to "
|
|
215
225
|
f"{call[0]!r}")
|
|
@@ -217,14 +227,14 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
217
227
|
task = asyncio.create_task(self._perform(turn, action))
|
|
218
228
|
turn.actions[request.action_id] = task
|
|
219
229
|
self._actions[request.action_id] = (turn, task)
|
|
220
|
-
return await self._state(task)
|
|
230
|
+
return await self._state(request.action_id, task)
|
|
221
231
|
|
|
222
232
|
@extension(PROXY_V1.result)
|
|
223
233
|
async def result(self, request: ProxyResultRequest) -> ProxyActionState:
|
|
224
234
|
self._check(request.token)
|
|
225
235
|
if (known := self._actions.get(request.action_id)) is None:
|
|
226
236
|
raise HTTPException(status_code=404, detail=f"no action {request.action_id!r}")
|
|
227
|
-
return await self._state(known[1])
|
|
237
|
+
return await self._state(request.action_id, known[1])
|
|
228
238
|
|
|
229
239
|
@extension(PROXY_V1.record)
|
|
230
240
|
async def record(self, request: ProxyRecordRequest) -> None:
|
|
@@ -233,7 +243,8 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
233
243
|
turn = self._open_turn(request.token, request.task_id)
|
|
234
244
|
if turn.pending:
|
|
235
245
|
raise HTTPException(status_code=409, detail=_unrun(turn))
|
|
236
|
-
turn.pending = [(name,
|
|
246
|
+
turn.pending = [_call(name, arguments, own_shell=offered[name].server is None)
|
|
247
|
+
for name, arguments in calls if name in offered]
|
|
237
248
|
turn.recorded = True
|
|
238
249
|
turn.model = request.model or turn.model
|
|
239
250
|
span = _span("chat", "chat", turn.last_event, turn.model, completion={"content": content})
|
|
@@ -277,15 +288,17 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
277
288
|
return turn
|
|
278
289
|
|
|
279
290
|
async def _tools(self, turn: _Turn) -> dict[str, ProxyTool]:
|
|
280
|
-
"""What the turn can call, listed once
|
|
281
|
-
name are refused."""
|
|
291
|
+
"""What the turn can call, listed once and all within ``TOOL_TIMEOUT_SECONDS``: each registered env server's
|
|
292
|
+
tools, and ``bash``. Two offering one name are refused."""
|
|
282
293
|
async with turn.listing:
|
|
283
294
|
if turn.tools is None:
|
|
284
|
-
offered = {BASH:
|
|
295
|
+
offered = {BASH: self._bash_tool} if self._shell else {}
|
|
296
|
+
loop = asyncio.get_running_loop()
|
|
297
|
+
deadline = loop.time() + TOOL_TIMEOUT_SECONDS
|
|
285
298
|
for server in turn.servers:
|
|
286
299
|
try:
|
|
287
|
-
listed = await asyncio.wait_for(self._listed(turn, server),
|
|
288
|
-
except TimeoutError:
|
|
300
|
+
listed = await asyncio.wait_for(self._listed(turn, server), deadline - loop.time())
|
|
301
|
+
except asyncio.TimeoutError:
|
|
289
302
|
raise HTTPException(status_code=502, detail=(
|
|
290
303
|
f"env server {server!r} didn't list its tools within {TOOL_TIMEOUT_SECONDS}s")) from None
|
|
291
304
|
except (ToolSessionError, httpx.HTTPError) as e:
|
|
@@ -327,33 +340,45 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
327
340
|
async def _perform(self, turn: _Turn, action: ProxyToolAction | ProxyBashAction) -> ProxyBashResult | ProxyToolResult:
|
|
328
341
|
started = _now()
|
|
329
342
|
if isinstance(action, ProxyBashAction):
|
|
330
|
-
timeout = action.timeout_seconds or BASH_TIMEOUT_SECONDS
|
|
331
|
-
result = await _run(action.command, timeout)
|
|
332
343
|
name, arguments = BASH, {"command": action.command}
|
|
333
|
-
|
|
334
|
-
else result.stdout + result.stderr)
|
|
335
|
-
completion = {"output": output, "exit_code": result.exit_code}
|
|
344
|
+
result, completion, failed = await self._bash(action)
|
|
336
345
|
else:
|
|
337
|
-
timeout = action.timeout_seconds or TOOL_TIMEOUT_SECONDS
|
|
338
346
|
name, arguments = action.name, action.arguments
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
except TimeoutError:
|
|
343
|
-
result = _tool_error(f"the tool call timed out after {timeout:g}s")
|
|
344
|
-
except (ToolSessionError, httpx.HTTPError) as e:
|
|
345
|
-
result = _tool_error(f"{type(e).__name__}: {e}")
|
|
346
|
-
text = "\n".join(block["text"] for block in result.content if block.get("type") == "text")
|
|
347
|
-
completion = {"error": text} if result.is_error else {"output": text}
|
|
348
|
-
span = _span(name, "execute_tool", started, None, prompt={"input": arguments}, completion=completion)
|
|
347
|
+
result, completion, failed = await self._tool(turn, action)
|
|
348
|
+
span = _span(name, "execute_tool", started, None, prompt={"input": arguments}, completion=completion,
|
|
349
|
+
failed=failed)
|
|
349
350
|
turn.trajectory.append(span)
|
|
350
351
|
turn.last_event = span["end_time"]
|
|
351
352
|
return result
|
|
352
353
|
|
|
353
|
-
async def
|
|
354
|
+
async def _bash(self, action: ProxyBashAction) -> tuple[ProxyBashResult, dict[str, Any], bool]:
|
|
355
|
+
"""Run a command; what it left reading output is drained until the proxy stops."""
|
|
356
|
+
timeout = action.timeout_seconds or BASH_TIMEOUT_SECONDS
|
|
357
|
+
result, draining = await _run(self._program, action.command, timeout)
|
|
358
|
+
for drain in draining:
|
|
359
|
+
self._draining.add(drain)
|
|
360
|
+
drain.add_done_callback(self._draining.discard)
|
|
361
|
+
output = f"error: the command timed out after {timeout:g}s" if result.timed_out else result.stdout + result.stderr
|
|
362
|
+
return result, {"output": output, "exit_code": result.exit_code}, result.timed_out or result.exit_code != 0
|
|
363
|
+
|
|
364
|
+
async def _tool(self, turn: _Turn, action: ProxyToolAction) -> tuple[ProxyToolResult, dict[str, Any], bool]:
|
|
365
|
+
timeout = action.timeout_seconds or TOOL_TIMEOUT_SECONDS
|
|
366
|
+
try:
|
|
367
|
+
called = await asyncio.wait_for(self._called(turn, action), timeout)
|
|
368
|
+
result = ProxyToolResult(content=called.content, is_error=called.isError)
|
|
369
|
+
except asyncio.TimeoutError:
|
|
370
|
+
result = _tool_error(f"the tool call timed out after {timeout:g}s")
|
|
371
|
+
except (ToolSessionError, httpx.HTTPError) as e:
|
|
372
|
+
result = _tool_error(f"{type(e).__name__}: {e}")
|
|
373
|
+
text = "\n".join(block["text"] for block in result.content if block.get("type") == "text")
|
|
374
|
+
return result, ({"error": text} if result.is_error else {"output": text}), result.is_error
|
|
375
|
+
|
|
376
|
+
async def _state(self, action_id: str, task: asyncio.Task[ProxyBashResult | ProxyToolResult]) -> ProxyActionState:
|
|
354
377
|
await asyncio.wait((task,), timeout=self._long_poll_seconds)
|
|
355
378
|
if not task.done():
|
|
356
379
|
return ProxyActionState(done=False)
|
|
380
|
+
if task.cancelled():
|
|
381
|
+
raise HTTPException(status_code=404, detail=f"action {action_id!r} was stopped: its turn is over")
|
|
357
382
|
return ProxyActionState(done=True, result=task.result())
|
|
358
383
|
|
|
359
384
|
async def _close(self, turn: _Turn) -> None:
|
|
@@ -367,6 +392,8 @@ class ProxyAgent(AgentEnvAgent):
|
|
|
367
392
|
|
|
368
393
|
async def _stop(self) -> None:
|
|
369
394
|
await asyncio.gather(*(self._close(turn) for turn in list(self._turns.values())), return_exceptions=True)
|
|
395
|
+
for drain in list(self._draining):
|
|
396
|
+
drain.cancel()
|
|
370
397
|
|
|
371
398
|
@contextlib.asynccontextmanager
|
|
372
399
|
async def _skill_folder(self, name: str) -> AsyncIterator[Path]:
|
|
@@ -471,8 +498,22 @@ def _unrun(turn: _Turn) -> str:
|
|
|
471
498
|
return f"the last recorded model call's calls to {sorted({name for name, _ in turn.pending})} haven't run"
|
|
472
499
|
|
|
473
500
|
|
|
474
|
-
def
|
|
475
|
-
|
|
501
|
+
def _call(name: str, arguments: dict[str, Any], *, own_shell: bool = False) -> tuple[str, str]:
|
|
502
|
+
"""A call as recorded calls and actions are compared: the proxy's own ``bash`` by its command alone, since its
|
|
503
|
+
action carries nothing else, and a whole float equal to the int a driver may have written for it."""
|
|
504
|
+
if own_shell:
|
|
505
|
+
arguments = {"command": arguments.get("command")}
|
|
506
|
+
return name, json.dumps(_whole(arguments), sort_keys=True, separators=(",", ":"), default=str)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _whole(value: Any) -> Any:
|
|
510
|
+
if isinstance(value, float) and value.is_integer():
|
|
511
|
+
return int(value)
|
|
512
|
+
if isinstance(value, dict):
|
|
513
|
+
return {key: _whole(item) for key, item in value.items()}
|
|
514
|
+
if isinstance(value, list):
|
|
515
|
+
return [_whole(item) for item in value]
|
|
516
|
+
return value
|
|
476
517
|
|
|
477
518
|
|
|
478
519
|
def _tool_error(message: str) -> ProxyToolResult:
|
|
@@ -480,7 +521,7 @@ def _tool_error(message: str) -> ProxyToolResult:
|
|
|
480
521
|
|
|
481
522
|
|
|
482
523
|
def _span(name: str, operation: str, started: str, model: str | None, *, prompt: Any = None,
|
|
483
|
-
completion: Any = None) -> dict[str, Any]:
|
|
524
|
+
completion: Any = None, failed: bool = False) -> dict[str, Any]:
|
|
484
525
|
"""An OTel GenAI span, the trajectory format rubrics and the explorer read."""
|
|
485
526
|
attributes: dict[str, Any] = {"gen_ai.operation.name": operation}
|
|
486
527
|
if model:
|
|
@@ -489,28 +530,67 @@ def _span(name: str, operation: str, started: str, model: str | None, *, prompt:
|
|
|
489
530
|
attributes["gen_ai.prompt"] = json.dumps(prompt, default=str)
|
|
490
531
|
if completion is not None:
|
|
491
532
|
attributes["gen_ai.completion"] = json.dumps(completion, default=str)
|
|
492
|
-
return {"name": name, "start_time": started, "end_time": _now(),
|
|
533
|
+
return {"name": name, "start_time": started, "end_time": _now(),
|
|
534
|
+
"status": {"status_code": "ERROR" if failed else "OK"}, "attributes": attributes}
|
|
493
535
|
|
|
494
536
|
|
|
495
537
|
def _now() -> str:
|
|
496
538
|
return datetime.now(timezone.utc).isoformat()
|
|
497
539
|
|
|
498
540
|
|
|
499
|
-
async def _run(command: str, timeout: float) -> ProxyBashResult:
|
|
500
|
-
"""Run ``command`` in a process group of its own
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
541
|
+
async def _run(program: str, command: str, timeout: float) -> tuple[ProxyBashResult, list[asyncio.Task[None]]]:
|
|
542
|
+
"""Run ``command`` in a process group of its own. It's done when its shell exits: what it started in the background
|
|
543
|
+
keeps running, and output that arrives after ``OUTPUT_GRACE_SECONDS`` is read and dropped by the readers returned
|
|
544
|
+
with the result, so a server it started never blocks on a full pipe. A command not done within ``timeout``, or
|
|
545
|
+
whose call is cancelled, is stopped with whatever it started."""
|
|
546
|
+
try:
|
|
547
|
+
process = await asyncio.create_subprocess_exec(
|
|
548
|
+
program, "-c", command, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE,
|
|
549
|
+
stderr=asyncio.subprocess.PIPE, start_new_session=True,
|
|
550
|
+
)
|
|
551
|
+
except OSError as e:
|
|
552
|
+
# As a shell reports a command it can't execute: an argument list too long, say.
|
|
553
|
+
return ProxyBashResult(exit_code=126, stdout="", stderr=f"error: the command couldn't start: {e}\n",
|
|
554
|
+
timed_out=False), []
|
|
555
|
+
stdout, stderr = _Output(), _Output()
|
|
556
|
+
readers = [asyncio.create_task(_read(process.stdout, stdout)), asyncio.create_task(_read(process.stderr, stderr))]
|
|
506
557
|
try:
|
|
507
|
-
|
|
558
|
+
await asyncio.wait_for(_exited(process), timeout)
|
|
559
|
+
await asyncio.wait(readers, timeout=OUTPUT_GRACE_SECONDS)
|
|
508
560
|
except BaseException as e:
|
|
509
561
|
with contextlib.suppress(ProcessLookupError):
|
|
510
562
|
os.killpg(process.pid, signal.SIGKILL)
|
|
511
|
-
await process
|
|
563
|
+
await _exited(process)
|
|
564
|
+
for reader in readers:
|
|
565
|
+
reader.cancel()
|
|
512
566
|
if isinstance(e, asyncio.TimeoutError):
|
|
513
|
-
return ProxyBashResult(exit_code=None, stdout="", stderr="", timed_out=True)
|
|
567
|
+
return ProxyBashResult(exit_code=None, stdout="", stderr="", timed_out=True), []
|
|
514
568
|
raise
|
|
515
|
-
|
|
516
|
-
|
|
569
|
+
result = ProxyBashResult(exit_code=process.returncode, stdout=stdout.take(), stderr=stderr.take(), timed_out=False)
|
|
570
|
+
return result, [reader for reader in readers if not reader.done()]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def _exited(process: asyncio.subprocess.Process) -> None:
|
|
574
|
+
"""Until the shell exits: ``Process.wait()`` also waits for everything still holding its output."""
|
|
575
|
+
while process.returncode is None:
|
|
576
|
+
await asyncio.sleep(EXIT_POLL_SECONDS)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class _Output:
|
|
580
|
+
"""What a command writes to one stream, kept until it's taken and dropped after."""
|
|
581
|
+
|
|
582
|
+
def __init__(self) -> None:
|
|
583
|
+
self._data: bytearray | None = bytearray()
|
|
584
|
+
|
|
585
|
+
def add(self, chunk: bytes) -> None:
|
|
586
|
+
if self._data is not None:
|
|
587
|
+
self._data.extend(chunk)
|
|
588
|
+
|
|
589
|
+
def take(self) -> str:
|
|
590
|
+
data, self._data = self._data or bytearray(), None
|
|
591
|
+
return data.decode(errors="replace")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
async def _read(stream: asyncio.StreamReader, output: _Output) -> None:
|
|
595
|
+
while chunk := await stream.read(65536):
|
|
596
|
+
output.add(chunk)
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_proxy.py
RENAMED
|
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
5
8
|
import socket
|
|
6
9
|
import subprocess
|
|
7
10
|
import threading
|
|
@@ -13,7 +16,7 @@ import uvicorn
|
|
|
13
16
|
from starlette.testclient import TestClient
|
|
14
17
|
|
|
15
18
|
from agentenv_protocol import EnvironmentCard, ToolContext, ToolSession, create_fastmcp_app, tool
|
|
16
|
-
from agentenv_protocol.a2a_agent import proxy
|
|
19
|
+
from agentenv_protocol.a2a_agent import ProxyActionState, proxy
|
|
17
20
|
from agentenv_protocol.a2a_agent.proxy import ProxyAgent
|
|
18
21
|
|
|
19
22
|
TOKEN = "t" * 32
|
|
@@ -34,17 +37,38 @@ class _Items:
|
|
|
34
37
|
"""Always fails."""
|
|
35
38
|
raise RuntimeError("the tool blew up")
|
|
36
39
|
|
|
40
|
+
@tool()
|
|
41
|
+
def count(self, n: int) -> str:
|
|
42
|
+
"""Echo a number."""
|
|
43
|
+
return str(n)
|
|
44
|
+
|
|
37
45
|
@tool()
|
|
38
46
|
def whoami(self, ctx: ToolContext) -> str:
|
|
39
47
|
"""Who is calling."""
|
|
40
48
|
return json.dumps({"role": ctx.caller.role})
|
|
41
49
|
|
|
42
50
|
|
|
51
|
+
class _Terminal:
|
|
52
|
+
@tool()
|
|
53
|
+
def bash(self, command: str, cwd: str = "/") -> str:
|
|
54
|
+
"""Run a command in the env's own terminal."""
|
|
55
|
+
return f"{cwd}$ {command}"
|
|
56
|
+
|
|
57
|
+
|
|
43
58
|
@pytest.fixture(scope="module")
|
|
44
59
|
def env():
|
|
45
60
|
"""An items env's MCP server on loopback, as a task's env serves one."""
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
yield from _served(_Items(), "items")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture(scope="module")
|
|
65
|
+
def terminal():
|
|
66
|
+
"""An env whose own tool is named ``bash``."""
|
|
67
|
+
yield from _served(_Terminal(), "terminal")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _served(handler: Any, name: str):
|
|
71
|
+
app = create_fastmcp_app(handler, card=EnvironmentCard(name=name)).streamable_http_app()
|
|
48
72
|
sock = socket.socket()
|
|
49
73
|
sock.bind(("127.0.0.1", 0))
|
|
50
74
|
server = uvicorn.Server(uvicorn.Config(app, log_level="warning"))
|
|
@@ -198,6 +222,7 @@ def test_an_error_reply_fails_the_turn(client):
|
|
|
198
222
|
task = _settled(client, task_id)
|
|
199
223
|
assert task["status"]["state"] == "failed"
|
|
200
224
|
assert "the driver gave up" in task["status"]["message"]["parts"][0]["text"]
|
|
225
|
+
assert _trajectory(client, task_id)[-1]["status"] == {"status_code": "ERROR"}
|
|
201
226
|
|
|
202
227
|
|
|
203
228
|
def test_a_reply_names_exactly_one_open_turn_and_carries_parts_or_an_error(client):
|
|
@@ -237,8 +262,15 @@ def test_a_turn_offers_its_env_servers_tools_and_bash(client, env):
|
|
|
237
262
|
|
|
238
263
|
tools = {item["name"]: item for item in _proxy(client, "tools", task_id=task_id).json()["tools"]}
|
|
239
264
|
|
|
240
|
-
assert set(tools) == {"add_item", "explode", "whoami", "bash"}
|
|
265
|
+
assert set(tools) == {"add_item", "count", "explode", "whoami", "bash"}
|
|
241
266
|
assert (tools["add_item"]["server"], "server" in tools["bash"]) == ("items", False)
|
|
267
|
+
assert tools["bash"] == {
|
|
268
|
+
"name": "bash",
|
|
269
|
+
"description": "Run a bash command where the agent works, as its user, in its folder; returns its stdout, "
|
|
270
|
+
"then its stderr.",
|
|
271
|
+
"input_schema": {"type": "object", "properties": {"command": {"type": "string", "description": "The command."}},
|
|
272
|
+
"required": ["command"]},
|
|
273
|
+
}
|
|
242
274
|
assert tools["add_item"]["input_schema"]["properties"]["item"]["type"] == "string"
|
|
243
275
|
|
|
244
276
|
|
|
@@ -283,6 +315,38 @@ def test_a_tool_actions_deadline_covers_connecting_to_its_server(client):
|
|
|
283
315
|
assert time.monotonic() - started < 5
|
|
284
316
|
|
|
285
317
|
|
|
318
|
+
def test_a_server_that_never_lists_its_tools_is_a_bad_gateway(client, monkeypatch):
|
|
319
|
+
monkeypatch.setattr(proxy, "TOOL_TIMEOUT_SECONDS", 0.5)
|
|
320
|
+
with socket.socket() as silent:
|
|
321
|
+
silent.bind(("127.0.0.1", 0))
|
|
322
|
+
silent.listen()
|
|
323
|
+
_register(client, f"http://127.0.0.1:{silent.getsockname()[1]}/mcp")
|
|
324
|
+
task_id = _send(client, "go")
|
|
325
|
+
|
|
326
|
+
response = _proxy(client, "tools", task_id=task_id)
|
|
327
|
+
|
|
328
|
+
assert response.status_code == 502
|
|
329
|
+
assert "didn't list its tools within 0.5s" in response.text
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_a_turns_servers_list_their_tools_within_one_deadline_for_them_all(client, monkeypatch):
|
|
333
|
+
monkeypatch.setattr(proxy, "TOOL_TIMEOUT_SECONDS", 1.0)
|
|
334
|
+
|
|
335
|
+
async def slowly(self, turn, server):
|
|
336
|
+
await asyncio.sleep(0.6)
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
monkeypatch.setattr(proxy.ProxyAgent, "_listed", slowly)
|
|
340
|
+
_register(client, "http://127.0.0.1:9/mcp", name="items")
|
|
341
|
+
_register(client, "http://localhost:9/mcp", name="pantry")
|
|
342
|
+
task_id = _send(client, "go")
|
|
343
|
+
|
|
344
|
+
response = _proxy(client, "tools", task_id=task_id)
|
|
345
|
+
|
|
346
|
+
assert response.status_code == 502
|
|
347
|
+
assert "'pantry' didn't list its tools within 1.0s" in response.text
|
|
348
|
+
|
|
349
|
+
|
|
286
350
|
def test_a_turns_actions_share_one_connection_to_each_env_server_which_closes_with_the_turn(client, env, monkeypatch):
|
|
287
351
|
opened, closed, results = [], [], []
|
|
288
352
|
open_session, close_session = ToolSession.open, ToolSession.close
|
|
@@ -319,9 +383,25 @@ def test_a_bash_action_runs_in_the_proxys_folder_and_returns_a_structured_result
|
|
|
319
383
|
(tmp_path / "inputs").mkdir()
|
|
320
384
|
task_id = _send(client, "go")
|
|
321
385
|
|
|
322
|
-
result = _done(client, task_id, "b1", _bash("ls; echo out; echo err >&2; exit 3"))
|
|
386
|
+
result = _done(client, task_id, "b1", _bash("ls; echo $0; echo out; echo err >&2; exit 3"))
|
|
323
387
|
|
|
324
|
-
assert result == {
|
|
388
|
+
assert result == {
|
|
389
|
+
"kind": "bash", "exit_code": 3, "stdout": "inputs\nbash\nout\n", "stderr": "err\n", "timed_out": False}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_where_there_is_no_bash_the_bash_tool_runs_sh_and_says_so(monkeypatch, tmp_path):
|
|
393
|
+
which = shutil.which
|
|
394
|
+
monkeypatch.setattr(proxy.shutil, "which", lambda name, *args, **kwargs: None if name == "bash" else which(name, *args, **kwargs))
|
|
395
|
+
monkeypatch.chdir(tmp_path)
|
|
396
|
+
with TestClient(ProxyAgent(long_poll_seconds=0.2).create_app()) as client:
|
|
397
|
+
assert client.post("/ext/proxy/claim", json={"token": TOKEN}).status_code == 200
|
|
398
|
+
task_id = _send(client, "go")
|
|
399
|
+
|
|
400
|
+
tools = {item["name"]: item for item in _proxy(client, "tools", task_id=task_id).json()["tools"]}
|
|
401
|
+
result = _done(client, task_id, "s1", _bash("echo $0"))
|
|
402
|
+
|
|
403
|
+
assert "with POSIX sh: this place has no bash" in tools["bash"]["description"]
|
|
404
|
+
assert (result["exit_code"], result["stdout"]) == (0, "sh\n")
|
|
325
405
|
|
|
326
406
|
|
|
327
407
|
def test_an_action_done_within_the_long_poll_answers_at_once_and_a_slow_one_is_polled(client):
|
|
@@ -349,11 +429,70 @@ def test_a_command_that_outlives_its_timeout_is_stopped_with_what_it_started(cli
|
|
|
349
429
|
task_id = _send(client, "go")
|
|
350
430
|
|
|
351
431
|
waited = _done(client, task_id, "w", _bash("sleep 60 & echo $! > waited; wait", timeout_seconds=0.5))
|
|
352
|
-
left = _done(client, task_id, "l", _bash("sleep 60 & echo $! > left", timeout_seconds=0.5))
|
|
353
432
|
|
|
354
|
-
assert waited ==
|
|
433
|
+
assert waited == {"kind": "bash", "stdout": "", "stderr": "", "timed_out": True}
|
|
434
|
+
assert ProxyActionState.model_validate({"done": True, "result": waited}).result.exit_code is None
|
|
355
435
|
_stopped(int((tmp_path / "waited").read_text()))
|
|
356
|
-
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_a_command_is_done_when_its_shell_exits_and_what_it_left_in_the_background_runs_on(client, tmp_path):
|
|
439
|
+
task_id = _send(client, "go")
|
|
440
|
+
|
|
441
|
+
started = time.monotonic()
|
|
442
|
+
left = _done(client, task_id, "l", _bash("sleep 60 & echo $! > left; echo started"))
|
|
443
|
+
|
|
444
|
+
assert left == {"kind": "bash", "exit_code": 0, "stdout": "started\n", "stderr": "", "timed_out": False}
|
|
445
|
+
assert time.monotonic() - started < proxy.OUTPUT_GRACE_SECONDS + 2
|
|
446
|
+
pid = int((tmp_path / "left").read_text())
|
|
447
|
+
assert _state(pid) and not _state(pid).startswith("Z")
|
|
448
|
+
os.kill(pid, signal.SIGKILL)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_what_a_background_process_writes_after_its_command_is_done_never_blocks_it(client, monkeypatch, tmp_path):
|
|
452
|
+
monkeypatch.setattr(proxy, "OUTPUT_GRACE_SECONDS", 0.1)
|
|
453
|
+
task_id = _send(client, "go")
|
|
454
|
+
writes = "import sys, time; time.sleep(0.5); sys.stdout.write('x' * 200_000); sys.stdout.flush(); open('done', 'w')"
|
|
455
|
+
|
|
456
|
+
result = _done(client, task_id, "b", _bash(f"python3 -c \"{writes}\" & echo started"))
|
|
457
|
+
|
|
458
|
+
assert (result["exit_code"], result["stdout"]) == (0, "started\n")
|
|
459
|
+
deadline = time.monotonic() + 10
|
|
460
|
+
while not (tmp_path / "done").exists():
|
|
461
|
+
assert time.monotonic() < deadline, "the background process blocked on its output"
|
|
462
|
+
time.sleep(0.05)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_a_command_too_long_to_start_fails_as_a_shell_reports_it(client):
|
|
466
|
+
task_id = _send(client, "go")
|
|
467
|
+
|
|
468
|
+
result = _done(client, task_id, "huge", _bash("true " + "x" * 3_000_000))
|
|
469
|
+
|
|
470
|
+
assert (result["exit_code"], result["timed_out"], result["stdout"]) == (126, False, "")
|
|
471
|
+
assert "couldn't start" in result["stderr"]
|
|
472
|
+
_proxy(client, "reply", task_id=task_id, parts=[{"kind": "text", "text": "done"}])
|
|
473
|
+
_settled(client, task_id)
|
|
474
|
+
[action, _] = _trajectory(client, task_id)
|
|
475
|
+
assert (action["name"], action["status"]) == ("bash", {"status_code": "ERROR"})
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_an_action_polled_while_its_turn_closes_is_answered_404(monkeypatch, tmp_path):
|
|
479
|
+
monkeypatch.chdir(tmp_path)
|
|
480
|
+
agent = ProxyAgent(long_poll_seconds=10)
|
|
481
|
+
with TestClient(agent.create_app()) as client:
|
|
482
|
+
client.post("/ext/proxy/claim", json={"token": TOKEN})
|
|
483
|
+
task_id = _send(client, "go")
|
|
484
|
+
polled = {}
|
|
485
|
+
poll = threading.Thread(target=lambda: polled.update(response=_proxy(
|
|
486
|
+
client, "act", task_id=task_id, action_id="long", action=_bash("sleep 30"))))
|
|
487
|
+
poll.start()
|
|
488
|
+
while "long" not in agent._actions:
|
|
489
|
+
time.sleep(0.01)
|
|
490
|
+
|
|
491
|
+
_proxy(client, "reply", task_id=task_id, parts=[{"kind": "text", "text": "done"}])
|
|
492
|
+
poll.join(10)
|
|
493
|
+
|
|
494
|
+
assert polled["response"].status_code == 404
|
|
495
|
+
assert "'long' was stopped: its turn is over" in polled["response"].text
|
|
357
496
|
|
|
358
497
|
|
|
359
498
|
def test_a_turn_that_closes_stops_its_running_actions_and_forgets_them(client, tmp_path):
|
|
@@ -369,6 +508,19 @@ def test_a_turn_that_closes_stops_its_running_actions_and_forgets_them(client, t
|
|
|
369
508
|
assert _proxy(client, "act", task_id=task_id, action_id="new", action=_bash("true")).status_code == 404
|
|
370
509
|
|
|
371
510
|
|
|
511
|
+
def test_a_turn_that_closes_while_a_done_commands_output_is_awaited_stops_what_it_left(client, monkeypatch, tmp_path):
|
|
512
|
+
monkeypatch.setattr(proxy, "OUTPUT_GRACE_SECONDS", 30)
|
|
513
|
+
task_id = _send(client, "go")
|
|
514
|
+
assert _proxy(client, "act", task_id=task_id, action_id="left",
|
|
515
|
+
action=_bash("sleep 60 & echo $! > child")).json()["done"] is False
|
|
516
|
+
|
|
517
|
+
_proxy(client, "reply", task_id=task_id, parts=[{"kind": "text", "text": "done"}])
|
|
518
|
+
_settled(client, task_id)
|
|
519
|
+
|
|
520
|
+
_stopped(int((tmp_path / "child").read_text()))
|
|
521
|
+
assert _proxy(client, "result", action_id="left").status_code == 404
|
|
522
|
+
|
|
523
|
+
|
|
372
524
|
def test_a_proxy_without_a_shell_offers_no_bash_and_keeps_no_skills():
|
|
373
525
|
with TestClient(ProxyAgent(shell=False, long_poll_seconds=0.2).create_app()) as client:
|
|
374
526
|
client.post("/ext/proxy/claim", json={"token": TOKEN})
|
|
@@ -386,8 +538,10 @@ def test_recorded_model_calls_are_checked_against_the_actions_that_run(client, e
|
|
|
386
538
|
message = {"role": "assistant", "content": "Adding it.", "reasoning_content": "The user wants a crate.",
|
|
387
539
|
"tool_calls": [
|
|
388
540
|
{"id": "c1", "type": "function", "function": {"name": "add_item", "arguments": '{"item": "crate"}'}},
|
|
389
|
-
{"id": "c2", "type": "function", "function": {"name": "bash",
|
|
390
|
-
|
|
541
|
+
{"id": "c2", "type": "function", "function": {"name": "bash",
|
|
542
|
+
"arguments": '{"command": "true", "timeout": 30}'}},
|
|
543
|
+
{"id": "c3", "type": "function", "function": {"name": "my_own_tool", "arguments": "{}"}},
|
|
544
|
+
{"id": "c4", "type": "function", "function": {"name": "count", "arguments": '{"n": 2.0}'}}]}
|
|
391
545
|
|
|
392
546
|
assert _proxy(client, "record", task_id=task_id, message=message, model="qwen",
|
|
393
547
|
usage={"prompt_tokens": 12, "completion_tokens": 7}).status_code == 200
|
|
@@ -398,22 +552,45 @@ def test_recorded_model_calls_are_checked_against_the_actions_that_run(client, e
|
|
|
398
552
|
assert _proxy(client, "reply", task_id=task_id, parts=[{"kind": "text", "text": "x"}]).status_code == 409
|
|
399
553
|
_done(client, task_id, "b", _bash("true"))
|
|
400
554
|
_done(client, task_id, "a", {"kind": "tool", "server": "items", "name": "add_item", "arguments": {"item": "crate"}})
|
|
555
|
+
_done(client, task_id, "n", {"kind": "tool", "server": "items", "name": "count", "arguments": {"n": 2}})
|
|
401
556
|
assert _proxy(client, "record", task_id=task_id, message="Done.").status_code == 200
|
|
402
557
|
_proxy(client, "reply", task_id=task_id, parts=[{"kind": "text", "text": "Done."}])
|
|
403
558
|
|
|
404
559
|
task = _settled(client, task_id)
|
|
405
560
|
spans = _trajectory(client, task_id)
|
|
406
561
|
assert [(span["name"], span["attributes"]["gen_ai.operation.name"]) for span in spans] == [
|
|
407
|
-
("chat", "chat"), ("bash", "execute_tool"), ("add_item", "execute_tool"), ("
|
|
562
|
+
("chat", "chat"), ("bash", "execute_tool"), ("add_item", "execute_tool"), ("count", "execute_tool"),
|
|
563
|
+
("chat", "chat"), ("turn", "chain")]
|
|
564
|
+
assert {span["status"]["status_code"] for span in spans} == {"OK"}
|
|
408
565
|
assert _attrs(spans[0], "gen_ai.completion")["content"] == [
|
|
409
566
|
{"type": "thinking", "thinking": "The user wants a crate."}, {"type": "text", "text": "Adding it."},
|
|
410
567
|
{"type": "tool_use", "id": "c1", "name": "add_item", "input": {"item": "crate"}},
|
|
411
|
-
{"type": "tool_use", "id": "c2", "name": "bash", "input": {"command": "true"}},
|
|
412
|
-
{"type": "tool_use", "id": "c3", "name": "my_own_tool", "input": {}}
|
|
568
|
+
{"type": "tool_use", "id": "c2", "name": "bash", "input": {"command": "true", "timeout": 30}},
|
|
569
|
+
{"type": "tool_use", "id": "c3", "name": "my_own_tool", "input": {}},
|
|
570
|
+
{"type": "tool_use", "id": "c4", "name": "count", "input": {"n": 2.0}}]
|
|
413
571
|
assert (spans[0]["attributes"]["gen_ai.request.model"], spans[0]["attributes"]["gen_ai.usage.input_tokens"],
|
|
414
572
|
spans[0]["attributes"]["gen_ai.usage.output_tokens"]) == ("qwen", 12, 7)
|
|
415
573
|
assert _attrs(spans[2], "gen_ai.prompt") == {"input": {"item": "crate"}}
|
|
416
|
-
assert task["status"]["message"]["parts"][1]["data"]["usage"]["tool_call_count"] ==
|
|
574
|
+
assert task["status"]["message"]["parts"][1]["data"]["usage"]["tool_call_count"] == 3
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_an_env_tool_named_bash_is_held_to_all_its_recorded_arguments(terminal, monkeypatch, tmp_path):
|
|
578
|
+
monkeypatch.chdir(tmp_path)
|
|
579
|
+
with TestClient(ProxyAgent(shell=False, long_poll_seconds=0.2).create_app()) as client:
|
|
580
|
+
client.post("/ext/proxy/claim", json={"token": TOKEN})
|
|
581
|
+
_register(client, terminal[1], name="terminal")
|
|
582
|
+
task_id = _send(client, "go")
|
|
583
|
+
message = {"role": "assistant", "content": None, "tool_calls": [{"id": "c1", "type": "function", "function": {
|
|
584
|
+
"name": "bash", "arguments": '{"command": "ls", "cwd": "/a"}'}}]}
|
|
585
|
+
|
|
586
|
+
assert _proxy(client, "record", task_id=task_id, message=message).status_code == 200
|
|
587
|
+
moved = _proxy(client, "act", task_id=task_id, action_id="b", action={
|
|
588
|
+
"kind": "tool", "server": "terminal", "name": "bash", "arguments": {"command": "ls", "cwd": "/b"}})
|
|
589
|
+
ran = _done(client, task_id, "a", {"kind": "tool", "server": "terminal", "name": "bash",
|
|
590
|
+
"arguments": {"command": "ls", "cwd": "/a"}})
|
|
591
|
+
|
|
592
|
+
assert moved.status_code == 409
|
|
593
|
+
assert ran["content"][0]["text"] == "/a$ ls"
|
|
417
594
|
|
|
418
595
|
|
|
419
596
|
def test_two_model_calls_recorded_at_once_are_held_one_after_the_other(client, monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/THIRD_PARTY_NOTICES.md
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/basic_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_intake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_manifest.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_preflight.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|