Skip to content

fix(runtime): give a late steer an injection point in the turn it was aimed at - #3533

Merged
Astro-Han merged 2 commits into
apache:mainfrom
shaokeyibb:fix/steer-injection-in-tool-free-turn
Aug 23, 2026
Merged

fix(runtime): give a late steer an injection point in the turn it was aimed at#3533
Astro-Han merged 2 commits into
apache:mainfrom
shaokeyibb:fix/steer-injection-in-tool-free-turn

Conversation

@shaokeyibb

Copy link
Copy Markdown
Contributor

Summary

drainSteeringInto runs only at the top of an agentLoop iteration, and the loop only iterates again when the step returned tool calls. A tool-free turn therefore has exactly one drain, and it happens before the model's first token — so a steer typed while the answer streams is never pulled. Whether "Steer" works at all depended on whether the model happened to call a tool afterwards, which the user cannot know when they press Enter.

This drains once more before leaving the loop and takes another step when something was injected, so the message lands in the turn the user aimed it at. A step-limited, stopped, or aborted turn deliberately skips it — the budget is spent or the turn is ending on purpose, and the Host folds the message into the next Turn instead.

No protocol change: drainSteeringInto already owns pull, injection and ack, and scope.injectedSteeringMessages already tells the caller whether anything landed. pullSteering is a lease, so it cannot be used as a probe — draining and then continuing is what keeps the lease honest.

Fixes #3529

Verification

New test injects a steer that arrives after the turn last tool-call boundary: a pullSteering that is empty on the first call and yields a message on the second — the real timing of pressing Enter after streaming starts — against a text-only model.

It asserts the model is actually asked again with the steer, not just that the echo was emitted:

assert.equal(model.doStreamCalls.length, 2);
assert.match(JSON.stringify(model.doStreamCalls[1]?.prompt), /late steer/);

Checked against both weaker builds, per the repo's habit of proving a guard bites:

  • without the fix: steering_message count expected: 1, actual: 0
  • with a drain that does not take another step: doStreamCalls expected: 2, actual: 1 — the echo and the ack both happen, and the user still never gets an answer

Ran: ai-sdk-backend (205), fake-backend (4), overflow-reactive-recovery (43) — all green; message-coordinator and execution-host-message in runtime-host (34) — green; biome check on both changed files; tsc -p packages/runtime.

agent-run-steering-recovery fails 8/8 here, all EBUSY: resource busy or locked, unlink ...runtime.sqlite on teardown. Verified pre-existing: identical 8/8 against a clean origin/main build. Windows-only fixture problem, unrelated to the change.

Not run: Desktop and Playwright.

Review focus

The fake backend already drains between chunks and once after the last chunk, commented "Final stranded drain (grok-build safety): a steer that landed after the last boundary still lands in this turn instead of being lost". That safety net is why the suites were green while the real backend lost the message — this change gives AiSdkBackend the boundary the fake has always assumed.

Independent of #3530: that one fixes what happens to a message that still ends up folded (step limit, stop, abort). Both are needed; neither subsumes the other.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — investigation, root-cause tracing, the test, and the fix.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Copilot AI lite review requested due to automatic review settings August 22, 2026 19:46

Copilot AI 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.

Pull request overview

This PR adds a final steering drain so late messages can trigger another model step during tool-free turns.

Changes:

  • Adds late-steering injection and continuation logic.
  • Adds regression coverage verifying the steer reaches the next model request.
  • A critical stop-after-step race remains unresolved when the drain is awaiting consumption.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Reviewed changes
packages/runtime/src/ai-sdk-backend.ts Adds late-steering handling; requires a post-drain stop-state recheck.
packages/runtime/src/__tests__/ai-sdk-backend.test.ts Adds late-steering regression coverage.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/runtime/src/ai-sdk-backend.ts Outdated
… aimed at

