Skip to content

Commit 1a9e8e2

Browse files
committed
feat: align built-in tool contracts
1 parent aa656bd commit 1a9e8e2

23 files changed

Lines changed: 1265 additions & 582 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added strict `generate_object` argument validation, root JSON Schema support
13+
for references, composition, constants, arrays, and scalars, and active
14+
generation-timeout propagation through governed LLM clients.
15+
- Added typed HTTP cancellation, transport, and retry-exhaustion status errors,
16+
including the SDK-visible `transport` tool error kind.
17+
18+
### Changed
19+
20+
- Required `parallel_task` calls to contain 2-32 independent foreground
21+
branches and reject invalid timeout and partial-success thresholds.
22+
- Kept branch retry ownership inside provider and child runtimes; the parallel
23+
fan-out layer no longer replays failures based on rendered error messages.
24+
- Synchronized the Node `ToolErrorKind` declarations with every Rust wire
25+
variant, including cancellation, partial failure, rate limiting, and
26+
transport failures.
27+
28+
### Fixed
29+
30+
- Preserved local `$defs` and `definitions` references when provider-facing
31+
structured-output schemas wrap root arrays or scalar values.
32+
- Preserved typed transport and cancellation failures across OpenAI-compatible
33+
and Anthropic blocking and streaming request paths.
34+
1035
## [6.5.1] - 2026-07-28
1136

1237
### Added

core/src/agent/llm_invoker.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::llm::{
1515
use async_trait::async_trait;
1616
use std::future::Future;
1717
use std::sync::Arc;
18+
use std::time::Duration;
1819
use tokio::sync::mpsc;
1920
use tokio::task::JoinHandle;
2021
use tokio_util::sync::CancellationToken;
@@ -171,6 +172,18 @@ impl LlmClient for LlmInvoker {
171172
self.inner.model_generation_concurrency()
172173
}
173174

175+
fn fork_for_session(&self, session_id: &str) -> Option<Arc<dyn LlmClient>> {
176+
self.inner
177+
.fork_for_session(session_id)
178+
.map(|inner| Arc::new(Self::new(inner, self.invocation.clone())) as Arc<dyn LlmClient>)
179+
}
180+
181+
fn with_active_generation_timeout(&self, timeout: Duration) -> Option<Arc<dyn LlmClient>> {
182+
self.inner
183+
.with_active_generation_timeout(timeout)
184+
.map(|inner| Arc::new(Self::new(inner, self.invocation.clone())) as Arc<dyn LlmClient>)
185+
}
186+
174187
async fn complete(
175188
&self,
176189
messages: &[Message],
@@ -510,4 +523,34 @@ mod tests {
510523
vec!["child-session".to_string()]
511524
);
512525
}
526+
527+
#[tokio::test]
528+
async fn governed_client_keeps_provider_session_forking_available() {
529+
let observed_sessions = Arc::new(Mutex::new(Vec::new()));
530+
let client: Arc<dyn LlmClient> = Arc::new(SessionBindingClient {
531+
bound_session: None,
532+
observed_sessions: Arc::clone(&observed_sessions),
533+
});
534+
let invocation = InvocationContext::new(
535+
Arc::<str>::from("parent-run"),
536+
Arc::<str>::from("parent-session"),
537+
CancellationToken::new(),
538+
None,
539+
InvocationGovernance::default(),
540+
);
541+
let governed: Arc<dyn LlmClient> = Arc::new(LlmInvoker::new(client, invocation));
542+
let forked = governed
543+
.fork_for_session("nested-child-session")
544+
.expect("governed client must preserve provider session forking");
545+
546+
forked
547+
.complete(&[Message::user("hello")], None, &[])
548+
.await
549+
.unwrap();
550+
551+
assert_eq!(
552+
*observed_sessions.lock().unwrap(),
553+
vec!["nested-child-session".to_string()]
554+
);
555+
}
513556
}

