Repository navigation
feat(agent-server): add POST /conversations/{id}/execute_tool - #6099
analisaperlengkapan wants to merge 14 commits into
Conversation
|
📁 PR Artifacts Notice This PR contains a |
Fieldnote-Echo
left a comment
There was a problem hiding this comment.
Thanks for reviving this. The persistent-terminal path solves a real gap. The authentication and observation-masking paths also look correct. I did not find an authentication bypass.
I found four issues that need correction before this is ready:
- The request validates
actionagainst the globalActionhierarchy, not the tool selected bytool_name. A validGrepActionwithtool_name="terminal"reachedTerminalExecutorand returned 500. The same annotation renames 38 existing OpenAPI action components. - The direct tool worker has no complete lifecycle owner or capacity admission. Delete, idle eviction, server shutdown, an agent run, or another direct call can overlap it. Request cancellation does not stop the synchronous worker.
- Terminal setup state is local to one tmux pane. A later parallel terminal command can use a new pane without that state.
- The generic route does not work for all named tools.
ClientToolreports that it was dispatched, but noActionEventexists to dispatch it. The same path skips tool hooks and conversation audit events.
The focused route, remote-client, and live-server tests pass. They do not cover these boundaries. GitHub has not run the full test workflow on this draft head.
Thank you for review @Fieldnote-Echo, @OpenHands fix it |
|
I'm on it! analisaperlengkapan can track my progress at all-hands.dev |
|
: conversation, tool_name) And in _pinned: PooledTmuxTerminal | None
def pin(self, terminal) -> None:
with self._lock:
if self._closed or terminal not in self._all_panes:
return
self._pinned = terminal
@property
def is_pinned(self) -> bool: return self._pinned is not None
def checkout(self, timeout=None):
if self._pinned is not None:
# All work must run in the pinned pane; wait for it if busy.
if timeout is None: self._semaphore.acquire()
elif not self._semaphore.acquire(timeout=timeout): raise TimeoutError(...)
return self._pinned
...existing...Hmm, the semaphore accounting: when pinned, So when pinned, the semaphore must be sized 1. Simplest: when pinning, drain/replace the semaphore with a fresh Alternative: track pinned availability with a dedicated lock/condition rather than the semaphore. Let me simplify the pinned path with its own _pinned_lock: threading.Lock # a plain mutex held while the pinned pane is in use
Hmm, but a pane checked out before pinning and checked in after — it should release the semaphore (it acquired it). And the pinned pane: if it was checked out before pinning, its checkin should release the semaphore (it acquired it). Ugh, tracking which mechanism a checkout used. Simplify: store on the terminal or in a dict which mechanism was used. Or: make This is getting messy. Let me reconsider. Much simpler approach to (B): instead of pinning within the pool, make the direct setup call and all subsequent calls use a single dedicated pane by replacing the pool with a single-pane pool — i.e., when pinning, call Simplest of all: Give Hmm, let me reconsider. What if pinning is implemented at the def _execute_pinned(self, action, conversation):
with self._pinned_lock:
session = self._wrap_session(self._pinned_terminal)
self._prepare_pooled_session(session)
...
observation = session.execute(cmd_action)
return self._mask_observation(observation, conversation)And But wait: But: the pinned pane could be if action.reset or self._pinned_terminal._closed:
self._discard_session(self._pinned_terminal)
self._pinned_terminal = self._pool.replace(self._pinned_terminal) # but replace expects a checked-out paneHmm, Hmm, this is doable but it's a fair amount of new code in the terminal tool, plus tests. Hold on. Let me reconsider the problem statement once more. The reviewer's repro: "With max_panes=2, I exported SETUP_MARK in one pane, kept that pane busy, and forced a second pane." "kept that pane busy" — how? By having a parallel command occupying it. In the real setup flow, would there be a parallel command? The setup runs pre-run, then the agent runs. The agent's tool calls within one LLM response can be parallel. So yes, plausible. Given the complexity, maybe the right answer for this PR is actually (A) but implemented at the tmux-session level in a robust way, OR (B) via a simpler route: have the direct execute_tool path run the setup on ALL panes? No, panes are created lazily. Hmm, what about: make the pool share state by having every pane source a state file? No. OK, let me reconsider (A) with a robust capture mechanism that avoids parsing Bash has Actually! There's a neat trick: after running the setup command in pane A, we can ask bash to dump the environment in a NUL-delimited format that's trivially parseable and preserves newlines: for n in $(compgen -e); do printf '%s=%s\0' "$n" "${!n}"; doneHmm, But this dumps ALL exported vars (including secrets like SESSION_API_KEY? no — But: it's a big, subtle feature. And it changes behavior for every pane creation. And existing live panes still wouldn't have it (the reviewer's repro: pane B is created after setup, so it WOULD get it — good; but a pane already live before setup wouldn't). The reviewer's repro creates pane B after setup, so (A) handles it. Hmm, (A) is a general improvement but larger and riskier. Let me weigh: the reviewer explicitly offered both. (B) pinning is smaller in conceptual surface but requires careful pool/executor changes. (A) env propagation requires shell-level parsing but is a cleaner "make it work for every future pane" story. Given the risk of shell parsing bugs and the reviewer's explicit sanction, I'll implement (B) pinning — but let me implement it as simply as possible. Simplest (B) implementation: Add to Actually, here's a cleaner variant: implement pinning entirely in the pool by resizing the semaphore accounting to the pinned pane, using a Hmm, let me reconsider implementing pinning at the executor level with a dedicated single-session terminal (not a pool pane). I.e., when pinning, we stop using the pool and use a single Let me just carefully implement State:
Methods:
And the server calls Hmm, OK. But wait: is there a better trigger? The whole point is that Alright, the server calling a duck-typed method is acceptable if I make it explicit and typed. But the server would do: tool = conversation.agent.tools_map.get(tool_name)
executor = tool.executor
pin = getattr(executor, "pin_session", None)
if callable(pin): pin()Hmm, duck typing in the server for a known tool ("terminal") is fine since the server already imports terminal constants. I can do an explicit isinstance check: from openhands.tools.terminal.impl import TerminalExecutor
if isinstance(executor, TerminalExecutor): executor.pin_session()The server already imports from Hmm, wait. But should pinning happen on EVERY direct terminal call, or only when the call is a "setup" call? If a user calls execute_tool for an unrelated terminal command (e.g., Hmm. That's a real concern. Pinning permanently changes terminal concurrency semantics based on an unrelated call. Ugh. OK, this makes me lean back toward (A): propagate the environment so panes stay independent (no semantic change to concurrency), which is a strictly-additive improvement. Let me reconsider (A) implementation difficulty honestly. What we need: after the direct setup command runs in pane A, any pane created later must have the same exported variables. Implementation in How to get the vars from pane A? In But the "direct call" trigger is still needed (we don't want to capture after every command — expensive). And the trigger is exactly the server's direct path. Hmm, but actually: do we even need the trigger? What if we capture-and-propagate after EVERY pooled command? Cost: one extra command per terminal call ( Alternatively, propagate only when the command looks like it might export (contains So we still need the "this was an out-of-band setup call" signal. Both (A) and (B) need that signal. Given that, the signal should be explicit and principled. Where should it live? The most principled place: Solution: introduce a generic, documented hook on the ToolExecutor protocol: e.g. Hmm, but adding a Protocol method is an API change. It's additive with a default no-op, so it's fine. Hmm, but is it over-engineering? Let me think about naming: Hmm, let me reconsider — maybe simpler: since OK you know, I've been going back and forth. Let me make a decision and commit: Decision for Fix 3: (A) propagate the pane's exported environment to the tmux session environment, so every pane created afterwards inherits it — triggered by an explicit hook invoked only for direct (out-of-band) executions. Wait, but "make setup state available to every future pane" — with (A), panes created later inherit. What about the cwd and shell functions/aliases? So (A) is incomplete for the reviewer's full list! Only (B) pinning truly addresses all of it ("pin setup-dependent terminal work to one initialized pane"). OK. (B) it is. Pinning addresses exports, functions, aliases, and cwd — everything. And the reviewer explicitly offered it. Now, the "unrelated call pins the pool" concern: I can make pinning conditional on the call being a setup call. But how do we know? Hmm — maybe it's fine to pin after any direct execute_tool: direct execute_tool is documented as a pre-run setup mechanism, not a general command channel. If someone uses it for Hmm, but I could make it even safer: only pin when the direct call actually could have changed shell state — i.e., always for terminal. Fine, pin always after a direct terminal call. Hmm, wait. Actually, hold on. Let me reconsider the semantics of pinning more carefully with respect to the pool's semaphore, because I want this to be simple and correct. Proposal: implement pinning inside _pinned: PooledTmuxTerminal | None = None
_pinned_in_use: bool = False # guarded by _lock
_pinned_cv: threading.Condition # notifies when the pinned pane is returned
with self._lock:
if self._pinned is not None:
# wait until the pinned pane is free
deadline = None if timeout is None else time.monotonic() + timeout
while self._pinned_in_use:
remaining = None if deadline is None else deadline - time.monotonic()
if remaining is not None and remaining <= 0:
raise TimeoutError(...)
if not self._pinned |
|
Addressed all four findings in fa28a63. Summary of what changed and why:
Verification on this head:
Not marked ready for review: the PR template's This comment was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan. |
⏳ Review waiting on checksThe current head
No action is needed. The scheduled scan retries once every check on the head reports a conclusion. This is an automated check - no AI was used to generate this comment. |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Scope: this is the Agent Server REST surface, which this repository owns, and it follows the SDK/Agent Server -> OpenAPI -> clients/typescript flow. No cross-repo move needed.
I reviewed the current head e761e5a3 (diff from main), read the linked issue #6094 (enhancement, priority:medium, ready-for-dev), and ran the focused suite (tests/agent_server/test_conversation_router.py, test_event_service.py, tests/sdk/conversation/remote/test_remote_conversation.py, tests/sdk/conversation/local/test_execute_tool.py, tests/tools/terminal/test_tmux_pane_pool.py): 350 passed. Head checks for e761e5a3 are green (Validate PR description, check-pr-artifacts).
One material finding, verified end to end against a live agent-server.
ConversationBusyError is not mapped to 409 on the run/goal routes (returns 500)
The new ConversationBusyError is a RuntimeError (event_service.py:106), and it is raised on the busy paths the PR describes: run() (:1556), start_goal_loop() (:1813), and execute_tool() (:2450, :2456). Only the /execute_tool route catches it (conversation_router.py:819 -> 409). POST /run and POST /goal catch only ValueError (conversation_router.py:434, :474), which ConversationBusyError is not, and api.py registers no ConversationBusyError handler (only ConversationRunLimitExceeded, ConversationOwnershipLostError, CredentialBindingActivationRequired). The generic Exception handler then turns it into a 500.
This contradicts the route's own declaration (/run and /goal both advertise 409: "Conversation is already running") and the PR body ("run()/start_goal_loop() while a direct call is in flight is 409").
Reproduced on this head against a real agent-server: with a direct call in flight, POST /api/conversations/{id}/run returns 500 {"detail":"Internal Server Error","exception":"A direct tool execution is in flight; retry when it finishes"}, not 409. A plain client can also hit this: send_message has no _direct_tool_in_flight check, so POST /events with run=true reaches run() and fails the same way.
Reachability note: a normal concurrent /run is usually serialized behind the direct call by the per-conversation lifecycle, so the flag is already clear by the time run() executes. But the abandoned-worker state the PR documents (a cancelled request whose worker still holds the executor) leaves the flag set with the lifecycle released, so the guard does fire in practice. A ConversationBusyError handler in api.py (mapped to 409) or catching it in the run/goal routes fixes it consistently.
Secondary: resume_goal_loop omits the direct-call guard
resume_goal_loop (event_service.py:2027) carries the comment "Same busy guard as start_goal_loop" but references _direct_tool_in_flight zero times, while start_goal_loop does (:1813). So POST /goal/resume can start a goal loop underneath an in-flight direct call, which is the overlap this PR set out to prevent. I could not force a clean end-to-end repro (no resumable goal existed in my probe), so this is a code-level gap rather than a demonstrated failure; worth aligning with start_goal_loop.
Everything else in the change reads as intended: tool-scoped action validation via the resolved action_type (422, not 500), the None -> 404 path, the run_in_executor offload, the close() interrupt-and-drain of a running worker, and pane pinning. Once the busy-error status mapping is fixed, this is in good shape.
🔄 CHANGES REQUESTED
Run one tool through a conversation's own executor so state that lives there (the terminal session) survives into the agent's later calls. This is the missing piece a caller (app-server, Agent Canvas) needs to run a workspace bootstrap script such as .openhands/setup.sh so its export/source reaches the agent's terminal, which a fresh bash subprocess cannot do. - agent-server: ExecuteToolRequest/Response models, route, service and EventService delegation via run_in_executor (non-blocking), 404/422 mapping. - SDK: RemoteConversation.execute_tool posts and deserializes the observation instead of raising NotImplementedError. - Feature map: F10 execute-tool-* bullets, F33.conversation-execute-tool. - OpenAPI weak-schema allowlist: follow the Action union's -Input/-Output split. Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Address the four review findings on POST /conversations/{id}/execute_tool:
- Validate the action against the tool named by `tool_name`, not the global
`Action` union, so a payload for another tool (e.g. a `GrepAction` with
`tool_name="terminal"`) is a 422 instead of a 500 out of the executor.
`ExecuteToolRequest.action` becomes a plain JSON object; only the tools in
`DIRECT_EXECUTION_TOOLS` (`terminal`) are accepted. Typing the field as the
union had also split every action schema into `-Input`/`-Output` twins in the
public contract; the weak-schema allowlist is now upstream/main plus one
entry for the new `additionalProperties` location.
- Give the direct worker a complete lifecycle owner: the per-conversation
lifecycle and an in-flight flag, released on success, on a rejected action,
and, for a cancelled request whose worker already started, only when that
worker exits. `run()`/`start_goal_loop()` and idle eviction respect the flag,
and `close()` abandons the worker via a generation bump.
- Pin the terminal pane pool to the pane that ran the direct call, so shell
state a bootstrap script establishes stays reachable from later commands
instead of being stranded behind a fresh pane.
- Keep the generic route off the tools it cannot serve correctly (ClientTool
reports a dispatch with no ActionEvent; tool hooks and audit events are
skipped), by restricting direct execution to the allowlist above.
Co-authored-by: openhands <openhands@all-hands.dev>
… claim Follow-up to the execute_tool hardening on the remote tip. Two of the review findings were still open there: - TypeScript parity: the new route had no counterpart in ConversationClient or RemoteConversation, so a browser client could not source a setup script through its typed conversation API. Both now expose executeTool, with ExecuteToolRequest/ExecuteToolResponse/ToolAction exported from the package root. The method is gated on a new executeTool feature requirement whose floor (1.57.0) is the next unreleased minor, so a server without the endpoint is rejected before the call can 404. The endpoint-audit config records the client-ahead route and the public weak-type budget accounts for the new observation index signature. - Cold-start event claim: the route docstrings, the EventService docstring and the design doc all said the call "appends no event". On a conversation that has never run, initializing the agent emits the SystemPromptEvent, so the claim only holds once a message has initialized the agent. The docs now say so, and test_execute_tool_cold_start_emits_system_prompt_event pins it. Co-authored-by: openhands <openhands@all-hands.dev>
…ecutors close() abandoned the direct tool worker with a generation bump, which only stops a worker that has not reached the executor yet. A worker already inside LocalConversation.execute_tool kept running while teardown closed the conversation's executors under it, so a setup command lost its terminal mid-run or kept writing after the conversation was deleted. Track the worker future and, in close(), interrupt the running command (Ctrl+C for the terminal, the same signal conversation.interrupt() uses) and await the worker before the executor teardown. A 10s bound keeps close() from hanging on a wedged command; the abandon bump still covers workers that have not reached the executor. test_close_interrupts_and_drains_a_running_direct_call fails on the previous behaviour: the worker's interrupt is never delivered and close() returns before the command finishes. Co-authored-by: openhands <openhands@all-hands.dev>
…he guard Both tests stubbed `_resolve_direct_tool` and blocked there, which is before the worker exists, so cancellation took the `worker is None` path and the tests never reached the stale-generation guard. They also read `_resolve_direct_tool_original` from the test class while writing it on the service, which raised AttributeError inside the worker instead of calling the original method (a pyright error, and the reason they passed). Hold the worker in `_run_direct_tool_sync` instead — after it exists, before it reads the generation — so the abandon bump lands first. Removing the generation guard now fails test_cancelled_direct_call_does_not_run_the_tool. Also assert the conversation is released, and drop the docstring claim that close() relies on the generation bump (the `_closing` check is what stops a released worker there). Co-authored-by: openhands <openhands@all-hands.dev>
…rrupt on cancel
Address the second review batch on POST /conversations/{id}/execute_tool:
- Route the busy conflict to HTTP 409. `run_conversation`,
`start_goal_in_conversation` and `resume_goal_in_conversation` now catch
`ConversationBusyError` (a `RuntimeError`, so the existing `ValueError`
handler missed it) and answer 409 instead of 500 while a direct call is in
flight; `start_goal_loop()` also refuses a run admitted underneath one.
- Serialize admission under `_run_lock`. The direct path's execution-status
read awaits a worker and releases the event loop, so without a shared lock a
run could be admitted during that window and both would then use the
executor. Admission now takes `_run_lock` (as run()/start_goal_loop() do) and
rechecks run state and closing while holding it.
- Interrupt the running command when the request is cancelled. The cancel
cannot stop the synchronous worker, so the executor is interrupted (the same
Ctrl+C the terminal uses) before the handler returns; close()/delete still
owns the worker and drains it. The executor handle is tracked so the
interrupt has a target, and cleared on every release path.
- Validate the declared `kind` against the selected tool. `TerminalAction`
derives from `Action`, so a foreign payload can carry fields that happen to
validate; the explicit check makes a `GrepAction` sent to `terminal` a 422
instead of a 500 out of the executor (and holds under `python -O`, where the
discriminator assertion is stripped).
- Gate the TypeScript client on a capability, not a version floor. The route
ships in the same package version as servers that predate it (this
checkout's ServerInfo reports 1.55.1), so a `1.57.0` compare rejected a
working endpoint. `/server_info` now advertises `direct_tool_execution_v1`
and `assertAgentServerSupports` treats that capability as authoritative for
`executeTool`; the error names the capability.
Tests: router 409 cases, admission serialization, foreign-kind rejection,
cancel/close drain, capability advertisement, and the TS success/rejection
paths. Feature map gains `F10.execute-tool-teardown-owner` and
`F10.execute-tool-capability` (947 sub-features).
Co-authored-by: openhands <openhands@all-hands.dev>
The busy conflict is raised by run(), start_goal_loop(), resume_goal_loop() and execute_tool(), but only the /execute_tool route caught it. Because ConversationBusyError subclasses RuntimeError, the /run and /goal routes' `except ValueError` missed it and the generic handler answered 500 — and `POST /events` with `run=true` reaches run() the same way. Register a ConversationBusyError handler in api.py that answers 409, matching how ConversationRunLimitExceeded is mapped to 429, and drop the now-redundant per-route catches. The declared 409 responses on /run, /goal and /goal/resume are now produced for every entry point, including /events with run=true. Tests: the router and event-router fixtures install the production handlers so the mapping is exercised as it ships; a new /events run=true case covers the entry point the per-route catch missed. Co-authored-by: openhands <openhands@all-hands.dev>
9b62c9b to
6d1fa2c
Compare
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Re-reviewed at head 6d1fa2c3. Scope is unchanged: this is the Agent Server REST surface, which this repository owns, following the SDK/Agent Server -> OpenAPI -> clients/typescript flow. Linked issue #6094 is enhancement, priority:medium, ready-for-dev.
Both findings from my earlier review are resolved at this head, verified against a live agent-server.
ConversationBusyErrornot mapped to 409. The prior head caught it only in the/execute_toolroute, so/run,/goal, andPOST /eventswithrun=trueanswered 500 in the busy state. Commit6d1fa2c3registers a central handler inapi.py(_busy_handler) and drops the redundant per-route catches. I reproduced the PR's own abandoned-worker state (cancel the direct-call request while its synchronous worker still holds the executor, so the flag stays set and the lifecycle is released) and confirmed all three entry points now answer409 {"detail":"A direct tool execution is in flight; retry when it finishes"}.resume_goal_loopmissing the direct-call guard. It now carries the same_direct_tool_in_flightcheck asstart_goal_loopandrun, and the router/event-router fixtures install the production handlers so the mapping is exercised as it ships.
Verification. Focused suite (tests/agent_server/test_conversation_router.py, test_event_service.py, test_event_router.py, tests/sdk/conversation/remote/test_remote_conversation.py, tests/sdk/conversation/local/test_execute_tool.py, tests/tools/terminal/test_tmux_pane_pool.py, test_pool_integration.py): 385 passed. ruff check and ruff format --check clean on the changed modules. Live-server -k execute_tool: 4 passed; the fifth (requires_auth_and_masks_secrets) fails only because this environment exports an ambient OH_RUNTIME_LAUNCHED_PROFILE, and passes once that variable is unset, so it is not a PR defect.
CI. This is a fork PR, so the workflow runs for 6d1fa2c3 are action_required (awaiting maintainer approval to run); PR Description Check and PR Artifacts completed successfully.
Non-blocking, documentation accuracy. The feature map added here states twice that a bare action {"command": ...} without kind is 422 (F10-fork-navigate-condense-ask.md lines 92-93 and 761-762). Against a live server at this head it is accepted: POST /execute_tool with {"tool_name":"terminal","action":{"command":"echo no-kind"}} returns 200 and runs the command. _validate_direct_action rejects a kind only when it is present and mismatched, and TerminalAction.model_validate succeeds without it. The behavior itself is safe (the tool is restricted to the allowlist and the payload still validates against that tool's action type), so this is a doc correction rather than a code defect: either drop the claim or reject a missing kind. Flagged inline.
Everything else reads as intended: tool-scoped action validation (422, not 500), the None -> 404 path, the run_in_executor offload, pane pinning with the pinned-pane wait, and the cancel/close() teardown ownership of a running worker.
✅ APPROVED
Cancelling an execute_tool request called executor.interrupt() inline on the FastAPI event loop. The PowerShell backend's interrupt performs two blocking child-process scans (each able to wait five seconds for a subprocess), so a cancelled call stalled every other request on the loop -- /alive included -- for over ten seconds on Windows. Offload the interrupt to a worker thread (run_in_executor) and retain the future in _direct_tool_interrupt_tasks; close() drains those futures alongside _direct_tool_worker before tearing down the terminal executor, so teardown cannot close an executor an interrupt still uses. Already-finished entries are pruned (with their exception retrieved) on each cancellation. Tests: - test_cancel_offloads_interrupt_off_the_event_loop: injects a blocking interrupt and proves a real /alive request on the same loop still answers while the interrupt is in flight, and that the interrupt ran off the loop thread. Fails without the fix. - test_close_drains_offloaded_interrupt_before_teardown: proves close() does not reach conversation teardown until the offloaded interrupt returns. - live-server smoke test that a dropped request does not stall /alive. Feature map F10.execute-tool-nonblocking and F10.execute-tool-teardown-owner extended; design.md lifecycle note updated. Co-authored-by: openhands <openhands@all-hands.dev>
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Reviewed at head da1d4737. Scope is unchanged: this is the Agent Server REST surface, which this repository owns, following the SDK/Agent Server -> OpenAPI -> clients/typescript flow. Linked issue #6094 is open with enhancement, priority:medium, ready-for-dev.
What changed since my last review. The single new commit (da1d4737) offloads the direct-call executor.interrupt() to a worker thread and retains the future in _direct_tool_interrupt_tasks, draining it in close() alongside _direct_tool_worker before executor teardown. That is a real fix for the cancel path: on the PowerShell backend interrupt() performs blocking child-process scans, so calling it inline on the FastAPI loop stalled unrelated requests. The two new unit tests (test_cancel_offloads_interrupt_off_the_event_loop, test_close_drains_offloaded_interrupt_before_teardown) exercise the loop-freedom and the drain-before-teardown ordering, and pass; a live-server smoke test covers the wiring.
Both findings from my earlier review remain resolved at this head, re-verified against a live agent-server: (1) ConversationBusyError is mapped centrally to 409 in api.py, so /run, /goal, and POST /events with run=true all answer 409 in the abandoned-worker busy state instead of 500; (2) resume_goal_loop carries the _direct_tool_in_flight guard.
Verification. Focused suite (test_conversation_router.py, test_event_service.py, test_event_router.py, tests/sdk/conversation/remote/test_remote_conversation.py, tests/sdk/conversation/local/test_execute_tool.py, tests/tools/terminal/test_tmux_pane_pool.py, test_pool_integration.py): 387 passed. Live-server -k execute_tool: 6 passed (with the ambient OH_RUNTIME_LAUNCHED_PROFILE unset; that variable is environmental and unrelated to this change). ruff check and ruff format --check clean on the changed module.
CI. This is a fork PR, so the workflow runs for da1d4737 are action_required (awaiting maintainer approval to execute); PR Description Check and PR Artifacts completed successfully.
Non-blocking, carried over from my prior review. The feature map added here states twice that a bare action {"command": ...} without kind is 422 (F10-fork-navigate-condense-ask.md lines 93 and 769). It is not: at this head POST /execute_tool with {"tool_name":"terminal","action":{"command":"echo no-kind"}} returns 200 and runs the command, because _validate_direct_action only rejects a kind that is present and mismatched. The behavior is safe (the tool is allowlisted and the payload still validates against that tool's action type), so this is a documentation correction, not a code defect. Flagged inline.
Everything else reads as intended: tool-scoped action validation (422, not 500), the None -> 404 path, the run_in_executor offload, pane pinning with the pinned-pane wait, and the cancel/close() ownership of both the worker and the offloaded interrupt.
✅ APPROVED
…ishes
An offloaded executor.interrupt() outlives the synchronous direct-call worker
it belongs to. _direct_tool_worker_done released the in-flight guard as soon
as the worker exited, but admission (execute_tool, run, start_goal_loop) and
is_idle_evictable only read that guard, so the next command or run could start
while the old interrupt was still scanning for the terminal process. On the
PowerShell backend that scan signals the process it finds, so the lingering
interrupt could kill the next command's child process.
Release the guard only when both the worker and every offloaded interrupt have
finished: _release_direct_tool_if_settled() checks both, the worker done
callback and a new interrupt done callback both call it, and the owning-call
path (_end_direct_tool_sync, no interrupt of its own) still clears directly.
Tests:
- test_delayed_interrupt_keeps_the_conversation_busy: the worker exits while
the interrupt is still blocked; the conversation stays busy (409 for a new
direct call, is_idle_evictable false) and frees only once the interrupt
returns. Fails without the fix.
- execute_tool now also rejects an action that omits its 'kind' discriminator,
matching the request model's documented contract and the feature map's
stated behavior (a bare {"command": ...} was previously accepted). Unit and
live-server coverage added.
Feature map F10.execute-tool-teardown-owner and design.md updated.
Co-authored-by: openhands <openhands@all-hands.dev>
|
Fixed in d395092. Delayed interrupt could kill the next command. An offloaded The guard now covers the interrupt, not just the worker:
Test: Gates: focused suites 347 passed; live-server This comment was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan. |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Reviewed at head d3950927. Scope is unchanged: this is the Agent Server REST surface, which this repository owns, following the SDK/Agent Server -> OpenAPI -> clients/typescript flow. Linked issue #6094 is open with enhancement, priority:medium, ready-for-dev.
What changed since my last review. One new commit (d3950927) closes a real race in the previous head: an offloaded executor.interrupt() outlives the synchronous direct-call worker, so releasing the in-flight guard at worker exit could admit a new command (or run) while the old interrupt was still scanning for the terminal process - and on the PowerShell backend that scan signals the process it finds. The guard is now released by _release_direct_tool_if_settled() only when the worker and every future in _direct_tool_interrupt_tasks are done, driven by both the worker and the new interrupt done callback; the owning-call path still clears directly. is_idle_evictable() reads the same flag, so eviction is blocked for the same window. I confirmed the new test_delayed_interrupt_keeps_the_conversation_busy fails against the previous head's event_service.py and passes here, so it genuinely discriminates.
All three findings from my earlier reviews are resolved at this head, re-verified against a live agent-server:
ConversationBusyError-> 409 centrally:/run,/goal, andPOST /eventswithrun=trueall answer409 {"detail":"A direct tool execution is in flight; retry when it finishes"}in the abandoned-worker busy state, not 500.resume_goal_loopcarries the_direct_tool_in_flightguard.- My earlier documentation finding is fixed at the source: a body that omits
kindis now rejected with422 {"detail":"Action for tool 'terminal' must declare its 'kind' (expected 'TerminalAction')"}, matchingExecuteToolRequest's documented contract and the feature map. The Python client (action.model_dump(mode="json"), which includeskind) and the TypeScriptToolAction(kindrequired) both already send it, so no client is broken by the stricter validation.
Verification. Focused suite (test_conversation_router.py, test_event_service.py, test_event_router.py, tests/sdk/conversation/remote/test_remote_conversation.py, tests/sdk/conversation/local/test_execute_tool.py, tests/tools/terminal/test_tmux_pane_pool.py, test_pool_integration.py): 389 passed. Live-server -k execute_tool: 6 passed (with the ambient OH_RUNTIME_LAUNCHED_PROFILE unset; that variable is environmental and unrelated to this change). control-agent-server map check -> ok, 34 files, 947 ids. ruff check and ruff format --check clean on the changed modules.
CI. This is a fork PR, so the workflow runs for d3950927 are action_required (awaiting maintainer approval to execute); PR Description Check and PR Artifacts completed successfully.
No material findings remain. The endpoint, its validation, the per-conversation serialization, pane pinning, and the cancel/close() ownership of both the worker and the offloaded interrupt all read as intended.
✅ APPROVED
Fieldnote-Echo
left a comment
There was a problem hiding this comment.
Thanks for the updates. I re-reviewed the current head, d3950927.
The changes address the tool validation, allowlist, per-conversation lifecycle, cancellation, delayed interrupt, HTTP 409, and missing kind concerns.
One part of the earlier capacity finding remains. EventService.execute_tool() still submits work through run_in_executor(None, ...). The _run_lock and _direct_tool_in_flight guard only one conversation. Direct calls from different conversations do not acquire the shared _run_semaphore and do not count against max_concurrent_runs.
As a result, many conversations can submit direct tool work outside the server-wide limit and queue work in the process default executor.
Please use bounded server-wide admission for direct tool execution. Add a focused test with multiple conversations that verifies the shared capacity limit. The test should also cover competition between a normal conversation run and a direct tool call.
The branch currently has merge conflicts. Please resolve them before the final validation. The updated head will need current CI and another review.
…xecute-tool-endpoint # Conflicts: # .agents/skills/verify-agent-server/references/feature-map/README.md # openhands-agent-server/openhands/agent_server/conversation_router.py # openhands-agent-server/openhands/agent_server/event_service.py # tests/agent_server/test_conversation_router.py # tests/agent_server/test_event_service.py
A direct `execute_tool` call is real work on the conversation's own executor, but it bypassed `max_concurrent_runs`: every conversation could submit one at once and queue unboundedly in the process-wide default executor. A direct call now acquires the same server-wide run permit a run does, so a full server answers 429 with the same detail a run gets instead of admitting the call. The permit is returned when the call finishes, and the cancellation path keeps it until the abandoned worker and its offloaded interrupt are done. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks — both points are addressed on the new head Bounded server-wide admission ( self._direct_tool_slot = await RunSlot.acquire(self._run_semaphore)
self._direct_tool_in_flight = TrueSo a direct call counts against Tests. Three, all verified to fail with the
Merge conflicts. Resolved in This reply was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan. |
Co-authored-by: openhands <openhands@all-hands.dev>
|
Follow-up: The PR's CI runs show This reply was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan. |
|
Could you keep the permit until both settle and add a focused close-during-work regression? The test should confirm that a competing call gets 429 during close, then succeeds after close finishes. |
| # and lets a blocked admission through. | ||
| self._direct_tool_abandon += 1 | ||
| self._direct_tool_in_flight = False | ||
| self._release_direct_tool_slot() |
There was a problem hiding this comment.
EventService.close() currently calls _release_direct_tool_slot() before it waits for _direct_tool_worker and drains _direct_tool_interrupt_tasks. With max_concurrent_runs=1, this can admit work in another conversation while the closing worker or interrupt is still active.
Could you keep the permit until both settle and add a focused close-during-work regression? The test should confirm that a competing call gets 429 during close, then succeeds after close finishes.
Fieldnote-Echo
left a comment
There was a problem hiding this comment.
Please address the inline EventService.close() capacity finding before this PR is ready.
Co-authored-by: openhands <openhands@all-hands.dev>
HUMAN:
I hit this while running Agent Canvas against a local agent-server:
.openhands/setup.shwas present in the workspace but never ran, so the dependencies it installs and the variables it exports were missing. I reproduced that failure, then verified the fix against a live local server — anexportfrom oneexecute_toolcall survives into the nextterminalcall in the same conversation.AGENT:
Why
A workspace bootstrap script such as
.openhands/setup.shexists to mutate theenvironment (
export PATH=...,nvm use,pyenv activate, sourced files).Today neither mechanism that can run it makes those changes visible to the
agent's later
terminalcommands:.openhands/setup.shviaAsyncRemoteWorkspace.execute_command()->POST /api/bash/execute_bash_command,a fresh
asyncio.create_subprocess_shell;export/sourcedie with it.session_starthook runs in a freshsubprocess.run(..., shell=True); sameproblem. Hooks persist filesystem effects only.
setup.sh(git grep setup.sh= 0 hits),so on a local Agent Canvas stack the script is not run at all.
The in-process primitive that does persist env already exists
(
LocalConversation.execute_tool(), #1833): it runs a tool on the conversation'sown executor, i.e. the tmux terminal the agent later uses. What was missing was
the HTTP path to it and the client method. This is the server-side dependency the
closed PR #2356 provided for OpenHands/OpenHands#13285; both were closed by the
stale bot rather than on design grounds.
Closes #6094.
Summary
POST /api/conversations/{conversation_id}/execute_toolwith
ExecuteToolRequest{tool_name, action}/ExecuteToolResponse{observation}.actionis a plain JSON object, not the SDKActionunion: the serverresolves
tool_name, requires it to be inDIRECT_EXECUTION_TOOLS(
terminal), and validates the payload against that tool'saction_type,so a payload for another tool is a 422 instead of a 500 out of the executor.
ConversationService.execute_toolreturnsNonefor an unknown id (-> 404);EventService.execute_toolrunsLocalConversation.execute_toolinloop.run_in_executorso a slow tool does not hold the event loop.direct in-flight flag, so a delete, an idle eviction, a run or another direct
call cannot overlap it.
run()/start_goal_loop()while a direct call is inflight is 409, a second direct call is 409, and
close()abandons the worker.The flag is released on success, on a rejected action, and, after a cancelled
request whose worker already started, only when that worker actually exits.
ran it, so shell state a bootstrap script establishes (exports, functions, the
working directory) stays reachable from later commands, including parallel
ones, instead of being stranded behind a fresh pane.
RemoteConversation.execute_tool()posts to the endpoint anddeserializes the observation instead of raising
NotImplementedError.F10.execute-tool-*bullets (terminal, observation shape,persistent env, errors, auth/masking, non-blocking, tool-scoped action
validation, serialization against runs/other direct calls, pane pinning) and
F33.conversation-execute-tool; README counts updated to 949 sub-features.ExecuteToolRequest/properties/action/additionalPropertiesweak location(63 -> 64). No existing pointer changes; the checked-in TS client is unchanged
because it is generated from the pinned published spec.
Design notes:
.pr/design.mdReview follow-up
Fieldnote-Echo's review raised five issues; the
first four are addressed in
fa28a635and the capacity one infcb23626:ExecuteToolRequest.actionis nowdict[str, Any];EventServiceresolvestool_name, enforcesDIRECT_EXECUTION_TOOLS, and validates against thattool's
action_type(InvalidToolActionError-> 422). The union annotationthat renamed 38 action components is gone; the allowlist is upstream/main
plus one entry.
per-conversation lifecycle, sets a direct in-flight flag respected by
run()/start_goal_loop()/idle eviction, and is abandoned byclose(). Acancelled request whose worker already started keeps the conversation busy
until the worker exits (no premature
worker.cancel()).TmuxPanePool.pin()restricts the pool to the setuppane (
TerminalExecutor.on_direct_execution()pins it), so a later parallelcommand waits for that pane instead of landing on a fresh one.
ClientTool(which reports a dispatch with noActionEventto deliver it)and the skipped tool hooks/audit events cannot be reached through the route.
server-wide run permit a run does (
RunSlot.acquire(self._run_semaphore)),so many conversations cannot submit direct work outside
max_concurrent_runsand queue it in the process default executor. A full server answers 429 with
the same detail a run gets; the permit is returned when the call finishes,
and the cancellation path keeps it until the abandoned worker and its
offloaded interrupt are done. Covered by
test_direct_tool_calls_share_the_server_run_limitandtest_direct_tool_call_competes_with_a_run_for_capacity(multipleconversations, plus direct-vs-run competition) and the live
test_execute_tool_live_server_shares_the_run_limit; each was verified tofail with the
RunSlot.acquireline removed.Merge conflicts with
mainwere resolved in816d2b1a.Issue Number
Closes #6094
How to Test
uv run pytest tests/agent_server/test_conversation_router.py tests/agent_server/test_event_service.py tests/sdk/conversation/remote/test_remote_conversation.py tests/sdk/conversation/local/test_execute_tool.py tests/tools/terminal/test_tmux_pane_pool.py -quv run pytest tests/cross/test_remote_conversation_live_server.py -q -k execute_toolExpect 7 passed: env persistence, a foreign-tool action 422, a second call
admitted, error cases, auth + secret masking, the shared run limit, and the
cancellation smoke test.
uv run ruff check .,npm run check:agent-server-api(inclients/typescript), andcontrol-agent-server map check.Type
Notes
analyzer and appends no event, matching
LocalConversation.execute_tool()bydesign; this is stated in the route docstring and its OpenAPI description. It
is a pre-run/setup mechanism, not a way to drive the agent, so it is refused
(409) while
run()is in flight rather than supported concurrently.<workspace>/.openhands/setup.shis an explicit non-goalhere: the agent-server loads no project convention on its own
(
.openhands/hooks.jsonis read only throughPOST /api/hooks). Wiring thecaller that owns the convention (app-server / Agent Canvas) is a change in
OpenHands/OpenHands, tracked separately.event shape changes, so no REST deprecation runway is needed. The pre-existing
StartChildConversation*oneOf drift the breakage gate reports is on theunmodified baseline too.
This PR was created by an AI agent (OpenHands) on behalf of analisaperlengkapan.