The agent loop drained steering only at the top of an iteration, and only
iterated again when the step returned tool calls. A tool-free turn therefore
has exactly one drain, before the model's first token, so a steer typed while
the answer streams was never pulled — whether Steer worked depended on the
model happening to call a tool afterwards.

Drain once more before leaving the loop, and take another step when it
injected something. A step-limited, stopped, or aborted turn skips it: its
budget is spent, and the Host folds the message into the next Turn.

The stop flags are re-read after that drain rather than reused from before it.
The drain awaits a durable push, so an `after_step` stop can land while it is
in flight; deciding from the stale value dispatched a provider step the user
had already stopped. Reported by Copilot review on apache#3533 and confirmed
reachable — the regression test fails with two provider calls without it.

Generated-by: Claude Code
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shaokeyibb
shaokeyibb force-pushed the fix/steer-injection-in-tool-free-turn branch from 41f657c to b561e29 Compare August 22, 2026 21:35
@Astro-Han

Copy link
Copy Markdown
Contributor

Reviewed at b561e298 by two independent lines plus a cross-check. No P0–P2 findings; we did not manufacture a P3 to look thorough. Recording the reasoning, because most of it is about paths that don't break.

The problem is precisely stated and I want to restate it because it is easy to under-rate: a tool-free turn runs exactly one provider step, and the only drain sits at the top of the loop — before the model has emitted a token. A steer typed while the answer streams therefore has no boundary left to land on, and whether "Steer" works at all ends up depending on whether the model happens to call a tool afterwards. That is an ordinary user path, not a constructed one.

On shape. The new drain is not a second authority. It is the drain that the break path was missing — the same one the next iteration's top-of-loop would have run had a tool call kept the loop alive. What the change removes is the implicit precondition that a steer only takes effect if a tool call follows it. That is a reduction, not another layer.

On frequency. This was the question worth asking, since the same-looking code in a different position is how these things go wrong. The extra drain runs only when the step returned zero tool calls and another step is still permitted — symmetric with the tool-call branch, which continues and drains at the top of the next iteration. It is not a once-per-turn helper relocated into a hot path.

On the new await window. The change re-reads loopStopRequested and aborted after the drain, and the accompanying test pins the race directly: consuming the echo is what resolves the drain's push, so an after_step stop lands exactly in the gap between that resolution and the post-drain decision, and the test asserts doStreamCalls.length === 1 — stop wins, the message stays durable, the Host carries it into the next Turn. We enumerated what else can change across that window: injectedSteeringMessages (checked via injectedBefore), consumerDetached and abort (both throw inside drainSteeringInto), and runtimeSteps / maxSteps, which cannot move while the loop is suspended.

On double-settlement. A pulled lease is in flight until ack, so after the end-of-loop drain acks and continues, the top-of-loop pull() returns empty. There is no path that acks a lease and then nacks the same one.

On unbounded looping. With maxSteps === undefined, stepLimitReached is permanently false — but that is not new, and the backstop is the same as on the tool-call path: stop and abort. Each additional step also requires a fresh user-supplied steer, so the loop is bounded by user input rather than by the model.

On the deliberately-skipped case. A step-limited turn does not drain, and one reviewer specifically checked whether that is a silent drop. It is not: skipping the drain means the lease is never pulled, so it stays in the queue and the Host folds it into the next Turn, exactly as the comment claims. This is the one place where "the code is right but the branch is unreachable" could have hidden, and it holds up.

One process note, since it affects how much this review is worth: the two lines were asked to reach their conclusions without reading each other, and one of them independently arrived at the same answer through a different route — the maxSteps and double-settlement questions came from the second line, the reachability of the step-limited skip from the first. Where the reviews agree here, they agree from separate work.

On CI: no checks have been reported on this head, so there is no CI evidence yet — worth a maintainer kicking the runs off. That is the only thing standing between this and an approval from our side; the code review itself is clean.

