From 444b3a15a08be886730d64e1d9797a0f96f654fc Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 28 Sep 2026 19:56:11 +0530 Subject: [PATCH 1/2] chore(vendor): bump tinytools to 82c0d97 and tinyinference to c29d511 tinytools 52e9ab1..82c0d97 (#25, #26, #27, #29, #30, #31): text tool-call parsing fixes, including named wrapped in , a call tag on a fence line, and element-form calls for offered tools. tinyinference 6aff95c..c29d511 (#28, #37 in tinyinference-llm; the rest is the new, unused tinyinference-decisions crate): a streamed numeric error code is read as the HTTP status, and a 429's Retry-After reaches the provider error. Refs tinyhumansai/openhuman#6722 Refs tinyhumansai/openhuman#6724 --- vendor/tinyinference | 2 +- vendor/tinytools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyinference b/vendor/tinyinference index 6aff95c54..c29d5118e 160000 --- a/vendor/tinyinference +++ b/vendor/tinyinference @@ -1 +1 @@ -Subproject commit 6aff95c549357d082f27bb32e492994df379c5d6 +Subproject commit c29d5118eddfa12b83c266d7d5f07f6918dc3787 diff --git a/vendor/tinytools b/vendor/tinytools index 52e9ab108..82c0d975e 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 52e9ab10833b5a7a275ccbe8f45dfc8f428128fc +Subproject commit 82c0d975e2667e1c525afe525006134431641785 From 4064174ca866106f6c9d0a9863d7f4589b75ac57 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 28 Sep 2026 19:56:11 +0530 Subject: [PATCH 2/2] test(integration): acceptance tests for the vendor bump through the harness - A named wrapped in (the sanitized production shape) and a todo element call are dispatched under the Python code dialect. - A streamed {"error":{"code":400}} fails the run on the first attempt, while a streamed 503 is still retried (control). Each fails at the previous submodule pins. Refs tinyhumansai/openhuman#6722 Refs tinyhumansai/openhuman#6724 --- .../tests/e2e_stream_error_code.rs | 134 ++++++++++++++++++ .../tests/e2e_tool_dialects.rs | 105 ++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 crates/tinyagents-integration-tests/tests/e2e_stream_error_code.rs diff --git a/crates/tinyagents-integration-tests/tests/e2e_stream_error_code.rs b/crates/tinyagents-integration-tests/tests/e2e_stream_error_code.rs new file mode 100644 index 000000000..7edd2f6ff --- /dev/null +++ b/crates/tinyagents-integration-tests/tests/e2e_stream_error_code.rs @@ -0,0 +1,134 @@ +//! A provider error sent *inside* an HTTP 200 stream, through the harness. +//! +//! OpenRouter-style providers report a request error as an SSE event carrying +//! the HTTP status as a numeric `code` (`{"error":{"code":400,…}}`) while the +//! response itself is a 200. A 400 is deterministic: retrying it re-sends the +//! same bad request. These tests stand up a loopback server that answers every +//! request with that stream and count how many requests the harness makes +//! (openhuman#6724). +//! +//! The server is hand-rolled on `std::net` for the same reason as +//! `provider_local_wire.rs`: the dev-dependency `tokio` has no `net` feature. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tinyagents_harness::runtime::AgentHarness; +use tinyinference_llm::message::Message; +use tinyinference_llm::providers::openai::OpenAiModel; + +/// Answers every request with `sse_body` as a 200 event stream and counts +/// the requests. +struct StreamErrorServer { + base_url: String, + requests: Arc, +} + +impl StreamErrorServer { + fn start(sse_body: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind a loopback port"); + let port = listener.local_addr().expect("local addr").port(); + let requests = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&requests); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + serve(stream, sse_body, &counter); + } + }); + Self { + base_url: format!("http://127.0.0.1:{port}"), + requests, + } + } + + fn requests(&self) -> usize { + self.requests.load(Ordering::SeqCst) + } +} + +/// Reads one request (headers and body), counts it, then writes the stream. +/// Counting happens before the reply so the client cannot observe the reply +/// before the count. +fn serve(mut stream: TcpStream, sse_body: &str, counter: &AtomicUsize) { + let Ok(clone) = stream.try_clone() else { + return; + }; + let mut reader = BufReader::new(clone); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + if let Some(value) = line + .to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + { + content_length = value; + } + } + let mut body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut body); + counter.fetch_add(1, Ordering::SeqCst); + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + sse_body.len(), + sse_body + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +async fn streamed_run_against(server: &StreamErrorServer) -> tinyagents_harness::Result<()> { + let model = OpenAiModel::new("test-key") + .with_base_url(&server.base_url) + .with_model("test-model"); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("remote", Arc::new(model)) + .set_default_model("remote"); + harness + .invoke_streaming_default(&(), vec![Message::user("go")]) + .await + .map(|_| ()) +} + +#[tokio::test] +async fn a_streamed_numeric_400_error_fails_on_the_first_attempt() { + let server = StreamErrorServer::start( + "data: {\"error\":{\"code\":400,\"message\":\"Provider returned error\"}}\n\ndata: [DONE]\n\n", + ); + + let result = streamed_run_against(&server).await; + + assert!(result.is_err(), "a 400 must fail the run"); + assert_eq!( + server.requests(), + 1, + "a deterministic 400 is not retried: exactly one provider request" + ); +} + +#[tokio::test] +async fn a_streamed_numeric_503_error_is_still_retried() { + // Control: the status is read as a status, so a transient 5xx keeps its + // retries. Without this, "one request" above could also mean retries were + // switched off altogether. + let server = StreamErrorServer::start( + "data: {\"error\":{\"code\":503,\"message\":\"Provider returned error\"}}\n\ndata: [DONE]\n\n", + ); + + let result = streamed_run_against(&server).await; + + assert!(result.is_err(), "the 503 persists, so the run still fails"); + assert!( + server.requests() > 1, + "a transient 503 is retried; saw {} request(s)", + server.requests() + ); +} diff --git a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs index 24d2f58f5..18f1cab73 100644 --- a/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs +++ b/crates/tinyagents-integration-tests/tests/e2e_tool_dialects.rs @@ -1691,3 +1691,108 @@ async fn a_language_tagged_fenced_call_is_not_dispatched_unary_or_streamed() { "streamed" ); } + +/// A tool with a real schema that records the arguments of every call it +/// receives, so an acceptance test can assert what was actually dispatched. +struct RecordingTool { + name: &'static str, + schema: serde_json::Value, + calls: Arc>>, +} + +#[async_trait] +impl Tool for RecordingTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "Records its calls." + } + + fn parameters_schema(&self) -> serde_json::Value { + self.schema.clone() + } + + async fn execute(&self, arguments: serde_json::Value) -> anyhow::Result { + self.calls.lock().unwrap().push(arguments); + Ok(ToolResult::success("ok")) + } +} + +/// A `todo` tool and a `search_repositories` tool shaped like the ones in the +/// production thread behind openhuman#6722, under the Python code dialect, +/// with `text` as the model's first reply. Returns each tool's recorded calls. +async fn python_dialect_dispatch(text: &str) -> (Vec, Vec) { + let todo_calls = Arc::new(Mutex::new(Vec::new())); + let search_calls = Arc::new(Mutex::new(Vec::new())); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", Arc::new(ScriptedModel::replies(vec![text, "done"]))) + .set_default_model("mock") + .register_tool(Arc::new(RecordingTool { + name: "todo", + schema: json!({ + "type": "object", + "properties": { "todos": { "type": "array", "items": { "type": "object" } } } + }), + calls: todo_calls.clone(), + })) + .register_tool(Arc::new(RecordingTool { + name: "search_repositories", + schema: json!({ + "type": "object", + "properties": { + "q": { "type": "string" }, + "sort": { "type": "string" }, + "per_page": { "type": "integer" } + }, + "required": ["q"] + }), + calls: search_calls.clone(), + })) + .with_policy(RunPolicy { + tool_dialect: ToolDispatcher::Python, + ..RunPolicy::default() + }); + harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + let todo = todo_calls.lock().unwrap().clone(); + let search = search_calls.lock().unwrap().clone(); + (todo, search) +} + +#[tokio::test] +async fn a_named_invoke_wrapped_in_tool_call_is_dispatched_under_the_python_dialect() { + // The shape of the production record behind openhuman#6722 (sanitized): + // a `todo` element closed by a stray ``, a code fence opened on + // the same line as the next ``, and a named `` with + // `string=` attributes inside it. + let text = "\n\n[{\"status\": \"in_progress\", \"description\": \"Find the repositories\"}, {\"status\": \"pending\", \"description\": \"Fetch the issues\"}]\n\n\n```\n\nexample\nstars\n20\n\n"; + + let (_todo, search) = python_dialect_dispatch(text).await; + + assert_eq!(search.len(), 1, "the wrapped invoke is dispatched once"); + assert_eq!(search[0]["q"], "example"); + assert_eq!(search[0]["sort"], "stars"); + assert_eq!( + search[0]["per_page"], 20, + "string=\"false\" decodes as JSON" + ); +} + +#[tokio::test] +async fn a_todo_element_call_is_dispatched_under_the_python_dialect() { + // The `…` element form the same model used + // for its `todo` updates (openhuman#6722). + let text = "Let me fetch the issues.\n\n\n\n[{\"status\": \"in_progress\", \"description\": \"Fetch the issues\"}, {\"status\": \"pending\", \"description\": \"Build the roadmap\"}]\n\n"; + + let (todo, search) = python_dialect_dispatch(text).await; + + assert_eq!(todo.len(), 1, "the element call is dispatched once"); + assert_eq!(todo[0]["todos"][0]["status"], "in_progress"); + assert_eq!(todo[0]["todos"].as_array().map(Vec::len), Some(2)); + assert!(search.is_empty()); +}