mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
feat: implement toolcall-file-actions spec
This commit is contained in:
parent
7378abee46
commit
618d1e0a31
7 changed files with 661 additions and 63 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/.turbo
|
||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/dist
|
||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/node_modules
|
||||
|
|
@ -23,7 +23,10 @@ use tokio::sync::{broadcast, Mutex};
|
|||
use tokio::time::interval;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
|
||||
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
||||
use crate::router::{
|
||||
AppState, CreateSessionRequest, PermissionReply, SessionFileAction, SessionFileActionKind,
|
||||
ToolCallSnapshot, ToolCallStatus,
|
||||
};
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_universal_agent_schema::{
|
||||
|
|
@ -1275,6 +1278,103 @@ fn tool_input_from_arguments(arguments: Option<&str>) -> Value {
|
|||
json!({ "arguments": arguments })
|
||||
}
|
||||
|
||||
fn file_action_path(action: &SessionFileAction) -> &str {
|
||||
action
|
||||
.destination
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| action.path.as_str())
|
||||
}
|
||||
|
||||
fn file_action_mime(action: &SessionFileAction) -> &'static str {
|
||||
if matches!(action.action, SessionFileActionKind::Patch) || action.diff.is_some() {
|
||||
"text/x-diff"
|
||||
} else {
|
||||
"text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
fn file_actions_to_parts(
|
||||
session_id: &str,
|
||||
message_id: &str,
|
||||
actions: &[SessionFileAction],
|
||||
) -> Vec<Value> {
|
||||
actions
|
||||
.iter()
|
||||
.map(|action| {
|
||||
let path = file_action_path(action);
|
||||
build_file_part_from_path(
|
||||
session_id,
|
||||
message_id,
|
||||
path,
|
||||
file_action_mime(action),
|
||||
action.diff.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn file_actions_from_refs(
|
||||
file_refs: &[(String, FileAction, Option<String>)],
|
||||
) -> Vec<SessionFileAction> {
|
||||
file_refs
|
||||
.iter()
|
||||
.filter_map(|(path, action, diff)| match action {
|
||||
FileAction::Write => Some(SessionFileAction {
|
||||
path: path.clone(),
|
||||
action: SessionFileActionKind::Write,
|
||||
diff: diff.clone(),
|
||||
destination: None,
|
||||
}),
|
||||
FileAction::Patch => Some(SessionFileAction {
|
||||
path: path.clone(),
|
||||
action: SessionFileActionKind::Patch,
|
||||
diff: diff.clone(),
|
||||
destination: None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_state_from_snapshot(
|
||||
snapshot: &ToolCallSnapshot,
|
||||
now: i64,
|
||||
attachments: Vec<Value>,
|
||||
) -> Value {
|
||||
let input_value = tool_input_from_arguments(Some(snapshot.arguments.as_str()));
|
||||
let raw_args = snapshot.arguments.clone();
|
||||
let started_at = snapshot.started_at_ms.unwrap_or(now);
|
||||
let completed_at = snapshot.completed_at_ms.unwrap_or(now);
|
||||
match snapshot.status {
|
||||
ToolCallStatus::Pending => json!({
|
||||
"status": "pending",
|
||||
"input": input_value,
|
||||
"raw": raw_args,
|
||||
}),
|
||||
ToolCallStatus::Running => json!({
|
||||
"status": "running",
|
||||
"input": input_value,
|
||||
"time": {"start": started_at},
|
||||
}),
|
||||
ToolCallStatus::Completed => json!({
|
||||
"status": "completed",
|
||||
"input": input_value,
|
||||
"output": snapshot.output.clone().unwrap_or_default(),
|
||||
"title": "Tool result",
|
||||
"metadata": {},
|
||||
"time": {"start": started_at, "end": completed_at},
|
||||
"attachments": attachments,
|
||||
}),
|
||||
ToolCallStatus::Error => json!({
|
||||
"status": "error",
|
||||
"input": input_value,
|
||||
"error": snapshot.output.clone().unwrap_or_else(|| "Tool failed".to_string()),
|
||||
"metadata": {},
|
||||
"time": {"start": started_at, "end": completed_at},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> {
|
||||
let mut patterns = Vec::new();
|
||||
let Some(metadata) = metadata else {
|
||||
|
|
@ -1747,10 +1847,27 @@ async fn apply_tool_item_event(
|
|||
item: UniversalItem,
|
||||
) {
|
||||
let session_id = event.session_id.clone();
|
||||
let tool_info = extract_tool_content(&item.content);
|
||||
let call_id = match tool_info.call_id.clone() {
|
||||
Some(call_id) => call_id,
|
||||
None => return,
|
||||
let tool_snapshot = state
|
||||
.inner
|
||||
.session_manager()
|
||||
.tool_call_snapshot_for_item(
|
||||
&session_id,
|
||||
&item.item_id,
|
||||
item.native_item_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let fallback_info = if tool_snapshot.is_none() {
|
||||
extract_tool_content(&item.content)
|
||||
} else {
|
||||
ToolContentInfo::default()
|
||||
};
|
||||
let call_id = tool_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.call_id.clone())
|
||||
.or_else(|| fallback_info.call_id.clone())
|
||||
.or_else(|| item.native_item_id.clone());
|
||||
let Some(call_id) = call_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let item_id_key = if item.item_id.is_empty() {
|
||||
|
|
@ -1845,22 +1962,26 @@ async fn apply_tool_item_event(
|
|||
.opencode
|
||||
.emit_event(message_event("message.updated", &info));
|
||||
|
||||
let file_actions = tool_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.file_actions.clone())
|
||||
.unwrap_or_else(|| file_actions_from_refs(&fallback_info.file_refs));
|
||||
let mut attachments = Vec::new();
|
||||
if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted {
|
||||
for (path, action, diff) in tool_info.file_refs.iter() {
|
||||
let mime = match action {
|
||||
FileAction::Patch => "text/x-diff",
|
||||
_ => "text/plain",
|
||||
};
|
||||
let part =
|
||||
build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref());
|
||||
let parts = file_actions_to_parts(&session_id, &message_id, &file_actions);
|
||||
for part in parts {
|
||||
let path = part
|
||||
.get("filename")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await;
|
||||
state
|
||||
.opencode
|
||||
.emit_event(part_event("message.part.updated", &part));
|
||||
attachments.push(part.clone());
|
||||
if matches!(action, FileAction::Write | FileAction::Patch) {
|
||||
emit_file_edited(&state.opencode, path);
|
||||
if !path.is_empty() {
|
||||
emit_file_edited(&state.opencode, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1870,67 +1991,78 @@ async fn apply_tool_item_event(
|
|||
.get(&call_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
|
||||
let tool_name = tool_info
|
||||
.tool_name
|
||||
.clone()
|
||||
let tool_name = tool_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.name.clone())
|
||||
.or_else(|| fallback_info.tool_name.clone())
|
||||
.unwrap_or_else(|| "tool".to_string());
|
||||
let input_value = tool_input_from_arguments(tool_info.arguments.as_deref());
|
||||
let raw_args = tool_info.arguments.clone().unwrap_or_default();
|
||||
let output_value = tool_info
|
||||
.output
|
||||
.clone()
|
||||
let arguments = tool_snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.arguments.clone())
|
||||
.or_else(|| fallback_info.arguments.clone())
|
||||
.unwrap_or_default();
|
||||
let input_value = tool_input_from_arguments(Some(arguments.as_str()));
|
||||
let raw_args = arguments.clone();
|
||||
let output_value = tool_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.output.clone())
|
||||
.or_else(|| fallback_info.output.clone())
|
||||
.or_else(|| extract_text_from_content(&item.content));
|
||||
|
||||
let state_value = match event.event_type {
|
||||
UniversalEventType::ItemStarted => {
|
||||
if item.kind == ItemKind::ToolResult {
|
||||
json!({
|
||||
"status": "running",
|
||||
"input": input_value,
|
||||
"time": {"start": now}
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"status": "pending",
|
||||
"input": input_value,
|
||||
"raw": raw_args,
|
||||
})
|
||||
}
|
||||
}
|
||||
UniversalEventType::ItemCompleted => {
|
||||
if item.kind == ItemKind::ToolResult {
|
||||
if matches!(item.status, ItemStatus::Failed) {
|
||||
let state_value = if let Some(snapshot) = tool_snapshot.as_ref() {
|
||||
tool_state_from_snapshot(snapshot, now, attachments.clone())
|
||||
} else {
|
||||
match event.event_type {
|
||||
UniversalEventType::ItemStarted => {
|
||||
if item.kind == ItemKind::ToolResult {
|
||||
json!({
|
||||
"status": "error",
|
||||
"status": "running",
|
||||
"input": input_value,
|
||||
"error": output_value.unwrap_or_else(|| "Tool failed".to_string()),
|
||||
"metadata": {},
|
||||
"time": {"start": now, "end": now},
|
||||
"time": {"start": now}
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"status": "completed",
|
||||
"status": "pending",
|
||||
"input": input_value,
|
||||
"output": output_value.unwrap_or_default(),
|
||||
"title": "Tool result",
|
||||
"metadata": {},
|
||||
"time": {"start": now, "end": now},
|
||||
"attachments": attachments,
|
||||
"raw": raw_args,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"status": "running",
|
||||
"input": input_value,
|
||||
"time": {"start": now},
|
||||
})
|
||||
}
|
||||
UniversalEventType::ItemCompleted => {
|
||||
if item.kind == ItemKind::ToolResult {
|
||||
if matches!(item.status, ItemStatus::Failed) {
|
||||
json!({
|
||||
"status": "error",
|
||||
"input": input_value,
|
||||
"error": output_value.unwrap_or_else(|| "Tool failed".to_string()),
|
||||
"metadata": {},
|
||||
"time": {"start": now, "end": now},
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"status": "completed",
|
||||
"input": input_value,
|
||||
"output": output_value.unwrap_or_default(),
|
||||
"title": "Tool result",
|
||||
"metadata": {},
|
||||
"time": {"start": now, "end": now},
|
||||
"attachments": attachments,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"status": "running",
|
||||
"input": input_value,
|
||||
"time": {"start": now},
|
||||
})
|
||||
}
|
||||
}
|
||||
_ => json!({
|
||||
"status": "pending",
|
||||
"input": input_value,
|
||||
"raw": raw_args,
|
||||
}),
|
||||
}
|
||||
_ => json!({
|
||||
"status": "pending",
|
||||
"input": input_value,
|
||||
"raw": raw_args,
|
||||
}),
|
||||
};
|
||||
|
||||
let tool_part = build_tool_part(
|
||||
|
|
@ -1980,6 +2112,73 @@ async fn apply_item_delta(
|
|||
if is_user_delta {
|
||||
return;
|
||||
}
|
||||
let tool_snapshot = state
|
||||
.inner
|
||||
.session_manager()
|
||||
.tool_call_snapshot_for_item(
|
||||
&session_id,
|
||||
item_id_key.as_deref().unwrap_or(""),
|
||||
native_id_key.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if let Some(snapshot) = tool_snapshot {
|
||||
let runtime = state
|
||||
.opencode
|
||||
.update_runtime(&session_id, |_| {})
|
||||
.await;
|
||||
let message_id = runtime
|
||||
.tool_message_by_call
|
||||
.get(&snapshot.call_id)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
item_id_key
|
||||
.as_ref()
|
||||
.and_then(|key| runtime.message_id_for_item.get(key).cloned())
|
||||
})
|
||||
.or_else(|| {
|
||||
native_id_key
|
||||
.as_ref()
|
||||
.and_then(|key| runtime.message_id_for_item.get(key).cloned())
|
||||
});
|
||||
let Some(message_id) = message_id else {
|
||||
return;
|
||||
};
|
||||
let now = state.opencode.now_ms();
|
||||
let part_id = runtime
|
||||
.tool_part_by_call
|
||||
.get(&snapshot.call_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
|
||||
let tool_name = snapshot
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "tool".to_string());
|
||||
let state_value = tool_state_from_snapshot(&snapshot, now, Vec::new());
|
||||
let tool_part = build_tool_part(
|
||||
&session_id,
|
||||
&message_id,
|
||||
&part_id,
|
||||
&snapshot.call_id,
|
||||
&tool_name,
|
||||
state_value,
|
||||
);
|
||||
upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()).await;
|
||||
state
|
||||
.opencode
|
||||
.emit_event(part_event("message.part.updated", &tool_part));
|
||||
let _ = state
|
||||
.opencode
|
||||
.update_runtime(&session_id, |runtime| {
|
||||
runtime
|
||||
.tool_part_by_call
|
||||
.insert(snapshot.call_id.clone(), part_id);
|
||||
runtime
|
||||
.tool_message_by_call
|
||||
.insert(snapshot.call_id.clone(), message_id.clone());
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut parent_id: Option<String> = None;
|
||||
let runtime = state
|
||||
|
|
|
|||
|
|
@ -336,6 +336,9 @@ struct SessionState {
|
|||
item_started: HashSet<String>,
|
||||
item_delta_seen: HashSet<String>,
|
||||
item_map: HashMap<String, String>,
|
||||
tool_calls: HashMap<String, ToolCallRecord>,
|
||||
tool_call_by_item: HashMap<String, String>,
|
||||
tool_item_kind: HashMap<String, ItemKind>,
|
||||
mock_sequence: u64,
|
||||
broadcaster: broadcast::Sender<UniversalEvent>,
|
||||
opencode_stream_started: bool,
|
||||
|
|
@ -360,6 +363,141 @@ struct PendingQuestion {
|
|||
options: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ToolCallStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SessionFileActionKind {
|
||||
Write,
|
||||
Patch,
|
||||
Rename,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionFileAction {
|
||||
pub path: String,
|
||||
pub action: SessionFileActionKind,
|
||||
pub diff: Option<String>,
|
||||
pub destination: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ToolCallSnapshot {
|
||||
pub call_id: String,
|
||||
pub name: Option<String>,
|
||||
pub arguments: String,
|
||||
pub output: Option<String>,
|
||||
pub status: ToolCallStatus,
|
||||
pub started_at_ms: Option<i64>,
|
||||
pub completed_at_ms: Option<i64>,
|
||||
pub file_actions: Vec<SessionFileAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolCallRecord {
|
||||
call_id: String,
|
||||
name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
arguments_delta: String,
|
||||
output: Option<String>,
|
||||
output_delta: String,
|
||||
status: ToolCallStatus,
|
||||
started_at_ms: Option<i64>,
|
||||
completed_at_ms: Option<i64>,
|
||||
file_actions: Vec<SessionFileAction>,
|
||||
}
|
||||
|
||||
impl ToolCallRecord {
|
||||
fn new(call_id: String) -> Self {
|
||||
Self {
|
||||
call_id,
|
||||
name: None,
|
||||
arguments: None,
|
||||
arguments_delta: String::new(),
|
||||
output: None,
|
||||
output_delta: String::new(),
|
||||
status: ToolCallStatus::Pending,
|
||||
started_at_ms: None,
|
||||
completed_at_ms: None,
|
||||
file_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn arguments_value(&self) -> String {
|
||||
self.arguments
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.arguments_delta.clone())
|
||||
}
|
||||
|
||||
fn output_value(&self) -> Option<String> {
|
||||
if let Some(output) = self.output.clone() {
|
||||
Some(output)
|
||||
} else if self.output_delta.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.output_delta.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> ToolCallSnapshot {
|
||||
ToolCallSnapshot {
|
||||
call_id: self.call_id.clone(),
|
||||
name: self.name.clone(),
|
||||
arguments: self.arguments_value(),
|
||||
output: self.output_value(),
|
||||
status: self.status,
|
||||
started_at_ms: self.started_at_ms,
|
||||
completed_at_ms: self.completed_at_ms,
|
||||
file_actions: self.file_actions.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_status(&mut self, status: ToolCallStatus, now_ms: i64) {
|
||||
if matches!(status, ToolCallStatus::Pending | ToolCallStatus::Running) {
|
||||
if self.started_at_ms.is_none() {
|
||||
self.started_at_ms = Some(now_ms);
|
||||
}
|
||||
}
|
||||
if matches!(status, ToolCallStatus::Completed | ToolCallStatus::Error) {
|
||||
if self.completed_at_ms.is_none() {
|
||||
self.completed_at_ms = Some(now_ms);
|
||||
}
|
||||
}
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
fn apply_delta(&mut self, kind: Option<ItemKind>, delta: &str) {
|
||||
match kind {
|
||||
Some(ItemKind::ToolCall) => {
|
||||
self.arguments_delta.push_str(delta);
|
||||
}
|
||||
Some(ItemKind::ToolResult) => {
|
||||
self.output_delta.push_str(delta);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_file_actions(&mut self, actions: Vec<SessionFileAction>) {
|
||||
for action in actions {
|
||||
if !self.file_actions.iter().any(|existing| {
|
||||
existing.path == action.path
|
||||
&& existing.action == action.action
|
||||
&& existing.destination == action.destination
|
||||
&& existing.diff == action.diff
|
||||
}) {
|
||||
self.file_actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
fn new(
|
||||
session_id: String,
|
||||
|
|
@ -394,6 +532,9 @@ impl SessionState {
|
|||
item_started: HashSet::new(),
|
||||
item_delta_seen: HashSet::new(),
|
||||
item_map: HashMap::new(),
|
||||
tool_calls: HashMap::new(),
|
||||
tool_call_by_item: HashMap::new(),
|
||||
tool_item_kind: HashMap::new(),
|
||||
mock_sequence: 0,
|
||||
broadcaster,
|
||||
opencode_stream_started: false,
|
||||
|
|
@ -620,6 +761,7 @@ impl SessionState {
|
|||
|
||||
self.update_pending(&event);
|
||||
self.update_item_tracking(&event);
|
||||
self.update_tool_tracking(&event);
|
||||
self.events.push(event.clone());
|
||||
let _ = self.broadcaster.send(event.clone());
|
||||
if self.native_session_id.is_none() {
|
||||
|
|
@ -689,6 +831,132 @@ impl SessionState {
|
|||
}
|
||||
}
|
||||
|
||||
fn update_tool_tracking(&mut self, event: &UniversalEvent) {
|
||||
match event.event_type {
|
||||
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
|
||||
let UniversalEventData::Item(data) = &event.data else {
|
||||
return;
|
||||
};
|
||||
let item = &data.item;
|
||||
if !matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) {
|
||||
return;
|
||||
}
|
||||
let tool_info = tool_tracking_info_from_parts(&item.content);
|
||||
let call_id = tool_info
|
||||
.call_id
|
||||
.clone()
|
||||
.or_else(|| item.native_item_id.clone())
|
||||
.or_else(|| {
|
||||
if item.item_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.item_id.clone())
|
||||
}
|
||||
});
|
||||
let Some(call_id) = call_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let record = self
|
||||
.tool_calls
|
||||
.entry(call_id.clone())
|
||||
.or_insert_with(|| ToolCallRecord::new(call_id.clone()));
|
||||
if let Some(name) = tool_info.tool_name {
|
||||
record.name = Some(name);
|
||||
}
|
||||
if let Some(arguments) = tool_info.arguments {
|
||||
if !arguments.is_empty() {
|
||||
record.arguments = Some(arguments);
|
||||
}
|
||||
}
|
||||
if let Some(output) = tool_info.output {
|
||||
if !output.is_empty() {
|
||||
record.output = Some(output);
|
||||
}
|
||||
}
|
||||
if event.event_type == UniversalEventType::ItemCompleted
|
||||
&& item.kind == ItemKind::ToolResult
|
||||
{
|
||||
record.add_file_actions(tool_info.file_actions);
|
||||
}
|
||||
|
||||
let now_ms = now_epoch_ms();
|
||||
match item.kind {
|
||||
ItemKind::ToolCall => {
|
||||
if event.event_type == UniversalEventType::ItemStarted {
|
||||
record.mark_status(ToolCallStatus::Pending, now_ms);
|
||||
} else {
|
||||
record.mark_status(ToolCallStatus::Running, now_ms);
|
||||
}
|
||||
}
|
||||
ItemKind::ToolResult => {
|
||||
if event.event_type == UniversalEventType::ItemStarted {
|
||||
record.mark_status(ToolCallStatus::Running, now_ms);
|
||||
} else if matches!(item.status, ItemStatus::Failed) {
|
||||
record.mark_status(ToolCallStatus::Error, now_ms);
|
||||
} else {
|
||||
record.mark_status(ToolCallStatus::Completed, now_ms);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !item.item_id.is_empty() {
|
||||
self.tool_call_by_item
|
||||
.insert(item.item_id.clone(), call_id.clone());
|
||||
self.tool_item_kind
|
||||
.insert(item.item_id.clone(), item.kind.clone());
|
||||
}
|
||||
if let Some(native) = &item.native_item_id {
|
||||
self.tool_call_by_item
|
||||
.insert(native.clone(), call_id.clone());
|
||||
self.tool_item_kind
|
||||
.insert(native.clone(), item.kind.clone());
|
||||
}
|
||||
}
|
||||
UniversalEventType::ItemDelta => {
|
||||
let UniversalEventData::ItemDelta(data) = &event.data else {
|
||||
return;
|
||||
};
|
||||
let item_key = if !data.item_id.is_empty() {
|
||||
Some(data.item_id.clone())
|
||||
} else {
|
||||
data.native_item_id.clone()
|
||||
};
|
||||
let Some(item_key) = item_key else {
|
||||
return;
|
||||
};
|
||||
let Some(call_id) = self.tool_call_by_item.get(&item_key).cloned() else {
|
||||
return;
|
||||
};
|
||||
let kind = self.tool_item_kind.get(&item_key).cloned();
|
||||
let record = self
|
||||
.tool_calls
|
||||
.entry(call_id.clone())
|
||||
.or_insert_with(|| ToolCallRecord::new(call_id.clone()));
|
||||
record.apply_delta(kind, &data.delta);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_call_snapshot_for_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
native_item_id: Option<&str>,
|
||||
) -> Option<ToolCallSnapshot> {
|
||||
let call_id = if !item_id.is_empty() {
|
||||
self.tool_call_by_item.get(item_id).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| native_item_id.and_then(|id| self.tool_call_by_item.get(id).cloned()));
|
||||
let Some(call_id) = call_id else {
|
||||
return None;
|
||||
};
|
||||
self.tool_calls.get(&call_id).map(|record| record.snapshot())
|
||||
}
|
||||
|
||||
fn take_question(&mut self, question_id: &str) -> Option<PendingQuestion> {
|
||||
self.pending_questions.remove(question_id)
|
||||
}
|
||||
|
|
@ -2595,6 +2863,17 @@ impl SessionManager {
|
|||
Ok(session.record_conversions(conversions))
|
||||
}
|
||||
|
||||
pub(crate) async fn tool_call_snapshot_for_item(
|
||||
&self,
|
||||
session_id: &str,
|
||||
item_id: &str,
|
||||
native_item_id: Option<&str>,
|
||||
) -> Option<ToolCallSnapshot> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = Self::session_ref(&sessions, session_id)?;
|
||||
session.tool_call_snapshot_for_item(item_id, native_item_id)
|
||||
}
|
||||
|
||||
async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -6353,12 +6632,120 @@ fn agent_unparsed(location: &str, error: &str, raw: Value) -> EventConversion {
|
|||
.with_raw(Some(raw))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ToolTrackingInfo {
|
||||
call_id: Option<String>,
|
||||
tool_name: Option<String>,
|
||||
arguments: Option<String>,
|
||||
output: Option<String>,
|
||||
file_actions: Vec<SessionFileAction>,
|
||||
}
|
||||
|
||||
fn tool_tracking_info_from_parts(parts: &[ContentPart]) -> ToolTrackingInfo {
|
||||
let mut info = ToolTrackingInfo::default();
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::ToolCall {
|
||||
name,
|
||||
arguments,
|
||||
call_id,
|
||||
} => {
|
||||
info.call_id = Some(call_id.clone());
|
||||
info.tool_name = Some(name.clone());
|
||||
info.arguments = Some(arguments.clone());
|
||||
}
|
||||
ContentPart::ToolResult { call_id, output } => {
|
||||
info.call_id = Some(call_id.clone());
|
||||
info.output = Some(output.clone());
|
||||
}
|
||||
ContentPart::FileRef { path, action, diff } => {
|
||||
let action = match action {
|
||||
FileAction::Write => Some(SessionFileActionKind::Write),
|
||||
FileAction::Patch => Some(SessionFileActionKind::Patch),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
info.file_actions.push(SessionFileAction {
|
||||
path: path.clone(),
|
||||
action,
|
||||
diff: diff.clone(),
|
||||
destination: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ContentPart::Json { json } => {
|
||||
info.file_actions.extend(file_actions_from_json(json));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
fn file_actions_from_json(value: &Value) -> Vec<SessionFileAction> {
|
||||
let mut actions = Vec::new();
|
||||
let Some(changes) = value.get("changes").and_then(Value::as_array) else {
|
||||
return actions;
|
||||
};
|
||||
for change in changes {
|
||||
let Some(path) = change.get("path").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let diff = change
|
||||
.get("diff")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let kind_value = change.get("kind");
|
||||
let kind_type = kind_value
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| kind_value.and_then(Value::as_str));
|
||||
let Some(kind_type) = kind_type else {
|
||||
continue;
|
||||
};
|
||||
let destination = kind_value
|
||||
.and_then(|value| value.get("move_path"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string());
|
||||
let action = match kind_type {
|
||||
"add" => Some(SessionFileActionKind::Write),
|
||||
"delete" => Some(SessionFileActionKind::Delete),
|
||||
"update" => {
|
||||
if destination.is_some() {
|
||||
Some(SessionFileActionKind::Rename)
|
||||
} else {
|
||||
Some(SessionFileActionKind::Patch)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
actions.push(SessionFileAction {
|
||||
path: path.to_string(),
|
||||
action,
|
||||
diff: diff.clone(),
|
||||
destination,
|
||||
});
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn now_rfc3339() -> String {
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
fn now_epoch_ms() -> i64 {
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let timestamp = now.unix_timestamp();
|
||||
let nanos = now.nanosecond();
|
||||
timestamp
|
||||
.saturating_mul(1000)
|
||||
.saturating_add(i64::from(nanos / 1_000_000))
|
||||
}
|
||||
|
||||
struct TurnStreamState {
|
||||
initial_events: VecDeque<UniversalEvent>,
|
||||
receiver: broadcast::Receiver<UniversalEvent>,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
|||
tool: false,
|
||||
file: false,
|
||||
edited: false,
|
||||
toolStatuses: [] as string[],
|
||||
};
|
||||
|
||||
const waiter = new Promise<void>((resolve, reject) => {
|
||||
|
|
@ -48,6 +49,10 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
|||
const part = event.properties?.part;
|
||||
if (part?.type === "tool") {
|
||||
tracker.tool = true;
|
||||
const status = part?.state?.status;
|
||||
if (status && tracker.toolStatuses[tracker.toolStatuses.length - 1] !== status) {
|
||||
tracker.toolStatuses.push(status);
|
||||
}
|
||||
}
|
||||
if (part?.type === "file") {
|
||||
tracker.file = true;
|
||||
|
|
@ -56,7 +61,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
|||
if (event.type === "file.edited") {
|
||||
tracker.edited = true;
|
||||
}
|
||||
if (tracker.tool && tracker.file && tracker.edited) {
|
||||
if (tracker.tool && tracker.file && tracker.edited && tracker.toolStatuses.includes("error")) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
|
|
@ -81,5 +86,8 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
|||
expect(tracker.tool).toBe(true);
|
||||
expect(tracker.file).toBe(true);
|
||||
expect(tracker.edited).toBe(true);
|
||||
expect(tracker.toolStatuses).toContain("pending");
|
||||
expect(tracker.toolStatuses).toContain("running");
|
||||
expect(tracker.toolStatuses).toContain("error");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
1
target
Symbolic link
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/target
|
||||
Loading…
Add table
Add a link
Reference in a new issue