diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index 45a28293d..decbfbc9b 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -83,9 +83,9 @@ pub enum ToolRegistryError { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ToolCallDispatchMode { /// Wait for the complete assistant response before starting any tools. - #[default] AfterResponse, /// Start calls at trustworthy provider per-call completion boundaries. + #[default] OnToolCallComplete, } diff --git a/crates/agen/tests/parallel_execution_test.rs b/crates/agen/tests/parallel_execution_test.rs index 279c65eeb..de3156607 100644 --- a/crates/agen/tests/parallel_execution_test.rs +++ b/crates/agen/tests/parallel_execution_test.rs @@ -20,8 +20,8 @@ use agen::tool::{ ToolResultDisposition, }; use agen::{ - Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolCallDispatchMode, - ToolExecutionPolicy, + Engine, EngineConfig, EngineError, EngineRunExit, History, Item, RunInterruptionReason, + ToolCallDispatchMode, ToolExecutionPolicy, }; use async_trait::async_trait; @@ -465,12 +465,13 @@ impl Tool for BarrierTool { // ============================================================================= #[tokio::test] -async fn default_dispatch_waits_for_response_completion() { +async fn explicit_after_response_dispatch_waits_for_response_completion() { let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); let tool = BarrierTool::new("barrier_default"); let probe = tool.clone(); let mut engine = Engine::new(client); engine.set_max_turns(Some(1)); + engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse); engine.register_tool(tool.definition()); let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -528,7 +529,6 @@ async fn turn_end_policy_incompatibility_visibly_falls_back_before_side_effects( let tool = BarrierTool::new("turn_end_fallback"); let probe = tool.clone(); let mut engine = Engine::new(client); - engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete); engine.set_interceptor(TurnEndPause); engine.register_tool(tool.definition()); let (warning_tx, mut warning_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -762,7 +762,11 @@ async fn blocking_pre_tool_policy_keeps_receiving_stream_and_services_cancel() { } #[tokio::test] -async fn per_block_dispatch_starts_siblings_without_waiting_for_stream_or_prior_result() { +async fn default_per_block_dispatch_starts_siblings_without_waiting_for_stream_or_prior_result() { + assert_eq!( + EngineConfig::default().tool_call_dispatch, + ToolCallDispatchMode::OnToolCallComplete + ); let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); let first = BarrierTool::new("barrier_a"); let second = BarrierTool::new("barrier_b"); @@ -770,7 +774,6 @@ async fn per_block_dispatch_starts_siblings_without_waiting_for_stream_or_prior_ let second_probe = second.clone(); let mut engine = Engine::new(client); engine.set_max_turns(Some(1)); - engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete); engine.register_tool(first.definition()); engine.register_tool(second.definition()); @@ -1641,13 +1644,12 @@ async fn clean_eof_without_provider_completion_after_early_start_stops_without_c } #[tokio::test] -async fn unsupported_provider_visibly_defers_opt_in_dispatch() { +async fn unsupported_provider_visibly_defers_default_dispatch() { let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::ResponseComplete); let tool = BarrierTool::new("barrier_fallback"); let probe = tool.clone(); let mut engine = Engine::new(client); engine.set_max_turns(Some(1)); - engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete); engine.register_tool(tool.definition()); let warnings = Arc::new(Mutex::new(Vec::new())); let warning_probe = warnings.clone(); diff --git a/crates/agen/tests/response_completion_compatibility_test.rs b/crates/agen/tests/response_completion_compatibility_test.rs index c7b7d8fac..23dd157d2 100644 --- a/crates/agen/tests/response_completion_compatibility_test.rs +++ b/crates/agen/tests/response_completion_compatibility_test.rs @@ -80,33 +80,38 @@ async fn assert_response_complete_scheme_accepts_clean_eof( ToolCallCompletionSupport::ResponseComplete ); - for mode in [ - ToolCallDispatchMode::AfterResponse, - ToolCallDispatchMode::OnToolCallComplete, + for (label, mode) in [ + ("default on_tool_call_complete fallback", None), + ( + "explicit after_response", + Some(ToolCallDispatchMode::AfterResponse), + ), ] { let client = SchemeReplayClient::new(scheme.clone(), vec![frames.clone()]); let probe = client.clone(); let mut engine = Engine::new(client); - engine.set_tool_call_dispatch_mode(mode); + if let Some(mode) = mode { + engine.set_tool_call_dispatch_mode(mode); + } let mut history = History::new(); let output = engine.run(&mut history, "hello").await; assert!( matches!(output.result, EngineRunExit::Finished), - "{mode:?} should accept the response-complete scheme's clean EOF, got {:?}", + "{label} should accept the response-complete scheme's clean EOF, got {:?}", output.result ); assert_eq!( probe.stream_count(), 1, - "{mode:?} must not open a continuation request after the adapter's normal EOF" + "{label} must not open a continuation request after the adapter's normal EOF" ); } } #[tokio::test] -async fn openai_chat_clean_eof_completes_in_default_and_opt_in_fallback_modes() { +async fn openai_chat_clean_eof_completes_in_default_and_explicit_after_response_modes() { assert_response_complete_scheme_accepts_clean_eof( OpenAIScheme::new(), vec![ @@ -124,7 +129,7 @@ async fn openai_chat_clean_eof_completes_in_default_and_opt_in_fallback_modes() } #[tokio::test] -async fn gemini_clean_eof_completes_in_default_and_opt_in_fallback_modes() { +async fn gemini_clean_eof_completes_in_default_and_explicit_after_response_modes() { assert_response_complete_scheme_accepts_clean_eof( GeminiScheme::new(), vec![ diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index d291883ef..75caeee9d 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -1348,35 +1348,78 @@ mod tests { } #[test] - fn tool_call_dispatch_defaults_merges_and_round_trips() { + fn tool_call_dispatch_default_merge_and_snapshots() { + assert_eq!( + EngineManifestConfig::default().tool_call_dispatch, + None, + "partial configs must preserve omission until final resolution" + ); + let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); assert_eq!( manifest.engine.tool_call_dispatch, - crate::ToolCallDispatchMode::AfterResponse + crate::ToolCallDispatchMode::OnToolCallComplete ); let lower = WorkerManifestConfig { + engine: EngineManifestConfig { + tool_call_dispatch: Some(crate::ToolCallDispatchMode::AfterResponse), + ..Default::default() + }, + ..Default::default() + }; + let merged = lower.clone().merge(WorkerManifestConfig::default()); + assert_eq!( + merged.engine.tool_call_dispatch, + Some(crate::ToolCallDispatchMode::AfterResponse), + "an omitted upper profile must not replace a lower explicit value" + ); + let overridden = lower.merge(WorkerManifestConfig { engine: EngineManifestConfig { tool_call_dispatch: Some(crate::ToolCallDispatchMode::OnToolCallComplete), ..Default::default() }, ..Default::default() - }; - let upper = WorkerManifestConfig::default(); + }); assert_eq!( - lower.merge(upper).engine.tool_call_dispatch, + overridden.engine.tool_call_dispatch, Some(crate::ToolCallDispatchMode::OnToolCallComplete) ); - let mut configured = minimal_valid(); - configured.engine.tool_call_dispatch = + let mut explicit_early = minimal_valid(); + explicit_early.engine.tool_call_dispatch = Some(crate::ToolCallDispatchMode::OnToolCallComplete); + let explicit_early: WorkerManifest = explicit_early.try_into().unwrap(); + assert_eq!( + explicit_early.engine.tool_call_dispatch, + crate::ToolCallDispatchMode::OnToolCallComplete + ); + + let mut configured = minimal_valid(); + configured.engine.tool_call_dispatch = Some(crate::ToolCallDispatchMode::AfterResponse); let manifest: WorkerManifest = configured.try_into().unwrap(); - let snapshot = serde_json::to_value(&manifest).unwrap(); - let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap(); + assert_eq!( + manifest.engine.tool_call_dispatch, + crate::ToolCallDispatchMode::AfterResponse + ); + let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + let restored = crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap(); assert_eq!( restored.engine.tool_call_dispatch, - crate::ToolCallDispatchMode::OnToolCallComplete + crate::ToolCallDispatchMode::AfterResponse, + "an explicitly persisted after_response value must survive restore" + ); + + let mut field_missing = snapshot; + field_missing["manifest"]["engine"] + .as_object_mut() + .unwrap() + .remove("tool_call_dispatch"); + let restored = crate::read_persisted_worker_manifest_snapshot(field_missing).unwrap(); + assert_eq!( + restored.engine.tool_call_dispatch, + crate::ToolCallDispatchMode::OnToolCallComplete, + "snapshots without the field must use the new default" ); } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 4cb678470..5a53f084c 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -594,10 +594,10 @@ pub struct WorkerMeta { #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ToolCallDispatchMode { - /// Preserve the historical behavior: wait for the whole response stream. - #[default] + /// Wait for the whole response stream before starting any tools. AfterResponse, /// Start each call once the provider emits a trustworthy per-call completion. + #[default] OnToolCallComplete, } @@ -628,7 +628,7 @@ pub struct EngineManifest { pub stop_sequences: Vec, #[serde(default)] pub reasoning: Option, - /// Tool-call start timing. Omitted manifests preserve `after_response`. + /// Tool-call start timing. Omitted manifests use `on_tool_call_complete`. #[serde(default)] pub tool_call_dispatch: ToolCallDispatchMode, /// Byte-size caps applied to tool `content` before it reaches the diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 74a17ac47..0f7a389e1 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -2228,7 +2228,7 @@ max_tokens = 3333 &[("reviewer", "reviewer.toml", REVIEWER_PROFILE)], ); let mut parent = parent_manifest(&parent_root, Some(&parent_root.join("deny"))); - parent.engine.tool_call_dispatch = manifest::ToolCallDispatchMode::OnToolCallComplete; + parent.engine.tool_call_dispatch = manifest::ToolCallDispatchMode::AfterResponse; let scope = vec![abs_rule(&delegated, Permission::Read)]; let profile_config = child_config_from_profile( @@ -2252,7 +2252,7 @@ max_tokens = 3333 assert_eq!( inherit_config.engine.tool_call_dispatch, - Some(manifest::ToolCallDispatchMode::OnToolCallComplete) + Some(manifest::ToolCallDispatchMode::AfterResponse) ); for config in [profile_config, inherit_config] { assert_eq!(config.scope.allow, scope); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 45183e4d0..63823aea6 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -8726,7 +8726,7 @@ mod build_summary_prompt_tests { } #[test] - fn engine_manifest_generation_settings_become_request_config() { + fn engine_manifest_generation_and_dispatch_settings_are_applied() { let manifest = manifest::EngineManifest { instruction: "unused".into(), language: manifest::defaults::WORKER_LANGUAGE.into(), @@ -8756,6 +8756,14 @@ mod build_summary_prompt_tests { engine.tool_call_dispatch_mode(), agen::ToolCallDispatchMode::OnToolCallComplete ); + + let mut explicit_after_response = manifest; + explicit_after_response.tool_call_dispatch = manifest::ToolCallDispatchMode::AfterResponse; + apply_worker_manifest(&mut engine, &explicit_after_response); + assert_eq!( + engine.tool_call_dispatch_mode(), + agen::ToolCallDispatchMode::AfterResponse + ); } #[test] diff --git a/docs/manifest.toml b/docs/manifest.toml index c1bcccc9f..884866ef7 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -121,14 +121,15 @@ ref = "anthropic/claude-sonnet-4-6" # reasoning = "medium" # reasoning = -1 -# 任意。デフォルト: "after_response"。 -# "on_tool_call_complete" は provider が個別 ToolCall の完成を保証する場合だけ、 -# そのブロック完成後に実行開始を前倒しする。安全な完成境界がない provider、または -# 応答全体を見て実行前 Pause を判断する turn-end hook がある場合は、応答完了まで -# 警告付きで明示的に遅延する。これは直列実行モードではなく、先行 call の完了を -# 待たず後続 call も開始するため、完了順・DB 反映順・採番順は保証しない。 -# 早期開始後に stream が切断された場合は自動 continuation せず、安全に terminalize して停止する。 -# tool_call_dispatch = "on_tool_call_complete" +# 任意。デフォルト: "on_tool_call_complete"。 +# provider が個別 ToolCall の完成を保証する場合、そのブロック完成後に実行開始を +# 前倒しする。安全な完成境界がない provider、または応答全体を見て実行前 Pause を +# 判断する turn-end hook がある場合は、応答完了まで警告付きで明示的に遅延する。 +# これは直列実行モードではなく、先行 call の完了を待たず後続 call も開始するため、 +# 完了順・DB 反映順・採番順は保証しない。早期開始後に stream が切断された場合は +# 自動 continuation せず、安全に terminalize して停止する。 +# 従来どおり応答全体の完了後に実行を開始するには "after_response" を明示する。 +# tool_call_dispatch = "after_response" # 任意。tool 実行 content の byte 長キャップ。 # セクション省略時は default_max_bytes = 64 * 1024、per_tool 空。