feat: add Task tool functionality

- Introduced a new `Task` presentation type in the `ToolCallStream` to handle task-related projections.
- Implemented the `TaskProjection` struct with fields for description, prompt, subagent type, model, resume, and environment.
- Added a `project` method to `TaskProjection` to process task-related events and generate interaction updates.
- Created a `task_partial` function to format task updates for the agent server message.
- Included unit tests to verify the correct behavior of task description projections.
This commit is contained in:
leokun
2026-08-31 16:15:42 +08:00
parent 5de547041c
commit 49c1fb6378
3 changed files with 145 additions and 1 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{
};
pub use query::tool_query;
pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial};
pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial, task_partial};
pub use render::{dynamic_mcp_placeholder, render_dynamic_mcp, tool_completed};
pub use request::{abort, mcp_request, mcp_state_request, request};
pub(crate) use request::{edit_read_request, json_object_to_prost, mcp_meta_request};
+43
View File
@@ -54,6 +54,49 @@ pub(crate) fn edit_content_delta(call: &ToolCall, content: String) -> pb::AgentS
)))
}
pub(crate) fn task_partial(
call: &ToolCall,
description: &str,
prompt: &str,
subagent: &str,
model: &str,
resume: &str,
environment: &str,
) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::PartialToolCall(
pb::PartialToolCallUpdate {
call_id: call.call_id.clone(),
tool_call: Some(pb::ToolCall {
hook_additional_contexts: Vec::new(),
tool_call_id: Some(call.call_id.clone()),
started_at_ms: None,
completed_at_ms: None,
tool: Some(pb::tool_call::Tool::TaskToolCall(pb::TaskToolCall {
args: Some(pb::TaskArgs {
description: description.into(),
prompt: prompt.into(),
subagent_type: Some(subagent_type(subagent)),
model: (!model.is_empty()).then(|| model.into()),
resume: (!resume.is_empty()).then(|| resume.into()),
agent_id: None,
attachments: Vec::new(),
mode: 0,
responding_to_message_ids: Vec::new(),
environment: execution_environment(
(!environment.is_empty()).then_some(environment),
),
machine: None,
}),
result: None,
..Default::default()
})),
}),
args_text_delta: String::new(),
model_call_id: call.model_call_id.clone(),
},
))
}
pub(crate) fn create_plan_partial(
call: &ToolCall,
name: &str,
+101
View File
@@ -20,6 +20,7 @@ enum Presentation {
DynamicMcp(pb::McpToolDefinition),
Edit(EditProjection),
CreatePlan(CreatePlanProjection),
Task(TaskProjection),
}
struct EditProjection {
@@ -38,6 +39,17 @@ struct CreatePlanProjection {
overview: String,
}
#[derive(Default)]
struct TaskProjection {
fields: JsonStringFields,
description: String,
prompt: String,
subagent_type: String,
model: String,
resume: String,
environment: String,
}
impl ToolCallStream {
pub fn new(name: &str, dynamic_mcp: Option<&pb::McpToolDefinition>) -> Self {
let presentation = match dynamic_mcp {
@@ -49,6 +61,7 @@ impl ToolCallStream {
Presentation::Edit(EditProjection::new("target_notebook", "new_string"))
}
"createplan" => Presentation::CreatePlan(CreatePlanProjection::default()),
"task" => Presentation::Task(TaskProjection::default()),
_ => Presentation::Plain,
},
};
@@ -73,10 +86,48 @@ impl ToolCallStream {
Ok(messages)
}
Presentation::CreatePlan(plan) => plan.project(call, raw_delta),
Presentation::Task(task) => task.project(call, raw_delta),
}
}
}
impl TaskProjection {
fn project(&mut self, call: &ToolCall, raw_delta: &str) -> Result<Vec<pb::AgentServerMessage>> {
let mut description_completed = false;
for event in self.fields.push(raw_delta)? {
match event {
StringFieldEvent::Delta { name, text } => match name.as_str() {
"description" => self.description.push_str(&text),
"prompt" => self.prompt.push_str(&text),
"subagent_type" => self.subagent_type.push_str(&text),
"model" => self.model.push_str(&text),
"resume" => self.resume.push_str(&text),
"environment" => self.environment.push_str(&text),
_ => {}
},
StringFieldEvent::End { name } if name == "description" => {
description_completed = true
}
_ => {}
}
}
Ok(description_completed
.then(|| {
interaction::task_partial(
call,
&self.description,
&self.prompt,
&self.subagent_type,
&self.model,
&self.resume,
&self.environment,
)
})
.into_iter()
.collect())
}
}
impl CreatePlanProjection {
fn project(&mut self, call: &ToolCall, raw_delta: &str) -> Result<Vec<pb::AgentServerMessage>> {
let mut completed_field = false;
@@ -184,3 +235,53 @@ fn normalized(value: &str) -> String {
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn task_call(arguments_text: &str) -> ToolCall {
ToolCall {
index: 0,
call_id: "task-1".into(),
model_call_id: "model-1".into(),
name: "Task".into(),
arguments_text: arguments_text.into(),
arguments: serde_json::Value::Null,
}
}
#[test]
fn task_description_projects_a_visible_partial_card_before_execution() {
let mut stream = ToolCallStream::new("Task", None);
let first = r#"{"description":"Review K10"#;
assert!(stream
.arguments_delta(&task_call(first), first)
.unwrap()
.is_empty());
let closing = "\",";
let messages = stream
.arguments_delta(&task_call(&format!("{first}{closing}")), closing)
.unwrap();
assert_eq!(messages.len(), 1);
let Some(pb::agent_server_message::Message::InteractionUpdate(update)) =
messages[0].message.as_ref()
else {
panic!("expected interaction update")
};
let Some(pb::interaction_update::Message::PartialToolCall(partial)) =
update.message.as_ref()
else {
panic!("expected partial tool call")
};
let tool_call = partial.tool_call.as_ref().expect("expected tool call");
assert_eq!(tool_call.started_at_ms, None);
let Some(pb::tool_call::Tool::TaskToolCall(task)) = tool_call.tool.as_ref() else {
panic!("expected Task tool call")
};
let args = task.args.as_ref().expect("expected partial Task args");
assert_eq!(args.description, "Review K10");
assert_eq!(args.prompt, "");
}
}