mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 21:03:26 +00:00
feat: add Claude adapter improvements for HITL support (#30)
* feat: add Claude adapter improvements for HITL support - Add question and permission handling for Claude sessions - Add Claude sender channel for interactive communication - Add stream event and control request handling - Update agent compatibility documentation * fix: restore Claude HITL streaming input and permission handling - Add streaming_input field to SpawnOptions for Claude stdin streaming - Enable --input-format stream-json, --permission-prompt-tool stdio flags - Pipe stdin for Claude (not just Codex) in spawn_streaming - Update Claude capabilities: permissions, questions, tool_calls, tool_results, streaming_deltas - Fix permission mode normalization to respect user's choice instead of forcing bypass - Add acceptEdits permission mode support - Add libc dependency for is_running_as_root check
This commit is contained in:
parent
c7d6482fd4
commit
0ee60920c8
7 changed files with 513 additions and 67 deletions
|
|
@ -6,9 +6,12 @@ use crate::{
|
|||
ContentPart,
|
||||
EventConversion,
|
||||
ItemEventData,
|
||||
ItemDeltaData,
|
||||
ItemKind,
|
||||
ItemRole,
|
||||
ItemStatus,
|
||||
PermissionEventData,
|
||||
PermissionStatus,
|
||||
QuestionEventData,
|
||||
QuestionStatus,
|
||||
SessionStartedData,
|
||||
|
|
@ -31,11 +34,14 @@ pub fn event_to_universal_with_session(
|
|||
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let mut conversions = match event_type {
|
||||
"system" => vec![system_event_to_universal(event)],
|
||||
"user" => Vec::new(),
|
||||
"user" => user_event_to_universal(event),
|
||||
"assistant" => assistant_event_to_universal(event, &session_id),
|
||||
"tool_use" => tool_use_event_to_universal(event, &session_id),
|
||||
"tool_result" => tool_result_event_to_universal(event),
|
||||
"result" => result_event_to_universal(event, &session_id),
|
||||
"stream_event" => stream_event_to_universal(event),
|
||||
"control_request" => control_request_to_universal(event)?,
|
||||
"control_response" | "keep_alive" | "update_environment_variables" => Vec::new(),
|
||||
_ => return Err(format!("unsupported Claude event type: {event_type}")),
|
||||
};
|
||||
|
||||
|
|
@ -85,6 +91,44 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
|||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
let is_exit_plan_mode = matches!(
|
||||
name,
|
||||
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||
);
|
||||
let is_question_tool = matches!(
|
||||
name,
|
||||
"AskUserQuestion" | "ask_user_question" | "askUserQuestion"
|
||||
| "ask-user-question"
|
||||
) || is_exit_plan_mode;
|
||||
let has_question_payload = input.get("questions").is_some();
|
||||
if is_question_tool || has_question_payload {
|
||||
if let Some(question) = question_from_claude_input(&input, call_id.clone()) {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(question),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
} else if is_exit_plan_mode {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(QuestionEventData {
|
||||
question_id: call_id.clone(),
|
||||
prompt: "Approve plan execution?".to_string(),
|
||||
options: vec![
|
||||
"approve".to_string(),
|
||||
"reject".to_string(),
|
||||
],
|
||||
response: None,
|
||||
status: QuestionStatus::Requested,
|
||||
}),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
|
||||
let tool_item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
|
|
@ -120,7 +164,49 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
|||
status: ItemStatus::InProgress,
|
||||
};
|
||||
|
||||
conversions.extend(message_started_events(message_item, message_parts));
|
||||
conversions.extend(message_started_events(message_item));
|
||||
conversions
|
||||
}
|
||||
|
||||
fn user_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||
let mut conversions = Vec::new();
|
||||
let content = event
|
||||
.get("message")
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
for block in content {
|
||||
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if block_type != "tool_result" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.or_else(|| block.get("toolUseId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
let output = block.get("content").cloned().unwrap_or(Value::Null);
|
||||
let output_text = serde_json::to_string(&output).unwrap_or_default();
|
||||
|
||||
let tool_item = UniversalItem {
|
||||
item_id: next_temp_id("tmp_claude_tool_result"),
|
||||
native_item_id: Some(tool_use_id.clone()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::ToolResult,
|
||||
role: Some(ItemRole::Tool),
|
||||
content: vec![ContentPart::ToolResult {
|
||||
call_id: tool_use_id,
|
||||
output: output_text,
|
||||
}],
|
||||
status: ItemStatus::Completed,
|
||||
};
|
||||
conversions.extend(item_events(tool_item, true));
|
||||
}
|
||||
|
||||
conversions
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +227,14 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
|||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
|
||||
let is_exit_plan_mode = matches!(
|
||||
name,
|
||||
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||
);
|
||||
let is_question_tool = matches!(
|
||||
name,
|
||||
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
|
||||
);
|
||||
) || is_exit_plan_mode;
|
||||
let has_question_payload = input.get("questions").is_some();
|
||||
if is_question_tool || has_question_payload {
|
||||
if let Some(question) = question_from_claude_input(&input, id.clone()) {
|
||||
|
|
@ -155,6 +245,20 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
|||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
} else if is_exit_plan_mode {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(QuestionEventData {
|
||||
question_id: id.clone(),
|
||||
prompt: "Approve plan execution?".to_string(),
|
||||
options: vec!["approve".to_string(), "reject".to_string()],
|
||||
response: None,
|
||||
status: QuestionStatus::Requested,
|
||||
}),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +330,88 @@ fn tool_result_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
|||
conversions
|
||||
}
|
||||
|
||||
fn stream_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||
let mut conversions = Vec::new();
|
||||
let Some(raw_event) = event.get("event").and_then(Value::as_object) else {
|
||||
return conversions;
|
||||
};
|
||||
let event_type = raw_event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if event_type != "content_block_delta" {
|
||||
return conversions;
|
||||
}
|
||||
let delta_text = raw_event
|
||||
.get("delta")
|
||||
.and_then(|delta| delta.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if delta_text.is_empty() {
|
||||
return conversions;
|
||||
}
|
||||
|
||||
conversions.push(EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(ItemDeltaData {
|
||||
item_id: String::new(),
|
||||
native_item_id: None,
|
||||
delta: delta_text.to_string(),
|
||||
}),
|
||||
));
|
||||
|
||||
conversions
|
||||
}
|
||||
|
||||
fn control_request_to_universal(event: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let request_id = event
|
||||
.get("request_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "missing request_id".to_string())?;
|
||||
let request = event
|
||||
.get("request")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "missing request".to_string())?;
|
||||
let subtype = request
|
||||
.get("subtype")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
|
||||
if subtype != "can_use_tool" {
|
||||
return Err(format!("unsupported Claude control_request subtype: {subtype}"));
|
||||
}
|
||||
|
||||
let tool_name = request
|
||||
.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||
let permission_suggestions = request
|
||||
.get("permission_suggestions")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let blocked_path = request
|
||||
.get("blocked_path")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"toolName": tool_name,
|
||||
"input": input,
|
||||
"permissionSuggestions": permission_suggestions,
|
||||
"blockedPath": blocked_path,
|
||||
});
|
||||
|
||||
let permission = PermissionEventData {
|
||||
permission_id: request_id.to_string(),
|
||||
action: tool_name.to_string(),
|
||||
status: PermissionStatus::Requested,
|
||||
metadata: Some(metadata),
|
||||
};
|
||||
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::PermissionRequested,
|
||||
UniversalEventData::Permission(permission),
|
||||
)])
|
||||
}
|
||||
|
||||
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
|
||||
// The `result` event completes the message started by `assistant`.
|
||||
// Use the same native_item_id so they link to the same universal item.
|
||||
|
|
@ -285,7 +471,7 @@ fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec<EventConversio
|
|||
|
||||
/// Emits item.started + item.delta only (for `assistant` event).
|
||||
/// The item.completed will come from the `result` event.
|
||||
fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<EventConversion> {
|
||||
fn message_started_events(item: UniversalItem) -> Vec<EventConversion> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Emit item.started (in-progress)
|
||||
|
|
@ -293,25 +479,6 @@ fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<E
|
|||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item: item.clone() }),
|
||||
));
|
||||
|
||||
// Emit item.delta with the text content
|
||||
let mut delta_text = String::new();
|
||||
for part in &parts {
|
||||
if let ContentPart::Text { text } = part {
|
||||
delta_text.push_str(text);
|
||||
}
|
||||
}
|
||||
if !delta_text.is_empty() {
|
||||
events.push(EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(crate::ItemDeltaData {
|
||||
item_id: item.item_id.clone(),
|
||||
native_item_id: item.native_item_id.clone(),
|
||||
delta: delta_text,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue