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.
Files changed (51) hide show
  1. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/PKG-INFO +18 -9
  2. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/README.md +17 -8
  3. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/extensions.py +1 -1
  5. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/proxy.py +125 -45
  6. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_proxy.py +192 -15
  7. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/.gitignore +0 -0
  8. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/LICENSE +0 -0
  9. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/NOTICE +0 -0
  10. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/THIRD_PARTY_NOTICES.md +0 -0
  11. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/__init__.py +0 -0
  12. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/advanced_agent.py +0 -0
  13. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/basic_agent.py +0 -0
  14. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/custom_extensions_agent.py +0 -0
  15. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/multimodal_agent.py +0 -0
  16. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/reference_model.py +0 -0
  17. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/examples/streaming_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/__init__.py +0 -0
  19. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  20. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  21. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  22. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  23. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  24. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  25. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  26. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  27. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  28. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/agent_env_environment.py +0 -0
  29. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/client.py +0 -0
  30. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/manifest.py +0 -0
  31. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/preflight.py +0 -0
  32. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/tool_client.py +0 -0
  33. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/tool_context.py +0 -0
  34. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/transfers.py +0 -0
  35. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/src/agentenv_protocol/types.py +0 -0
  36. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_agent.py +0 -0
  37. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_agent_examples.py +0 -0
  38. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_object_transfers.py +0 -0
  39. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_peer_agents.py +0 -0
  40. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_staging.py +0 -0
  41. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_a2a_trajectory_log.py +0 -0
  42. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_agent_env_environment.py +0 -0
  43. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_client.py +0 -0
  44. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_data_objects.py +0 -0
  45. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_intake.py +0 -0
  46. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_manifest.py +0 -0
  47. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_preflight.py +0 -0
  48. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_client.py +0 -0
  49. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_context.py +0 -0
  50. {agentenv_framework_protocol-0.1.372 → agentenv_framework_protocol-0.1.373}/tests/test_tool_context_fastmcp.py +0 -0
  51. {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.372
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; either takes
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 started. It answers
769
- `{"done", "result"}` at once when the action ends within the long-poll; `result`
770
- (`{"token", "action_id"}`) long-polls one that doesn't. A `bash` result is
771
- `{"exit_code", "stdout", "stderr", "timed_out"}`, a tool's `{"content", "is_error"}`.
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, and all of them must run before the next `record` or the `reply`, else 409.
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. Skills it is given
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; either takes
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 started. It answers
723
- `{"done", "result"}` at once when the action ends within the long-poll; `result`
724
- (`{"token", "action_id"}`) long-polls one that doesn't. A `bash` result is
725
- `{"exit_code", "stdout", "stderr", "timed_out"}`, a tool's `{"content", "is_error"}`.
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, and all of them must run before the next `record` or the `reply`, else 409.
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. Skills it is given
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.372"
3
+ version = "0.1.373"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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, _canonical(action.arguments)) if isinstance(action, ProxyToolAction) else (
212
- BASH, _canonical({"command": action.command}))
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, _canonical(arguments)) for name, arguments in calls if name in offered]
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: each registered env server's tools, and ``bash``. Two offering one
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: BASH_TOOL} if self._shell else {}
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), TOOL_TIMEOUT_SECONDS)
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
- output = (f"error: the command timed out after {timeout:g}s" if result.timed_out
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
- try:
340
- called = await asyncio.wait_for(self._called(turn, action), timeout)
341
- result = ProxyToolResult(content=called.content, is_error=called.isError)
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 _state(self, task: asyncio.Task[ProxyBashResult | ProxyToolResult]) -> ProxyActionState:
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 _canonical(arguments: dict[str, Any]) -> str:
475
- return json.dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
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(), "attributes": attributes}
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, stopped with whatever it started if it isn't done within
501
- ``timeout``, or if the call is cancelled, even when ``bash`` has exited and a child it left holds the output open."""
502
- process = await asyncio.create_subprocess_exec(
503
- "bash", "-c", command, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE,
504
- stderr=asyncio.subprocess.PIPE, start_new_session=True,
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
- stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
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.wait()
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
- return ProxyBashResult(exit_code=process.returncode, stdout=stdout.decode(errors="replace"),
516
- stderr=stderr.decode(errors="replace"), timed_out=False)
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)
@@ -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
- handler = _Items()
47
- app = create_fastmcp_app(handler, card=EnvironmentCard(name="items")).streamable_http_app()
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 == {"kind": "bash", "exit_code": 3, "stdout": "inputs\nout\n", "stderr": "err\n", "timed_out": False}
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 == left == {"kind": "bash", "stdout": "", "stderr": "", "timed_out": True}
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
- _stopped(int((tmp_path / "left").read_text()))
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", "arguments": '{"command": "true"}'}},
390
- {"id": "c3", "type": "function", "function": {"name": "my_own_tool", "arguments": "{}"}}]}
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"), ("chat", "chat"), ("turn", "chain")]
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"] == 2
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):