feat: default tool calls to early dispatch
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -80,33 +80,38 @@ async fn assert_response_complete_scheme_accepts_clean_eof<S: Scheme>(
|
||||
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![
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningControl>,
|
||||
/// 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
+9
-8
@@ -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 空。
|
||||
|
||||
Reference in New Issue
Block a user