1180 lines
50 KiB
Rust
1180 lines
50 KiB
Rust
/// Walks a conversation's task tree and produces a hierarchical directory of YAML files
|
|
/// suitable for grep-based conversation search.
|
|
#[cfg(test)]
|
|
#[path = "conversation_yaml_tests.rs"]
|
|
mod tests;
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use api::message::tool_call::Tool;
|
|
use api::message::tool_call_result::Result as ToolCallResultType;
|
|
use api::message::Message;
|
|
use warp_multi_agent_api as api;
|
|
|
|
use super::task::helper::{SubagentExt, ToolExt};
|
|
|
|
const BASE_DIR_NAME: &str = "warp_conversation_search";
|
|
|
|
/// Returns the base directory for conversation search temp files.
|
|
///
|
|
/// Uses the platform temp directory so paths are fully qualified with
|
|
/// native separators on every OS (e.g. includes drive prefix on Windows).
|
|
pub(crate) fn base_dir() -> PathBuf {
|
|
std::env::temp_dir().join(BASE_DIR_NAME)
|
|
}
|
|
|
|
/// Materializes a conversation's tasks into a directory of YAML files.
|
|
///
|
|
/// Returns the path to the root directory, or an error string.
|
|
pub fn materialize_tasks_to_yaml(tasks: &[api::Task]) -> Result<String, String> {
|
|
let base_dir = base_dir();
|
|
fs::create_dir_all(&base_dir).map_err(|e| format!("Failed to create base dir: {e}"))?;
|
|
|
|
let dir = tempfile::tempdir_in(&base_dir)
|
|
.map_err(|e| format!("Failed to create temp dir: {e}"))?
|
|
.keep();
|
|
|
|
// Build task lookup by id.
|
|
let task_map: HashMap<&str, &api::Task> = tasks.iter().map(|t| (t.id.as_str(), t)).collect();
|
|
|
|
// Find root task (no parent or empty parent).
|
|
let root = tasks
|
|
.iter()
|
|
.find(|t| {
|
|
t.dependencies
|
|
.as_ref()
|
|
.is_none_or(|d| d.parent_task_id.is_empty())
|
|
})
|
|
.ok_or_else(|| "No root task found".to_string())?;
|
|
|
|
let mut index: u32 = 0;
|
|
if let Err(e) = write_task_messages(root, &dir, &mut index, &task_map) {
|
|
let _ = fs::remove_dir_all(&dir);
|
|
return Err(e);
|
|
}
|
|
|
|
Ok(dir.to_string_lossy().into_owned())
|
|
}
|
|
|
|
/// Writes YAML files for each message in a task, recursing into subtasks.
|
|
fn write_task_messages(
|
|
task: &api::Task,
|
|
dir: &Path,
|
|
index: &mut u32,
|
|
task_map: &HashMap<&str, &api::Task>,
|
|
) -> Result<(), String> {
|
|
// Build a tool_call_id → tool name map for resolving ToolCallResult names.
|
|
let tool_call_names: HashMap<&str, &'static str> = task
|
|
.messages
|
|
.iter()
|
|
.filter_map(|m| {
|
|
if let Some(Message::ToolCall(tc)) = &m.message {
|
|
let name = tc.tool.as_ref().map(|t| t.name()).unwrap_or("unknown");
|
|
Some((tc.tool_call_id.as_str(), name))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
for msg in &task.messages {
|
|
let msg_id = &msg.id;
|
|
let Some(message) = &msg.message else {
|
|
continue;
|
|
};
|
|
|
|
match message {
|
|
Message::UserQuery(uq) => {
|
|
let filename = format!("{:03}.{msg_id}.user_query.yaml", *index);
|
|
let mut content = String::new();
|
|
content.push_str("type: user_query\n");
|
|
content.push_str("query: |\n");
|
|
write_block_scalar(&mut content, &uq.query);
|
|
if let Some(ctx) = &uq.context {
|
|
if let Some(dir_ctx) = &ctx.directory {
|
|
if !dir_ctx.pwd.is_empty() {
|
|
content.push_str(&format!("working_directory: {}\n", dir_ctx.pwd));
|
|
}
|
|
}
|
|
}
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
Message::AgentOutput(ao) => {
|
|
let filename = format!("{:03}.{msg_id}.agent_output.yaml", *index);
|
|
let mut content = String::new();
|
|
content.push_str("type: agent_output\n");
|
|
content.push_str("text: |\n");
|
|
write_block_scalar(&mut content, &ao.text);
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
Message::AgentReasoning(ar) => {
|
|
let filename = format!("{:03}.{msg_id}.agent_reasoning.yaml", *index);
|
|
let mut content = String::new();
|
|
content.push_str("type: agent_reasoning\n");
|
|
content.push_str("reasoning: |\n");
|
|
write_block_scalar(&mut content, &ar.reasoning);
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
Message::Summarization(s) => {
|
|
if let Some(api::message::summarization::SummaryType::ConversationSummary(cs)) =
|
|
&s.summary_type
|
|
{
|
|
let filename = format!("{:03}.{msg_id}.summarization.yaml", *index);
|
|
let mut content = String::new();
|
|
content.push_str("type: summarization\n");
|
|
content.push_str("summary: |\n");
|
|
write_block_scalar(&mut content, &cs.summary);
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
}
|
|
Message::ToolCall(tc) => {
|
|
let tool_call_id = &tc.tool_call_id;
|
|
match &tc.tool {
|
|
Some(Tool::Server(_)) => {
|
|
// Skip opaque server tool calls.
|
|
}
|
|
Some(Tool::Subagent(sub)) => {
|
|
let subtask_id = &sub.task_id;
|
|
let subagent_type = sub.type_name();
|
|
let subagent_index = *index;
|
|
let filename = format!(
|
|
"{:03}.{msg_id}.subagent.{tool_call_id}.{subtask_id}.{subagent_type}.yaml",
|
|
subagent_index
|
|
);
|
|
let mut content = String::new();
|
|
content.push_str("type: subagent\n");
|
|
content.push_str(&format!("tool_call_id: {tool_call_id}\n"));
|
|
content.push_str(&format!("task_id: {subtask_id}\n"));
|
|
content.push_str(&format!("subagent_type: {subagent_type}\n"));
|
|
if !sub.payload.is_empty() {
|
|
content.push_str("payload: |\n");
|
|
write_block_scalar(&mut content, &sub.payload);
|
|
}
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
|
|
// Recurse into the subtask directory.
|
|
if let Some(subtask) = task_map.get(subtask_id.as_str()) {
|
|
let subdir_name = format!("{:03}.{subtask_id}", subagent_index);
|
|
let subdir = dir.join(&subdir_name);
|
|
fs::create_dir_all(&subdir).map_err(|e| {
|
|
format!("Failed to create subdir {subdir_name}: {e}")
|
|
})?;
|
|
let mut sub_index: u32 = 0;
|
|
write_task_messages(subtask, &subdir, &mut sub_index, task_map)?;
|
|
}
|
|
}
|
|
Some(tool) => {
|
|
let name = tool.name();
|
|
let filename = format!(
|
|
"{:03}.{msg_id}.tool_call.{tool_call_id}.{name}.yaml",
|
|
*index
|
|
);
|
|
let mut content = String::new();
|
|
content.push_str("type: tool_call\n");
|
|
content.push_str(&format!("tool_name: {name}\n"));
|
|
content.push_str(&format!("tool_call_id: {tool_call_id}\n"));
|
|
write_tool_call_args(&mut content, tool);
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
Message::ToolCallResult(tcr) => {
|
|
let tool_call_id = &tcr.tool_call_id;
|
|
match &tcr.result {
|
|
Some(ToolCallResultType::Server(_)) => {
|
|
// Skip opaque server results.
|
|
}
|
|
Some(ToolCallResultType::Subagent(_)) => {
|
|
let subtask_id = find_subtask_id_for_tool_call(task, tool_call_id);
|
|
let tid = subtask_id.as_deref().unwrap_or("unknown");
|
|
let filename = format!(
|
|
"{:03}.{msg_id}.subagent_result.{tool_call_id}.{tid}.yaml",
|
|
*index
|
|
);
|
|
let mut content = String::new();
|
|
content.push_str("type: subagent_result\n");
|
|
content.push_str(&format!("tool_call_id: {tool_call_id}\n"));
|
|
content.push_str(&format!("task_id: {tid}\n"));
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
Some(result) => {
|
|
let name = tool_call_names
|
|
.get(tool_call_id.as_str())
|
|
.copied()
|
|
.unwrap_or("unknown");
|
|
let filename = format!(
|
|
"{:03}.{msg_id}.tool_call_result.{tool_call_id}.{name}.yaml",
|
|
*index
|
|
);
|
|
let mut content = String::new();
|
|
content.push_str("type: tool_call_result\n");
|
|
content.push_str(&format!("tool_name: {name}\n"));
|
|
content.push_str(&format!("tool_call_id: {tool_call_id}\n"));
|
|
write_tool_call_result_content(&mut content, result);
|
|
write_yaml_file(dir, &filename, &content)?;
|
|
*index += 1;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
// No searchable content.
|
|
Message::WebSearch(_)
|
|
| Message::WebFetch(_)
|
|
| Message::ModelUsed(_)
|
|
| Message::UpdateTodos(_)
|
|
| Message::UpdateReviewComments(_)
|
|
| Message::DebugOutput(_)
|
|
| Message::ArtifactEvent(_)
|
|
| Message::MessagesReceivedFromAgents(_)
|
|
| Message::EventsFromAgents(_)
|
|
| Message::PassiveSuggestionResult(_)
|
|
| Message::SystemQuery(_)
|
|
| Message::CodeReview(_)
|
|
| Message::ServerEvent(_)
|
|
| Message::InvokeSkill(_)
|
|
| Message::OrchestrationConfigSnapshot(_) => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_yaml_file(dir: &Path, filename: &str, content: &str) -> Result<(), String> {
|
|
let path = dir.join(filename);
|
|
let mut file =
|
|
fs::File::create(&path).map_err(|e| format!("Failed to create {}: {e}", path.display()))?;
|
|
file.write_all(content.as_bytes())
|
|
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes a YAML block scalar (|) with each line indented by 2 spaces.
|
|
fn write_block_scalar(out: &mut String, text: &str) {
|
|
write_block_scalar_with_indent(out, text, 2);
|
|
}
|
|
|
|
fn write_block_scalar_with_indent(out: &mut String, text: &str, indent: usize) {
|
|
let indent = " ".repeat(indent);
|
|
for line in text.lines() {
|
|
out.push_str(&indent);
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
// Ensure trailing newline for empty text.
|
|
if text.is_empty() {
|
|
out.push_str(&indent);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
|
|
/// Writes key arguments from structured tool calls into the YAML content.
|
|
fn write_tool_call_args(out: &mut String, tool: &Tool) {
|
|
match tool {
|
|
Tool::RunShellCommand(cmd) => {
|
|
out.push_str("command: |\n");
|
|
write_block_scalar(out, &cmd.command);
|
|
}
|
|
Tool::SearchCodebase(sc) => {
|
|
out.push_str(&format!("query: \"{}\"\n", escape_yaml_string(&sc.query)));
|
|
if !sc.codebase_path.is_empty() {
|
|
out.push_str(&format!("codebase_path: {}\n", sc.codebase_path));
|
|
}
|
|
if !sc.path_filters.is_empty() {
|
|
out.push_str("path_filters:\n");
|
|
for p in &sc.path_filters {
|
|
out.push_str(&format!(" - \"{}\"\n", escape_yaml_string(p)));
|
|
}
|
|
}
|
|
}
|
|
Tool::ReadFiles(rf) => {
|
|
out.push_str("files:\n");
|
|
for f in &rf.files {
|
|
out.push_str(&format!(" - name: {}\n", f.name));
|
|
if !f.line_ranges.is_empty() {
|
|
out.push_str(" ranges:\n");
|
|
for r in &f.line_ranges {
|
|
out.push_str(&format!(" - {}-{}\n", r.start, r.end));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Tool::UploadFileArtifact(upload) => {
|
|
if let Some(file) = &upload.file {
|
|
out.push_str("file:\n");
|
|
out.push_str(&format!(" file_path: {}\n", file.file_path));
|
|
}
|
|
if !upload.description.is_empty() {
|
|
out.push_str(&format!(
|
|
"description: \"{}\"\n",
|
|
escape_yaml_string(&upload.description)
|
|
));
|
|
}
|
|
}
|
|
Tool::Grep(g) => {
|
|
out.push_str("queries:\n");
|
|
for q in &g.queries {
|
|
out.push_str(&format!(" - \"{}\"\n", escape_yaml_string(q)));
|
|
}
|
|
out.push_str(&format!("path: {}\n", g.path));
|
|
}
|
|
Tool::FileGlobV2(fg) => {
|
|
out.push_str("patterns:\n");
|
|
for p in &fg.patterns {
|
|
out.push_str(&format!(" - \"{}\"\n", escape_yaml_string(p)));
|
|
}
|
|
if !fg.search_dir.is_empty() {
|
|
out.push_str(&format!("search_dir: {}\n", fg.search_dir));
|
|
}
|
|
}
|
|
Tool::CallMcpTool(mcp) => {
|
|
out.push_str(&format!("name: {}\n", mcp.name));
|
|
if let Some(args) = &mcp.args {
|
|
out.push_str("args: |\n");
|
|
let json =
|
|
serde_json::to_string_pretty(&prost_struct_to_json(args)).unwrap_or_default();
|
|
write_block_scalar(out, &json);
|
|
}
|
|
}
|
|
Tool::ReadMcpResource(r) => {
|
|
out.push_str(&format!("uri: {}\n", r.uri));
|
|
}
|
|
Tool::FetchConversation(fc) => {
|
|
out.push_str(&format!("conversation_id: {}\n", fc.conversation_id));
|
|
}
|
|
Tool::CreateDocuments(cd) => {
|
|
out.push_str("documents:\n");
|
|
for doc in &cd.new_documents {
|
|
out.push_str(&format!(
|
|
" - title: \"{}\"\n",
|
|
escape_yaml_string(&doc.title)
|
|
));
|
|
let preview = truncate_content(&doc.content, 2048);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Tool::ReadDocuments(rd) => {
|
|
out.push_str("documents:\n");
|
|
for doc in &rd.documents {
|
|
out.push_str(&format!(" - document_id: {}\n", doc.document_id));
|
|
}
|
|
}
|
|
Tool::EditDocuments(ed) => {
|
|
out.push_str(&format!("diff_count: {}\n", ed.diffs.len()));
|
|
for diff in &ed.diffs {
|
|
out.push_str(&format!(" - document_id: {}\n", diff.document_id));
|
|
if !diff.search.is_empty() {
|
|
out.push_str(" search: |\n");
|
|
write_block_scalar(out, truncate_content(&diff.search, 1024));
|
|
}
|
|
if !diff.replace.is_empty() {
|
|
out.push_str(" replace: |\n");
|
|
write_block_scalar(out, truncate_content(&diff.replace, 1024));
|
|
}
|
|
}
|
|
}
|
|
Tool::StartAgent(sa) => {
|
|
out.push_str(&format!("name: \"{}\"\n", escape_yaml_string(&sa.name)));
|
|
out.push_str("prompt: |\n");
|
|
write_block_scalar(out, &sa.prompt);
|
|
}
|
|
Tool::StartAgentV2(sa) => {
|
|
out.push_str(&format!("name: \"{}\"\n", escape_yaml_string(&sa.name)));
|
|
out.push_str("prompt: |\n");
|
|
write_block_scalar(out, &sa.prompt);
|
|
}
|
|
#[allow(deprecated)]
|
|
Tool::FileGlob(fg) => {
|
|
out.push_str("patterns:\n");
|
|
for p in &fg.patterns {
|
|
out.push_str(&format!(" - \"{}\"\n", escape_yaml_string(p)));
|
|
}
|
|
if !fg.path.is_empty() {
|
|
out.push_str(&format!("search_dir: {}\n", fg.path));
|
|
}
|
|
}
|
|
Tool::WriteToLongRunningShellCommand(w) => {
|
|
out.push_str(&format!("command_id: {}\n", w.command_id));
|
|
if let Ok(text) = std::str::from_utf8(&w.input) {
|
|
out.push_str("input: |\n");
|
|
write_block_scalar(out, truncate_content(text, 1024));
|
|
}
|
|
}
|
|
Tool::InsertReviewComments(i) => {
|
|
out.push_str(&format!("repo_path: {}\n", i.repo_path));
|
|
if !i.base_branch.is_empty() {
|
|
out.push_str(&format!("base_branch: {}\n", i.base_branch));
|
|
}
|
|
out.push_str("comments:\n");
|
|
for c in &i.comments {
|
|
if let Some(loc) = &c.location {
|
|
out.push_str(&format!(" - file: {}\n", loc.file_path));
|
|
} else {
|
|
out.push_str(" - file: <pr-level>\n");
|
|
}
|
|
out.push_str(" body: |\n");
|
|
write_block_scalar(out, truncate_content(&c.comment_body, 512));
|
|
}
|
|
}
|
|
Tool::ReadSkill(rs) => {
|
|
use api::message::tool_call::read_skill::SkillReference;
|
|
match &rs.skill_reference {
|
|
Some(SkillReference::SkillPath(path)) => {
|
|
out.push_str(&format!("skill_path: {}\n", path));
|
|
}
|
|
Some(SkillReference::BundledSkillId(id)) => {
|
|
out.push_str(&format!("bundled_skill_id: {}\n", id));
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
Tool::SendMessageToAgent(s) => {
|
|
out.push_str(&format!(
|
|
"subject: \"{}\"\n",
|
|
escape_yaml_string(&s.subject)
|
|
));
|
|
out.push_str("message: |\n");
|
|
write_block_scalar(out, truncate_content(&s.message, 2048));
|
|
}
|
|
Tool::AskUserQuestion(ask) => {
|
|
if ask.questions.is_empty() {
|
|
out.push_str("questions: []\n");
|
|
return;
|
|
}
|
|
out.push_str("questions:\n");
|
|
for question in &ask.questions {
|
|
out.push_str(&format!(
|
|
" - question_id: \"{}\"\n",
|
|
escape_yaml_string(&question.question_id)
|
|
));
|
|
out.push_str(" question: |\n");
|
|
write_block_scalar_with_indent(out, &question.question, 6);
|
|
match &question.question_type {
|
|
Some(api::ask_user_question::question::QuestionType::MultipleChoice(mc)) => {
|
|
out.push_str(" question_type: multiple_choice\n");
|
|
out.push_str(&format!(" is_multiselect: {}\n", mc.is_multiselect));
|
|
out.push_str(&format!(" supports_other: {}\n", mc.supports_other));
|
|
if mc.options.is_empty() {
|
|
out.push_str(" options: []\n");
|
|
} else {
|
|
out.push_str(" options:\n");
|
|
for option in &mc.options {
|
|
out.push_str(&format!(
|
|
" - label: \"{}\"\n",
|
|
escape_yaml_string(&option.label)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
out.push_str(" question_type: missing\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Tool::ApplyFileDiffs(afd) => {
|
|
out.push_str(&format!(
|
|
"summary: \"{}\"\n",
|
|
escape_yaml_string(&afd.summary)
|
|
));
|
|
out.push_str("files:\n");
|
|
for d in &afd.diffs {
|
|
out.push_str(&format!(" - {}\n", d.file_path));
|
|
}
|
|
for d in &afd.v4a_updates {
|
|
out.push_str(&format!(" - {}\n", d.file_path));
|
|
}
|
|
for nf in &afd.new_files {
|
|
out.push_str(&format!(" - new: {}\n", nf.file_path));
|
|
}
|
|
for df in &afd.deleted_files {
|
|
out.push_str(&format!(" - deleted: {}\n", df.file_path));
|
|
}
|
|
}
|
|
Tool::RunAgents(o) => {
|
|
out.push_str(&format!(
|
|
"summary: \"{}\"\n",
|
|
escape_yaml_string(&o.summary)
|
|
));
|
|
out.push_str("agents:\n");
|
|
for cfg in &o.agent_run_configs {
|
|
out.push_str(&format!(
|
|
" - name: \"{}\"\n",
|
|
escape_yaml_string(&cfg.name)
|
|
));
|
|
}
|
|
}
|
|
// No additional args worth serializing.
|
|
Tool::ReadShellCommandOutput(_)
|
|
| Tool::UseComputer(_)
|
|
| Tool::RequestComputerUse(_)
|
|
| Tool::SuggestPlan(_)
|
|
| Tool::SuggestCreatePlan(_)
|
|
| Tool::SuggestNewConversation(_)
|
|
| Tool::SuggestPrompt(_)
|
|
| Tool::OpenCodeReview(_)
|
|
| Tool::InitProject(_)
|
|
| Tool::Server(_)
|
|
| Tool::Subagent(_)
|
|
| Tool::TransferShellCommandControlToUser(_)
|
|
| Tool::WaitForEvents(_) => {}
|
|
}
|
|
}
|
|
|
|
/// Writes content from structured tool call results.
|
|
fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) {
|
|
match result {
|
|
ToolCallResultType::RunAgentsResult(r) => match &r.outcome {
|
|
Some(api::run_agents_result::Outcome::Launched(launched)) => {
|
|
out.push_str("status: launched\n");
|
|
out.push_str(&format!("agent_count: {}\n", launched.agents.len()));
|
|
if !launched.agents.is_empty() {
|
|
out.push_str("agents:\n");
|
|
for agent in &launched.agents {
|
|
out.push_str(&format!(
|
|
" - name: \"{}\"\n",
|
|
escape_yaml_string(&agent.name)
|
|
));
|
|
match &agent.result {
|
|
Some(api::run_agents_result::agent_outcome::Result::Launched(
|
|
launched,
|
|
)) => {
|
|
out.push_str(" status: launched\n");
|
|
out.push_str(&format!(" agent_id: {}\n", launched.agent_id));
|
|
}
|
|
Some(api::run_agents_result::agent_outcome::Result::Failed(failed)) => {
|
|
out.push_str(" status: failed\n");
|
|
out.push_str(&format!(
|
|
" error: \"{}\"\n",
|
|
escape_yaml_string(&failed.error)
|
|
));
|
|
}
|
|
None => {
|
|
out.push_str(" status: unknown\n");
|
|
}
|
|
}
|
|
}
|
|
out.push_str(
|
|
"next_step: \"Use send_message_to_agent with the existing agent_id instead of running agents again.\"\n",
|
|
);
|
|
}
|
|
}
|
|
Some(api::run_agents_result::Outcome::Denied(denied)) => {
|
|
out.push_str("status: launch_denied\n");
|
|
out.push_str(&format!(
|
|
"reason: \"{}\"\n",
|
|
escape_yaml_string(&denied.reason)
|
|
));
|
|
}
|
|
Some(api::run_agents_result::Outcome::Failure(failure)) => {
|
|
out.push_str("status: failure\n");
|
|
out.push_str(&format!(
|
|
"error: \"{}\"\n",
|
|
escape_yaml_string(&failure.error)
|
|
));
|
|
}
|
|
None => {}
|
|
},
|
|
ToolCallResultType::StartAgentV2(r) => match &r.result {
|
|
Some(api::start_agent_v2_result::Result::Success(s)) => {
|
|
out.push_str(&format!("agent_id: {}\n", s.agent_id));
|
|
}
|
|
Some(api::start_agent_v2_result::Result::Error(e)) => {
|
|
out.push_str(&format!("error: {}\n", e.error));
|
|
}
|
|
None => {}
|
|
},
|
|
ToolCallResultType::WaitForEvents(_) => {}
|
|
ToolCallResultType::RunShellCommand(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::run_shell_command_result::Result;
|
|
match res {
|
|
Result::CommandFinished(c) => {
|
|
out.push_str(&format!("exit_code: {}\n", c.exit_code));
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, &c.output);
|
|
}
|
|
Result::LongRunningCommandSnapshot(s) => {
|
|
out.push_str("status: long_running\n");
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, &s.output);
|
|
}
|
|
Result::PermissionDenied(_) => {
|
|
out.push_str("status: permission_denied\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::SearchCodebase(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::search_codebase_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("files:\n");
|
|
for f in &s.files {
|
|
out.push_str(&format!(" - name: {}\n", f.file_path));
|
|
let preview = truncate_content(&f.content, 2048);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ReadFiles(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::read_files_result::Result;
|
|
match res {
|
|
Result::TextFilesSuccess(s) => {
|
|
out.push_str("files:\n");
|
|
for f in &s.files {
|
|
out.push_str(&format!(" - name: {}\n", f.file_path));
|
|
let preview = truncate_content(&f.content, 4096);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::AnyFilesSuccess(s) => {
|
|
out.push_str("files:\n");
|
|
for f in &s.files {
|
|
let (path, preview) = any_file_content_summary(f);
|
|
out.push_str(&format!(" - name: {}\n", path));
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::UploadFileArtifact(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::upload_file_artifact_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str(&format!("artifact_uid: {}\n", s.artifact_uid));
|
|
out.push_str(&format!("mime_type: {}\n", s.mime_type));
|
|
out.push_str(&format!("size_bytes: {}\n", s.size_bytes));
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::Grep(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::grep_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("matched_files:\n");
|
|
for f in &s.matched_files {
|
|
out.push_str(&format!(" - file: {}\n", f.file_path));
|
|
if !f.matched_lines.is_empty() {
|
|
out.push_str(" lines:\n");
|
|
for line in &f.matched_lines {
|
|
out.push_str(&format!(" - {}\n", line.line_number));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ApplyFileDiffs(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::apply_file_diffs_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("status: success\n");
|
|
out.push_str("files:\n");
|
|
for uf in &s.updated_files_v2 {
|
|
if let Some(f) = &uf.file {
|
|
out.push_str(&format!(" - {}\n", f.file_path));
|
|
}
|
|
}
|
|
for df in &s.deleted_files {
|
|
out.push_str(&format!(" - deleted: {}\n", df.file_path));
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("is_error: true\nerror: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::FetchConversation(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::fetch_conversation_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str(&format!("directory_path: {}\n", s.directory_path));
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[allow(deprecated)]
|
|
ToolCallResultType::FileGlob(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::file_glob_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("matched_files: |\n");
|
|
write_block_scalar(out, truncate_content(&s.matched_files, 4096));
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::FileGlobV2(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::file_glob_v2_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("matched_files:\n");
|
|
for f in &s.matched_files {
|
|
out.push_str(&format!(" - {}\n", f.file_path));
|
|
}
|
|
if !s.warnings.is_empty() {
|
|
out.push_str(&format!(
|
|
"warnings: \"{}\"\n",
|
|
escape_yaml_string(&s.warnings)
|
|
));
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::WriteToLongRunningShellCommand(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::write_to_long_running_shell_command_result::Result;
|
|
match res {
|
|
Result::LongRunningCommandSnapshot(s) => {
|
|
out.push_str("status: long_running\n");
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, truncate_content(&s.output, 4096));
|
|
}
|
|
Result::CommandFinished(c) => {
|
|
out.push_str(&format!("exit_code: {}\n", c.exit_code));
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, truncate_content(&c.output, 4096));
|
|
}
|
|
Result::Error(_) => {
|
|
out.push_str("status: error\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ReadShellCommandOutput(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::read_shell_command_output_result::Result;
|
|
match res {
|
|
Result::CommandFinished(c) => {
|
|
out.push_str(&format!("exit_code: {}\n", c.exit_code));
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, truncate_content(&c.output, 4096));
|
|
}
|
|
Result::LongRunningCommandSnapshot(s) => {
|
|
out.push_str("status: long_running\n");
|
|
out.push_str("output: |\n");
|
|
write_block_scalar(out, truncate_content(&s.output, 4096));
|
|
}
|
|
Result::Error(_) => {
|
|
out.push_str("status: error\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::CallMcpTool(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::call_mcp_tool_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("results:\n");
|
|
for item in &s.results {
|
|
use api::call_mcp_tool_result::success::result::Result as ItemResult;
|
|
match &item.result {
|
|
Some(ItemResult::Text(t)) => {
|
|
out.push_str(" - type: text\n");
|
|
out.push_str(" content: |\n");
|
|
for line in truncate_content(&t.text, 4096).lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
Some(ItemResult::Image(_)) => {
|
|
out.push_str(" - type: image\n");
|
|
}
|
|
Some(ItemResult::Resource(r)) => {
|
|
out.push_str(&format!(
|
|
" - type: resource\n uri: {}\n",
|
|
r.uri
|
|
));
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ReadMcpResource(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::read_mcp_resource_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("contents:\n");
|
|
for content in &s.contents {
|
|
out.push_str(&format!(" - uri: {}\n", content.uri));
|
|
if let Some(api::mcp_resource_content::ContentType::Text(t)) =
|
|
&content.content_type
|
|
{
|
|
out.push_str(" text: |\n");
|
|
for line in truncate_content(&t.content, 4096).lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ReadSkill(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::read_skill_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
if let Some(content) = &s.content {
|
|
out.push_str(&format!("file_path: {}\n", content.file_path));
|
|
out.push_str("content: |\n");
|
|
write_block_scalar(out, truncate_content(&content.content, 4096));
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::ReadDocuments(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::read_documents_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("documents:\n");
|
|
for doc in &s.documents {
|
|
out.push_str(&format!(" - document_id: {}\n", doc.document_id));
|
|
let preview = truncate_content(&doc.content, 4096);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::EditDocuments(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::edit_documents_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("updated_documents:\n");
|
|
for doc in &s.updated_documents {
|
|
out.push_str(&format!(" - document_id: {}\n", doc.document_id));
|
|
let preview = truncate_content(&doc.content, 4096);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::CreateDocuments(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::create_documents_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str("created_documents:\n");
|
|
for doc in &s.created_documents {
|
|
out.push_str(&format!(" - document_id: {}\n", doc.document_id));
|
|
let preview = truncate_content(&doc.content, 4096);
|
|
if !preview.is_empty() {
|
|
out.push_str(" content: |\n");
|
|
for line in preview.lines() {
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::StartAgent(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::start_agent_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str(&format!("agent_id: {}\n", s.agent_id));
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.error));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::SendMessageToAgent(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::send_message_to_agent_result::Result;
|
|
match res {
|
|
Result::Success(s) => {
|
|
out.push_str(&format!("message_id: {}\n", s.message_id));
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::InsertReviewComments(r) => {
|
|
if let Some(res) = &r.result {
|
|
use api::insert_review_comments_result::Result;
|
|
match res {
|
|
Result::Success(_) => {
|
|
out.push_str("status: success\n");
|
|
}
|
|
Result::Error(e) => {
|
|
out.push_str(&format!("error: {}\n", e.message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::AskUserQuestion(r) => {
|
|
use api::ask_user_question_result::Result;
|
|
match &r.result {
|
|
Some(Result::Success(success)) => {
|
|
out.push_str("status: completed\n");
|
|
if success.answers.is_empty() {
|
|
out.push_str("answers: []\n");
|
|
} else {
|
|
out.push_str("answers:\n");
|
|
for answer in &success.answers {
|
|
out.push_str(&format!(
|
|
" - question_id: \"{}\"\n",
|
|
escape_yaml_string(&answer.question_id)
|
|
));
|
|
match &answer.answer {
|
|
Some(
|
|
api::ask_user_question_result::answer_item::Answer::MultipleChoice(
|
|
multiple_choice,
|
|
),
|
|
) => {
|
|
out.push_str(" answer_type: multiple_choice\n");
|
|
if multiple_choice.selected_options.is_empty() {
|
|
out.push_str(" selected_options: []\n");
|
|
} else {
|
|
out.push_str(" selected_options:\n");
|
|
for option in &multiple_choice.selected_options {
|
|
out.push_str(&format!(
|
|
" - \"{}\"\n",
|
|
escape_yaml_string(option)
|
|
));
|
|
}
|
|
}
|
|
if !multiple_choice.other_text.is_empty() {
|
|
out.push_str(" other_text: |\n");
|
|
write_block_scalar_with_indent(
|
|
out,
|
|
&multiple_choice.other_text,
|
|
6,
|
|
);
|
|
}
|
|
}
|
|
Some(api::ask_user_question_result::answer_item::Answer::Skipped(()))
|
|
| None => {
|
|
out.push_str(" answer_type: skipped\n");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Some(Result::Error(error)) => {
|
|
out.push_str("status: error\n");
|
|
out.push_str("error: |\n");
|
|
write_block_scalar_with_indent(out, &error.message, 2);
|
|
}
|
|
None => {
|
|
out.push_str("status: cancelled\n");
|
|
}
|
|
}
|
|
}
|
|
ToolCallResultType::Cancel(_) => {
|
|
out.push_str("status: cancelled\n");
|
|
}
|
|
// No structured content worth serializing.
|
|
ToolCallResultType::Server(_)
|
|
| ToolCallResultType::Subagent(_)
|
|
| ToolCallResultType::UseComputer(_)
|
|
| ToolCallResultType::RequestComputerUseResult(_)
|
|
| ToolCallResultType::SuggestNewConversation(_)
|
|
| ToolCallResultType::SuggestPrompt(_)
|
|
| ToolCallResultType::OpenCodeReview(_)
|
|
| ToolCallResultType::InitProject(_)
|
|
| ToolCallResultType::TransferShellCommandControlToUser(_)
|
|
| ToolCallResultType::SuggestCreatePlan(_)
|
|
| ToolCallResultType::SuggestPlan(_) => {
|
|
out.push_str("status: completed\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Looks up the subtask_id for a given tool_call_id by scanning the task's messages for a
|
|
/// matching Subagent tool call.
|
|
fn find_subtask_id_for_tool_call(task: &api::Task, tool_call_id: &str) -> Option<String> {
|
|
task.messages.iter().find_map(|m| {
|
|
if let Some(Message::ToolCall(tc)) = &m.message {
|
|
if tc.tool_call_id == tool_call_id {
|
|
if let Some(Tool::Subagent(sub)) = &tc.tool {
|
|
return Some(sub.task_id.clone());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
})
|
|
}
|
|
|
|
fn escape_yaml_string(s: &str) -> String {
|
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
|
}
|
|
|
|
fn truncate_content(s: &str, max_bytes: usize) -> &str {
|
|
if s.len() <= max_bytes {
|
|
return s;
|
|
}
|
|
// Find a safe UTF-8 boundary.
|
|
let mut end = max_bytes;
|
|
while end > 0 && !s.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
&s[..end]
|
|
}
|
|
|
|
/// Returns (file_path, truncated_content_preview) for an AnyFileContent.
|
|
fn any_file_content_summary(content: &api::AnyFileContent) -> (String, String) {
|
|
match &content.content {
|
|
Some(api::any_file_content::Content::TextContent(f)) => (
|
|
f.file_path.clone(),
|
|
truncate_content(&f.content, 4096).to_string(),
|
|
),
|
|
Some(api::any_file_content::Content::BinaryContent(b)) => {
|
|
(b.file_path.clone(), "<binary>".to_string())
|
|
}
|
|
None => (String::new(), String::new()),
|
|
}
|
|
}
|
|
|
|
/// Converts a prost Struct to a serde_json Value for pretty-printing.
|
|
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
|
let map: serde_json::Map<String, serde_json::Value> = s
|
|
.fields
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), prost_value_to_json(v)))
|
|
.collect();
|
|
serde_json::Value::Object(map)
|
|
}
|
|
|
|
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
|
use prost_types::value::Kind;
|
|
match &v.kind {
|
|
Some(Kind::NullValue(_)) => serde_json::Value::Null,
|
|
Some(Kind::NumberValue(n)) => serde_json::json!(*n),
|
|
Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
|
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
|
Some(Kind::StructValue(s)) => prost_struct_to_json(s),
|
|
Some(Kind::ListValue(l)) => {
|
|
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
|
}
|
|
None => serde_json::Value::Null,
|
|
}
|
|
}
|