Skip to content

fix(acp): steer Hermes without replacing active turns - #4154

Open
atomlink-ye wants to merge 4 commits into
getpaseo:mainfrom
atomlink-ye:fix/hermes-acp-concurrent-steering
Open

fix(acp): steer Hermes without replacing active turns#4154
atomlink-ye wants to merge 4 commits into
getpaseo:mainfrom
atomlink-ye:fix/hermes-acp-concurrent-steering

Conversation

@atomlink-ye

@atomlink-ye atomlink-ye commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Addresses #3964 and #3999. Related to #4061 and #4112.

This PR carries the exact patch from #4113 as a dedicated commit with Burak Cabadan's original authorship, because derived ACP providers otherwise lose steerActiveTurn at the wrapper boundary.

Type of change

  • Bug fix
  • New feature
  • Enhancement
  • Refactor
  • Docs

Reasoning

Paseo currently treats an unavailable ACP steer as interrupt-and-replace. That is particularly destructive with Hermes: a message sent while Hermes is running a tool cancels the tool, starts another foreground turn, and can strand Hermes' own queued prompt behind its interrupted ACP turn.

Hermes already has the transport needed for the intended behavior. A second plain-text session/prompt received while its turn is active is redirected into the same AIAgent turn. This change lets a generic ACP provider explicitly opt into that transport. A small internal HermesACPAgentClient owns the Hermes default, following the existing provider-specific ACP client pattern instead of adding command sniffing to the generic registry. Paseo no longer sends session/cancel for an ordinary Hermes steer.

This follows the optional AgentSession.steerActiveTurn contract used by the Pi implementation in #3752 while preserving provider-specific acknowledgment semantics. Pi can queue first because its native steer RPC acknowledges admission; ACP session/prompt resolves only when the turn ends, so concurrent ACP steering clears a blocking permission before dispatch and sends nothing if that clear fails.

Goals

  • Steer an active Hermes ACP turn through a concurrent session/prompt, without canceling or replacing its foreground turn.
  • Keep the initial prompt, correction, tool output, and final answer on one Paseo turn.
  • Preserve explicit Stop/Interrupt: Stop can preempt a steer whose auxiliary ACP response never settles.
  • Keep unsupported ACP providers on their existing behavior unless they explicitly opt in.
  • Enable existing canonical hermes acp provider configs while preserving activeTurnSteering: "none" as an explicit opt-out.
  • Preserve exact user-message identity and suppress only matching provider echoes.
  • Handle both original-first and steer-first ACP response ordering without losing late updates.
  • Prevent a late steer completion from an interrupted turn from mutating or settling the next turn.

Non-goals

  • No Hermes source changes.
  • No generic _session/steering extension support; Add steering support for ACP providers that indicate support #3921's vendor-extension approach was reverted and is separate from Hermes' existing redirect transport.
  • No change to the global composer send policy or explicit Interrupt behavior.
  • No /reload-mcp or broader Hermes CLI/ACP command parity work.
  • Concurrent steering is plain non-command text only. Attachments, slash commands, and control input fail before dispatch.

QA

Before

On Paseo 0.7.0 with the installed Hermes ACP provider, I started a foreground sleep 60 tool call and sent a message while it was active. Paseo canceled the original turn and sent a replacement prompt. The observed result was:

[Command interrupted]
Internal error: 'NoneType' object has no attribute 'startswith'
Queued for the next turn. (1 queued)

The replacement produced a second Paseo foreground turn and the queued Hermes message did not run.

A deterministic ACP subprocess fixture also recorded one session/cancel followed by the replacement session/prompt on the unmodified path.

After

Tested on macOS 26.2, Apple Silicon, Paseo 0.7.0, and:

Hermes Agent v0.20.5 (2026.8.19)

The final exact-SHA test bundle used an in-process daemon on an OS-assigned loopback port with a temporary PASEO_HOME and temporary workspace. It did not restart or send test traffic through the normal daemon on port 6767.

Focused deterministic daemon E2E:

$ npx vitest run packages/server/src/server/daemon-e2e/acp-active-turn-dispatch.e2e.test.ts --bail=1
Test Files  1 passed (1)
Tests       3 passed (3)

Those cases cover:

  • steer response before original response;
  • original response before steer response, including a permission release and late update;
  • an unresolved steer response followed by explicit Stop, which must deliver session/cancel promptly, emit one canceled turn, and never resume from the late steer response.

Real Hermes E2E on exact candidate 0b00d2c59f60e65a958faeffeb3f4d1aa6c14bb8:

$ npm run test:e2e:hermes-steering --workspace=@getpaseo/server
Test Files  1 passed (1)
Tests       1 passed (1)

Observed redacted summary:

{
  "candidateHead": "0b00d2c59f60e65a958faeffeb3f4d1aa6c14bb8",
  "actual": {
    "finalStatus": "idle",
    "turnStartedCount": 1,
    "turnCanceledCount": 0,
    "assistantText": "Redirected the active turn with your correction. HERMES_ACP_STEER_ACK"
  },
  "nextPrompt": {
    "status": "idle",
    "acknowledged": true
  },
  "cleanup": {
    "workspaceRemoved": true,
    "paseoHomeRemoved": true
  }
}

The completed shell tool contained both HERMES_ACP_STEER_START and HERMES_ACP_STEER_END. The initial prompt and correction share <turn-1>; the health-check prompt is <turn-2>. The evidence artifacts contain no raw UUIDs or local home paths, and both temporary roots were removed.

Focused adapter, registry, catalog, deterministic daemon, and evidence tests on the final candidate:

Test Files  7 passed (7)
Tests       184 passed (184)

An additional lifecycle regression holds a steer from turn A, interrupts A, admits and defers a steer on turn B, then proves the stale A completion cannot decrement or flush B's settlement state.

Repository gates:

npm run typecheck     # passed
npm run lint          # 0 warnings, 0 errors
npm run format:check  # all matched files correctly formatted

Not tested manually on Windows, Linux, iOS, Android, or Electron UI. This change has no UI surface.

Known follow-ups from isolated local deployment

Before the history cleanup, I built the equivalent code tree at 9d334208a26990211824c91bd9f0b891ee3ec747 and ran it as a second daemon with an isolated PASEO_HOME on 127.0.0.1:6769, while the normal daemon remained on 6767. The installed Hermes provider completed a 60-second tool, accepted a correction through activeTurnBehavior: "steer" on the same turn without cancellation or replacement, and then completed a normal next turn. The rewritten candidate above was revalidated only through local unit, deterministic daemon, and ephemeral real-Hermes tests; it was not redeployed.

That deployment exposed two independent follow-ups which are intentionally outside this PR:

  1. The paseo send CLI currently calls ordinary sendAgentMessage without setting activeTurnBehavior: "steer". A CLI message sent during an active turn is therefore queued for the next turn, while the UI/composer and direct client API can exercise same-turn steering. A future CLI change could expose this explicitly, for example as paseo send --steer.
  2. When Hermes deliberately runs a tool in the background and then waits through the process tool, the process wait timeline row can remain running after the process and agent have completed. The same stale status reproduces without any steering message, so it is an existing Hermes ACP tool-status mapping issue rather than a regression from concurrent steering.