Comment thread packages/runtime/src/ai-sdk-backend.ts Outdated
Comment on lines +2794 to +2815
if (mayTakeAnotherStep) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}

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] This new continuation edge has no durable-reader requirement, and without one the second request loses the assistant output that was just steered.

At the top of the loop (:2211-2220), the two branches are not equivalent:

if (this.input.loadTurnRuntimeEvents) {
  requestMessages = await loadDurableTurnProjection();   // full projection, incl. the assistant turn
} else {
  const missingSteering = steeringMessagesMissingFromBase(...);
  if (missingSteering.length > 0)
    requestMessages = [...requestMessages, ...missingSteering];   // steering only
}

The no-reader branch only ever appends steering. It never appends the assistant message that was just flushed. So when this edge fires without a reader, the second provider call carries the original user prompt plus the steer envelope, and nothing else — the model is asked to correct or redirect work it cannot see. Repetition or self-contradiction is the natural outcome.

What makes this look like an oversight rather than a decision is the tool-continuation edge a hundred lines up, at :2683-2685:

if (continuationBudgetRemains && !this.input.loadTurnRuntimeEvents) {
  throw new Error('durable current-run reader is required for tool continuation');
}

That edge states the requirement outright and refuses to proceed. Its comment gives the reason — queue consumption alone does not prove the latest assistant facts are still readable. That reasoning applies just as directly here: this edge also continues the turn after an assistant step, and also needs the model to see what it just produced.

loadTurnRuntimeEvents is optional on the input interface, so the no-reader configuration is a supported contract, not a broken setup. The hosted path wires a runtime event store, which narrows exposure in production.

Either require the reader on this edge as the tool path does, or make the fallback projection genuinely equivalent by appending the flushed assistant message alongside the steering. As written, the fix changes "the steer is dropped" into "the turn continues with missing context", which is quieter but not obviously better.

Worth noting the new regression test only asserts that the second prompt contains the steer. Asserting that it also still contains the first assistant output would have caught this.

@Astro-Han

Copy link
Copy Markdown
Contributor

Correcting our earlier comment on this PR.

In #issuecomment-5383862406 we reported no P0–P2 findings on b561e2983eb2f9cbbd5843013d2816f71f3849d5. A second independent line, on the same head and from a different model, found a P2 that we missed, and we've verified it in the code. It's left inline at the new continuation edge: #discussion_r3837640511.

Short version: without loadTurnRuntimeEvents, the second provider request keeps the original user prompt plus the steer envelope but drops the assistant output that was just produced — the model is asked to redirect work it cannot see. The tool-continuation edge at :2683-2685 refuses to run in exactly that configuration (durable current-run reader is required for tool continuation); the new tool-free edge has no equivalent requirement.

Our first pass verified that the drain re-reads the stop flags after the await, that the step budget stays closed, that the lease cannot double-settle, and that the steer is persisted before dispatch — all of which hold. What we did not do was follow the no-reader branch to the end and ask what the second prompt actually contains. That is the miss.

The mechanism this PR adds is still the right one: a steer landing after the final tool-call boundary previously depended on the model happening to call a tool afterwards, which is not a contract. The remaining question is narrower — whether the fallback projection on the new edge should be made equivalent to the durable one, or whether the edge should require the reader the way the tool path does.

CI remains terminal green on this head (test: completed / success). We are not approving while the P2 stands.

…on edge

The no-reader projection at the top of the loop appends steering alone; it
never appends the assistant output of the step just finished. Taking the new
continuation edge without a reader therefore sent the model the original user
prompt plus the steer envelope and nothing else — asking it to redirect work it
could not see. Measured on a no-reader backend: the second request carried
roles ["user","user"] with the assistant answer absent.

Before this edge existed, a backend without `loadTurnRuntimeEvents` could never
reach a second provider step — the tool-call edge refuses outright. Gate the
edge on the reader to restore that invariant. It is skipped rather than
throwing, so the turn still completes and the Host folds the message into the
next Turn, exactly as before apache#3529.

