Skip to content

feat(agent-server): add POST /conversations/{id}/execute_tool - #6099

Open
analisaperlengkapan wants to merge 14 commits into
OpenHands:mainfrom
analisaperlengkapan:feat/conversation-execute-tool-endpoint
Open

analisaperlengkapan wants to merge 14 commits into
OpenHands:mainfrom
analisaperlengkapan:feat/conversation-execute-tool-endpoint

Conversation

@analisaperlengkapan

@analisaperlengkapan analisaperlengkapan commented Oct 19, 2026 •

Copy link
Copy Markdown

HUMAN:

I hit this while running Agent Canvas against a local agent-server:
.openhands/setup.sh was 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 — an export from one execute_tool call survives into the next terminal call in the same conversation.


AGENT:

Why

A workspace bootstrap script such as .openhands/setup.sh exists to mutate the
environment (export PATH=..., nvm use, pyenv activate, sourced files).
Today neither mechanism that can run it makes those changes visible to the
agent's later terminal commands:

  • The app-server runs .openhands/setup.sh via
    AsyncRemoteWorkspace.execute_command() -> POST /api/bash/execute_bash_command,
    a fresh asyncio.create_subprocess_shell; export/source die with it.
  • A session_start hook runs in a fresh subprocess.run(..., shell=True); same
    problem. Hooks persist filesystem effects only.
  • The agent-server itself never runs 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's
own 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

  • agent-server: new POST /api/conversations/{conversation_id}/execute_tool
    with ExecuteToolRequest{tool_name, action} / ExecuteToolResponse{observation}.
    action is a plain JSON object, not the SDK Action union: the server
    resolves tool_name, requires it to be in DIRECT_EXECUTION_TOOLS
    (terminal), and validates the payload against that tool's action_type,
    so a payload for another tool is a 422 instead of a 500 out of the executor.
    ConversationService.execute_tool returns None for an unknown id (-> 404);
    EventService.execute_tool runs LocalConversation.execute_tool in
    loop.run_in_executor so a slow tool does not hold the event loop.
  • Direct-call lifecycle: the call holds the per-conversation lifecycle and a
    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 in
    flight 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.
  • Pane pinning: a direct call pins the terminal pane pool to the pane that
    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.
  • SDK: RemoteConversation.execute_tool() posts to the endpoint and
    deserializes the observation instead of raising NotImplementedError.
  • Feature map: 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.
  • OpenAPI allowlist: one entry is added for the new
    ExecuteToolRequest/properties/action/additionalProperties weak 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.md

Review follow-up

Fieldnote-Echo's review raised five issues; the
first four are addressed in fa28a635 and the capacity one in fcb23626:

  1. Action validated against the global union, not the selected tool.
    ExecuteToolRequest.action is now dict[str, Any]; EventService resolves
    tool_name, enforces DIRECT_EXECUTION_TOOLS, and validates against that
    tool's action_type (InvalidToolActionError -> 422). The union annotation
    that renamed 38 action components is gone; the allowlist is upstream/main
    plus one entry.
  2. No complete lifecycle owner or capacity admission. The call now holds the
    per-conversation lifecycle, sets a direct in-flight flag respected by
    run()/start_goal_loop()/idle eviction, and is abandoned by close(). A
    cancelled request whose worker already started keeps the conversation busy
    until the worker exits (no premature worker.cancel()).
  3. Pane-local setup state. TmuxPanePool.pin() restricts the pool to the setup
    pane (TerminalExecutor.on_direct_execution() pins it), so a later parallel
    command waits for that pane instead of landing on a fresh one.
  4. Generic route hazards. Direct execution is limited to the allowlist, so
    ClientTool (which reports a dispatch with no ActionEvent to deliver it)
    and the skipped tool hooks/audit events cannot be reached through the route.
  5. Bounded server-wide admission. A direct call now acquires the same
    server-wide run permit a run does (RunSlot.acquire(self._run_semaphore)),
    so many conversations cannot submit direct work outside max_concurrent_runs
    and 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_limit and
    test_direct_tool_call_competes_with_a_run_for_capacity (multiple
    conversations, plus direct-vs-run competition) and the live
    test_execute_tool_live_server_shares_the_run_limit; each was verified to
    fail with the RunSlot.acquire line removed.

