Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
447fb45
fix(middleware): remove unused library modules
senamakel Sep 30, 2026
6465d30
fix(middleware): handle missing wrap_up field gracefully
senamakel Sep 30, 2026
7a30407
fix(middleware): handle missing wrap_up state gracefully
senamakel Sep 30, 2026
dea6374
fix(middleware): correct argument recovery for repeat progress and wr…
senamakel Sep 30, 2026
51c9207
fix(middleware): remove credential scrub and repeat progress middleware
senamakel Sep 30, 2026
81edef9
feat(harness): add configurable credential scrubber and expose middle…
senamakel Sep 30, 2026
8ce26b7
feat(discover): add embedding-based tool ranker
senamakel Sep 30, 2026
53cb2f3
fix(tool/discover): handle empty embedding list in ranker
senamakel Sep 30, 2026
495f2cf
chore: files changed crates/tinyagents-harness/src/middleware/library…
senamakel Sep 30, 2026
487684c
chore(tests): reorganise middleware test modules and improve credenti…
senamakel Sep 30, 2026
88c7925
test: add test modules for arg recovery, artifact toc, and wrap up mi…
senamakel Sep 30, 2026
7b5c3f7
feat(harness): add artifact table of contents test module
senamakel Sep 30, 2026
269a7d6
fix(test): correct image trimming test to verify actual output
senamakel Sep 30, 2026
0f99737
chore(deps): adopt Rust 1.79 div_ceil and let-chains syntax
senamakel Sep 30, 2026
06d7ee0
Merge remote-tracking branch 'refs/remotes/upstream/oh-extract-leaves…
senamakel Sep 30, 2026
b2e1180
feat(middleware): scrub credentials in multi-block tool results and c…
senamakel Sep 30, 2026
63bac87
fix(middleware): handle store errors gracefully in artifact TOC
senamakel Sep 30, 2026
6c7739e
fix(middleware): handle non-text content blocks in credential scrub a…
senamakel Sep 30, 2026
3bb65e0
feat(artifact_toc): emit warning when all artifact index reads fail
senamakel Sep 30, 2026
74207df
fix(middleware): track wrap-up firing per run context
senamakel Sep 30, 2026
43ca45a
feat(harness): support concurrent agent runs in repeat-progress middl…
senamakel Sep 30, 2026
ffda7e4
test(repeat-progress): set instance id in test context
senamakel Sep 30, 2026
f186c59
refactor(middleware): flatten nested if-let with let-chain syntax
senamakel Sep 30, 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tinyagents-harness/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tracing = { workspace = true }
tinyagents-definition = { path = "../tinyagents-definition", version = "2.1.2" }
tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.4.1", default-features = false }
tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" }
tinyinference-core = { path = "../../vendor/tinyinference/crates/tinyinference-core", version = "0.3.0" }
tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinference-embeddings", version = "0.3.0" }
tinyinference-image = { path = "../../vendor/tinyinference/crates/tinyinference-image", version = "0.3.0", optional = true }
tinyinference-video = { path = "../../vendor/tinyinference/crates/tinyinference-video", version = "0.3.0", optional = true }
Expand Down
180 changes: 180 additions & 0 deletions crates/tinyagents-harness/src/middleware/library/arg_recovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! [`ArgRecoveryMiddleware`]: repair malformed tool-call arguments before the
//! harness runs its fatal schema gate. Generic over the run-context payload.

use std::sync::Arc;

use async_trait::async_trait;

use crate::context::RunContext;
use crate::error::Result as TaResult;
use crate::middleware::Middleware;
use tinyinference_llm::tool::{ToolCall as TaToolCall, ToolSchema};

use tinytools::Tool;

