mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-21 05:02:17 +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 tokio::time::interval;
|
||||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
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_error::SandboxError;
|
||||||
use sandbox_agent_agent_management::agents::AgentId;
|
use sandbox_agent_agent_management::agents::AgentId;
|
||||||
use sandbox_agent_universal_agent_schema::{
|
use sandbox_agent_universal_agent_schema::{
|
||||||
|
|
@ -1275,6 +1278,103 @@ fn tool_input_from_arguments(arguments: Option<&str>) -> Value {
|
||||||
json!({ "arguments": arguments })
|
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> {
|
fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> {
|
||||||
let mut patterns = Vec::new();
|
let mut patterns = Vec::new();
|
||||||
let Some(metadata) = metadata else {
|
let Some(metadata) = metadata else {
|
||||||
|
|
@ -1747,10 +1847,27 @@ async fn apply_tool_item_event(
|
||||||
item: UniversalItem,
|
item: UniversalItem,
|
||||||
) {
|
) {
|
||||||
let session_id = event.session_id.clone();
|
let session_id = event.session_id.clone();
|
||||||
let tool_info = extract_tool_content(&item.content);
|
let tool_snapshot = state
|
||||||
let call_id = match tool_info.call_id.clone() {
|
.inner
|
||||||
Some(call_id) => call_id,
|
.session_manager()
|
||||||
None => return,
|
.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() {
|
let item_id_key = if item.item_id.is_empty() {
|
||||||
|
|
@ -1845,22 +1962,26 @@ async fn apply_tool_item_event(
|
||||||
.opencode
|
.opencode
|
||||||
.emit_event(message_event("message.updated", &info));
|
.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();
|
let mut attachments = Vec::new();
|
||||||
if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted {
|
if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted {
|
||||||
for (path, action, diff) in tool_info.file_refs.iter() {
|
let parts = file_actions_to_parts(&session_id, &message_id, &file_actions);
|
||||||
let mime = match action {
|
for part in parts {
|
||||||
FileAction::Patch => "text/x-diff",
|
let path = part
|
||||||
_ => "text/plain",
|
.get("filename")
|
||||||
};
|
.and_then(|value| value.as_str())
|
||||||
let part =
|
.unwrap_or("")
|
||||||
build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref());
|
.to_string();
|
||||||
upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await;
|
upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await;
|
||||||
state
|
state
|
||||||
.opencode
|
.opencode
|
||||||
.emit_event(part_event("message.part.updated", &part));
|
.emit_event(part_event("message.part.updated", &part));
|
||||||
attachments.push(part.clone());
|
attachments.push(part.clone());
|
||||||
if matches!(action, FileAction::Write | FileAction::Patch) {
|
if !path.is_empty() {
|
||||||
emit_file_edited(&state.opencode, path);
|
emit_file_edited(&state.opencode, &path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1870,18 +1991,28 @@ async fn apply_tool_item_event(
|
||||||
.get(&call_id)
|
.get(&call_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
|
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
|
||||||
let tool_name = tool_info
|
let tool_name = tool_snapshot
|
||||||
.tool_name
|
.as_ref()
|
||||||
.clone()
|
.and_then(|snapshot| snapshot.name.clone())
|
||||||
|
.or_else(|| fallback_info.tool_name.clone())
|
||||||
.unwrap_or_else(|| "tool".to_string());
|
.unwrap_or_else(|| "tool".to_string());
|
||||||
let input_value = tool_input_from_arguments(tool_info.arguments.as_deref());
|
let arguments = tool_snapshot
|
||||||
let raw_args = tool_info.arguments.clone().unwrap_or_default();
|
.as_ref()
|
||||||
let output_value = tool_info
|
.map(|snapshot| snapshot.arguments.clone())
|
||||||
.output
|
.or_else(|| fallback_info.arguments.clone())
|
||||||
.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));
|
.or_else(|| extract_text_from_content(&item.content));
|
||||||
|
|
||||||
let state_value = match event.event_type {
|
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 => {
|
UniversalEventType::ItemStarted => {
|
||||||
if item.kind == ItemKind::ToolResult {
|
if item.kind == ItemKind::ToolResult {
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -1931,6 +2062,7 @@ async fn apply_tool_item_event(
|
||||||
"input": input_value,
|
"input": input_value,
|
||||||
"raw": raw_args,
|
"raw": raw_args,
|
||||||
}),
|
}),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tool_part = build_tool_part(
|
let tool_part = build_tool_part(
|
||||||
|
|
@ -1980,6 +2112,73 @@ async fn apply_item_delta(
|
||||||
if is_user_delta {
|
if is_user_delta {
|
||||||
return;
|
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 message_id: Option<String> = None;
|
||||||
let mut parent_id: Option<String> = None;
|
let mut parent_id: Option<String> = None;
|
||||||
let runtime = state
|
let runtime = state
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,9 @@ struct SessionState {
|
||||||
item_started: HashSet<String>,
|
item_started: HashSet<String>,
|
||||||
item_delta_seen: HashSet<String>,
|
item_delta_seen: HashSet<String>,
|
||||||
item_map: HashMap<String, 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,
|
mock_sequence: u64,
|
||||||
broadcaster: broadcast::Sender<UniversalEvent>,
|
broadcaster: broadcast::Sender<UniversalEvent>,
|
||||||
opencode_stream_started: bool,
|
opencode_stream_started: bool,
|
||||||
|
|
@ -360,6 +363,141 @@ struct PendingQuestion {
|
||||||
options: Vec<String>,
|
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 {
|
impl SessionState {
|
||||||
fn new(
|
fn new(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
@ -394,6 +532,9 @@ impl SessionState {
|
||||||
item_started: HashSet::new(),
|
item_started: HashSet::new(),
|
||||||
item_delta_seen: HashSet::new(),
|
item_delta_seen: HashSet::new(),
|
||||||
item_map: HashMap::new(),
|
item_map: HashMap::new(),
|
||||||
|
tool_calls: HashMap::new(),
|
||||||
|
tool_call_by_item: HashMap::new(),
|
||||||
|
tool_item_kind: HashMap::new(),
|
||||||
mock_sequence: 0,
|
mock_sequence: 0,
|
||||||
broadcaster,
|
broadcaster,
|
||||||
opencode_stream_started: false,
|
opencode_stream_started: false,
|
||||||
|
|
@ -620,6 +761,7 @@ impl SessionState {
|
||||||
|
|
||||||
self.update_pending(&event);
|
self.update_pending(&event);
|
||||||
self.update_item_tracking(&event);
|
self.update_item_tracking(&event);
|
||||||
|
self.update_tool_tracking(&event);
|
||||||
self.events.push(event.clone());
|
self.events.push(event.clone());
|
||||||
let _ = self.broadcaster.send(event.clone());
|
let _ = self.broadcaster.send(event.clone());
|
||||||
if self.native_session_id.is_none() {
|
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> {
|
fn take_question(&mut self, question_id: &str) -> Option<PendingQuestion> {
|
||||||
self.pending_questions.remove(question_id)
|
self.pending_questions.remove(question_id)
|
||||||
}
|
}
|
||||||
|
|
@ -2595,6 +2863,17 @@ impl SessionManager {
|
||||||
Ok(session.record_conversions(conversions))
|
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> {
|
async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
|
|
@ -6353,12 +6632,120 @@ fn agent_unparsed(location: &str, error: &str, raw: Value) -> EventConversion {
|
||||||
.with_raw(Some(raw))
|
.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 {
|
fn now_rfc3339() -> String {
|
||||||
time::OffsetDateTime::now_utc()
|
time::OffsetDateTime::now_utc()
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
.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 {
|
struct TurnStreamState {
|
||||||
initial_events: VecDeque<UniversalEvent>,
|
initial_events: VecDeque<UniversalEvent>,
|
||||||
receiver: broadcast::Receiver<UniversalEvent>,
|
receiver: broadcast::Receiver<UniversalEvent>,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
||||||
tool: false,
|
tool: false,
|
||||||
file: false,
|
file: false,
|
||||||
edited: false,
|
edited: false,
|
||||||
|
toolStatuses: [] as string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const waiter = new Promise<void>((resolve, reject) => {
|
const waiter = new Promise<void>((resolve, reject) => {
|
||||||
|
|
@ -48,6 +49,10 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
||||||
const part = event.properties?.part;
|
const part = event.properties?.part;
|
||||||
if (part?.type === "tool") {
|
if (part?.type === "tool") {
|
||||||
tracker.tool = true;
|
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") {
|
if (part?.type === "file") {
|
||||||
tracker.file = true;
|
tracker.file = true;
|
||||||
|
|
@ -56,7 +61,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
||||||
if (event.type === "file.edited") {
|
if (event.type === "file.edited") {
|
||||||
tracker.edited = true;
|
tracker.edited = true;
|
||||||
}
|
}
|
||||||
if (tracker.tool && tracker.file && tracker.edited) {
|
if (tracker.tool && tracker.file && tracker.edited && tracker.toolStatuses.includes("error")) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
break;
|
break;
|
||||||
|
|
@ -81,5 +86,8 @@ describe("OpenCode-compatible Tool + File Actions", () => {
|
||||||
expect(tracker.tool).toBe(true);
|
expect(tracker.tool).toBe(true);
|
||||||
expect(tracker.file).toBe(true);
|
expect(tracker.file).toBe(true);
|
||||||
expect(tracker.edited).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