Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
737422b
examples: add local model compatibility probe
senamakel Jul 11, 2026
d443041
harness(openai): degrade named tool_choice + json_object for local se…
senamakel Jul 11, 2026
7093942
harness(openai): auto-degrade tool_choice/json_object on HTTP 400
senamakel Jul 11, 2026
f64bf51
docs(openai): document local-server tool_choice/json_object degradation
senamakel Jul 11, 2026
71a3400
harness(openai): extract inline <think> reasoning tags
senamakel Jul 11, 2026
3e69fdc
harness(openai): test streamed and non-streamed inline reasoning extr…
senamakel Jul 11, 2026
ef2b179
docs(openai): document inline reasoning-tag extraction
senamakel Jul 11, 2026
79069c0
Merge branch 'worktree-agent-a40cafa1fbce6990b' into harness/local-mo…
senamakel Jul 11, 2026
fafb746
harness/openai: tolerate non-conforming tool-call wire fields
senamakel Jul 11, 2026
6a391c0
harness/openai: keep index-0 parallel tool calls distinct in stream
senamakel Jul 11, 2026
2e9748c
harness: recover from malformed tool arguments instead of failing the…
senamakel Jul 11, 2026
8a81608
docs(harness): document local-model tool-call tolerance and invalid-a…
senamakel Jul 11, 2026
fe0e6c7
Merge branch 'harness/openai-local-model-hardening' into harness/loca…
senamakel Jul 11, 2026
92bff1d
harness/agent-loop: auto-retry truncated-empty model responses
senamakel Jul 12, 2026
ac68f76
harness(openai): gate inline think extraction to compat endpoints + f…
senamakel Jul 12, 2026
f22d9c0
docs(openai): reflect hosted-vs-compat gating of think-tag extraction
senamakel Jul 12, 2026
0040fa3
harness/agent-loop: test truncated-empty retry recovery
senamakel Jul 12, 2026
4110669
docs(agent-loop): document truncated-empty response recovery
senamakel Jul 12, 2026
1274879
Merge branch 'harness/local-model-degrade-empty' into harness/local-m…
senamakel Jul 12, 2026
9af7a67
Merge branch 'main' into harness/local-model-compat
senamakel Jul 12, 2026
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
23 changes: 23 additions & 0 deletions docs/modules/harness/tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,29 @@ assert!(run.messages.iter().any(|m| m.text().contains("unknown tool `missing`"))
// A single UnknownToolCall event was recorded with recovery == "tool_error".
```

## Invalid tool-argument recovery

Two distinct failures can affect a provider-supplied call's arguments, and they
are handled separately:

- **Schema-invalid** (well-formed JSON that violates the tool's input schema) is
governed by `RunPolicy::invalid_args: InvalidArgsPolicy`. `Fail` (default,
historical) aborts the turn; `ReturnToolError` injects a repairable tool-error
message (carrying the validation detail and the expected schema) and continues.
- **Unparseable** (malformed JSON the provider could not parse into arguments at
all) is surfaced by the provider as a `ToolCall` with `invalid: Some(reason)`
and the raw string preserved in `arguments`. Small local models (Ollama, LM
Studio, llama.cpp, vLLM) emit this occasionally. The agent loop **always**
recovers here — independent of `InvalidArgsPolicy`, since an unparseable
payload is a transport-level defect, not a schema violation — by injecting the
parse `reason` back to the model as an error tool result so it can retry. The
recovery emits `AgentEvent::InvalidToolArgs { call_id, tool_name, arguments,
error, recovery: "tool_error" }` and consumes one tool-call budget slot, so
`RunLimits::max_tool_calls` bounds the retry loop. Because the call always
resolves, a malformed argument blob can never become a never-resolving tool
call that stalls the loop. See the OpenAI provider README for how the wire
parser produces these invalid calls.

## Tool policy enforcement

Beyond the model-visible `ToolSchema`, each tool advertises a structured,
Expand Down
35 changes: 21 additions & 14 deletions examples/local_model_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ fn first_call(response: &ModelResponse) -> Option<&ToolCall> {
response.message.tool_calls.first()
}


fn msg_text(message: &AssistantMessage) -> String {
message
.content
Expand Down Expand Up @@ -123,7 +122,10 @@ async fn tool_call(model: &OpenAiModel) -> Outcome {
format!(
"no tool_calls; finish={:?} text={:?}",
resp.finish_reason,
msg_text(&resp.message).chars().take(120).collect::<String>()
msg_text(&resp.message)
.chars()
.take(120)
.collect::<String>()
),
),
},
Expand Down Expand Up @@ -240,18 +242,18 @@ async fn parallel_tools(model: &OpenAiModel) -> Outcome {
.iter()
.map(|c| c.id.as_str())
.collect();
let unique = ids
.iter()
.collect::<std::collections::HashSet<_>>()
.len();
let unique = ids.iter().collect::<std::collections::HashSet<_>>().len();
if n >= 2 && unique == n {
ok("parallel-tools", format!("{n} calls, ids={ids:?}"))
} else if n >= 2 {
fail("parallel-tools", format!("duplicate ids: {ids:?}"))
} else {
// Small models often serialize calls across turns; only flag
// this as informational, not a harness bug.
ok("parallel-tools", format!("model made {n} call(s) (model behavior)"))
ok(
"parallel-tools",
format!("model made {n} call(s) (model behavior)"),
)
}
}
Err(e) => fail("parallel-tools", e.to_string()),
Expand Down Expand Up @@ -313,16 +315,16 @@ async fn streaming_tools(model: &OpenAiModel) -> Outcome {
"streaming-tools",
format!("id={:?} args={}", call.id, call.arguments),
),
Some(call) => fail(
"streaming-tools",
format!("bad args: {}", call.arguments),
),
Some(call) => fail("streaming-tools", format!("bad args: {}", call.arguments)),
None => fail(
"streaming-tools",
format!(
"no tool call; finish={:?} text={:?}",
resp.finish_reason,
msg_text(&resp.message).chars().take(120).collect::<String>()
msg_text(&resp.message)
.chars()
.take(120)
.collect::<String>()
),
),
},
Expand All @@ -345,7 +347,9 @@ async fn json_object(model: &OpenAiModel) -> Outcome {
Ok(resp) => {
let text = msg_text(&resp.message);
match serde_json::from_str::<serde_json::Value>(text.trim()) {
Ok(v) if v.is_object() => ok("json-object", text.chars().take(80).collect::<String>()),
Ok(v) if v.is_object() => {
ok("json-object", text.chars().take(80).collect::<String>())
}
_ => fail("json-object", format!("not a JSON object: {text:?}")),
}
}
Expand Down Expand Up @@ -395,7 +399,10 @@ async fn thinking_leak(model: &OpenAiModel) -> Outcome {
if text.contains("<think>") || text.contains("</think>") {
fail(
"thinking-leak",
format!("<think> leaked into text: {:?}", text.chars().take(120).collect::<String>()),
format!(
"<think> leaked into text: {:?}",
text.chars().take(120).collect::<String>()
),
)
} else {
ok("thinking-leak", text.chars().take(60).collect::<String>())
Expand Down
1 change: 1 addition & 0 deletions src/graph/goals/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ mod tool_tests {
id: id.to_string(),
name: name.to_string(),
arguments: args,
invalid: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/graph/todos/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ mod tool_tests {
id: "c1".to_string(),
name: "todo".to_string(),
arguments: args,
invalid: None,
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/harness/agent_loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ deterministic) and enabled per policy via
`retry::RetryPolicy::with_backoff_sleep`. A real provider integration retries
after a genuine, growing delay while unit tests stay sleep-free.

## Truncated-empty recovery

Local reasoning models (for example `qwen3` via Ollama) intermittently spend
their entire token budget on the hidden reasoning channel and return
`finish_reason == "length"` with no visible text, no tool calls, and no
structured output — a result useless to every caller. Before finalizing such a
turn (and before structured extraction, which would otherwise fail on the empty
completion) the loop retries the model call up to
`runtime::RunPolicy::truncated_empty_retries` times (default `1`, so two
attempts total). Each retry drops the useless assistant row, doubles the
request's `max_tokens` when one was set — clamped at 4x the original cap, and a
deliberate override of the per-turn output cap that caused the truncation — or
re-issues unchanged when no budget was set (the failure is stochastic, so a
plain retry still helps). Each attempt counts as a model call and emits
`AgentEvent::RetryScheduled`. The retry runs *before*
`RunPolicy::error_on_empty_response`; only once the retries are exhausted does
that guard (if enabled) turn the still-blank final into
`TinyAgentsError::EmptyResponse`. Set `truncated_empty_retries` to `0` to
restore exact-replay behavior. The recovery lives in the shared `run_loop`, so
it applies identically to the unary and streaming paths.

## Public surface

- `AgentHarness::invoke(state, ctx_data, config, input) -> Result<AgentRun>` —
Expand Down
63 changes: 62 additions & 1 deletion src/harness/agent_loop/run_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
status.mark_running(HarnessPhase::Middleware);
self.middleware.run_before_agent(ctx, state).await?;

// Truncated-empty recovery state (see `RunPolicy::truncated_empty_retries`).
// These persist across the retry `continue` within a single logical turn:
// `boosted_max_tokens` overrides the next request's cap, `truncation_base`
// records the original cap so growth stays clamped at 4x, and the counter
// bounds how many times we re-issue the call.
let mut truncated_empty_retries_used: u32 = 0;
let mut boosted_max_tokens: Option<u32> = None;
let mut truncation_base: Option<u32> = None;
Comment on lines +55 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset truncated-empty retry state after the retried turn

These retry variables live for the entire agent loop, not just the logical turn described in the comment. If a truncated-empty attempt is retried and the retry returns tool calls, the run continues after executing the tools with truncated_empty_retries_used already consumed and boosted_max_tokens still set, so later model turns can exceed the configured per-turn cap and cannot use the configured truncated-empty recovery if they hit the same failure. Reset this state once a non-truncated response proceeds to tool execution (or scope it to one turn).

Useful? React with 👍 / 👎.


loop {
// Safe cancellation checkpoint: if an orchestrator requested
// cooperative cancellation, stop before doing any further work
Expand Down Expand Up @@ -97,6 +106,15 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
request.max_tokens =
Some(request.max_tokens.map_or(cap, |current| current.min(cap)));
}
// Truncated-empty recovery: a prior attempt this turn exhausted its
// token budget on the (hidden) reasoning channel and returned no
// usable content, so re-issue the call with a larger cap. The boost
// deliberately wins over the per-turn cap above — that cap is what
// truncated the response — and was already clamped to 4x the
// original budget when it was computed below.
if let Some(boost) = boosted_max_tokens {
request.max_tokens = Some(boost);
}

status.mark_running(HarnessPhase::Middleware);
self.middleware
Expand Down Expand Up @@ -197,6 +215,10 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
.capture
.model_io
.then(|| serde_json::to_value(&request.messages).unwrap_or(Value::Null));
// Snapshot the effective token cap before `request` moves into the
// model-wrap onion, so truncated-empty recovery can compute the next
// (doubled) budget from what was actually sent.
let attempt_max_tokens = request.max_tokens;
let mut response = self
.middleware
.run_wrapped_model(ctx, state, request, &base)
Expand Down Expand Up @@ -225,7 +247,7 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
.model_io
.then(|| serde_json::to_value(&response.message).unwrap_or(Value::Null));
let record = ctx.emit(AgentEvent::ModelCompleted {
call_id,
call_id: call_id.clone(),
started_at_ms: Some(model_started_at_ms),
usage: response.usage,
input: captured_input,
Expand Down Expand Up @@ -272,6 +294,45 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
);

if tool_calls.is_empty() || structured_tool_hit {
// Truncated-empty recovery (runs before structured extraction,
// which would otherwise fail on the empty completion). A local
// reasoning model can burn the whole token budget on its hidden
// reasoning channel and return `finish_reason == "length"` with
// no visible text, no tool calls, and no structured output — a
// result useless to every caller. Retry the call (bumping the
// token budget when one was set) instead of surfacing the blank.
// A structured tool hit carries a real payload, so it is never
// treated as truncated-empty.
let truncated_empty = tool_calls.is_empty()
&& response.finish_reason.as_deref() == Some("length")
&& response.text().trim().is_empty();
if truncated_empty
&& truncated_empty_retries_used < self.policy.truncated_empty_retries
{
// Drop the useless empty assistant row appended above so the
// retry re-sends the identical transcript.
messages.pop();
truncated_empty_retries_used += 1;
Comment on lines +314 to +315

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass cache when retrying an uncapped truncated-empty call

When a response cache is attached and the original request has no max_tokens, the first truncated-empty response is cached by invoke_model_with_retry before this recovery branch runs; after messages.pop(), the retry rebuilds the identical request, so it is served from that cached empty response instead of reissuing the stochastic provider call. That makes the documented plain retry for uncapped calls ineffective (and can keep returning a blank final) unless caching is disabled or the retry bypasses/evicts the cached truncated-empty response.

Useful? React with 👍 / 👎.

// Grow the token budget when the request set one: double it,
// clamped at 4x the original cap. An unset budget stays unset
// (a plain retry is still worthwhile — the failure is
// stochastic).
if let Some(sent) = attempt_max_tokens {
let base = *truncation_base.get_or_insert(sent);
let next = boosted_max_tokens
.unwrap_or(sent)
.saturating_mul(2)
.min(base.saturating_mul(4));
boosted_max_tokens = Some(next);
}
let record = ctx.emit(AgentEvent::RetryScheduled {
call_id: call_id.clone(),
attempt: truncated_empty_retries_used as usize,
});
status.set_last_event(record.id);
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
Loading