TUIに向けたprotocolの詳細調整
This commit is contained in:
@@ -162,6 +162,11 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||
/// can be forwarded to the user — distinct from `tracing::warn!`,
|
||||
/// which is for developer-facing logs.
|
||||
warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>,
|
||||
/// Tool-result callbacks. Invoked once per completed tool call
|
||||
/// after post-execution interceptors and the output byte-cap
|
||||
/// truncation have been applied — i.e. on the same data that
|
||||
/// enters history.
|
||||
tool_result_cbs: Vec<Box<dyn Fn(&ToolResult) + Send + Sync>>,
|
||||
/// Request configuration (max_tokens, temperature, etc.)
|
||||
request_config: RequestConfig,
|
||||
/// Whether the previous run was interrupted
|
||||
@@ -302,6 +307,22 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked once per completed tool execution.
|
||||
///
|
||||
/// Fired after `post_tool_call` interceptors and any `content`
|
||||
/// truncation from `tool_output_limits`, so the callback observes
|
||||
/// exactly what is persisted to history. Intended for upper layers
|
||||
/// (e.g. Pod) to forward tool results to clients.
|
||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||
self.tool_result_cbs.push(Box::new(callback));
|
||||
}
|
||||
|
||||
fn emit_tool_result(&self, result: &ToolResult) {
|
||||
for cb in &self.tool_result_cbs {
|
||||
cb(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a turn-end callback (receives 0-based turn number).
|
||||
pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) {
|
||||
self.turn_end_cbs.push(Box::new(callback));
|
||||
@@ -753,6 +774,11 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit per-result callbacks on the post-truncation payload.
|
||||
for tool_result in &results {
|
||||
self.emit_tool_result(tool_result);
|
||||
}
|
||||
|
||||
Ok(ToolExecutionResult::Completed(results))
|
||||
}
|
||||
|
||||
@@ -1016,6 +1042,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
turn_start_cbs: Vec::new(),
|
||||
turn_end_cbs: Vec::new(),
|
||||
warning_cbs: Vec::new(),
|
||||
tool_result_cbs: Vec::new(),
|
||||
request_config: RequestConfig::default(),
|
||||
last_run_interrupted: false,
|
||||
cancel_tx,
|
||||
@@ -1270,6 +1297,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
turn_start_cbs: self.turn_start_cbs,
|
||||
turn_end_cbs: self.turn_end_cbs,
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
@@ -1344,6 +1372,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||
turn_start_cbs: self.turn_start_cbs,
|
||||
turn_end_cbs: self.turn_end_cbs,
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ mod common;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
@@ -149,6 +151,145 @@ async fn test_callback_turn_events() {
|
||||
assert_eq!(ends[0], 0);
|
||||
}
|
||||
|
||||
/// Stub tool returning a fixed [`ToolOutput`] for result-callback tests.
|
||||
struct FixedOutputTool {
|
||||
output: ToolOutput,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedOutputTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(self.output.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_tool(name: &'static str, output: ToolOutput) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(FixedOutputTool {
|
||||
output: output.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that on_tool_result fires once per executed tool with
|
||||
/// summary/content/is_error matching what the tool returned.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_events() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "fixed"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
worker.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
ToolOutput {
|
||||
summary: "did the thing".into(),
|
||||
content: Some("full detail body".into()),
|
||||
},
|
||||
));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
worker.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = worker.run("call it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_1");
|
||||
assert_eq!(observed[0].1, "did the thing");
|
||||
assert_eq!(observed[0].2.as_deref(), Some("full detail body"));
|
||||
assert!(!observed[0].3);
|
||||
}
|
||||
|
||||
/// Stub tool that always fails, for exercising the error path through
|
||||
/// `on_tool_result`.
|
||||
struct ErroringTool {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ErroringTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Err(ToolError::ExecutionFailed(self.message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn erroring_tool(name: &'static str, message: &'static str) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(ErroringTool {
|
||||
message: message.to_string(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify on_tool_result also fires for failed executions with
|
||||
/// is_error=true, and that the ToolOutput content channel stays empty.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_error_path() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_err", "erroring"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
worker.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
worker.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = worker.run("fail it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_err");
|
||||
assert!(
|
||||
observed[0].1.contains("boom"),
|
||||
"summary should carry the error message: {}",
|
||||
observed[0].1
|
||||
);
|
||||
assert!(observed[0].2.is_none());
|
||||
assert!(observed[0].3);
|
||||
}
|
||||
|
||||
/// Verify that on_usage callback receives usage events
|
||||
#[tokio::test]
|
||||
async fn test_callback_usage_events() {
|
||||
|
||||
@@ -186,6 +186,16 @@ impl PodController {
|
||||
});
|
||||
});
|
||||
|
||||
let tx = event_tx.clone();
|
||||
worker.on_tool_result(move |result| {
|
||||
let _ = tx.send(Event::ToolResult {
|
||||
id: result.tool_use_id.clone(),
|
||||
summary: result.summary.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: result.is_error,
|
||||
});
|
||||
});
|
||||
|
||||
let tx = event_tx.clone();
|
||||
worker.on_usage(move |event| {
|
||||
let _ = tx.send(Event::Usage {
|
||||
|
||||
@@ -111,7 +111,14 @@ pub enum Event {
|
||||
},
|
||||
ToolResult {
|
||||
id: String,
|
||||
output: String,
|
||||
/// Short human-readable summary. Always present; used by clients
|
||||
/// that only want a 1-line rendering (e.g. collapsed views).
|
||||
summary: String,
|
||||
/// Full tool output. Absent when the tool chose to return
|
||||
/// summary-only, or when the result was pruned.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
Usage {
|
||||
@@ -502,6 +509,82 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_tool_result_roundtrip() {
|
||||
let event = Event::ToolResult {
|
||||
id: "call_1".into(),
|
||||
summary: "Read 128 bytes".into(),
|
||||
output: Some("hello world".into()),
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "tool_result");
|
||||
assert_eq!(parsed["data"]["id"], "call_1");
|
||||
assert_eq!(parsed["data"]["summary"], "Read 128 bytes");
|
||||
assert_eq!(parsed["data"]["output"], "hello world");
|
||||
assert_eq!(parsed["data"]["is_error"], false);
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::ToolResult {
|
||||
id,
|
||||
summary,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
assert_eq!(id, "call_1");
|
||||
assert_eq!(summary, "Read 128 bytes");
|
||||
assert_eq!(output.as_deref(), Some("hello world"));
|
||||
assert!(!is_error);
|
||||
}
|
||||
other => panic!("expected ToolResult, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_tool_result_omits_absent_output() {
|
||||
let event = Event::ToolResult {
|
||||
id: "call_2".into(),
|
||||
summary: "ok".into(),
|
||||
output: None,
|
||||
is_error: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(
|
||||
!json.contains("\"output\""),
|
||||
"absent output must not be serialized: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_tool_result_error_roundtrip() {
|
||||
let event = Event::ToolResult {
|
||||
id: "call_3".into(),
|
||||
summary: "invalid argument".into(),
|
||||
output: None,
|
||||
is_error: true,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["data"]["is_error"], true);
|
||||
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::ToolResult {
|
||||
summary,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(summary, "invalid argument");
|
||||
assert!(output.is_none());
|
||||
assert!(is_error);
|
||||
}
|
||||
other => panic!("expected ToolResult, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_error_format() {
|
||||
let event = Event::Error {
|
||||
|
||||
+4
-14
@@ -140,21 +140,16 @@ impl App {
|
||||
));
|
||||
}
|
||||
Event::ToolResult {
|
||||
output, is_error, ..
|
||||
summary, is_error, ..
|
||||
} => {
|
||||
let prefix = if is_error {
|
||||
"[tool error]"
|
||||
} else {
|
||||
"[tool result]"
|
||||
};
|
||||
let display = if output.len() > 200 {
|
||||
format!("{}...", &output[..200])
|
||||
} else {
|
||||
output
|
||||
};
|
||||
self.output_queue.push(OutputItem::Padded(
|
||||
MessageKind::Tool,
|
||||
format!("{prefix} {display}"),
|
||||
format!("{prefix} {summary}"),
|
||||
));
|
||||
}
|
||||
Event::Usage {
|
||||
@@ -345,15 +340,10 @@ impl App {
|
||||
));
|
||||
}
|
||||
"tool_result" => {
|
||||
let output = item["output"].as_str().unwrap_or("");
|
||||
let display = if output.len() > 200 {
|
||||
format!("{}...", &output[..200])
|
||||
} else {
|
||||
output.to_owned()
|
||||
};
|
||||
let summary = item["summary"].as_str().unwrap_or("");
|
||||
self.output_queue.push(OutputItem::Padded(
|
||||
MessageKind::Tool,
|
||||
format!("[tool result] {display}"),
|
||||
format!("[tool result] {summary}"),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
|
||||
Reference in New Issue
Block a user