Tests: the injection test now runs on the durable harness and asserts the second
request carries the first assistant answer as well as the steer; a new test pins
the no-reader contract; the stop test moved onto the durable harness too, or it
would have passed while exercising nothing.

Reported by Astro-Han in review of apache#3533.

Generated-by: Claude Code
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shaokeyibb

Copy link
Copy Markdown
Contributor Author

Correcting our earlier comment on this PR.更正我们之前对此 PR 的评论。

In #issuecomment-5383862406 we reported no P0–P2 findings on b561e2983eb2f9cbbd5843013d2816f71f3849d5. A second independent line, on the same head and from a different model, found a P2 that we missed, and we've verified it in the code. It's left inline at the new continuation edge: #discussion_r3837640511.我们在之前的报告中未发现 b561e2983eb2f9cbbd5843013d2816f71f3849d5 上的 P0–P2 序列。然而,在同一个头部,使用不同的模型,我们独立地发现了一个之前遗漏的 P2 序列,并且已经在代码中验证了这一点。该序列保留在新延续边缘处。

Short version: without loadTurnRuntimeEvents, the second provider request keeps the original user prompt plus the steer envelope but drops the assistant output that was just produced — the model is asked to redirect work it cannot see. The tool-continuation edge at :2683-2685 refuses to run in exactly that configuration (durable current-run reader is required for tool continuation); the new tool-free edge has no equivalent requirement.简而言之:如果没有 loadTurnRuntimeEvents ,第二个提供程序请求会保留原始用户提示和转向包络,但会丢弃刚刚生成的助手输出——模型被要求重定向它无法看到的工作。位于 :2683-2685 工具延续边拒绝在这种配置下运行( durable current-run reader is required for tool continuation );新的无工具边没有类似的要求。

Our first pass verified that the drain re-reads the stop flags after the await, that the step budget stays closed, that the lease cannot double-settle, and that the steer is persisted before dispatch — all of which hold. What we did not do was follow the no-reader branch to the end and ask what the second prompt actually contains. That is the miss.我们第一次测试验证了以下几点:等待结束后,排水阀会重新读取停止标志;步进预算保持关闭状态;租赁不会发生双重结算;以及调度前转向指令会持久化——所有这些都符合要求。但我们没有做的是,沿着“无读取器”分支一直执行下去,并探究第二个提示的实际内容。这就是问题所在。

The mechanism this PR adds is still the right one: a steer landing after the final tool-call boundary previously depended on the model happening to call a tool afterwards, which is not a contract. The remaining question is narrower — whether the fallback projection on the new edge should be made equivalent to the durable one, or whether the edge should require the reader the way the tool path does.此 PR 添加的机制仍然是正确的:之前,转向路径在最终工具调用边界之后的着陆依赖于模型恰好在之后调用了工具,但这并非约定。剩下的问题更具体——新边上的回退投影是否应该与持久投影等效,或者该边是否应该像工具路径一样需要读取器。

CI remains terminal green on this head (test: completed / success). We are not approving while the P2 stands.CI 对此仍持最终绿色( test :已完成/成功)。只要 P2 状态不变,我们就不会批准。

Confirmed — measured it on a no-reader backend before changing anything. The second request really does go out without the assistant turn:

call#1 hasUserPrompt=true
call#1 hasAssistantAnswer=false
call#1 hasSteer=true
call#1 roles=["user","user"]

One thing that sharpens your point: before this edge existed, a backend without loadTurnRuntimeEvents could never reach a second provider step at all. The tool-call edge refuses outright, and a tool-free turn breaks. So the no-reader fallback has only ever had to serve a single step — requestMessages never needed to carry an assistant message, and this edge was the first thing to ask it to. That makes it an invariant I broke rather than a fallback that was already weak, which is what pushed me to your first option rather than the second.

So the edge now requires the reader, matching the tool-call edge above it.

One deliberate deviation: it is skipped rather than throwing. The tool path can throw because it has already emitted tool calls and cannot finish coherently without continuing. This edge can simply not fire — the turn completes and the Host folds the message into the next Turn, which is exactly the behaviour before #3529. Throwing here would turn turns that complete fine today into errors.

Tests, all three of which your comment is responsible for:

  • the injection test now runs on the durable harness and asserts the second request carries the first assistant answer alongside the steer — your closing point, and it is the assertion that would have caught this;
  • a new test pins the no-reader contract: one provider call, no steering echo, nothing acked;
  • the stop test moved onto the durable harness too. It had been using a no-reader backend, so once the gate landed it would have passed because the edge was skipped — a green test exercising nothing. That one only surfaced because of this review.

Verified by removing the gate: the no-reader test fails expected: 1, actual: 2. 254 green in ai-sdk-backend, 34 in the runtime-host message suites.

1 similar comment
@shaokeyibb

Copy link
Copy Markdown
Contributor Author

Correcting our earlier comment on this PR.更正我们之前对此 PR 的评论。

In #issuecomment-5383862406 we reported no P0–P2 findings on b561e2983eb2f9cbbd5843013d2816f71f3849d5. A second independent line, on the same head and from a different model, found a P2 that we missed, and we've verified it in the code. It's left inline at the new continuation edge: #discussion_r3837640511.我们在之前的报告中未发现 b561e2983eb2f9cbbd5843013d2816f71f3849d5 上的 P0–P2 序列。然而,在同一个头部,使用不同的模型,我们独立地发现了一个之前遗漏的 P2 序列,并且已经在代码中验证了这一点。该序列保留在新延续边缘处。

Short version: without loadTurnRuntimeEvents, the second provider request keeps the original user prompt plus the steer envelope but drops the assistant output that was just produced — the model is asked to redirect work it cannot see. The tool-continuation edge at :2683-2685 refuses to run in exactly that configuration (durable current-run reader is required for tool continuation); the new tool-free edge has no equivalent requirement.简而言之:如果没有 loadTurnRuntimeEvents ,第二个提供程序请求会保留原始用户提示和转向包络,但会丢弃刚刚生成的助手输出——模型被要求重定向它无法看到的工作。位于 :2683-2685 工具延续边拒绝在这种配置下运行( durable current-run reader is required for tool continuation );新的无工具边没有类似的要求。

Our first pass verified that the drain re-reads the stop flags after the await, that the step budget stays closed, that the lease cannot double-settle, and that the steer is persisted before dispatch — all of which hold. What we did not do was follow the no-reader branch to the end and ask what the second prompt actually contains. That is the miss.我们第一次测试验证了以下几点:等待结束后,排水阀会重新读取停止标志;步进预算保持关闭状态;租赁不会发生双重结算;以及调度前转向指令会持久化——所有这些都符合要求。但我们没有做的是,沿着“无读取器”分支一直执行下去,并探究第二个提示的实际内容。这就是问题所在。

The mechanism this PR adds is still the right one: a steer landing after the final tool-call boundary previously depended on the model happening to call a tool afterwards, which is not a contract. The remaining question is narrower — whether the fallback projection on the new edge should be made equivalent to the durable one, or whether the edge should require the reader the way the tool path does.此 PR 添加的机制仍然是正确的:之前,转向路径在最终工具调用边界之后的着陆依赖于模型恰好在之后调用了工具,但这并非约定。剩下的问题更具体——新边上的回退投影是否应该与持久投影等效,或者该边是否应该像工具路径一样需要读取器。

CI remains terminal green on this head (test: completed / success). We are not approving while the P2 stands.CI 对此仍持最终绿色( test :已完成/成功)。只要 P2 状态不变,我们就不会批准。

Confirmed — measured it on a no-reader backend before changing anything. The second request really does go out without the assistant turn:

call#1 hasUserPrompt=true
call#1 hasAssistantAnswer=false
call#1 hasSteer=true
call#1 roles=["user","user"]