core/src/agent_api/tests.rs

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5767,6 +5767,7 @@ async fn test_registered_parallel_task_inherits_final_confirmation_manager() {
57675767
serde_json::json!({"file_path": "note.txt"}),
57685768
),
57695769
scripted_text_response("child read complete"),
5770+
scripted_text_response("control child complete"),
57705771
]));
57715772
let worker = crate::subagent::WorkerAgentSpec::custom(
57725773
"needs-parent-hitl",
@@ -5777,6 +5778,7 @@ async fn test_registered_parallel_task_inherits_final_confirmation_manager() {
57775778
let opts = SessionOptions::new()
57785779
.with_llm_client(client)
57795780
.with_worker_agent(worker)
5781+
.with_max_parallel_tasks(1)
57805782
.with_confirmation_policy(crate::hitl::ConfirmationPolicy::enabled());
57815783
let session = agent
57825784
.session_async(dir.path().to_string_lossy().to_string(), Some(opts))
@@ -5786,12 +5788,20 @@ async fn test_registered_parallel_task_inherits_final_confirmation_manager() {
57865788
let (_rx, join) = session.tool_with_events(
57875789
"parallel_task",
57885790
serde_json::json!({
5789-
"tasks": [{
5790-
"agent": "needs-parent-hitl",
5791-
"description": "Read a note",
5792-
"prompt": "Read note.txt",
5793-
"max_steps": 3
5794-
}]
5791+
"tasks": [
5792+
{
5793+
"agent": "needs-parent-hitl",
5794+
"description": "Read a note",
5795+
"prompt": "Read note.txt",
5796+
"max_steps": 3
5797+
},
5798+
{
5799+
"agent": "needs-parent-hitl",
5800+
"description": "Independent control",
5801+
"prompt": "Return a short control result.",
5802+
"max_steps": 1
5803+
}
5804+
]
57955805
}),
57965806
);
57975807

@@ -5840,10 +5850,11 @@ async fn test_dynamic_workflow_parallel_explore_can_use_readonly_web_tools() {
58405850
serde_json::json!({"url": "not-a-url"}),
58415851
),
58425852
scripted_text_response("explore web fetch completed"),
5853+
scripted_text_response("independent web review completed"),
58435854
]));
58445855
let opts = SessionOptions::new()
58455856
.with_llm_client(client)
5846-
.with_max_parallel_tasks(2)
5857+
.with_max_parallel_tasks(1)
58475858
.with_manual_delegation_enabled(true);
58485859
let session = agent
58495860
.session_async(dir.path().to_string_lossy().to_string(), Some(opts))
@@ -5863,12 +5874,20 @@ async function run(ctx, inputs) {
58635874
step_id: "web_research",
58645875
step_name: "parallel_task",
58655876
input: {
5866-
tasks: [{
5867-
agent: "explore",
5868-
description: "Fetch web evidence",
5869-
prompt: "Use web_fetch on the requested URL and summarize the result.",
5870-
max_steps: 3,
5871-
}],
5877+
tasks: [
5878+
{
5879+
agent: "explore",
5880+
description: "Fetch web evidence",
5881+
prompt: "Use web_fetch on the requested URL and summarize the result.",
5882+
max_steps: 3,
5883+
},
5884+
{
5885+
agent: "explore",
5886+
description: "Review web evidence",
5887+
prompt: "Return a short independent review.",
5888+
max_steps: 1,
5889+
},
5890+
],
58725891
},
58735892
retry: { max_attempts: 1, delay_ms: 0 },
58745893
};
@@ -5934,13 +5953,14 @@ async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_permission
59345953
serde_json::json!({"command": "echo inherited-dynamic-workflow-deep-research"}),
59355954
),
59365955
scripted_text_response("deep-research child bash completed"),
5956+
scripted_text_response("deep-research control child completed"),
59375957
]));
59385958
let policy = crate::permissions::PermissionPolicy::new().allow("bash(*)");
59395959
let opts = SessionOptions::new()
59405960
.with_llm_client(client)
59415961
.with_permission_policy(policy)
59425962
.with_workspace_backend(services)
5943-
.with_max_parallel_tasks(2)
5963+
.with_max_parallel_tasks(1)
59445964
.with_manual_delegation_enabled(true);
59455965
let session = agent
59465966
.session_async(dir.path().to_string_lossy().to_string(), Some(opts))
@@ -5960,12 +5980,20 @@ async function run(ctx, inputs) {
59605980
step_id: "deep_research",
59615981
step_name: "parallel_task",
59625982
input: {
5963-
tasks: [{
5964-
agent: "deep-research",
5965-
description: "Use inherited parent tool policy",
5966-
prompt: "Run the harmless bash command requested by this regression test.",
5967-
max_steps: 3,
5968-
}],
5983+
tasks: [
5984+
{
5985+
agent: "deep-research",
5986+
description: "Use inherited parent tool policy",
5987+
prompt: "Run the harmless bash command requested by this regression test.",
5988+
max_steps: 3,
5989+
},
5990+
{
5991+
agent: "deep-research",
5992+
description: "Verify inherited policy independently",
5993+
prompt: "Return a short control result.",
5994+
max_steps: 1,
5995+
},
5996+
],
59695997
},
59705998
retry: { max_attempts: 1, delay_ms: 0 },
59715999
};
@@ -6032,12 +6060,13 @@ async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_write_perm
60326060
}),
60336061
),
60346062
scripted_text_response("deep-research child write completed"),
6063+
scripted_text_response("deep-research write control completed"),
60356064
]));
60366065
let policy = crate::permissions::PermissionPolicy::new().allow("write(*)");
60376066
let opts = SessionOptions::new()
60386067
.with_llm_client(client)
60396068
.with_permission_policy(policy)
6040-
.with_max_parallel_tasks(2)
6069+
.with_max_parallel_tasks(1)
60416070
.with_manual_delegation_enabled(true);
60426071
let session = agent
60436072
.session_async(dir.path().to_string_lossy().to_string(), Some(opts))
@@ -6057,12 +6086,20 @@ async function run(ctx, inputs) {
60576086
step_id: "deep_research",
60586087
step_name: "parallel_task",
60596088
input: {
6060-
tasks: [{
6061-
agent: "deep-research",
6062-
description: "Use inherited parent write policy",
6063-
prompt: "Write the requested child evidence file.",
6064-
max_steps: 3,
6065-
}],
6089+
tasks: [
6090+
{
6091+
agent: "deep-research",
6092+
description: "Use inherited parent write policy",
6093+
prompt: "Write the requested child evidence file.",
6094+
max_steps: 3,
6095+
},
6096+
{
6097+
agent: "deep-research",
6098+
description: "Verify inherited write policy independently",
6099+
prompt: "Return a short control result.",
6100+
max_steps: 1,
6101+
},
6102+
],
60666103
},
60676104
retry: { max_attempts: 1, delay_ms: 0 },
60686105
};