/// `before_tool`: repair a tool call's arguments *before* the harness runs its
/// fatal pre-execution schema gate (issues #4249 / #4451). A model can emit
/// arguments the model adapter parses to a non-object `Value` — invalid JSON
/// decodes to `Value::Null`, and some providers emit the whole arguments blob as
/// a JSON-encoded *string* (optionally wrapped in a ```json markdown fence). Left
/// alone the harness rejects those against an object schema and aborts the whole
/// turn.
///
/// Recovery, in order:
/// 1. Already a JSON object → leave it (the common, valid case).
/// 2. A JSON-encoded string (optionally fenced) that decodes to an object →
/// decode and use it.
/// 3. Otherwise a non-object whose tool schema declares **no** required fields →
/// coerce to `{}` (legacy-engine parity: the tool runs and produces its own
/// recoverable error).
/// 4. Otherwise (non-object + schema has required fields) → leave the arguments
/// untouched so the crate's `InvalidArgsPolicy::ReturnToolError` path reports
/// the original validation failure. Coercing to `{}` would discard the raw
/// malformed value without improving recovery.
pub struct ArgRecoveryMiddleware {
/// The same `Arc`-shared tool sets the runner registers, used to resolve a
/// call's schema so we can tell whether coercing to `{}` is safe.
tool_sets: Vec<Arc<Vec<Box<dyn Tool>>>>,
}

impl ArgRecoveryMiddleware {
/// Build the middleware over the runner's shared tool sets.
pub fn new(tool_sets: Vec<Arc<Vec<Box<dyn Tool>>>>) -> Self {
Self { tool_sets }
}

fn schema_for(&self, name: &str) -> Option<ToolSchema> {
schema_for_tool(&self.tool_sets, name)
}
}

#[async_trait]
impl<C: Send + Sync> Middleware<(), C> for ArgRecoveryMiddleware {
fn name(&self) -> &str {
"arg_recovery"
}

async fn before_tool(
&self,
_ctx: &mut RunContext<C>,
_state: &(),
call: &mut TaToolCall,
) -> TaResult<()> {
// (1) Already valid object shape — nothing to do.
if call.arguments.is_object() {
return Ok(());
}

// (2) JSON-encoded-string arguments (optionally markdown-fenced): decode
// and adopt the inner object.
if let Some(raw) = call.arguments.as_str()
&& let Some(obj) = recover_object_from_json_string(raw)
{
tracing::debug!(
tool = call.name.as_str(),
"[tinyagents::mw] arg_recovery: decoded JSON-encoded-string tool arguments to object"
);
call.arguments = obj;
return Ok(());
}

// (3) Non-object with a permissive schema (no required fields): coerce to
// `{}` so the tool runs and produces its own recoverable error — engine
// parity for tools that predate the schema gate.
let has_required = self
.schema_for(&call.name)
.map(|schema| schema_has_required_fields(&schema.parameters))
.unwrap_or(false);
if !has_required {
tracing::debug!(
tool = call.name.as_str(),
"[tinyagents::mw] arg_recovery: coercing non-object tool arguments to {{}} (schema declares no required fields)"
);
call.arguments = serde_json::json!({});
return Ok(());
}

// (4) Non-object + schema has required fields: leave untouched. The
// crate admission policy surfaces a descriptive, recoverable tool
// result without executing the real tool.
tracing::debug!(
tool = call.name.as_str(),
args_kind = json_value_kind(&call.arguments),
"[tinyagents::mw] arg_recovery: leaving non-object tool arguments for crate invalid-args recovery"
);
Ok(())
}
}

/// Resolves the harness [`ToolSchema`] for `name` across the runner's shared
/// tool sets.
///
/// It constructs the canonical TinyInference schema directly from the
/// TinyTools declaration, so the `parameters` we validate against are
/// byte-identical to the ones the crate's fatal `validate_call` gate checks —
/// otherwise our pre-validation could disagree with the crate and either miss
/// a fatal case or stub a call the crate still rejects.
fn schema_for_tool(tool_sets: &[Arc<Vec<Box<dyn Tool>>>], name: &str) -> Option<ToolSchema> {
tool_sets
.iter()
.flat_map(|set| set.iter())
.find(|tool| tool.name() == name)
.map(|tool| {
let spec = tool.spec();
ToolSchema::new(spec.name, spec.description, spec.parameters)
})
}

/// Whether a tool's JSON-schema `parameters` declares any `required` field.
fn schema_has_required_fields(parameters: &serde_json::Value) -> bool {
parameters
.get("required")
.and_then(serde_json::Value::as_array)
.map(|required| required.iter().any(serde_json::Value::is_string))
.unwrap_or(false)
}

/// Attempts to recover a JSON **object** from a string-encoded arguments payload:
/// providers sometimes emit the whole arguments blob as a JSON string, optionally
/// wrapped in a ```json markdown fence. Returns `None` when the string does not
/// decode to a JSON object.
fn recover_object_from_json_string(raw: &str) -> Option<serde_json::Value> {
let candidate = strip_code_fence(raw);
serde_json::from_str::<serde_json::Value>(candidate)
.ok()
.filter(serde_json::Value::is_object)
}

/// Strips a surrounding markdown code fence (```` ```json … ``` ````) and its
/// optional language tag, returning the inner text. A string with no fence is
/// returned trimmed and unchanged.
fn strip_code_fence(raw: &str) -> &str {
let trimmed = raw.trim();
let Some(after_open) = trimmed.strip_prefix("```") else {
return trimmed;
};
// Drop an optional language tag on the opening fence line (e.g. `json`).
let body = match after_open.find('\n') {
Some(newline)
if after_open[..newline]
.chars()
.all(|c| c.is_ascii_alphanumeric()) =>
{
&after_open[newline + 1..]
}
_ => after_open,
};
body.trim().strip_suffix("```").unwrap_or(body).trim()
}

/// A short, human-readable kind label for a JSON value, for debug logging.
fn json_value_kind(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Tests for [`ArgRecoveryMiddleware`].

use std::sync::Arc;

use serde_json::json;

use super::*;
use crate::context::{RunConfig, RunContext};
use crate::middleware::Middleware;
use tinyinference_llm::tool::ToolCall;
use tinytools::{Tool, ToolResult};

struct Stub {
name: &'static str,
required: bool,
}

#[async_trait::async_trait]
impl Tool for Stub {
fn name(&self) -> &str {
self.name
}

fn description(&self) -> &str {
"stub"
}

fn parameters_schema(&self) -> serde_json::Value {
if self.required {
json!({"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]})
} else {
json!({"type": "object", "properties": {}})
}
}

async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("ok"))
}
}

fn mw() -> ArgRecoveryMiddleware {
let set: Vec<Box<dyn Tool>> = vec![
Box::new(Stub {
name: "loose",
required: false,
}),
Box::new(Stub {
name: "strict",
required: true,
}),
];
ArgRecoveryMiddleware::new(vec![Arc::new(set)])
}

async fn recover(name: &str, arguments: serde_json::Value) -> serde_json::Value {
let mut ctx: RunContext = RunContext::new(RunConfig::new("mw-test"), ());
let mut call = ToolCall::new("c1", name, arguments);
mw().before_tool(&mut ctx, &(), &mut call).await.unwrap();
call.arguments
}

#[tokio::test]
async fn an_object_is_left_alone() {
assert_eq!(
recover("strict", json!({"q": "x"})).await,
json!({"q": "x"})
);
}

#[tokio::test]
async fn a_json_encoded_string_is_decoded() {
assert_eq!(
recover("strict", json!("{\"q\": \"x\"}")).await,
json!({"q": "x"})
);
}

#[tokio::test]
async fn a_fenced_json_string_is_decoded() {
assert_eq!(
recover("strict", json!("```json\n{\"q\": \"x\"}\n```")).await,
json!({"q": "x"})
);
}

#[tokio::test]
async fn a_non_object_for_a_schema_without_required_fields_becomes_an_empty_object() {
assert_eq!(recover("loose", json!(null)).await, json!({}));
}

#[tokio::test]
async fn a_non_object_for_a_schema_with_required_fields_is_left_for_the_crate_gate() {
assert_eq!(recover("strict", json!(null)).await, json!(null));
}

#[tokio::test]
async fn an_unknown_tool_is_treated_as_permissive() {
assert_eq!(recover("nope", json!(3)).await, json!({}));
}
Loading
Loading