use agent_client_protocol::schema::v1 as schema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
Read,
Edit,
Delete,
Move,
Search,
Execute,
Think,
Fetch,
SwitchMode,
#[serde(other)]
Unknown,
}
impl ToolKind {
fn from_acp(kind: schema::ToolKind) -> Self {
match kind {
schema::ToolKind::Read => Self::Read,
schema::ToolKind::Edit => Self::Edit,
schema::ToolKind::Delete => Self::Delete,
schema::ToolKind::Move => Self::Move,
schema::ToolKind::Search => Self::Search,
schema::ToolKind::Execute => Self::Execute,
schema::ToolKind::Think => Self::Think,
schema::ToolKind::Fetch => Self::Fetch,
schema::ToolKind::SwitchMode => Self::SwitchMode,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Pending,
InProgress,
Completed,
Failed,
#[serde(other)]
Unknown,
}
impl ToolCallStatus {
fn from_acp(status: schema::ToolCallStatus) -> Self {
match status {
schema::ToolCallStatus::Pending => Self::Pending,
schema::ToolCallStatus::InProgress => Self::InProgress,
schema::ToolCallStatus::Completed => Self::Completed,
schema::ToolCallStatus::Failed => Self::Failed,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryPriority {
High,
Medium,
Low,
#[serde(other)]
Unknown,
}
impl PlanEntryPriority {
fn from_acp(priority: schema::PlanEntryPriority) -> Self {
match priority {
schema::PlanEntryPriority::High => Self::High,
schema::PlanEntryPriority::Medium => Self::Medium,
schema::PlanEntryPriority::Low => Self::Low,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
#[serde(other)]
Unknown,
}
impl PlanEntryStatus {
fn from_acp(status: schema::PlanEntryStatus) -> Self {
match status {
schema::PlanEntryStatus::Pending => Self::Pending,
schema::PlanEntryStatus::InProgress => Self::InProgress,
schema::PlanEntryStatus::Completed => Self::Completed,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanEntry {
pub content: String,
pub priority: PlanEntryPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ToolOutputBlock {
pub text: String,
pub skipped: u32,
pub diff: Option<ToolDiffSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolDiffSummary {
pub path: String,
pub added: u32,
pub removed: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum SubagentEvent {
Message {
text: String,
},
Thought {
text: String,
},
ToolCall {
id: String,
title: String,
kind: ToolKind,
status: ToolCallStatus,
raw_input: Option<serde_json::Value>,
},
ToolCallUpdate {
id: String,
status: Option<ToolCallStatus>,
title: Option<String>,
raw_output: Option<serde_json::Value>,
content: ToolOutputBlock,
},
Plan {
entries: Vec<PlanEntry>,
},
Usage {
context_window: u64,
tokens_in_context: u64,
},
Mode {
id: String,
},
SessionInfo {
title: Option<String>,
},
}
impl SubagentEvent {
pub(crate) fn from_session_update(update: schema::SessionUpdate) -> Option<Self> {
match update {
schema::SessionUpdate::AgentMessageChunk(chunk) => {
chunk_text(chunk, "agent message").map(|text| Self::Message { text })
}
schema::SessionUpdate::AgentThoughtChunk(chunk) => {
chunk_text(chunk, "agent thought").map(|text| Self::Thought { text })
}
schema::SessionUpdate::ToolCall(call) => Some(Self::from_tool_call(call)),
schema::SessionUpdate::ToolCallUpdate(update) => {
Some(Self::from_tool_call_update(update))
}
schema::SessionUpdate::Plan(plan) => Some(Self::from_plan(plan)),
schema::SessionUpdate::UsageUpdate(update) => Some(Self::from_usage(update)),
schema::SessionUpdate::CurrentModeUpdate(schema::CurrentModeUpdate {
current_mode_id,
..
}) => Some(Self::Mode {
id: current_mode_id.0.to_string(),
}),
schema::SessionUpdate::SessionInfoUpdate(update) => {
session_info_title(&update).map(|title| Self::SessionInfo { title: Some(title) })
}
_ => None,
}
}
fn from_tool_call(call: schema::ToolCall) -> Self {
Self::ToolCall {
id: call.tool_call_id.0.to_string(),
title: call.title,
kind: ToolKind::from_acp(call.kind),
status: ToolCallStatus::from_acp(call.status),
raw_input: call.raw_input,
}
}
fn from_tool_call_update(update: schema::ToolCallUpdate) -> Self {
let fields = update.fields;
Self::ToolCallUpdate {
id: update.tool_call_id.0.to_string(),
status: fields.status.map(ToolCallStatus::from_acp),
title: fields.title,
raw_output: fields.raw_output,
content: extract_content_text(fields.content.unwrap_or_default()),
}
}
fn from_plan(plan: schema::Plan) -> Self {
Self::Plan {
entries: plan
.entries
.into_iter()
.map(
|schema::PlanEntry {
content,
priority,
status,
..
}| PlanEntry {
content,
priority: PlanEntryPriority::from_acp(priority),
status: PlanEntryStatus::from_acp(status),
},
)
.collect(),
}
}
fn from_usage(schema::UsageUpdate { used, size, .. }: schema::UsageUpdate) -> Self {
Self::Usage {
context_window: size,
tokens_in_context: used,
}
}
}
fn chunk_text(
schema::ContentChunk { content, .. }: schema::ContentChunk,
what: &str,
) -> Option<String> {
match content {
schema::ContentBlock::Text(text) => Some(text.text),
other => {
log::debug!("sub-agent sent a non-text {what} chunk: {other:?}");
None
}
}
}
fn session_info_title(update: &schema::SessionInfoUpdate) -> Option<String> {
update.title.value().cloned()
}
pub(crate) fn extract_content_text(content: Vec<schema::ToolCallContent>) -> ToolOutputBlock {
let mut text = String::new();
let mut skipped = 0_u32;
let mut diff = None;
for block in content {
match block {
schema::ToolCallContent::Content(inner) => match inner.content {
schema::ContentBlock::Text(t) => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&t.text);
}
_ => skipped += 1,
},
schema::ToolCallContent::Diff(d) => {
diff = Some(diff_summary(
&d.path.to_string_lossy(),
d.old_text.as_deref(),
&d.new_text,
));
}
_ => skipped += 1,
}
}
ToolOutputBlock {
text,
skipped,
diff,
}
}
pub(crate) const DIFF_SUMMARY_LINE_CAP: usize = 5_000;
pub(crate) fn diff_summary(path: &str, old_text: Option<&str>, new_text: &str) -> ToolDiffSummary {
let old_lines = old_text.unwrap_or("").lines().count();
let new_lines = new_text.lines().count();
let (added, removed) = match (old_text, old_lines, new_lines) {
(None, _, _) => (new_lines, 0),
(_, o, n) if o > DIFF_SUMMARY_LINE_CAP || n > DIFF_SUMMARY_LINE_CAP => {
(n.saturating_sub(o), o.saturating_sub(n))
}
_ => {
use std::collections::HashSet;
let old_set: HashSet<&str> = old_text.unwrap_or("").lines().collect();
let new_set: HashSet<&str> = new_text.lines().collect();
(
new_set.difference(&old_set).count(),
old_set.difference(&new_set).count(),
)
}
};
ToolDiffSummary {
path: path.to_string(),
added: u32::try_from(added).unwrap_or(u32::MAX),
removed: u32::try_from(removed).unwrap_or(u32::MAX),
}
}