Checklist

  • One focused change
  • npm run typecheck passes
  • npm run lint passes
  • npm run format passes
  • QA evidence
  • Tests added or updated where it made sense

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T10:13:52.694725Z 09fe435 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09fe435249

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2943 to +2945
if (this.concurrentSteerAdmissionCount > 0) {
this.deferredForegroundSettlement = this.promptResponseEvent(response, turnId);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow cancellation to bypass unresolved steer admissions

When the user presses Stop and the original ACP prompt resolves as cancelled while the concurrent steering prompt remains unresolved, this branch defers the cancellation forever because concurrentSteerAdmissionCount never reaches zero. AgentManager eventually force-cancels only its own run bookkeeping after a timeout, while this session retains activeForegroundTurnId; the next prompt then fails with “A foreground turn is already active.” Process canceled/failed responses immediately, or explicitly invalidate outstanding steer admissions during interrupt(), so a hung auxiliary prompt cannot leave the Hermes session unusable.

Useful? React with 👍 / 👎.

plutofog and others added 3 commits September 1, 2026 20:14
Let Generic ACP providers opt into concurrent session/prompt steering and enable that transport for Hermes. Preserve foreground ownership across response ordering, permission release, exact echo identity, Stop preemption, and stale steer completion.
wrapSessionProvider forwards a fixed list of the optional AgentSession
methods and steerActiveTurn was not on it, so any wrapped session lost the
method entirely. steerOrReplaceActiveTurn then took its unavailable branch
and replaced the running turn instead of steering it.

That hits every custom provider, and a built-in provider as soon as
profileModels or additionalModels is configured. The claude, codex and
opencode providers all implement steerActiveTurn, so with Default send set
to Steer a mid-turn message canceled the turn and the tool call in flight
came back denied.

The wrap test already walks the optional methods, so steerActiveTurn joins
that walk and the exhaustiveness list next to it.
Cover both concurrent prompt response orders, permission release, Stop preemption, stale completion isolation, real Hermes steering, and redacted evidence generation.
@atomlink-ye
atomlink-ye force-pushed the fix/hermes-acp-concurrent-steering branch from 9d33420 to 0b00d2c Compare September 1, 2026 12:20
@atomlink-ye

Copy link
Copy Markdown
Contributor Author

cc @boudra

This is the Hermes ACP counterpart to #3752 and is ready for independent review/QA at 0b00d2c59f60e65a958faeffeb3f4d1aa6c14bb8.

Highlights:

  • keeps a running Hermes tool and correction on one Paseo turn through a concurrent ACP session/prompt, without interrupt-and-replace;
  • preserves explicit Stop preemption, both prompt-response orderings, exact echo identity, permission release, and stale completion isolation;
  • documents the provider-specific distinction from Pi: native Pi steering can acknowledge queueing before permission cleanup, while ACP clears blocking permissions before dispatch because session/prompt only resolves at turn completion;
  • carries Burak's fix(server): forward steerActiveTurn through wrapSessionProvider #4113 wrapper-forwarding patch as its own author-preserving commit;
  • history is cleaned to three reviewable commits: feature, wrapper fix, and E2E/evidence coverage.

Local evidence on the rewritten exact SHA:

  • focused adapter/registry/catalog/deterministic E2E: 7 files, 184 tests passed;
  • npm run typecheck, npm run lint, and npm run format:check passed;
  • real Hermes E2E passed with one started turn, zero canceled turns, the tool reaching END, the correction acknowledged on the same turn, and a healthy next turn;
  • independent final history/code review: APPROVE.

The PR body also records two non-blocking follow-ups found during isolated deployment: the CLI does not yet expose an explicit send --steer, and Hermes background process wait rows can remain visually running even without steering.

Could you take a look and run the normal independent QA/delivery flow when appropriate? Fork Actions are currently waiting for maintainer approval rather than reporting code failures.

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds opt-in concurrent-prompt steering for generic ACP providers and enables it by default for canonical Hermes configurations, preserving a single foreground turn instead of canceling and replacing it.

  • Forwards the optional steering operation through derived provider wrappers and adds a Hermes-specific ACP client default.
  • Tracks concurrent steer admissions so foreground settlement waits for auxiliary ACP prompts while interrupt and close invalidate stale completions.
  • Preserves exact user-message identity for echo suppression and rejects unsupported steering inputs before dispatch.
  • Adds provider catalog configuration, documentation, deterministic daemon coverage, and an optional real-Hermes E2E entry point.

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking request to make the new steering tests less dependent on private lifecycle state and raw ACP framing.

The concurrent-steering implementation preserves turn identity and invalidates stale completions across interruption; the only accepted concern is maintainability and verification quality in parts of the new test suite.

Files Needing Attention: packages/server/src/server/agent/providers/acp-agent.test.ts and packages/server/src/server/daemon-e2e/acp-active-turn-dispatch.e2e.test.ts

Important Files Changed

Filename Overview
packages/server/src/server/agent/providers/acp-agent.ts Adds concurrent ACP steering, exact echo suppression, permission clearing, deferred terminal settlement, and stale-response generation guards.
packages/server/src/server/agent/providers/hermes-acp-agent.ts Introduces the Hermes-specific generic ACP client that defaults concurrent-prompt steering while preserving explicit opt-out configuration.
packages/server/src/server/agent/provider-registry.ts Preserves optional steering through session wrappers and selects the Hermes client for canonical derived Hermes providers.
packages/server/src/server/agent/providers/acp-agent.test.ts Covers steering lifecycle and identity scenarios extensively, but several cases are coupled to private lifecycle state and mocked call details.
packages/server/src/server/daemon-e2e/acp-active-turn-dispatch.e2e.test.ts Exercises response ordering, permission release, and explicit Stop through a daemon fixture, with some assertions coupled to raw ACP framing.
packages/app/src/data/acp-provider-catalog.ts Enables concurrent-prompt steering in newly generated canonical Hermes provider configurations.

Sequence Diagram

sequenceDiagram
  participant U as Client
  participant M as AgentManager
  participant A as ACP session
  participant H as Hermes
  U->>M: steer active turn
  M->>A: steerActiveTurn(expectedTurnId)
  A->>A: validate input and clear permissions
  A->>H: concurrent session/prompt
  A-->>M: accepted
  H-->>A: original prompt response
  A->>A: defer foreground settlement while steer is pending
  H-->>A: steer prompt response
  A->>A: decrement admission count
  A-->>M: emit one foreground terminal event
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' into fix/hermes-acp-..." | Re-trigger Greptile

test("does not send when clearing a pending permission fails", async () => {
const session = createSession();
const { prompt, cancel } = primeConcurrentSteer(session);
vi.spyOn(session, "respondToPermission").mockRejectedValue(new Error("deny failed"));

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.

P2 Tests depend on private lifecycle state

The new steering tests replace session methods, inspect mock call storage, mutate private lifecycle fields, and assert raw ACP framing rather than exercising the caller-facing session and daemon interfaces. This couples the suite to implementation details, so internal refactors can break tests while defects in the real composition remain hidden.

Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

3 participants