Merge conflicts with main were resolved in 816d2b1a.

Issue Number

Closes #6094

How to Test

  1. Unit/integration:
    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 -q
  2. Live server round trip (real agent-server, real tmux):
    uv run pytest tests/cross/test_remote_conversation_live_server.py -q -k execute_tool
    Expect 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.
  3. Manual, the end-to-end case the issue asks for:
    CID=$(control-agent-server conversation start --placeholder-agent --tools terminal --no-autotitle --no-run --prompt qa --print-id)
    control-agent-server api POST "/api/conversations/$CID/execute_tool" \
      --json '{"tool_name": "terminal", "action": {"kind": "TerminalAction", "command": "export QA_MARK=hello"}}'
    control-agent-server api POST "/api/conversations/$CID/execute_tool" \
      --json '{"tool_name": "terminal", "action": {"kind": "TerminalAction", "command": "printf %s \"$QA_MARK\""}}'
    # second call's observation.content[0].text == "hello"
  4. Gates: uv run ruff check ., npm run check:agent-server-api (in
    clients/typescript), and control-agent-server map check.

Type

  • Feature

Notes

  • The endpoint bypasses the agent loop's confirmation policy and security
    analyzer and appends no event, matching LocalConversation.execute_tool() by
    design; 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.
  • Auto-discovery of <workspace>/.openhands/setup.sh is an explicit non-goal
    here: the agent-server loads no project convention on its own
    (.openhands/hooks.json is read only through POST /api/hooks). Wiring the
    caller that owns the convention (app-server / Agent Canvas) is a change in
    OpenHands/OpenHands, tracked separately.
  • Additive route: no existing conversation route, request/response schema or
    event shape changes, so no REST deprecation runway is needed. The pre-existing
    StartChildConversation* oneOf drift the breakage gate reports is on the
    unmodified baseline too.

This PR was created by an AI agent (OpenHands) on behalf of analisaperlengkapan.

@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed after this PR merges to main through a cleanup PR.

@Fieldnote-Echo Fieldnote-Echo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 action against the global Action hierarchy, not the tool selected by tool_name. A valid GrepAction with tool_name="terminal" reached TerminalExecutor and 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. ClientTool reports that it was dispatched, but no ActionEvent exists 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.

Comment thread openhands-agent-server/openhands/agent_server/models.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/event_service.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/event_service.py
@analisaperlengkapan

Copy link
Copy Markdown
Author

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 action against the global Action hierarchy, not the tool selected by tool_name. A valid GrepAction with tool_name="terminal" reached TerminalExecutor and 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. ClientTool reports that it was dispatched, but no ActionEvent exists 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

@openhands-ai

openhands-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

I'm on it! analisaperlengkapan can track my progress at all-hands.dev

@openhands-ai

openhands-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

: conversation, tool_name)
return observation


Hmm, but I need the pane. Let me make `TerminalExecutor` handle it internally and expose a single method `pin_pool_to_last_pane()`. Then the server calls it after a successful direct call.

Actually — cleaner! Let me flip it: `TerminalExecutor.__call__` records `self._last_pane`. Add:

```python
class TerminalExecutor:
    def adopt_pane_as_session(self) -> None:
        """Make the pane used by the most recent pooled call the pool's only pane.

        Workspace setup scripts mutate shell state that is local to one pane.
        After running one directly, pinning the pool to that pane keeps the
        state visible to every later command instead of stranding it behind a
        fresh pane created for a parallel call.
        """
        if self._pool is not None and self._last_pane is not None:
            self._pool.pin(self._last_pane)

And in TmuxPanePool:

_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, checkout acquires the semaphore and returns the pinned pane; checkin releases. But the pinned pane may be in _available or checked out. If it's checked out by someone else, checkout blocks on the semaphore (size max_panes) — but with max_panes=4 and 1 pane checked out, the semaphore has 3 permits free, so a second checkout would NOT block and would return the pinned pane concurrently → race!

So when pinned, the semaphore must be sized 1. Simplest: when pinning, drain/replace the semaphore with a fresh threading.Semaphore(1). But existing checked-out panes hold permits from the old semaphore; releasing them would release into the new one → over-permitting. Hmm.

Alternative: track pinned availability with a dedicated lock/condition rather than the semaphore.

Let me simplify the pinned path with its own threading.Lock:

_pinned_lock: threading.Lock  # a plain mutex held while the pinned pane is in use
  • checkout() when pinned: acquire _pinned_lock (blocking, with timeout) then return _pinned.
  • checkin(terminal) when pinned: release _pinned_lock instead of the semaphore.
  • Also checkin must handle panes not equal to pinned (e.g. a pane checked out before pinning): those still use the semaphore.

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 checkout always acquire the pinned lock when pinned, and always the semaphore when not pinned; and record per-terminal which was used via a set _held_semaphore: set.

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 pool.close() on extra panes? Still messy.

Simplest of all: Give TmuxPanePool a max_panes of 1 effectively by rebuilding the pool pinned at construction... no.

Hmm, let me reconsider. What if pinning is implemented at the TerminalExecutor level, bypassing the pool's semaphore entirely: when pinned, _execute_pooled doesn't use pool.pane(); instead it uses a dedicated _pinned_session guarded by a threading.Lock:

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 adopt_pane_as_session() sets self._pinned_terminal = self._last_pane and leaves the pane checked in. But the pane is checked in to the pool's _available, so the pool could hand it to a parallel call... but after pinning, all calls go through _execute_pinned, so the pool is never used again. The pool's other panes just sit idle. That's clean!

But wait: checkin already released the semaphore for the setup pane, so no leak. And we never call pool.pane() again. The pool remains open (holding the tmux session) — fine, closed on close().

But: the pinned pane could be replaced by a reset action. Need to handle action.reset in _execute_pinned by replacing the pinned terminal with a fresh pane from the pool:

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 pane

Hmm, replace is documented for a checked-out pane; it doesn't touch the semaphore, so replacing an already-checked-in pane is fine (it removes from _all_panes/_available and adds the new one). Actually replace removes old from _available if present and appends new to _all_panes. Good, semaphore unchanged.

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 export -p:

Bash has compgen -e to list exported variable names. And printf '%s\0' "${VAR}"... Still fiddly.

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}"; done

Hmm, compgen -e word-splitting on names is fine (env names have no spaces). And \0-separated values handle newlines. Then apply to the session env with tmux set-environment.

But this dumps ALL exported vars (including secrets like SESSION_API_KEY? no — sanitized_env strips those). It would set them in the tmux session env, and new panes inherit. This actually satisfies "make setup state available to every future pane".

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 TmuxPanePool a pinned mode that makes checkout always return one pane, implemented with a dedicated threading.Lock for the pinned pane and a per-terminal record of which mechanism it used.

Actually, here's a cleaner variant: implement pinning entirely in the pool by resizing the semaphore accounting to the pinned pane, using a threading.Condition-based approach... still messy.

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 TerminalSession created from the pool's tmux session? That's basically what _execute_pinned above does but reusing the pane.

Let me just carefully implement _execute_pinned in TerminalExecutor:

State:

  • self._pinned_terminal: PooledTmuxTerminal | None = None
  • self._pinned_lock = threading.Lock()
  • self._last_pane: PooledTmuxTerminal | None = None (set in _execute_pooled)

Methods:

  • _execute_pooled sets self._last_pane = handle.terminal (before checkin).
  • pin_session(): if pooled and _last_pane is not None and not closed → self._pinned_terminal = self._last_pane.
  • _execute_pinned(action, conversation): with _pinned_lock, handle reset/replace, wrap session, prepare, export envs, execute, mask.

__call__: if self._pinned_terminal is not None: return self._execute_pinned(...) else pooled/single.

And the server calls pin_session() after a successful direct terminal call. Hmm — but LocalConversation.execute_tool is the SDK primitive; the server's direct path is the only caller. Should the SDK's execute_tool pin? It can't import tools.

Hmm, OK. But wait: is there a better trigger? The whole point is that execute_tool is the out-of-band setup path. What if instead of the server calling a pin method, the TerminalTool.create receives a flag... no.

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 openhands.tools.terminal.* (constants, timeout_policy). And agent-server depends on openhands-tools. So an explicit import is fine and clean.

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., ls), pinning would silently disable the pane pool for the conversation — a surprising side effect.

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 TmuxPanePool: add a _session_env: dict[str,str] updated by a new method, and have _create_pane set those into the new pane's shell (via send_keys exports) or into the session env before creating.

How to get the vars from pane A? In TerminalExecutor, after a direct call, run a capture command in that same pane and parse the NUL-delimited dump. Then call pool.update_session_env(vars).

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 (for n in $(compgen -e); ...). That's meaningful overhead on every agent terminal call. Too costly.

Alternatively, propagate only when the command looks like it might export (contains export, source, ., PATH=)? Heuristic — the reviewer would hate it.

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: LocalConversation.execute_tool is defined as the out-of-band direct-execution path. But SDK can't import tools.

Solution: introduce a generic, documented hook on the ToolExecutor protocol: e.g. ToolExecutor.on_direct_execution() — a no-op default, overridden by TerminalExecutor to propagate/pin session state. LocalConversation.execute_tool calls tool.executor.on_direct_execution() after execution. This is clean layering: the SDK defines the protocol method, the tool implements it, no SDK→tools dependency.

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: ToolExecutor.on_direct_execution() — "called after the tool ran outside the agent loop, so a tool can persist state that the agent loop would otherwise establish". Actually a better name: after_direct_execution() or persist_session_state().

Hmm, let me reconsider — maybe simpler: since LocalConversation.execute_tool already has the tool, and the concern is generic ("direct execution may establish state the agent loop relies on"), a generic optional hook is genuinely appropriate.

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? tmux set-environment only carries exported env vars, not functions/aliases/cwd. The reviewer listed "exports, functions, aliases, and the current directory are pane-local". (A) only fixes exports (and exported functions via export -f, which appear in env as BASH_FUNC_...%%). Aliases and cwd remain pane-local.

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 ls, pinning the pool is a minor cost (terminal concurrency for that conversation drops to 1). Given the endpoint's documented purpose ("Intended for pre-run setup"), pinning is defensible.

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 TmuxPanePool with an explicit, simple mechanism:

_pinned: PooledTmuxTerminal | None = None
_pinned_in_use: bool = False   # guarded by _lock
_pinned_cv: threading.Condition  # notifies when the pinned pane is returned

checkout(timeout):

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

Copy link
Copy Markdown
Author

Addressed all four findings in fa28a63. Summary of what changed and why:

  1. Action bound to tool_name. ExecuteToolRequest.action is now dict[str, Any]; EventService resolves the tool, requires it in DIRECT_EXECUTION_TOOLS (terminal), and validates the payload against that tool's action_type. A GrepAction with tool_name="terminal" is now 422, not a 500. The union annotation that created the <Name>-Input/<Name>-Output twins is gone; the weak-schema allowlist is upstream/main plus one entry.
  2. Lifecycle + admission. The call holds _conversation_lifecycle for its whole duration, sets a direct in-flight flag respected by run()/start_goal_loop()/idle eviction, is serialized against a second direct call, and is abandoned by close(). A cancelled request whose worker already started keeps the conversation busy until the worker exits.
  3. Pane pinning. TerminalExecutor.on_direct_execution() pins TmuxPanePool to the setup pane, so later commands — including parallel ones — see the script's shell state.
  4. Generic-route hazards. Restricting direct execution to the allowlist keeps ClientTool (no ActionEvent to deliver its dispatch) and the skipped tool hooks/audit events off the route.

Verification on this head:

  • tests/agent_server/test_conversation_router.py tests/sdk/conversation/remote/test_remote_conversation.py tests/sdk/conversation/local/test_execute_tool.py tests/agent_server/test_event_service.py tests/tools/terminal/test_tmux_pane_pool.py: 348 passed.
  • tests/agent_server full suite: 2742 passed, 12 failed — all 12 are the known canvas_extensions/test_canvas_extension_backend.py container baseline failures (unrelated to this route; they fail on main).
  • tests/tools/terminal/test_pool_integration.py: 6 passed.
  • uv run ruff check . clean; control-agent-server map check ok (945 ids).
  • OpenAPI: schema set is identical to the pre-fix head except the action typing; no -Input/-Output twins added (check:agent-server-api generated and passed).

Not marked ready for review: the PR template's HUMAN: section is human-only and the AI-agent instruction is not to edit it or mark the PR ready.

This comment was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan.

@analisaperlengkapan
analisaperlengkapan marked this pull request as ready for review October 20, 2026 13:40
@neubig
neubig requested a review from all-hands-bot October 20, 2026 13:44
@all-hands-bot

all-hands-bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

⏳ Review waiting on checks

The current head e761e5a367ac still has checks that have not finished, so no review conversation was started:

  • Agent Server
  • Check Docstrings
  • Deprecation deadlines
  • Persisted settings compatibility checks
  • Pre-commit checks
  • REST API breakage checks
  • Review Thread Gate
  • Run tests
  • Run tests (macOS)
  • TypeScript client CI
  • TypeScript client endpoint audit
  • TypeScript client integration tests
  • Version bump guard

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.

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 14:02

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread openhands-agent-server/openhands/agent_server/event_service.py
@neubig
neubig requested a review from all-hands-bot October 20, 2026 16:55
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>
@analisaperlengkapan
analisaperlengkapan force-pushed the feat/conversation-execute-tool-endpoint branch from 9b62c9b to 6d1fa2c Compare October 20, 2026 17:06
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot October 20, 2026 17:08

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. ConversationBusyError not mapped to 409. The prior head caught it only in the /execute_tool route, so /run, /goal, and POST /events with run=true answered 500 in the busy state. Commit 6d1fa2c3 registers a central handler in api.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 answer 409 {"detail":"A direct tool execution is in flight; retry when it finishes"}.
  2. resume_goal_loop missing the direct-call guard. It now carries the same _direct_tool_in_flight check as start_goal_loop and run, 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

@all-hands-bot
all-hands-bot requested a review from enyst October 20, 2026 17:38
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>
@neubig
neubig requested a review from all-hands-bot October 20, 2026 23:15

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Author

Fixed in d395092.

Delayed interrupt could kill the next command. An offloaded executor.interrupt() outlives the synchronous direct-call worker it belongs to: on the PowerShell backend it keeps scanning for the terminal process after the worker returns. _direct_tool_worker_done released the in-flight guard at worker exit, and admission (execute_tool, run, start_goal_loop) plus is_idle_evictable only read that guard, so the next command or run could start while the old scan was still in flight — and that scan signals the process it finds.

The guard now covers the interrupt, not just the worker:

  • _release_direct_tool_if_settled() clears _direct_tool_in_flight only when the worker is done and every future in _direct_tool_interrupt_tasks is done.
  • The worker done callback and a new interrupt done callback both call it.
  • The owning-call path (_end_direct_tool_sync, which has no interrupt of its own) still clears directly, so a successful or validation-failed call releases the conversation immediately.
  • is_idle_evictable() already reads the same flag, so idle eviction is blocked for the same window.

Test: test_delayed_interrupt_keeps_the_conversation_busy lets the worker exit while the interrupt is still blocked and asserts the conversation stays busy (a new direct call is 409, is_idle_evictable() is false) and frees only once the interrupt returns. It fails on the previous head. Feature map F10.execute-tool-teardown-owner and .pr/design.md updated to describe the coordinated release.

Gates: focused suites 347 passed; live-server -k execute_tool 6 passed; ruff check/ruff format --check clean; control-agent-server map check ok (34 files, 947 ids); npm run check:agent-server-api current (contract unchanged); pyright 0 errors.

This comment was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan.

@neubig
neubig requested a review from all-hands-bot October 21, 2026 00:35

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. ConversationBusyError -> 409 centrally: /run, /goal, and POST /events with run=true all answer 409 {"detail":"A direct tool execution is in flight; retry when it finishes"} in the abandoned-worker busy state, not 500.
  2. resume_goal_loop carries the _direct_tool_in_flight guard.
  3. My earlier documentation finding is fixed at the source: a body that omits kind is now rejected with 422 {"detail":"Action for tool 'terminal' must declare its 'kind' (expected 'TerminalAction')"}, matching ExecuteToolRequest's documented contract and the feature map. The Python client (action.model_dump(mode="json"), which includes kind) and the TypeScript ToolAction (kind required) 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 Fieldnote-Echo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Author

Thanks — both points are addressed on the new head fcb23626.

Bounded server-wide admission (fcb23626). EventService.execute_tool() now acquires the same shared permit a run does, before submitting to the executor:

self._direct_tool_slot = await RunSlot.acquire(self._run_semaphore)
self._direct_tool_in_flight = True

So a direct call counts against max_concurrent_runs instead of queueing in the process default executor. A full server answers 429 with the same detail a run gets (ConversationRunLimitExceeded → "Conversation run limit reached. Retry the request later."), and the route now declares that response. The permit is released when the call settles; on cancellation it is held until the abandoned worker and its offloaded interrupt are done, so a new command cannot be admitted while the lingering interrupt can still signal its process. Acquisition is non-blocking and does not suspend, so it stays atomic under _run_lock.

Tests. Three, all verified to fail with the RunSlot.acquire line removed:

  • test_direct_tool_calls_share_the_server_run_limit — four conversations, max_concurrent_runs=2: two admitted, two refused with ConversationRunLimitExceeded rather than queueing; peak in-flight is 2.
  • test_direct_tool_call_competes_with_a_run_for_capacity — one permit held by a direct call refuses a run in another conversation (429), and releasing the direct call frees it for that run.
  • test_execute_tool_live_server_shares_the_run_limit — real server with max_concurrent_runs=1: a direct call while another conversation holds the only permit is 429, and the same call is admitted once the holder finishes (the positive control that the 429 was capacity, not a refusal).

Merge conflicts. Resolved in 816d2b1a (branch now reports mergeable: true). The feature work is in fcb23626, on top of the merge, so the merge commit is pure conflict resolution.

This reply was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan.

Co-authored-by: openhands <openhands@all-hands.dev>

Copy link
Copy Markdown
Author

Follow-up: 8991bbe6 adds the admission note to .pr/design.md (docs only, no code change). Final head is 8991bbe6; branch is mergeable: true.

The PR's CI runs show action_required because this is a fork PR — a maintainer needs to approve the workflow run before the suites execute. Local gates all pass on this head: ruff check clean, map check ok (34 files, 949 ids), check_agent_server_openapi_quality.py ok (64 allowlisted weak locations), 378 tests/agent_server tests, 107 tests/sdk/conversation + tests/tools/terminal tests, and 7 live-server execute_tool tests.

This reply was generated by an AI agent (OpenHands) on behalf of analisaperlengkapan.

@Fieldnote-Echo

Copy link
Copy Markdown
Contributor

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.

# and lets a blocked admission through.
self._direct_tool_abandon += 1
self._direct_tool_in_flight = False
self._release_direct_tool_slot()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Fieldnote-Echo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the inline EventService.close() capacity finding before this PR is ready.

Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Run a workspace bootstrap script (e.g. .openhands/setup.sh) in the agent's persistent terminal so env changes survive

5 participants