feat: add opt-in early tool call dispatch

This commit is contained in:
2026-09-29 11:07:59 +09:00
parent d1303ab638
commit cc2f8b3422
15 changed files with 1433 additions and 73 deletions
+37
View File
@@ -542,6 +542,8 @@ pub struct EngineManifestConfig {
#[serde(default)]
pub reasoning: Option<ReasoningControl>,
#[serde(default)]
pub tool_call_dispatch: Option<crate::ToolCallDispatchMode>,
#[serde(default)]
pub tool_output: ToolOutputLimitsPartial,
#[serde(default)]
pub file_upload: FileUploadLimitsPartial,
@@ -868,6 +870,7 @@ impl EngineManifestConfig {
top_k: upper.top_k.or(self.top_k),
stop_sequences: upper.stop_sequences.or(self.stop_sequences),
reasoning: upper.reasoning.or(self.reasoning),
tool_call_dispatch: upper.tool_call_dispatch.or(self.tool_call_dispatch),
tool_output: self.tool_output.merge(upper.tool_output),
file_upload: self.file_upload.merge(upper.file_upload),
}
@@ -1171,6 +1174,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
top_k: cfg.engine.top_k,
stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
reasoning: cfg.engine.reasoning,
tool_call_dispatch: cfg.engine.tool_call_dispatch.unwrap_or_default(),
tool_output: ToolOutputLimits {
default_max_bytes: cfg
.engine
@@ -1343,6 +1347,39 @@ mod tests {
}
}
#[test]
fn tool_call_dispatch_defaults_merges_and_round_trips() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert_eq!(
manifest.engine.tool_call_dispatch,
crate::ToolCallDispatchMode::AfterResponse
);
let lower = 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,
Some(crate::ToolCallDispatchMode::OnToolCallComplete)
);
let mut configured = minimal_valid();
configured.engine.tool_call_dispatch =
Some(crate::ToolCallDispatchMode::OnToolCallComplete);
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!(
restored.engine.tool_call_dispatch,
crate::ToolCallDispatchMode::OnToolCallComplete
);
}
#[test]
fn resolve_minimal_succeeds() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
+17
View File
@@ -587,6 +587,20 @@ pub struct WorkerMeta {
pub name: String,
}
/// When the engine may begin executing provider-confirmed tool calls.
///
/// `OnToolCallComplete` advances only the start time: calls may still overlap,
/// so completion order and externally allocated identifiers are not ordered.
#[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]
AfterResponse,
/// Start each call once the provider emits a trustworthy per-call completion.
OnToolCallComplete,
}
/// Worker-level configuration embedded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineManifest {
@@ -614,6 +628,9 @@ pub struct EngineManifest {
pub stop_sequences: Vec<String>,
#[serde(default)]
pub reasoning: Option<ReasoningControl>,
/// Tool-call start timing. Omitted manifests preserve `after_response`.
#[serde(default)]
pub tool_call_dispatch: ToolCallDispatchMode,
/// Byte-size caps applied to tool `content` before it reaches the
/// conversation history. The section is optional in TOML — when
/// omitted, `ToolOutputLimits::default()` (64 KiB default cap, no