Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/agent_loop_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> Mod
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -96,6 +97,7 @@ fn text_response(text: &str) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/graph/subagent_node/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fn tool_call_response(id: &str, name: &str) -> ModelResponse {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
15 changes: 15 additions & 0 deletions src/harness/agent_loop/run_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,21 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
continue;
}

// The model says it is not finished (`ModelResponse::continue_turn`).
// Hand the floor back and ask for another reply instead of taking
// this response as the turn's answer. Checked after truncated-empty
// recovery — a truncated response is broken, not a deliberate
// continue — and before structured extraction, which would treat it
// as terminal.
//
// The assistant row is already on `messages` (appended above), so
// only the nudge is needed. `max_model_calls` bounds the resulting
// loop exactly as it bounds a tool-calling one.
if !structured_tool_hit && let Some(nudge) = response.continue_turn.clone() {
messages.push(Message::user(nudge));
continue;
}

// Final response: optionally extract structured output using the
// resolved plan (provider-native schema or tool-call arguments).
if let Some((strategy, name, schema)) = &structured_plan {
Expand Down
93 changes: 93 additions & 0 deletions src/harness/agent_loop/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> Mod
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -169,6 +170,7 @@ fn invalid_tool_call_response(id: &str, name: &str, raw: &str) -> ModelResponse
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -185,6 +187,7 @@ fn text_response(text: &str, input: u64, output: u64) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -205,6 +208,7 @@ fn truncated_empty_response(reasoning_tokens: u64) -> ModelResponse {
finish_reason: Some("length".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -2346,6 +2350,7 @@ fn multi_tool_call_response(calls: Vec<(&str, &str)>) -> ModelResponse {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -2842,3 +2847,91 @@ async fn tool_completed_event_carries_outcome() {
assert!(duration_ms.is_some(), "wall-clock duration present");
assert_eq!(output_bytes, Some(4), "\"nope\".len() == 4");
}

// ── `ModelResponse::continue_turn` ───────────────────────────────────────────

/// A tool-less response that keeps the floor by carrying `nudge`.
fn continuing(text: &str, nudge: &str) -> ModelResponse {
let mut response = ModelResponse::assistant(text.to_string());
response.continue_turn = Some(nudge.to_string());
response
}

/// A tool-less response normally ends the turn. `continue_turn` overrides that:
/// the loop appends the carried nudge as the next user turn and asks for another
/// reply — which is what lets a model speak before it acts.
#[tokio::test]
async fn continue_turn_keeps_the_floor_and_appends_the_nudge() {
let mut harness: AgentHarness<()> = AgentHarness::new();
harness.register_model(
"mock",
Arc::new(MockModel::with_responses(vec![
continuing("let me look that up", "(next)"),
ModelResponse::assistant("found it".to_string()),
])),
);

let run = harness
.invoke_default(&(), vec![Message::user("hi")])
.await
.expect("run succeeds");

assert_eq!(
run.model_calls, 2,
"the continuing reply must not end the turn"
);
assert_eq!(run.text(), Some("found it".to_string()));
// user, assistant(continuing), user(nudge), assistant(final)
assert_eq!(run.messages.len(), 4);
assert_eq!(run.messages[2].text(), "(next)");
}

/// The default is `None`, which must leave the historical behaviour byte-for-byte
/// intact: the first tool-less reply ends the turn.
#[tokio::test]
async fn no_continue_turn_ends_on_the_first_tool_less_reply() {
let mut harness: AgentHarness<()> = AgentHarness::new();
harness.register_model(
"mock",
Arc::new(MockModel::with_responses(vec![
ModelResponse::assistant("done".to_string()),
ModelResponse::assistant("never reached".to_string()),
])),
);

let run = harness
.invoke_default(&(), vec![Message::user("hi")])
.await
.expect("run succeeds");

assert_eq!(run.model_calls, 1);
assert_eq!(run.text(), Some("done".to_string()));
}

/// Continuing needs no cap of its own: each continue costs one model call, so a
/// model that never stops is already bounded by `max_model_calls`.
#[tokio::test]
async fn an_endless_continue_is_bounded_by_max_model_calls() {
let mut harness: AgentHarness<()> = AgentHarness::new();
harness.register_model(
"mock",
Arc::new(MockModel::with_responses(vec![
continuing("still going", "(next)"),
continuing("still going", "(next)"),
continuing("still going", "(next)"),
continuing("still going", "(next)"),
continuing("still going", "(next)"),
])),
);

let config = RunConfig::new("continue-cap").with_max_model_calls(3);
let err = harness
.invoke_in_context(&(), RunContext::new(config, ()), vec![Message::user("hi")])
.await
.expect_err("an endless continue must hit the model-call cap");

assert!(
err.to_string().contains("max model calls"),
"expected the model-call cap to stop it, got: {err}"
);
}
2 changes: 2 additions & 0 deletions src/harness/middleware/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fn response_with_usage(usage: Usage) -> ModelResponse {
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -997,6 +998,7 @@ fn response_text(text: &str) -> ModelResponse {
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/harness/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ impl ModelResponse {
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -851,6 +852,7 @@ impl StreamAccumulator {
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
})
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/harness/model/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,34 @@ pub struct ModelResponse {
/// Model selected by the harness/registry for this response.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_model: Option<ResolvedModel>,
/// When set, this response does **not** end the turn.
///
/// A response carrying tool calls always continues the turn — the loop runs
/// them and asks for another reply. A response *without* tool calls is
/// otherwise the turn's final answer. This field lets a model adapter say
/// "not done" anyway: the loop appends the carried string as the next user
/// turn and requests another reply.
///
/// `None` (the default) is the historical behaviour, so every existing
/// caller is unaffected.
///
/// **Why it exists.** Text-mode protocols encode tool calls as prose, and a
/// model that wants to say something *before* acting has no tool call to
/// ride. Without this the loop ends its turn on the first sentence, so such
/// a protocol cannot narrate — it must either stay silent until the work is
/// done, or fake a tool call purely to keep the floor.
///
/// The carried string is the nudge to hand back (e.g. `"(next)"`). It is a
/// string rather than a `bool` because most chat APIs reject two assistant
/// turns in a row, so *something* must be appended; letting the adapter
/// choose keeps the protocol's vocabulary out of the harness.
///
/// A continuing turn stays bounded by
/// [`RunLimits::max_model_calls`](crate::harness::runtime::RunLimits) — it
/// costs one model call per continue, like any other loop iteration — so
/// this needs no cap of its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continue_turn: Option<String>,
}

/// An incremental streamed chunk of a model response.
Expand Down
2 changes: 2 additions & 0 deletions src/harness/providers/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ impl<State: Send + Sync> ChatModel<State> for MockModel {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -327,6 +328,7 @@ impl MockModel {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}
}
1 change: 1 addition & 0 deletions src/harness/providers/openai/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ pub(super) fn parse_chat_response(
finish_reason: choice.finish_reason,
raw: Some(value),
resolved_model: None,
continue_turn: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions src/harness/providers/openai/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ pub(super) fn parse_responses_response(value: Value) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: Some(value),
resolved_model: None,
continue_turn: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/harness/providers/openai/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ impl OpenAiStreamAcc {
finish_reason: self.finish_reason,
raw: None,
resolved_model: None,
continue_turn: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/harness/steering/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn text_response(text: &str) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -82,6 +83,7 @@ impl ChatModel<()> for RecordingModel {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
})
} else {
Ok(text_response("done"))
Expand Down
2 changes: 2 additions & 0 deletions src/harness/subagent/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> Mod
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -56,6 +57,7 @@ fn text_response(text: &str) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions tests/e2e_agent_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ fn tool_call_response(id: &str, name: &str, arguments: serde_json::Value) -> Mod
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -66,6 +67,7 @@ fn text_response(text: &str, input: u64, output: u64) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
4 changes: 4 additions & 0 deletions tests/e2e_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn tool_call_response(id: &str, name: &str, input: u64, output: u64) -> ModelRes
finish_reason: Some("tool_calls".into()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -74,6 +75,7 @@ fn text_response(text: &str, input: u64, output: u64) -> ModelResponse {
finish_reason: Some("stop".into()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down Expand Up @@ -290,6 +292,7 @@ async fn cost_pricing_records_and_enforces_money_budget() {
requested: None,
source: ModelResolutionSource::RegistryDefault,
}),
continue_turn: None,
};

stack
Expand Down Expand Up @@ -515,6 +518,7 @@ async fn cached_input_budget_blocks_next_call() {
finish_reason: Some("stop".into()),
raw: None,
resolved_model: None,
continue_turn: None,
};
stack
.run_after_model(&mut ctx, &(), &mut resp)
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e_fuzz_graph_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ fn tool_call_response(calls: Vec<ToolCall>) -> ModelResponse {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand All @@ -276,6 +277,7 @@ fn text_response(text: impl Into<String>) -> ModelResponse {
finish_reason: Some("stop".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions tests/e2e_graph_subagent_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ fn tool_call_response(id: &str, name: &str) -> ModelResponse {
finish_reason: Some("tool_calls".to_string()),
raw: None,
resolved_model: None,
continue_turn: None,
}
}

Expand Down
Loading