One thing that sharpens your point: before this edge existed, a backend without loadTurnRuntimeEvents could never reach a second provider step at all. The tool-call edge refuses outright, and a tool-free turn breaks. So the no-reader fallback has only ever had to serve a single step — requestMessages never needed to carry an assistant message, and this edge was the first thing to ask it to. That makes it an invariant I broke rather than a fallback that was already weak, which is what pushed me to your first option rather than the second.

So the edge now requires the reader, matching the tool-call edge above it.

One deliberate deviation: it is skipped rather than throwing. The tool path can throw because it has already emitted tool calls and cannot finish coherently without continuing. This edge can simply not fire — the turn completes and the Host folds the message into the next Turn, which is exactly the behaviour before #3529. Throwing here would turn turns that complete fine today into errors.

Tests, all three of which your comment is responsible for:

  • the injection test now runs on the durable harness and asserts the second request carries the first assistant answer alongside the steer — your closing point, and it is the assertion that would have caught this;
  • a new test pins the no-reader contract: one provider call, no steering echo, nothing acked;
  • the stop test moved onto the durable harness too. It had been using a no-reader backend, so once the gate landed it would have passed because the edge was skipped — a green test exercising nothing. That one only surfaced because of this review.

Verified by removing the gate: the no-reader test fails expected: 1, actual: 2. 254 green in ai-sdk-backend, 34 in the runtime-host message suites.

@Astro-Han Astro-Han 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.

Review at exact head deb3ba768b83ec97316c056c7f073fc42db2bf1a.

APPROVE. This closes a real gap with a symmetric edge rather than a second mechanism, which is the right shape for it.

The bug is that a tool-free turn has exactly one steering boundary — the one at the top of the loop, before the model's first token. A steer arriving after that had no further boundary to be pulled at, so whether Steer worked at all depended on the model happening to call a tool afterwards. That is a normal user path, not an edge case: type a redirect while a long prose answer is streaming.

Three details make this correct rather than merely plausible:

  1. The durable-reader gate matches the tool-call edge's gate. Continuing the turn means the next request must carry the assistant output this step just produced, and only the ledger projection has it. Without loadTurnRuntimeEvents the new edge is skipped, not forced — appending steering alone would ask the model to redirect work it cannot see. Skipping preserves today's behaviour: the message stays durable and the Host folds it into the next Turn.
  2. The stop flags are re-read after the drain. drainSteeringInto awaits a durable push, so mayTakeAnotherStep is stale by the time it returns; an after_step stop or an abort can land inside that window. The code re-checks scope.loopStopRequested and scope.aborted before continuing, and stop wins. Reading a stop flag once before an await is the usual way this class of change goes wrong.
  3. The continue is conditional on something actually having been drained (injectedSteeringMessages.length > injectedBefore), so an empty drain ends the turn instead of spending a step on nothing.

A step-limited turn deliberately does not take this edge — its budget is spent and the Host folds the message into the next Turn. That is the same policy the tool-call edge already applies, so the two boundaries stay consistent.

I checked the loop-safety question specifically: each extra step requires a newly drained message, and maxSteps / stop / abort remain the same backstops that bound the pre-existing tool-call edge. No new unbounded path.

The tests pin the three contracts that matter — reader present, reader absent (provider called once, zero steering echo, lease not acked), and an after_step stop racing the drain — and they assert observable behaviour rather than call shape.

Verification: exact-head test is completed/success. An earlier run on this head failed in bounded election does not launch a Candidate after handshake exhausts the deadline, a Runtime Host timing test with no reachable path from ai-sdk-backend.ts; a re-run on the same head is green, confirming that as unrelated flakiness rather than a defect here.

@Astro-Han
Astro-Han merged commit 574c1f9 into apache:main Aug 23, 2026
1 of 3 checks passed
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.

bug(runtime): a steer submitted during a tool-free turn is never injected — the agent loop only drains steering at a tool-call boundary

3 participants