Skip to content

feat(acp): steer an agent's running turn through _lazurio/steer instead of cancelling it - #141

Open
immakermatty wants to merge 4 commits into
lazurio/rebuild-v0.1.3from
agent/DEV-6676-acp-active-steer
Open

immakermatty wants to merge 4 commits into
lazurio/rebuild-v0.1.3from
agent/DEV-6676-acp-active-steer

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 28, 2026 •

Copy link
Copy Markdown

Plan DEV-6676, the T3 half of #138. Base: lazurio/rebuild-v0.1.3 (#137, head 6f04174a), until it replaces main. Head: b08a8057, four commits. The Hermes half is Lazurio/hermes-agent#22 (merged) and #26 (the review fixes below).

Problem

ACP has no steering request. When a message arrives while an ACP agent works, T3 Code cancels the running turn (session/cancel) and sends the message as the next prompt. Buddy, Lazurio's Hermes agent, runs over ACP. So every message his person sent while he worked stopped his turn ("Operation interrupted: waiting for model response"), and he started the work again. The product owner's requirement of 2026-10-28: a message sent while Buddy works must never stop him, and must reach him once.

Fix

The capability. An ACP agent can say it steers: it lists "lazurio.activeSteer": 1 in the _meta of its initialize agent capabilities. For such an agent:

  • the adapter negotiates supportsActiveSteering for its session, so the orchestrator picks steer_active;
  • each prompt carries its turn's id in _meta (lazurio.turnId);
  • steerTurn sends the extension request _lazurio/steer {sessionId, turnId, messageId, text}. text is the message with its attachments as saved paths, the same text a prompt of that message would carry. messageId is the T3 message id, so it is stable across the effect worker's retries. The turn goes on;
  • the agent steers only the turn named and each message once, and answers a repeat as it did, also after that turn ended (acp: a steer reaches only the turn it names, once per message (review of Lazurio/t3code#141) hermes-agent#26).

Which message becomes the next turn. Such an agent's session runtime declares confirmsSteers (ProviderAdapter.ts). For it:

  • a steer whose turn completed while it was on its way is still sent, for the turn it names. The agent answers the steer that turn took, or refuses; it never steers another turn;
  • only the agent's refusal (steered: false, a ProviderAdapterSteerRunError with refused) makes the message the next turn (turnCompleted, as for other providers);
  • a failed answer (a lost reply, a transport error) is no refusal. The effect worker retries, and the retry asks again;
  • a refusal while the prompt still runs waits up to 10 s for the prompt to settle first. A prompt the agent has not started yet gets the message by the effect's retries.

Agents without the meta keep steering by interrupt and restart.

effect-acp. Its initialize normalization now keeps capabilities._meta, which it used to drop.

Overlay bookkeeping. The fork's CI allows the upstream files this change touches, each with a reason: apps/server/scripts/acp-mock-agent.ts, packages/effect-acp/src/client.ts, and since commit 4 packages/provider-core/src/server/ProviderAdapter.ts and apps/server/src/orchestration-v2/ProviderTurnControlService.ts. The release runbook lists the change as row 32. The new tests live in apps/server/src/lazurio/.

Review rounds

Round 1 (commit 3, 3738f7c9). Pablo found two holes.

  • P1: a steer reaching the next turn. If turn A ended and B started while A's steer was on its way, the agent steered B, and T3 took that as A's success.
  • P2: a duplicate after a lost answer. A retry could steer the same message twice.

The fix binds the steer to its turn and message end to end: the turn id in the prompt's _meta, turnId and messageId in the steer, and an agent that steers only that turn and each message once.

Round 2 (commit 4, b08a8057). The agent took message M into turn A, but its answer was lost, and A completed before the effect's retry. ProviderTurnControlService declined the retry without asking the agent, and the effect worker sent M again as a new prompt. Buddy got M twice. Fixed by confirmsSteers above. Hermes #26 keeps the matching receipts: the viewer answers a repeat after B started, and after a lost owner answer it asks the owner about A's admission under the same key.

Lease overlap. The effect worker never runs a second attempt while the first one holds the effect:

  • claimNext claims only pending effects;
  • a thread's running effect blocks the thread's other effects;
  • steer effects are process-bound, so a server restart cancels them instead of replaying them.

The new integration test lets the 30 s lease pass while the first attempt holds the steer, and drains the worker: no second attempt. A retry comes only after the first attempt failed, which is the lost-answer case. Even then, a second follow-up would reuse the effect's command id, command:steer-follow-up:<effect>, so it is deduplicated.

What does not change

  • Stop is still session/cancel.
  • Codex, Claude and the other adapters are untouched. They don't declare confirmsSteers and keep inferring the follow-up from the turn's end.
  • The composer's default and the pending-question guard come in a separate change by the T3 fork's thread.

Known limitations

  • A prompt the agent admitted but has not started yet gets not_running, for example a prompt queued behind another turn of the same Hermes session. The effect worker retries up to 5 times; each attempt waits at most 10 s for the prompt and uses 100–800 ms backoff. If the prompt never starts, the steer fails instead of reaching the turn. Tracked in acp: a steer sent while this editor's prompt is still queued never reaches its turn hermes-agent#23.
  • An agent that stays unreachable through all 5 attempts. The steer effect fails without a next turn rather than risk a second delivery: only the agent's answer decides.

Verified

On the Mac, with this worktree's own vp i, head b08a8057.

apps/server/src/lazurio/steerConfirmation.integration.test.ts (new): the orchestrator and its effect worker with a confirming agent. The agent's first answer is held while the worker's lease passes and the turn completes. 3 pass:

  • took it, answer lost: no next turn; the agent is asked twice;
  • refused: exactly one next turn; asked once;
  • did not take it, answer lost: exactly one next turn.

None of them interrupts the turn.

apps/server/src/lazurio/buddySteer.orchestrator.test.ts (new): Buddy's production runtime and the effect worker daemon. The mock agent ends the turn before it answers the steer. 2 pass:

  • answer lost: Hermes gets the prompts [work, next message], and the steer twice with identical params;
  • refused: the prompts [work, steered message], so exactly one next prompt.

There is no session/cancel in either.

On commit 3, both new files fail with the message delivered twice: ['message:first', 'message:steer'], and three prompts. The 5 new tests passed 5 runs in a row, 25/25.

AcpAdapterV2.test.ts, the 4 Lazurio steer tests:

  • the runtime declares confirmsSteers;
  • asked again after the turn ended, the agent answers the steer the turn took;
  • a new message for the ended turn is refused, and its request names the ended turn;
  • a lost answer is not refused, and its retry after the turn ended succeeds;
  • an agent without the meta declares nothing.

Related suites: AcpAdapterV2.test.ts, SteeringCompletion.integration.test.ts and apps/server/src/lazurio/: 40 files, 373 pass.

vp run check:changed:

  • pass: format, lint, knip 1/2, graphql, typecheck (36 packages), and the tests beside the changes (5 files, 105 tests);
  • knip 2/2 fails on the same 31 unused exports under apps/*/src/lazurio/ that are already on the base.

Contract: scripts/lazurio-release-contract.test.mjs, 47 pass.

Full suite: CI on Linux owns it. On commit 3, the Mac's 22 failures were all outside ACP.

The Hermes side is proven in Lazurio/hermes-agent#22 and #26 against a real gateway and hermes acp.

Rollout

  1. A Hermes release with Canary: t3 update přes SSH obchází launcherův trial a snapshot DB a nevidí kanál služby #22 and Vanilla desktop a mobil nabízejí serverům z forku upstream verzi, kterou náš kanál nemá #26.
  2. This PR.
  3. A T3 release.
  4. Buddy's runtime pin.
  5. Ring 0 and a browser check: send Buddy a message while he works. His work goes on, and the message arrives once.

Claude Opus 5.5 in Claude Code (T3 Code).

🤖 Generated with Claude Code

…ad of cancelling it

A message sent while an ACP agent works is steered by cancelling the
running turn (session/cancel) and sending the message as the next
prompt, because ACP has no steering request. For Buddy (Lazurio Hermes
over ACP) every such message stopped his work and he started it again.

An agent that lists "lazurio.activeSteer": 1 in its initialize
capabilities' _meta now gets supportsActiveSteering for its session, and
steerTurn sends the extension request _lazurio/steer {sessionId, text}
with the message's text and its attachments as saved paths. The turn
goes on. An answer other than steered waits up to 10 s for the prompt
to settle and fails: a turn that ended leaves the message to the next
turn; a prompt the agent has not started yet is steered by the effect's
retry. Agents without the meta keep steering by interrupt and restart.
effect-acp's initialize normalization now keeps capabilities._meta.

Tests: the mock ACP agent advertises the meta under
T3_ACP_LAZURIO_ACTIVE_STEER=1 and answers _lazurio/steer. The adapter
test steers a running turn, which completes with the steer's text and
without session/cancel, and a steer after the turn fails. A second test
keeps restart steering for an agent without the meta.

Claude Opus 5.5 in Claude Code (T3 Code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fork's CI refused the steer change: apps/server/scripts/acp-mock-agent.ts
and packages/effect-acp/src/client.ts were not on the overlay's allowlist.
Both are allowed now with their reason, and the release runbook lists the
change as row 32 (#138) with its files; the next rebuild
folds this into the row.

Claude Opus 5.5 in Claude Code (T3 Code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immakermatty added a commit to HumanAndMachines/Lazurio that referenced this pull request Oct 28, 2026
…es is Buddy's own; he keeps pace and overview, reasons at medium in the conversation, and a message while he works steers him (DEV-6676)

A task Buddy promised for 15 minutes took 50: bot results waited behind
his own turn, other topics mixed into the promised block, and even a
37-second QR payment went through a bot. The Organization Admin decided
on 2026-10-28:

1. a simple task of about two minutes, by feel, Buddy does himself, also
   in another Environment; what outgrows it goes to a bot and he returns
   to the user. This changes point 2 of the addendum "Buddy koordinuje a
   jeho pomocníci jsou boti"; he still implements nothing and never
   waits for a bot;
2. pace and overview: answer first, keep the promised time, stay on an
   agreed block's topic, one question at a time, say at once what he
   cannot see and ask for a screenshot, another app's buttons in its
   language;
3. reasoning effort medium in the conversation (default profile
   Důkladný); the installer writes it until Settings -> Buddy profiles;
4. a message while Buddy works steers him in the Lazurio chat and on
   Telegram (/busy stays the user's), and his messages to threads go
   with `auto`.

AGENTS.md and manual/buddy.md say the same in one sentence each.
Realization: Lazurio/LazurioPlatform#711, Lazurio/hermes-agent#22,
Lazurio/t3code#141.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty marked this pull request as ready for review October 28, 2026 22:57

@agentrozjedemeai agentrozjedemeai 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.

@immakermatty — Round 1, exact head abb60dc against 6f04174. CHANGES_REQUESTED.

P1 — A steer can reach the next turn. In packages/provider-acp/src/server/adapter.ts:7722–7741 we check activeTurn/providerTurnId once, then asynchronously resolve attachment paths and send only {sessionId,text}. startTurn can replace activeTurn while this effect is suspended (startTurn holds runtimeTransitionPermit, steerActiveTurn does not). If turn A settles and B starts before Hermes handles this request, Hermes #22 looks up the current admission for sessionId, not A’s turn ID, and can answer steered:true for B. T3 then marks A’s steering effect successful, so the message is neither delivered to A nor offered as A’s next-turn follow-up; it instead steers B. The 10-second not-steered fallback cannot protect this case because B acknowledges success. Please bind admission to the requested turn across the send/ack boundary (e.g. serialize with turn transition and reject a stale context before sending, or pass/verify an admission/generation token end-to-end), and add a deterministic A→B race test. The current two tests cover only an in-progress A and a late request after A with no B.

P2 — A retry can deliver the same steer twice. The extension request carries no message/effect id, and Hermes #22 enqueues session.steer without deduplication. If the owner accepts it but the ACP reply is lost or the request times out, ProviderTurnControlService reports failure and EffectWorker retries up to five times (apps/server/src/orchestration-v2/EffectWorker.ts:748–795). A second successful request inserts the identical guidance again. Please make acceptance idempotent with a stable message ID/receipt, or otherwise distinguish acknowledged acceptance from a retryable refusal; test an accepted request with a lost response.

Good: capability is negotiated on providerSession and CommandPolicy/Orchestrator read that session capability; non-opt-in agents retain interrupt/restart. Effect.fn span and the explained fnUntraced wrapper follow convention; failures are wrapped as typed ProviderAdapterSteerRunError. The success path, _meta normalization and release allowlist look aligned. Exact-head CI run 40772341696 passed server/web and CLI archives including darwin-arm64; git diff --check clean. In this isolated clone, release-contract tests reached 46/47 but the remaining test could not resolve @t3tools/shared because dependencies were not installed; I did not claim a local full pass. No merge or push.

Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts Outdated
@immakermatty

Copy link
Copy Markdown
Author

Review of abb60dc9: both findings are real. I fix them as a pair with a Hermes follow-up, because the protocol has to change on both sides. Until then this PR doesn't merge.

P1, a steer reaching the next turn. The fix binds the steer to its turn end to end, not only on T3's side:

  • T3 sends its provider turn id in the _meta of session/prompt (lazurio.turnId).
  • Hermes's viewer records that id with the admission it creates.
  • _lazurio/steer carries {sessionId, turnId, messageId, text}. Hermes answers steered only when the admission that is running holds that turnId; anything else gets not_running.
  • T3 also re-checks activeTurn/providerTurnId after the attachment paths resolve, right before it sends.
  • A deterministic A→B test: the mock completes A and starts B while the steer for A is held. Expected result: B isn't steered, and A's message becomes exactly one follow-up turn.

P2, a duplicate after a lost reply. messageId becomes the idempotency key. Hermes keeps the steered message ids per admission and answers a repeat with steered: true, without a second session.steer. The test makes the mock accept, drop the reply and get the retry; it expects one guidance.

Greptile's case, a refusal while the prompt runs: a test in which the mock answers {steered: false} while the prompt is active, the prompt finishes and the worker is drained. Expected result: exactly one follow-up turn, no session/cancel.

The Hermes half lands as a fork PR next to hermes-agent#22's row, and both come back to you for review.

…nly that turn and only once

Review of #141 found two holes:
- a steer checked against turn A could reach turn B, if A ended and B
  started while the steer was on its way: the agent steered whatever
  prompt of the session ran, and T3 then took that for A's success, so
  the message became neither A's steer nor its follow-up;
- a retry after a lost answer could steer the same message twice.

An agent that negotiated lazurio.activeSteer now gets its turn's id in
each prompt's _meta (lazurio.turnId). _lazurio/steer carries
{sessionId, turnId, messageId, text}. The agent steers only the turn
the request names, and each message once; Lazurio/hermes-agent#26 is
that side. T3 also fails the steer without sending it when the turn
ended while the message's attachments resolved.

Tests: the mock agent models the agent side. The first prompt works
until the test finishes it and answers with the steers it took:
- the steered turn: the prompt names the turn, the steer names the
  turn and the message, no session/cancel;
- a refusal while the prompt runs fails the steer only once the turn
  completes, so the orchestrator sends the message as the next turn;
- a first steer that was taken but whose answer was lost: the retry
  with the same message steers once.
4 of 4 mutations are caught. Overlay row 32 says the same.

Claude Opus 5.5 in Claude Code (T3 Code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Author

Round 1 answered in 3738f7c9, together with Lazurio/hermes-agent#26. I did not take the protocol change I outlined first in full. Instead, the turn and the message travel in the requests themselves:

  • P1. The prompt names its turn in _meta (lazurio.turnId), and _lazurio/steer names the turn and the message ({sessionId, turnId, messageId, text}). The agent steers only the turn the request names, so a steer for A that arrives after B started is not_running and reaches nothing. T3 then fails the steer once the prompt settled, as a completed turn, so the message becomes the next turn.
    • Hermes side: a deterministic A→B unit test against a fake owner, plus the wire test with the real hermes acp.
    • T3 side: a refusal while the prompt runs, with the follow-up condition checked.
    • T3 also refuses to send when the turn ended while the attachments resolved.
  • P2. messageId is the T3 message id, stable across the effect worker's retries. Hermes answers a message it already steered (or is steering) for that admission as the first steer did, with no second session.steer. A refusal or a failure is not kept, so a retry still works.
    • T3 test: the mock takes the first steer and fails its answer; the retry succeeds and the turn shows the steer once.
  • Greptile's case (a refusal while the prompt is active) is that second T3 test above.

The effect-worker step itself (turnCompleted → message.dispatch) is unchanged and covered by its existing tests. This adapter now proves the two inputs it reads: the steer fails, and the terminal is completed.

@agentrozjedemeai agentrozjedemeai 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.

@immakermatty — Round 2, CHANGES_REQUESTED on exact head 3738f7c (base 6f04174). Reviewed with Lazurio/hermes-agent#26 at 4191139fe9c1218476bf63a5fef2f0d8cc9dfcd1.

P2 — Accepted-but-unacknowledged steer can become a duplicate follow-up after A completes. The stable messageId survives a retry while the same prompt is active, but ProviderTurnControlService.ts:130-137 declines before contacting Hermes when A is now completed. EffectWorker.ts:248-299 treats this as undelivered and dispatches the same message as a new prompt. Hermes #26 also forgets its accepted receipt once B starts (lazurio_active_steer.py:73-83). Scenario: A accepts steer M, ACP reply is lost or a lease-expired worker has not committed success, A completes, B starts, then the effect retries. M was delivered to A and is sent again as a follow-up; the same risk exists if the first attempt succeeds but its outbox settlement loses the lease. I reproduced the Hermes half with a temporary review probe (accepted A, settle A, start B, retry M => not_running). Persist/consult an exact turn+message acceptance outcome across completion before invoking the completed-turn follow-up; test via the real effect worker including lost reply/lease overlap, and ensure a genuinely refused steer still produces exactly one follow-up.

Also resolve the still-open review thread: the current refused-steer test AcpAdapterV2.test.ts:4151-4181 asserts the adapter error and terminal status, not the worker's subsequent one-follow-up dispatch. The owner-RPC lost-answer path in Hermes #26 can also steer twice within A because its failed-future cleanup cannot distinguish a refused steer from an accepted steer with an unacknowledged owner reply.

The new prompt _meta/steer parameters and stable T3 message id address the original later-turn routing while A is active; attachment-resolution recheck is sensible. The unreleased capability may stay at version 1. Diff check clean and current CI checks green. I did not run the local T3 suite in this sparse dependency-free review clone; no merge/push/release.

… refused it

Review round 2 of #141: when the agent took a steered message but its answer
was lost, and the turn completed before the effect's retry, the turn control
service declined the retry without asking the agent, and the effect worker
sent the message again as the next turn. Buddy got it twice.

A runtime can now declare confirmsSteers: its agent answers a steer by turn
and message, also after that turn ended, and never steers an ended turn. For
such a runtime the service still sends the steer of a completed turn, and
makes the message the next turn only when the agent refused it
(ProviderAdapterSteerRunError.refused); a lost answer is retried. The ACP
adapter declares it where the agent negotiated lazurio.activeSteer, sends
_lazurio/steer for the turn the steer names whether or not it still runs, and
marks the agent's steered:false as refused.

Tests: the orchestrator and its effect worker with a confirming agent whose
first answer is held while the worker's lease passes and the turn completes
(taken and lost: no next turn; refused: exactly one; lost and not taken:
exactly one), and Buddy's production runtime with the mock agent ending the
turn before it answers (lost: one prompt with the message; refused: exactly
one next prompt; no session/cancel). Both fail on the previous head with the
message delivered twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immakermatty added a commit to Lazurio/hermes-agent that referenced this pull request Oct 29, 2026
… turn (review of Lazurio/t3code#141)

Review round 2 of Lazurio/t3code#141: the owner applied a steer of turn A,
its answer to the viewer was lost, A ended and B started. The viewer had
dropped the failed steer, so the client's retry for A met the turn check
(B is the latest prompt) and read not_running; T3 Code then sent the message
again as a new turn.

A steer whose owner call failed now keeps the admission it was for. Asked
again for the same turn and message, the viewer asks the owner about that
admission under the same idempotency key, even after A ended and B started:
the owner answers its receipt, or its fence (stale_generation, not_running)
where it never applied the steer. B is never asked. A refusal is still not
kept.

Test: tests/acp_adapter/test_lazurio_active_steer.py, the lost owner answer
asked again after the next turn started; it fails on the previous head
with not_running. Overlay row updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai 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.

@immakermatty Exact-head QA for b08a805, reviewed with Lazurio/hermes-agent#26 at 4c2c29e22a5529f935b6cc98d48b2bab067b055c: APPROVED. A confirming runtime asks the agent about the named turn even after completion, and only an explicit refusal causes one follow-up; other providers retain the old completion path. The ACP request names the persisted turn/message, and the Hermes viewer fences it to the original admission or replays its receipt, never to the next turn. The orchestrator/effect-worker TestClock scenarios cover accepted/lost, refused, and not-taken/lost responses and the 31-second lease overlap; the production Buddy runtime/mock ACP scenario covers late answers and no cancel. Exact-head server/web and linux CI passed, zero unresolved threads, isolated diff --check passed; the darwin-arm64 archive job is still in progress, so this approval is not a claim that all CI is green or that the PR is ready to merge. No real client Organization, Module, or Machine names introduced in the scoped change. No merge/release performed.

This branch has not been deployed

No deployments
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.

2 participants