core/src/llm/anthropic.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,17 @@ impl AnthropicClient {
209209
))
210210
}
211211
}
212-
Err(e) => AttemptOutcome::Fatal(e),
212+
Err(e) => {
213+
if crate::llm::http::is_retryable_http_failure(&e) {
214+
AttemptOutcome::Retryable {
215+
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
216+
body: format!("network error: {e}"),
217+
retry_after: None,
218+
}
219+
} else {
220+
AttemptOutcome::Fatal(e)
221+
}
222+
}
213223
}
214224
}
215225
})
@@ -351,28 +361,24 @@ impl AnthropicClient {
351361
async move {
352362
let resp = tokio::select! {
353363
_ = cancel_token.cancelled() => {
354-
return AttemptOutcome::Fatal(anyhow::anyhow!("HTTP request cancelled"));
364+
return AttemptOutcome::Fatal(anyhow::Error::new(
365+
crate::llm::HttpClientError::cancelled(
366+
"Anthropic streaming HTTP request",
367+
),
368+
));
355369
}
356370
result = http.post_streaming(url, headers, request_body, cancel_token.clone()) => {
357371
match result {
358372
Ok(r) => r,
359373
Err(e) => {
360-
// A transient network error (timeout, reset,
361-
// mid-flight drop — common on throttled
362-
// endpoints) carries no HTTP status. Retry it
363-
// with backoff like 429/5xx instead of failing
364-
// the turn; a real fatal error still bails.
365-
return if crate::retry::is_transient_error(&e) {
374+
return if crate::llm::http::is_retryable_http_failure(&e) {
366375
AttemptOutcome::Retryable {
367376
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
368377
body: format!("network error: {e}"),
369378
retry_after: None,
370379
}
371380
} else {
372-
AttemptOutcome::Fatal(anyhow::anyhow!(
373-
"HTTP request failed: {}",
374-
e
375-
))
381+
AttemptOutcome::Fatal(e.context("HTTP request failed"))
376382
};
377383
}
378384
}

0 commit comments

Comments
 (0)