Skip to content

harness(agent_loop): let a model response keep the floor#61

Open
yh928 wants to merge 1 commit into
tinyhumansai:mainfrom
yh928:harness/model-response-continue-turn
Open

harness(agent_loop): let a model response keep the floor#61
yh928 wants to merge 1 commit into
tinyhumansai:mainfrom
yh928:harness/model-response-continue-turn

Conversation

@yh928

@yh928 yh928 commented Jul 17, 2026

Copy link
Copy Markdown

Summary

A response carrying tool calls continues the turn; one without them is taken as the turn's final answer. That leaves no way for a model to say something before it acts.

The motivating case: a text-mode tool protocol

Some models can't use native tool calling — a large toolkit compiles to a provider grammar that blows the rule ceiling and gets rejected with a 400 before any generation, and local runtimes often reject the tools parameter outright. Those turns fall back to encoding tool calls as prose.

The one we drive looks like this. A reply is exactly one tagged block:

T Let me check your inbox for that.
C GMAIL_FETCH_EMAILS[Colorado|50]
E
  • T — say something to the user
  • C — call a tool (positional or JSON body)
  • E — done

The tag sits only at the very start of a reply and everything after it is payload verbatim, so a T block containing a line that begins with C stays literal text. That property is the whole point of a distinct format: the parser never scans for tags, so it can't false-positive on prose.

The model adapter parses each reply and hands the loop an ordinary ModelResponse, which lines up with the existing loop almost exactly:

block ModelResponse loop does
C real tool_calls runs them, feeds results back, continues — the native shape
E no tool calls finalizes the turn
T no tool calls finalizes the turn ← the problem

T is the one block with no home. It means "I have something to say and I am not finished", but it carries no tool call, so the loop ends the turn on it. A model can therefore never narrate before doing slow work — it must stay silent until the work is done, which is exactly the moment a user most wants to hear something.

The only workaround is to synthesise a fake tool call — register a no-op tool, attach a call to it on every T, and let the loop "execute" nothing — purely to buy one more reply. That is a real no-op round-trip in the transcript, and a tool in the registry that exists to lie to the loop. This field is that workaround, done honestly.

The change

Adds ModelResponse::continue_turn: Option<String>. When set, the loop appends the carried string as the next user turn and asks for another reply instead of finalizing:

if !structured_tool_hit && let Some(nudge) = response.continue_turn.clone() {
    messages.push(Message::user(nudge));
    continue;
}

Two placement details are deliberate:

  • After truncated-empty recovery — a truncated response is broken, not a deliberate continue.
  • Before structured extraction — which would otherwise treat the response as terminal.

Nothing in the harness knows what a T block is: it only learns that a given response does not end the turn. The protocol stays entirely in the adapter.

Why a String and not a bool

Most chat APIs reject two assistant turns in a row, so something must be appended to continue. Letting the adapter supply that string keeps the calling protocol's vocabulary (ours is "(next)") out of the harness — and it is the same token the protocol's system prompt teaches the model to expect, so the two can't drift.

Why no new limit

Each continue costs exactly one model call, so RunLimits::max_model_calls already bounds a model that never stops. An earlier draft of this grew a second, protocol-specific block cap; having two independent ceilings on one turn made "how long is a turn?" unanswerable, and this needs none.

API Or Behavior Changes

  • New public field ModelResponse::continue_turn: Option<String> (#[serde(default)]).
  • Behavior: unchanged by default. None is the default and preserves the existing loop exactly — covered by a test that pins it.
  • Breaking for struct-literal construction only. Adding a public field means every ModelResponse { .. } literal must name it; the in-crate literals (tests, examples, providers) are updated here. Callers using ModelResponse::assistant(..) are unaffected.

Tests

Three new tests in src/harness/agent_loop/test.rs:

  • continue_turn_keeps_the_floor_and_appends_the_nudge — a continuing reply does not end the turn (model_calls == 2) and the nudge lands in the transcript.
  • no_continue_turn_ends_on_the_first_tool_less_reply — pins the default None behavior.
  • an_endless_continue_is_bounded_by_max_model_calls — a model that never stops trips the model-call cap.

All commands run locally on this branch:

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo build --all-targets
  • cargo build --all-targets --all-features
  • cargo test
  • cargo test --all-features

Documentation

The field carries its own rustdoc: what it does, why it exists, why it's a String, and why it needs no cap of its own. The loop's branch carries the placement rationale. No architecture or example docs describe turn termination, so nothing else needed updating.

A response carrying tool calls continues the turn; one without them is
taken as the turn's final answer. That leaves no way for a model to say
something *before* it acts — a text-mode protocol, where tool calls are
encoded as prose, has no tool call to ride and so ends its turn on the
first sentence. Working around it means faking a tool call purely to keep
the loop going.

Add `ModelResponse::continue_turn: Option<String>`. When set, the loop
appends the carried string as the next user turn and asks for another
reply instead of finalizing. `None` is the default, so existing callers
are unaffected.

It carries a string rather than a bool because most chat APIs reject two
assistant turns in a row — something must be appended — and letting the
adapter choose keeps the protocol's vocabulary out of the harness.

Checked after truncated-empty recovery (a truncated response is broken,
not a deliberate continue) and before structured extraction (which would
treat it as terminal). No new cap: each continue costs one model call, so
`max_model_calls` already bounds a model that never stops.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an optional ModelResponse.continue_turn nudge, makes the agent loop append it and request another reply, and initializes the field across model providers, examples, unit tests, feature tests, and end-to-end fixtures.

Changes

Turn continuation

Layer / File(s) Summary
Response continuation contract
src/harness/model/*, src/harness/providers/*
ModelResponse now carries an optional continue_turn nudge, initialized by model, mock, OpenAI, and streaming response constructors.
Agent loop continuation
src/harness/agent_loop/run_loop.rs, src/harness/agent_loop/test.rs
Non-structured responses with a continuation nudge append a user message and continue; tests cover default termination, repeated continuation, and max_model_calls.
Response fixture alignment
examples/*, src/graph/*, src/harness/*/test.rs, tests/*
Mocked response builders and inline fixtures explicitly set continue_turn: None.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: senamakel

Poem

I’m a rabbit with a nudge in my ear,
“One more turn!”—the next reply is near.
Fields now line up, none left behind,
Tests hop onward, bounded and kind.
thump thump—the loop runs clear!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly matches the main change: allowing a model response to continue the current turn.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant