已合并
Merge remote-tracking branch 'upstream/dev' into dev #187
hypothesier创建于 6月13日
Merge remote-tracking branch 'upstream/dev' into dev #187
已合并
共 55 个文件变更+2150-393
| @@ -3722,9 +3722,11 @@ dependencies = [ | |||
| 3722 | "htmd", | 3722 | "htmd", |
| 3723 | "html2text", | 3723 | "html2text", |
| 3724 | "image", | 3724 | "image", |
| 3725 | + "lazy_static", | ||
| 3725 | "lopdf", | 3726 | "lopdf", |
| 3726 | "lsp", | 3727 | "lsp", |
| 3727 | "operation_backend", | 3728 | "operation_backend", |
| 3729 | + "regex", | ||
| 3728 | "reqwest", | 3730 | "reqwest", |
| 3729 | "rustls-native-certs", | 3731 | "rustls-native-certs", |
| 3730 | "serde", | 3732 | "serde", |
| @@ -80,6 +80,10 @@ enum Command { | |||
| 80 | 80 | ||
| 81 | no_tools: bool, | 81 | no_tools: bool, |
| 82 | 82 | ||
| 83 | + /// Restrict to a comma-separated allowlist of tools | ||
| 84 | + | ||
| 85 | + tools: Option<Vec<String>>, | ||
| 86 | + | ||
| 83 | /// Reasoning effort: off, high, or max | 87 | /// Reasoning effort: off, high, or max |
| 84 | 88 | ||
| 85 | reasoning_effort: Option<ReasoningEffort>, | 89 | reasoning_effort: Option<ReasoningEffort>, |
| @@ -130,6 +134,7 @@ where | |||
| 130 | system, | 134 | system, |
| 131 | max_turns, | 135 | max_turns, |
| 132 | no_tools, | 136 | no_tools, |
| 137 | + tools, | ||
| 133 | reasoning_effort, | 138 | reasoning_effort, |
| 134 | } => { | 139 | } => { |
| 135 | if let Some(path) = config_path.as_ref() { | 140 | if let Some(path) = config_path.as_ref() { |
| @@ -180,6 +185,7 @@ where | |||
| 180 | system_prompt: system, | 185 | system_prompt: system, |
| 181 | max_turns, | 186 | max_turns, |
| 182 | enable_tools: !no_tools, | 187 | enable_tools: !no_tools, |
| 188 | + visible_tools: tools.filter(|t| !t.is_empty()), | ||
| 183 | reasoning_effort, | 189 | reasoning_effort, |
| 184 | kvcache_enabled: llm.and_then(|l| l.kvcache_enabled).unwrap_or(false), | 190 | kvcache_enabled: llm.and_then(|l| l.kvcache_enabled).unwrap_or(false), |
| 185 | kvcache_debug_enabled: llm.and_then(|l| l.kvcache_debug_enabled).unwrap_or(false), | 191 | kvcache_debug_enabled: llm.and_then(|l| l.kvcache_debug_enabled).unwrap_or(false), |
| @@ -837,10 +843,10 @@ async fn run_once(config: CliConfig, prompt: String, debug: bool) { | |||
| 837 | api_key: config.api_key.clone(), | 843 | api_key: config.api_key.clone(), |
| 838 | api_key_env: config.api_key_env.clone(), | 844 | api_key_env: config.api_key_env.clone(), |
| 839 | api_base: config.api_base.clone(), | 845 | api_base: config.api_base.clone(), |
| 840 | - visible_tool_names: if config.enable_tools { | 846 | + visible_tool_names: if !config.enable_tools { |
| 841 | - None | ||
| 842 | - } else { | ||
| 843 | Some(Vec::new()) | 847 | Some(Vec::new()) |
| 848 | + } else { | ||
| 849 | + config.visible_tools.clone() | ||
| 844 | }, | 850 | }, |
| 845 | compression_pipeline: Some(compression_pipeline), | 851 | compression_pipeline: Some(compression_pipeline), |
| 846 | llm_provider: Some(llm_provider), | 852 | llm_provider: Some(llm_provider), |
| @@ -124,6 +124,7 @@ pub struct CliConfig { | |||
| 124 | pub system_prompt: String, | 124 | pub system_prompt: String, |
| 125 | pub max_turns: u32, | 125 | pub max_turns: u32, |
| 126 | pub enable_tools: bool, | 126 | pub enable_tools: bool, |
| 127 | + pub visible_tools: Option<Vec<String>>, | ||
| 127 | pub reasoning_effort: ReasoningEffort, | 128 | pub reasoning_effort: ReasoningEffort, |
| 128 | pub kvcache_enabled: bool, | 129 | pub kvcache_enabled: bool, |
| 129 | pub kvcache_debug_enabled: bool, | 130 | pub kvcache_debug_enabled: bool, |
| @@ -299,6 +300,7 @@ mod tests { | |||
| 299 | system_prompt: "test".to_string(), | 300 | system_prompt: "test".to_string(), |
| 300 | max_turns: 1, | 301 | max_turns: 1, |
| 301 | enable_tools: false, | 302 | enable_tools: false, |
| 303 | + visible_tools: None, | ||
| 302 | reasoning_effort: Default::default(), | 304 | reasoning_effort: Default::default(), |
| 303 | kvcache_enabled: true, | 305 | kvcache_enabled: true, |
| 304 | compact: crate::cli::config::CompactSection::default(), | 306 | compact: crate::cli::config::CompactSection::default(), |
| @@ -1,15 +1,15 @@ | |||
| 1 | use futures_util::StreamExt; | 1 | use futures_util::StreamExt; |
| 2 | use serde::{Deserialize, Serialize}; | 2 | use serde::{Deserialize, Serialize}; |
| 3 | -use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; | 3 | +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; |
| 4 | 4 | ||
| 5 | use agent_types::common::ids::AgentId; | 5 | use agent_types::common::ids::AgentId; |
| 6 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; | 6 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; |
| 7 | 7 | ||
| 8 | -use crate::app_state::{sandbox_display_name, AppState}; | 8 | +use crate::app_state::{AppState, sandbox_display_name}; |
| 9 | use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate}; | 9 | use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate}; |
| 10 | use crate::gateway::{ | 10 | use crate::gateway::{ |
| 11 | - AppTurnRequest, GatewayEntryContext, SessionCancelRequest, SessionInteractionRequest, | 11 | + GatewayEntryContext, RuntimeCancelRequest, RuntimeCloseRequest, RuntimeInteractionRequest, |
| 12 | - SessionOpenRequest, | 12 | + RuntimeOpenRequest, RuntimeTurnRequest, |
| 13 | }; | 13 | }; |
| 14 | use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult}; | 14 | use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult}; |
| 15 | use crate::remote_sessions_service::record_remote_session; | 15 | use crate::remote_sessions_service::record_remote_session; |
| @@ -58,6 +58,7 @@ enum RemoteSseEvent { | |||
| 58 | raw_reply: String, | 58 | raw_reply: String, |
| 59 | 59 | ||
| 60 | conversation_id: String, | 60 | conversation_id: String, |
| 61 | + | ||
| 61 | 62 | ||
| 62 | session_id: String, | 63 | session_id: String, |
| 63 | 64 | ||
| @@ -74,6 +75,7 @@ enum RemoteSseEvent { | |||
| 74 | error: String, | 75 | error: String, |
| 75 | }, | 76 | }, |
| 76 | Cancelled { | 77 | Cancelled { |
| 78 | + | ||
| 77 | session_id: String, | 79 | session_id: String, |
| 78 | }, | 80 | }, |
| 79 | } | 81 | } |
| @@ -185,7 +187,7 @@ impl GatewayRuntime { | |||
| 185 | &client, | 187 | &client, |
| 186 | &remote, | 188 | &remote, |
| 187 | token.as_deref(), | 189 | token.as_deref(), |
| 188 | - "/api/v1/sessions/open", | 190 | + "/api/v1/runtimes/open", |
| 189 | &open_request, | 191 | &open_request, |
| 190 | ) | 192 | ) |
| 191 | .await?; | 193 | .await?; |
| @@ -253,8 +255,8 @@ impl GatewayRuntime { | |||
| 253 | &client, | 255 | &client, |
| 254 | &remote, | 256 | &remote, |
| 255 | token.as_deref(), | 257 | token.as_deref(), |
| 256 | - "/api/v1/sessions/close", | 258 | + "/api/v1/runtimes/close", |
| 257 | - &crate::gateway::SessionCloseRequest { | 259 | + &RuntimeCloseRequest { |
| 258 | session_id: session_id.to_string(), | 260 | session_id: session_id.to_string(), |
| 259 | }, | 261 | }, |
| 260 | ) | 262 | ) |
| @@ -275,14 +277,14 @@ impl GatewayRuntime { | |||
| 275 | &client, | 277 | &client, |
| 276 | &remote, | 278 | &remote, |
| 277 | token.as_deref(), | 279 | token.as_deref(), |
| 278 | - "/api/v1/sessions/cancel", | 280 | + "/api/v1/runtimes/cancel", |
| 279 | - &SessionCancelRequest { session_id }, | 281 | + &RuntimeCancelRequest { session_id }, |
| 280 | ) | 282 | ) |
| 281 | .await; | 283 | .await; |
| 282 | }); | 284 | }); |
| 283 | } | 285 | } |
| 284 | 286 | ||
| 285 | - fn remote_session_open_request(&self, state: &AppState) -> Result<SessionOpenRequest, String> { | 287 | + fn remote_session_open_request(&self, state: &AppState) -> Result<RuntimeOpenRequest, String> { |
| 286 | Self::remote_session_open_request_for( | 288 | Self::remote_session_open_request_for( |
| 287 | state, | 289 | state, |
| 288 | self.remote.as_ref().map(|remote| remote.base_url.clone()), | 290 | self.remote.as_ref().map(|remote| remote.base_url.clone()), |
| @@ -292,9 +294,9 @@ impl GatewayRuntime { | |||
| 292 | fn remote_session_open_request_for( | 294 | fn remote_session_open_request_for( |
| 293 | state: &AppState, | 295 | state: &AppState, |
| 294 | base_url: Option<String>, | 296 | base_url: Option<String>, |
| 295 | - ) -> Result<SessionOpenRequest, String> { | 297 | + ) -> Result<RuntimeOpenRequest, String> { |
| 296 | let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?; | 298 | let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?; |
| 297 | - Ok(SessionOpenRequest { | 299 | + Ok(RuntimeOpenRequest { |
| 298 | session_id: state.session_id.clone(), | 300 | session_id: state.session_id.clone(), |
| 299 | conversation_id: state.session_id.clone(), | 301 | conversation_id: state.session_id.clone(), |
| 300 | sender_id, | 302 | sender_id, |
| @@ -309,9 +311,9 @@ impl GatewayRuntime { | |||
| 309 | &self, | 311 | &self, |
| 310 | state: &AppState, | 312 | state: &AppState, |
| 311 | text: String, | 313 | text: String, |
| 312 | - ) -> Result<AppTurnRequest, String> { | 314 | + ) -> Result<RuntimeTurnRequest, String> { |
| 313 | let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?; | 315 | let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?; |
| 314 | - Ok(AppTurnRequest { | 316 | + Ok(RuntimeTurnRequest { |
| 315 | session_id: state.session_id.clone(), | 317 | session_id: state.session_id.clone(), |
| 316 | entry: GatewayEntryContext::tui(self.remote.as_ref().map(|r| r.base_url.clone())), | 318 | entry: GatewayEntryContext::tui(self.remote.as_ref().map(|r| r.base_url.clone())), |
| 317 | channel: None, | 319 | channel: None, |
| @@ -334,11 +336,11 @@ async fn run_remote_stream( | |||
| 334 | client: reqwest::Client, | 336 | client: reqwest::Client, |
| 335 | remote: RemoteRuntimeConfig, | 337 | remote: RemoteRuntimeConfig, |
| 336 | token: Option<String>, | 338 | token: Option<String>, |
| 337 | - turn_request: AppTurnRequest, | 339 | + turn_request: RuntimeTurnRequest, |
| 338 | updates_tx: UnboundedSender<SessionTurnUpdate>, | 340 | updates_tx: UnboundedSender<SessionTurnUpdate>, |
| 339 | mut interaction_rx: UnboundedReceiver<UserPromptResult>, | 341 | mut interaction_rx: UnboundedReceiver<UserPromptResult>, |
| 340 | ) { | 342 | ) { |
| 341 | - let url = format!("{}/api/v1/sessions/input", remote.base_url); | 343 | + let url = format!("{}/api/v1/runtimes/input", remote.base_url); |
| 342 | let mut request = client.post(url).json(&turn_request); | 344 | let mut request = client.post(url).json(&turn_request); |
| 343 | if let Some(token) = token.as_ref() { | 345 | if let Some(token) = token.as_ref() { |
| 344 | request = request.bearer_auth(token); | 346 | request = request.bearer_auth(token); |
| @@ -461,8 +463,8 @@ async fn handle_remote_event( | |||
| 461 | client, | 463 | client, |
| 462 | remote, | 464 | remote, |
| 463 | token, | 465 | token, |
| 464 | - "/api/v1/sessions/interaction", | 466 | + "/api/v1/runtimes/interaction", |
| 465 | - &SessionInteractionRequest { | 467 | + &RuntimeInteractionRequest { |
| 466 | session_id: session_id.to_string(), | 468 | session_id: session_id.to_string(), |
| 467 | response, | 469 | response, |
| 468 | }, | 470 | }, |
| @@ -572,21 +574,24 @@ fn build_prompt_request(request: &InteractionRequest) -> PromptRequest { | |||
| 572 | body: None, | 574 | body: None, |
| 573 | choices: vec![ | 575 | choices: vec![ |
| 574 | PromptChoice { | 576 | PromptChoice { |
| 575 | - id: "approve".to_string(), | 577 | + id: "yes".to_string(), |
| 576 | - label: "Approve".to_string(), | 578 | + label: "Yes".to_string(), |
| 577 | description: None, | 579 | description: None, |
| 578 | }, | 580 | }, |
| 579 | PromptChoice { | 581 | PromptChoice { |
| 580 | - id: "reject".to_string(), | 582 | + id: "no".to_string(), |
| 581 | - label: "Reject".to_string(), | 583 | + label: "No".to_string(), |
| 582 | description: None, | 584 | description: None, |
| 583 | }, | 585 | }, |
| 584 | ], | 586 | ], |
| 585 | allow_custom_input: false, | 587 | allow_custom_input: false, |
| 586 | multi_select: false, | 588 | multi_select: false, |
| 587 | default_index: Some(0), | 589 | default_index: Some(0), |
| 590 | + is_secret: false, | ||
| 588 | }, | 591 | }, |
| 589 | - InteractionRequest::TextInput { prompt, .. } => PromptRequest { | 592 | + InteractionRequest::TextInput { |
| 593 | + prompt, is_secret, .. | ||
| 594 | + } => PromptRequest { | ||
| 590 | request_id: uuid::Uuid::new_v4().to_string(), | 595 | request_id: uuid::Uuid::new_v4().to_string(), |
| 591 | title: prompt.clone(), | 596 | title: prompt.clone(), |
| 592 | body: None, | 597 | body: None, |
| @@ -598,6 +603,7 @@ fn build_prompt_request(request: &InteractionRequest) -> PromptRequest { | |||
| 598 | allow_custom_input: true, | 603 | allow_custom_input: true, |
| 599 | multi_select: false, | 604 | multi_select: false, |
| 600 | default_index: Some(0), | 605 | default_index: Some(0), |
| 606 | + is_secret: *is_secret, | ||
| 601 | }, | 607 | }, |
| 602 | InteractionRequest::Choice { | 608 | InteractionRequest::Choice { |
| 603 | prompt, | 609 | prompt, |
| @@ -619,6 +625,7 @@ fn build_prompt_request(request: &InteractionRequest) -> PromptRequest { | |||
| 619 | allow_custom_input: *allow_custom_input, | 625 | allow_custom_input: *allow_custom_input, |
| 620 | multi_select: false, | 626 | multi_select: false, |
| 621 | default_index: Some(0), | 627 | default_index: Some(0), |
| 628 | + is_secret: false, // Choice type does not need password hiding | ||
| 622 | }, | 629 | }, |
| 623 | } | 630 | } |
| 624 | } | 631 | } |
| @@ -630,11 +637,23 @@ fn map_response( | |||
| 630 | match (request, response.resolution) { | 637 | match (request, response.resolution) { |
| 631 | (InteractionRequest::Confirm { .. }, PromptResolution::Single { choice_id, .. }) => { | 638 | (InteractionRequest::Confirm { .. }, PromptResolution::Single { choice_id, .. }) => { |
| 632 | Some(InteractionResponse::Confirmed { | 639 | Some(InteractionResponse::Confirmed { |
| 633 | - allowed: choice_id == "approve", | 640 | + allowed: choice_id == "yes", |
| 634 | }) | 641 | }) |
| 635 | } | 642 | } |
| 636 | - (InteractionRequest::TextInput { .. }, PromptResolution::Single { supplement, .. }) => { | 643 | + ( |
| 637 | - Some(InteractionResponse::Text { value: supplement }) | 644 | + InteractionRequest::TextInput { is_secret, .. }, |
| 645 | + PromptResolution::Single { supplement, .. }, | ||
| 646 | + ) => { | ||
| 647 | + // For secret inputs, use display_value to hide the password in messages | ||
| 648 | + let display_value = if *is_secret { | ||
| 649 | + Some("<SECRET>".to_string()) | ||
| 650 | + } else { | ||
| 651 | + None | ||
| 652 | + }; | ||
| 653 | + Some(InteractionResponse::Text { | ||
| 654 | + value: supplement, | ||
| 655 | + display_value, | ||
| 656 | + }) | ||
| 638 | } | 657 | } |
| 639 | ( | 658 | ( |
| 640 | InteractionRequest::Choice { .. }, | 659 | InteractionRequest::Choice { .. }, |
| @@ -653,14 +672,17 @@ fn map_response( | |||
| 653 | fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse { | 672 | fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse { |
| 654 | match request { | 673 | match request { |
| 655 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, | 674 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, |
| 656 | - InteractionRequest::TextInput { .. } => InteractionResponse::Text { value: None }, | 675 | + InteractionRequest::TextInput { .. } => InteractionResponse::Text { |
| 676 | + value: None, | ||
| 677 | + display_value: None, | ||
| 678 | + }, | ||
| 657 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, | 679 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, |
| 658 | } | 680 | } |
| 659 | } | 681 | } |
| 660 | 682 | ||
| 661 | 683 | ||
| 662 | mod tests { | 684 | mod tests { |
| 663 | - use super::{parse_sse_frame, take_sse_frame, RemoteSseEvent}; | 685 | + use super::{RemoteSseEvent, parse_sse_frame, take_sse_frame}; |
| 664 | 686 | ||
| 665 | 687 | ||
| 666 | fn parses_sse_frame_from_split_buffer() { | 688 | fn parses_sse_frame_from_split_buffer() { |
| @@ -25,21 +25,24 @@ impl ChannelInteractionHandle { | |||
| 25 | body: None, | 25 | body: None, |
| 26 | choices: vec![ | 26 | choices: vec![ |
| 27 | PromptChoice { | 27 | PromptChoice { |
| 28 | - id: "approve".to_string(), | 28 | + id: "yes".to_string(), |
| 29 | - label: "Approve".to_string(), | 29 | + label: "Yes".to_string(), |
| 30 | description: None, | 30 | description: None, |
| 31 | }, | 31 | }, |
| 32 | PromptChoice { | 32 | PromptChoice { |
| 33 | - id: "reject".to_string(), | 33 | + id: "no".to_string(), |
| 34 | - label: "Reject".to_string(), | 34 | + label: "No".to_string(), |
| 35 | description: None, | 35 | description: None, |
| 36 | }, | 36 | }, |
| 37 | ], | 37 | ], |
| 38 | allow_custom_input: false, | 38 | allow_custom_input: false, |
| 39 | multi_select: false, | 39 | multi_select: false, |
| 40 | default_index: Some(0), | 40 | default_index: Some(0), |
| 41 | + is_secret: false, | ||
| 41 | }, | 42 | }, |
| 42 | - InteractionRequest::TextInput { prompt, .. } => PromptRequest { | 43 | + InteractionRequest::TextInput { |
| 44 | + prompt, is_secret, .. | ||
| 45 | + } => PromptRequest { | ||
| 43 | request_id: uuid::Uuid::new_v4().to_string(), | 46 | request_id: uuid::Uuid::new_v4().to_string(), |
| 44 | title: prompt.clone(), | 47 | title: prompt.clone(), |
| 45 | body: None, | 48 | body: None, |
| @@ -51,6 +54,7 @@ impl ChannelInteractionHandle { | |||
| 51 | allow_custom_input: true, | 54 | allow_custom_input: true, |
| 52 | multi_select: false, | 55 | multi_select: false, |
| 53 | default_index: Some(0), | 56 | default_index: Some(0), |
| 57 | + is_secret: *is_secret, | ||
| 54 | }, | 58 | }, |
| 55 | InteractionRequest::Choice { | 59 | InteractionRequest::Choice { |
| 56 | prompt, | 60 | prompt, |
| @@ -72,6 +76,7 @@ impl ChannelInteractionHandle { | |||
| 72 | allow_custom_input: *allow_custom_input, | 76 | allow_custom_input: *allow_custom_input, |
| 73 | multi_select: false, | 77 | multi_select: false, |
| 74 | default_index: Some(0), | 78 | default_index: Some(0), |
| 79 | + is_secret: false, // Choice type does not need password hiding | ||
| 75 | }, | 80 | }, |
| 76 | } | 81 | } |
| 77 | } | 82 | } |
| @@ -83,11 +88,23 @@ impl ChannelInteractionHandle { | |||
| 83 | match (request, response.resolution) { | 88 | match (request, response.resolution) { |
| 84 | (InteractionRequest::Confirm { .. }, PromptResolution::Single { choice_id, .. }) => { | 89 | (InteractionRequest::Confirm { .. }, PromptResolution::Single { choice_id, .. }) => { |
| 85 | Some(InteractionResponse::Confirmed { | 90 | Some(InteractionResponse::Confirmed { |
| 86 | - allowed: choice_id == "approve", | 91 | + allowed: choice_id == "yes", |
| 87 | }) | 92 | }) |
| 88 | } | 93 | } |
| 89 | - (InteractionRequest::TextInput { .. }, PromptResolution::Single { supplement, .. }) => { | 94 | + ( |
| 90 | - Some(InteractionResponse::Text { value: supplement }) | 95 | + InteractionRequest::TextInput { is_secret, .. }, |
| 96 | + PromptResolution::Single { supplement, .. }, | ||
| 97 | + ) => { | ||
| 98 | + // For secret inputs, use display_value to hide the password in messages | ||
| 99 | + let display_value = if *is_secret { | ||
| 100 | + Some("<SECRET>".to_string()) | ||
| 101 | + } else { | ||
| 102 | + None | ||
| 103 | + }; | ||
| 104 | + Some(InteractionResponse::Text { | ||
| 105 | + value: supplement, | ||
| 106 | + display_value, | ||
| 107 | + }) | ||
| 91 | } | 108 | } |
| 92 | ( | 109 | ( |
| 93 | InteractionRequest::Choice { .. }, | 110 | InteractionRequest::Choice { .. }, |
| @@ -125,7 +142,10 @@ impl InteractionHandle for ChannelInteractionHandle { | |||
| 125 | 142 | ||
| 126 | match request { | 143 | match request { |
| 127 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, | 144 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, |
| 128 | - InteractionRequest::TextInput { .. } => InteractionResponse::Text { value: None }, | 145 | + InteractionRequest::TextInput { .. } => InteractionResponse::Text { |
| 146 | + value: None, | ||
| 147 | + display_value: None, | ||
| 148 | + }, | ||
| 129 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, | 149 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, |
| 130 | } | 150 | } |
| 131 | } | 151 | } |
| @@ -38,6 +38,15 @@ impl Input { | |||
| 38 | self.cursor | 38 | self.cursor |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | + /// Get display value (for password input, return masked string like "****") | ||
| 42 | + pub fn display_value(&self, is_secret: bool) -> String { | ||
| 43 | + if is_secret { | ||
| 44 | + "*".repeat(self.value.chars().count()) | ||
| 45 | + } else { | ||
| 46 | + self.value.clone() | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + | ||
| 41 | pub fn visual_cursor(&self) -> usize { | 50 | pub fn visual_cursor(&self) -> usize { |
| 42 | self.value | 51 | self.value |
| 43 | .chars() | 52 | .chars() |
| @@ -170,6 +179,80 @@ impl Input { | |||
| 170 | self.cursor = cursor; | 179 | self.cursor = cursor; |
| 171 | } | 180 | } |
| 172 | 181 | ||
| 182 | + fn move_cursor_up(&mut self) { | ||
| 183 | + let chars: Vec<char> = self.value.chars().collect(); | ||
| 184 | + if self.cursor == 0 { | ||
| 185 | + return; | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + let current_line_start = self.current_line_start(&chars); | ||
| 189 | + if current_line_start == 0 { | ||
| 190 | + self.cursor = 0; | ||
| 191 | + return; | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + let current_col = self.cursor - current_line_start; | ||
| 195 | + let prev_line_end = current_line_start.saturating_sub(1); | ||
| 196 | + let prev_line_start = self.line_start_before(&chars, prev_line_end); | ||
| 197 | + let prev_line_len = prev_line_end.saturating_sub(prev_line_start); | ||
| 198 | + | ||
| 199 | + self.cursor = prev_line_start + current_col.min(prev_line_len); | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + fn move_cursor_down(&mut self) { | ||
| 203 | + let chars: Vec<char> = self.value.chars().collect(); | ||
| 204 | + let total = chars.len(); | ||
| 205 | + if self.cursor >= total { | ||
| 206 | + return; | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + let current_line_start = self.current_line_start(&chars); | ||
| 210 | + let current_line_end = self.current_line_end(&chars, total); | ||
| 211 | + | ||
| 212 | + if current_line_end >= total { | ||
| 213 | + self.cursor = total; | ||
| 214 | + return; | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + let current_col = self.cursor - current_line_start; | ||
| 218 | + let next_line_start = current_line_end + 1; | ||
| 219 | + let next_line_end = self.current_line_end(&chars, total); | ||
| 220 | + let next_line_len = next_line_end.saturating_sub(next_line_start); | ||
| 221 | + | ||
| 222 | + self.cursor = next_line_start + current_col.min(next_line_len); | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + fn current_line_start(&self, chars: &[char]) -> usize { | ||
| 226 | + chars | ||
| 227 | + .iter() | ||
| 228 | + .take(self.cursor) | ||
| 229 | + .enumerate() | ||
| 230 | + .rev() | ||
| 231 | + .find(|(_, &c)| c == '\n') | ||
| 232 | + .map(|(i, _)| i + 1) | ||
| 233 | + .unwrap_or(0) | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + fn current_line_end(&self, chars: &[char], total: usize) -> usize { | ||
| 237 | + chars | ||
| 238 | + .iter() | ||
| 239 | + .skip(self.cursor) | ||
| 240 | + .position(|&c| c == '\n') | ||
| 241 | + .map(|p| self.cursor + p) | ||
| 242 | + .unwrap_or(total) | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + fn line_start_before(&self, chars: &[char], position: usize) -> usize { | ||
| 246 | + chars | ||
| 247 | + .iter() | ||
| 248 | + .take(position) | ||
| 249 | + .enumerate() | ||
| 250 | + .rev() | ||
| 251 | + .find(|(_, &c)| c == '\n') | ||
| 252 | + .map(|(i, _)| i + 1) | ||
| 253 | + .unwrap_or(0) | ||
| 254 | + } | ||
| 255 | + | ||
| 173 | fn is_backspace_compat(key: &crossterm::event::KeyEvent) -> bool { | 256 | fn is_backspace_compat(key: &crossterm::event::KeyEvent) -> bool { |
| 174 | match key.code { | 257 | match key.code { |
| 175 | KeyCode::Backspace => true, | 258 | KeyCode::Backspace => true, |
| @@ -277,6 +360,14 @@ impl EventHandler for Input { | |||
| 277 | } | 360 | } |
| 278 | self.selection_anchor = None; | 361 | self.selection_anchor = None; |
| 279 | } | 362 | } |
| 363 | + KeyCode::Up => { | ||
| 364 | + self.move_cursor_up(); | ||
| 365 | + self.selection_anchor = None; | ||
| 366 | + } | ||
| 367 | + KeyCode::Down => { | ||
| 368 | + self.move_cursor_down(); | ||
| 369 | + self.selection_anchor = None; | ||
| 370 | + } | ||
| 280 | KeyCode::Home => { | 371 | KeyCode::Home => { |
| 281 | let before: Vec<char> = self.value.chars().take(self.cursor).collect(); | 372 | let before: Vec<char> = self.value.chars().take(self.cursor).collect(); |
| 282 | let line_start = before | 373 | let line_start = before |
| @@ -326,14 +326,37 @@ impl App { | |||
| 326 | // Esc clears an active transcript selection (mirrors opencode's Esc handler). | 326 | // Esc clears an active transcript selection (mirrors opencode's Esc handler). |
| 327 | self.state.transcript_selection = None; | 327 | self.state.transcript_selection = None; |
| 328 | } | 328 | } |
| 329 | - KeyCode::Enter => self.submit_editing_input().await?, | 329 | + KeyCode::Enter => { |
| 330 | + if key.modifiers.contains(event::KeyModifiers::ALT) { | ||
| 331 | + self.state | ||
| 332 | + .chat_state | ||
| 333 | + .input | ||
| 334 | + .handle(crate::input::InputRequest::InsertChar('\n')); | ||
| 335 | + self.state.chat_state.reset_input_history_navigation(); | ||
| 336 | + self.state.note_input_changed(); | ||
| 337 | + } else { | ||
| 338 | + self.submit_editing_input().await? | ||
| 339 | + } | ||
| 340 | + } | ||
| 330 | KeyCode::Up if key.modifiers.is_empty() => { | 341 | KeyCode::Up if key.modifiers.is_empty() => { |
| 331 | - if self.state.chat_state.previous_input_history() { | 342 | + if self.state.chat_state.input_history_cursor.is_some() |
| 343 | + || self.state.chat_state.input.value().is_empty() | ||
| 344 | + { | ||
| 345 | + self.state.chat_state.previous_input_history(); | ||
| 346 | + self.state.note_input_changed(); | ||
| 347 | + } else { | ||
| 348 | + self.state.chat_state.input.handle_event(&Event::Key(key)); | ||
| 332 | self.state.note_input_changed(); | 349 | self.state.note_input_changed(); |
| 333 | } | 350 | } |
| 334 | } | 351 | } |
| 335 | KeyCode::Down if key.modifiers.is_empty() => { | 352 | KeyCode::Down if key.modifiers.is_empty() => { |
| 336 | - if self.state.chat_state.next_input_history() { | 353 | + if self.state.chat_state.input_history_cursor.is_some() |
| 354 | + || self.state.chat_state.input.value().is_empty() | ||
| 355 | + { | ||
| 356 | + self.state.chat_state.next_input_history(); | ||
| 357 | + self.state.note_input_changed(); | ||
| 358 | + } else { | ||
| 359 | + self.state.chat_state.input.handle_event(&Event::Key(key)); | ||
| 337 | self.state.note_input_changed(); | 360 | self.state.note_input_changed(); |
| 338 | } | 361 | } |
| 339 | } | 362 | } |
| @@ -1,4 +1,31 @@ | |||
| 1 | -You are a helpful assistant with access to tools: read files, edit files, run bash commands, search with glob/grep, and count text length. Use them when appropriate. | 1 | +You are xiaoO, an autonomous CLI agent for software-engineering tasks. You have tools to read files, edit files, run bash commands, search with glob/grep, and count text length. Use them to carry out the task, then stop. |
| 2 | + | ||
| 3 | +# Work in parallel | ||
| 4 | +When your next actions are independent of each other — e.g. reading several distinct files, or running several unrelated greps — issue them as parallel tool calls in a single turn instead of one per turn; the harness runs the batch concurrently and every turn re-reads the whole context, so batching independent lookups saves both tokens and wall-clock. Do NOT batch dependent steps: when one action's output decides the next (produce, then inspect, then react), keep them in separate turns. | ||
| 5 | + | ||
| 6 | +Use bash to its full extent: when you have several independent shell checks — list a directory, cat a config, run a couple of probes — put them in ONE bash call, one command per line so all of them run, rather than spending a whole turn on each. Separate independent commands by newlines (not `&&`, which aborts the rest at the first non-zero exit). Reserve `&&` for genuinely dependent steps where a later command must not run if an earlier one failed. | ||
| 7 | + | ||
| 8 | +# Output economy | ||
| 9 | +- Minimize what you write. Your visible text is re-sent on every later turn, so every word is a recurring cost. Address only the task at hand and skip tangential commentary. | ||
| 10 | +- No preamble or postamble: do not announce what you are about to do or summarize what you just did. Act through tool calls; once the edit is complete, stop instead of re-explaining it. | ||
| 11 | +- Use bash for actions, not to talk to yourself — do not echo plans, narration, or status into the terminal. | ||
| 12 | + | ||
| 13 | +# Investigating | ||
| 14 | +- Use the search tools (file_read, glob, grep) to understand the code before changing it, and prefer them over trial-and-error bash probing. Read enough to find the actual root cause, not just the first line that matches. | ||
| 15 | +- Before you edit, know what the target code is supposed to do and which call sites depend on it. | ||
| 16 | +- Push broad searches and throwaway reconnaissance into a disposable subagent (see Delegating Exploration) so their evidence never weighs down your own context. | ||
| 17 | +- Learn how THIS project runs its checks before you run anything. Discover its real test, build, and lint commands from the sources that encode them — the CI/automation config, the build and task-runner files, the contributor or developer docs, and the existing test directory and how its tests are already invoked. Projects frequently ship their own test or build entrypoint, so use the exact command the project itself uses rather than guessing a generic one or inventing invocation flags. | ||
| 18 | +- Find and run the failing test FIRST. Locate the exact test — or, if none exists, the minimal behaviour — the issue describes, and run it now to confirm it FAILS, capturing that red output. It is both your root-cause evidence and the precise check your fix must later flip to green; do not start editing until you have seen the failure yourself. | ||
| 19 | + | ||
| 20 | +# Making changes | ||
| 21 | +- Make the smallest change that fully fixes the issue. Match the surrounding code's style, naming, structure, and imports, and reuse the libraries and helpers the project already has rather than introducing new ones. | ||
| 22 | +- Do not add comments that merely restate the code; add one only where neighboring code already does and the change is genuinely non-obvious. | ||
| 23 | +- Never assume a library, tool, or command is available. Before relying on a dependency, confirm the project already uses it — check its manifest (pyproject.toml, requirements.txt, package.json, Cargo.toml, go.mod, …) or neighboring files. Do not install, upgrade, or build dependencies, or otherwise modify the environment, unless the task truly cannot proceed without it; if an install fails, work with what is already present rather than fighting the toolchain. | ||
| 24 | + | ||
| 25 | +# Verifying | ||
| 26 | +- The fix is proven only when the failing test you found while investigating goes from RED to GREEN. After your change, re-run that exact test and confirm it now passes, then run the broader tests covering the changed code to confirm nothing regressed. Stop only then — a different test that merely happens to pass proves nothing. | ||
| 27 | +- Never assume a specific test framework or command. Discover how the project runs its checks (from its config, README, or existing setup) before running anything. | ||
| 28 | +- If a test command fails because a library or runtime is not found, do not install anything — look for a pre-configured environment (a named conda env, virtualenv, or similar) that already has the dependencies; benchmark setups always provide one. | ||
| 2 | 29 | ||
| 3 | ## Skills System | 30 | ## Skills System |
| 4 | 31 | ||
| @@ -8,4 +35,20 @@ Skills are organized in priority levels from highest to lowest: | |||
| 8 | 35 | ||
| 9 | **Security Policy**: For general conversations and non-sensitive tasks, respond normally without enabling security verification. Enable security verification only when tasks involving secrets, credentials, or sensitive files. | 36 | **Security Policy**: For general conversations and non-sensitive tasks, respond normally without enabling security verification. Enable security verification only when tasks involving secrets, credentials, or sensitive files. |
| 10 | 37 | ||
| 11 | -**Usage**: Use `skill` tool to invoke, or type `/skill-name` for quick access. When skills have duplicate names, the highest priority version is used. | 38 | +**Usage**: Use `skill` tool to invoke, or type `/skill-name` for quick access. When skills have duplicate names, the highest priority version is used. |
| 39 | + | ||
| 40 | +## Delegating Exploration (keep your own context lean) | ||
| 41 | + | ||
| 42 | +Your context is the most expensive storage in this session: every file you read, every grep, and every command output stays in your history and is re-sent on every later turn. Exploration is necessary but its raw evidence is mostly worthless once you've extracted the answer. Push that throwaway work into a disposable subagent instead of carrying it yourself. | ||
| 43 | + | ||
| 44 | +Delegate with `spawn_subagent` (then `join_subagent` to collect the result) when the next step is: | ||
| 45 | +- a broad search ("where is X defined / used", "which files mention Y") that may touch many files, | ||
| 46 | +- multi-file reconnaissance to understand a subsystem before you change it, | ||
| 47 | +- a hypothesis check ("does this codepath handle Z?", "what does function W return for empty input?") whose intermediate reads you will not need afterward. | ||
| 48 | + | ||
| 49 | +How to delegate well: | ||
| 50 | +- Give the child a precise `task_goal` and the minimal `task_context`, and require a DISTILLED answer: exact file:line locations, the specific snippet that matters, and a one-paragraph conclusion — never a dump of everything it read. | ||
| 51 | +- Set `max_turns` to a bounded budget (around 20) for these scans so the child can explore aggressively in parallel yet a dead end is still force-terminated instead of running away. The child is read-only fan-out; you keep its conclusion, not its evidence. | ||
| 52 | +- Spawn several independent branches at once when they can be compared or aggregated later, then `join_subagent` each. | ||
| 53 | + | ||
| 54 | +Do the work directly only for tiny single-step lookups (a known file at a known path) or once you are editing — apply edits and run tests yourself; do not delegate the actual fix. | ||
| @@ -1,16 +1,16 @@ | |||
| 1 | -//! 交互式选项 + 可选补充输入(TUI 输入区上方)。 | 1 | +//! Interactive options + optional supplementary input (above TUI input area). |
| 2 | //! | 2 | //! |
| 3 | -//! ## 与后端接线(预埋) | 3 | +//! ## Backend wiring (pre-built) |
| 4 | -//! - **入站**:任一线程构造 [`PromptRequest`] 后调用 `App::open_interaction_prompt`(见 `app.rs`)。 | 4 | +//! - **Inbound**: Any thread constructs [`PromptRequest`] and calls `App::open_interaction_prompt` (see `app.rs`). |
| 5 | -//! - **出站**:通过打开时传入的 `UnboundedSender<UserPromptResult>` 将用户选择发回; | 5 | +//! - **Outbound**: Pass user selection back via `UnboundedSender<UserPromptResult>` passed during opening; |
| 6 | -//! 上层可写入会话、HTTP POST 或合并进下一轮 `ChatMessage`。 | 6 | +//! upper layer can write to session, HTTP POST, or merge into next `ChatMessage`. |
| 7 | -//! - 入站:`SessionTurnUpdate::InteractionPrompt` 由 `poll_stream_updates` 打开本面板。 | 7 | +//! - Inbound: `SessionTurnUpdate::InteractionPrompt` opens this panel via `poll_stream_updates`. |
| 8 | 8 | ||
| 9 | use crate::input::Input; | 9 | use crate::input::Input; |
| 10 | use ratatui::{ | 10 | use ratatui::{ |
| 11 | layout::{Constraint, Direction, Layout, Rect}, | 11 | layout::{Constraint, Direction, Layout, Rect}, |
| 12 | style::{Modifier, Style}, | 12 | style::{Modifier, Style}, |
| 13 | - text::{Line, Span}, | 13 | + text::{Line, Span, Text}, |
| 14 | widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph, Wrap}, | 14 | widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph, Wrap}, |
| 15 | Frame, | 15 | Frame, |
| 16 | }; | 16 | }; |
| @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; | |||
| 19 | use super::theme::Theme; | 19 | use super::theme::Theme; |
| 20 | use super::utils::sanitize_terminal_text; | 20 | use super::utils::sanitize_terminal_text; |
| 21 | 21 | ||
| 22 | -/// 单个可选项(可与 JSON 对齐)。 | 22 | +/// Single selectable option (can align with JSON). |
| 23 | 23 | ||
| 24 | pub struct PromptChoice { | 24 | pub struct PromptChoice { |
| 25 | pub id: String, | 25 | pub id: String, |
| @@ -27,7 +27,7 @@ pub struct PromptChoice { | |||
| 27 | pub description: Option<String>, | 27 | pub description: Option<String>, |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | -/// 后端 → TUI:请求用户从列表中选择,并可选择是否允许补充输入。 | 30 | +/// Backend → TUI: Request user to select from list, optionally allow supplementary input. |
| 31 | 31 | ||
| 32 | pub struct PromptRequest { | 32 | pub struct PromptRequest { |
| 33 | pub request_id: String, | 33 | pub request_id: String, |
| @@ -36,13 +36,16 @@ pub struct PromptRequest { | |||
| 36 | pub choices: Vec<PromptChoice>, | 36 | pub choices: Vec<PromptChoice>, |
| 37 | 37 | ||
| 38 | pub allow_custom_input: bool, | 38 | pub allow_custom_input: bool, |
| 39 | - /// 多选:列表中 Space 切换选中,Enter 提交 `PromptResolution::Multi`。 | 39 | + /// Multi-select: Space toggles selection in list, Enter submits `PromptResolution::Multi`. |
| 40 | 40 | ||
| 41 | pub multi_select: bool, | 41 | pub multi_select: bool, |
| 42 | pub default_index: Option<usize>, | 42 | pub default_index: Option<usize>, |
| 43 | + /// Whether this is password input (hide display) | ||
| 44 | + | ||
| 45 | + pub is_secret: bool, | ||
| 43 | } | 46 | } |
| 44 | 47 | ||
| 45 | -/// 用户操作结果(TUI → 后端 / 会话)。 | 48 | +/// User operation result (TUI → backend / session). |
| 46 | 49 | ||
| 47 | pub struct UserPromptResult { | 50 | pub struct UserPromptResult { |
| 48 | pub request_id: String, | 51 | pub request_id: String, |
| @@ -56,7 +59,7 @@ pub enum PromptResolution { | |||
| 56 | choice_id: String, | 59 | choice_id: String, |
| 57 | supplement: Option<String>, | 60 | supplement: Option<String>, |
| 58 | }, | 61 | }, |
| 59 | - /// 预留,与 `PromptRequest::multi_select` 对应。 | 62 | + /// Reserved, corresponds to `PromptRequest::multi_select`. |
| 60 | Multi { | 63 | Multi { |
| 61 | choice_ids: Vec<String>, | 64 | choice_ids: Vec<String>, |
| 62 | }, | 65 | }, |
| @@ -69,11 +72,11 @@ pub enum PromptFocus { | |||
| 69 | Supplement, | 72 | Supplement, |
| 70 | } | 73 | } |
| 71 | 74 | ||
| 72 | -/// 运行时 UI 状态(不参与序列化)。 | 75 | +/// Runtime UI state (not involved in serialization). |
| 73 | pub struct InteractionPromptState { | 76 | pub struct InteractionPromptState { |
| 74 | pub request: PromptRequest, | 77 | pub request: PromptRequest, |
| 75 | pub selected: usize, | 78 | pub selected: usize, |
| 76 | - /// 列表首行对应 `choices` 的下标(用于滚动)。 | 79 | + /// Index in `choices` corresponding to first visible list row (for scrolling). |
| 77 | pub list_scroll: usize, | 80 | pub list_scroll: usize, |
| 78 | pub focus: PromptFocus, | 81 | pub focus: PromptFocus, |
| 79 | pub supplement: Input, | 82 | pub supplement: Input, |
| @@ -179,9 +182,15 @@ impl InteractionPromptState { | |||
| 179 | } | 182 | } |
| 180 | } | 183 | } |
| 181 | 184 | ||
| 182 | -/// 计算提示块占用高度(含边框),用于 `Constraint::Length`。 | 185 | +/// Calculate prompt block height (including border), for use in `Constraint::Length`. |
| 183 | -pub fn interaction_prompt_outer_height(req: &PromptRequest) -> u16 { | 186 | +/// `inner_width` is the inner width of the dialog (excluding borders), used to wrap title. |
| 187 | +pub fn interaction_prompt_outer_height(req: &PromptRequest, inner_width: u16) -> u16 { | ||
| 184 | let border = 2u16; | 188 | let border = 2u16; |
| 189 | + let title_h = if inner_width > 0 { | ||
| 190 | + wrap_text_to_lines(&req.title, inner_width as usize).len() as u16 | ||
| 191 | + } else { | ||
| 192 | + 1 | ||
| 193 | + }; | ||
| 185 | let body_h = if req.body.as_ref().map_or(false, |s| !s.is_empty()) { | 194 | let body_h = if req.body.as_ref().map_or(false, |s| !s.is_empty()) { |
| 186 | 1 | 195 | 1 |
| 187 | } else { | 196 | } else { |
| @@ -190,9 +199,43 @@ pub fn interaction_prompt_outer_height(req: &PromptRequest) -> u16 { | |||
| 190 | let list_cap = if req.allow_custom_input { 4 } else { 6 }; | 199 | let list_cap = if req.allow_custom_input { 4 } else { 6 }; |
| 191 | let list_h = req.choices.len().min(list_cap) as u16; | 200 | let list_h = req.choices.len().min(list_cap) as u16; |
| 192 | let sup_h = if req.allow_custom_input { 3 } else { 0 }; | 201 | let sup_h = if req.allow_custom_input { 3 } else { 0 }; |
| 193 | - let total = border + body_h + list_h + sup_h; | 202 | + let total = border + title_h + body_h + list_h + sup_h; |
| 194 | - let max_outer = 11u16; | 203 | + total.max(border + 1) |
| 195 | - total.min(max_outer).max(border + 1) | 204 | +} |
| 205 | + | ||
| 206 | +/// Wrap text into multiple lines based on character display width. | ||
| 207 | +/// Returns a Vec of strings, each fitting within max_width columns. | ||
| 208 | +fn wrap_text_to_lines(text: &str, max_width: usize) -> Vec<String> { | ||
| 209 | + if max_width == 0 || text.is_empty() { | ||
| 210 | + return vec![text.to_string()]; | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + let mut lines = Vec::new(); | ||
| 214 | + let mut current_line = String::new(); | ||
| 215 | + let mut current_width = 0usize; | ||
| 216 | + | ||
| 217 | + for ch in text.chars() { | ||
| 218 | + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1); | ||
| 219 | + | ||
| 220 | + if current_width + cw > max_width { | ||
| 221 | + lines.push(current_line); | ||
| 222 | + current_line = String::new(); | ||
| 223 | + current_width = 0; | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + current_line.push(ch); | ||
| 227 | + current_width += cw; | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + if !current_line.is_empty() { | ||
| 231 | + lines.push(current_line); | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + if lines.is_empty() { | ||
| 235 | + lines.push(String::new()); | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + lines | ||
| 196 | } | 239 | } |
| 197 | 240 | ||
| 198 | pub fn render_interaction_prompt( | 241 | pub fn render_interaction_prompt( |
| @@ -206,22 +249,33 @@ pub fn render_interaction_prompt( | |||
| 206 | *list_hit_area = None; | 249 | *list_hit_area = None; |
| 207 | *supplement_hit_area = None; | 250 | *supplement_hit_area = None; |
| 208 | 251 | ||
| 209 | - let title = format!(" {} ", state.request.title); | ||
| 210 | let block = Block::default() | 252 | let block = Block::default() |
| 211 | .borders(Borders::ALL) | 253 | .borders(Borders::ALL) |
| 212 | .border_type(BorderType::Rounded) | 254 | .border_type(BorderType::Rounded) |
| 213 | .border_style(Style::default().fg(theme.border_active)) | 255 | .border_style(Style::default().fg(theme.border_active)) |
| 214 | - .title(title) | ||
| 215 | .style(Style::default().bg(theme.background)); | 256 | .style(Style::default().bg(theme.background)); |
| 216 | 257 | ||
| 217 | let inner = block.inner(area); | 258 | let inner = block.inner(area); |
| 218 | f.render_widget(block, area); | 259 | f.render_widget(block, area); |
| 219 | 260 | ||
| 261 | + // Calculate title lines based on inner width | ||
| 262 | + let title_lines_vec = if inner.width > 0 { | ||
| 263 | + wrap_text_to_lines(&state.request.title, inner.width as usize) | ||
| 264 | + } else { | ||
| 265 | + vec![state.request.title.clone()] | ||
| 266 | + }; | ||
| 267 | + let title_lines = title_lines_vec.len() as u16; | ||
| 268 | + | ||
| 269 | + let vmax = state.list_visible_max(); | ||
| 270 | + let list_h = state.request.choices.len().min(vmax) as u16; | ||
| 271 | + | ||
| 220 | let mut constraints: Vec<Constraint> = Vec::new(); | 272 | let mut constraints: Vec<Constraint> = Vec::new(); |
| 273 | + // Title area | ||
| 274 | + constraints.push(Constraint::Length(title_lines)); | ||
| 221 | if state.request.body.as_ref().map_or(false, |s| !s.is_empty()) { | 275 | if state.request.body.as_ref().map_or(false, |s| !s.is_empty()) { |
| 222 | constraints.push(Constraint::Length(1)); | 276 | constraints.push(Constraint::Length(1)); |
| 223 | } | 277 | } |
| 224 | - constraints.push(Constraint::Min(1)); | 278 | + constraints.push(Constraint::Length(list_h)); |
| 225 | if state.request.allow_custom_input { | 279 | if state.request.allow_custom_input { |
| 226 | constraints.push(Constraint::Length(3)); | 280 | constraints.push(Constraint::Length(3)); |
| 227 | } | 281 | } |
| @@ -231,7 +285,25 @@ pub fn render_interaction_prompt( | |||
| 231 | .constraints(constraints) | 285 | .constraints(constraints) |
| 232 | .split(inner); | 286 | .split(inner); |
| 233 | 287 | ||
| 288 | + // Render title as first element | ||
| 234 | let mut idx = 0usize; | 289 | let mut idx = 0usize; |
| 290 | + let title_text = Text::from( | ||
| 291 | + title_lines_vec | ||
| 292 | + .iter() | ||
| 293 | + .map(|line| { | ||
| 294 | + Line::styled( | ||
| 295 | + line.clone(), | ||
| 296 | + Style::default() | ||
| 297 | + .fg(theme.foreground) | ||
| 298 | + .add_modifier(Modifier::BOLD), | ||
| 299 | + ) | ||
| 300 | + }) | ||
| 301 | + .collect::<Vec<Line>>(), | ||
| 302 | + ); | ||
| 303 | + let title = Paragraph::new(title_text); | ||
| 304 | + f.render_widget(title, chunks[idx]); | ||
| 305 | + idx += 1; | ||
| 306 | + | ||
| 235 | if state.request.body.as_ref().map_or(false, |s| !s.is_empty()) { | 307 | if state.request.body.as_ref().map_or(false, |s| !s.is_empty()) { |
| 236 | let body = state.request.body.as_deref().unwrap_or_default(); | 308 | let body = state.request.body.as_deref().unwrap_or_default(); |
| 237 | let line = if body.chars().count() > 256 { | 309 | let line = if body.chars().count() > 256 { |
| @@ -248,7 +320,6 @@ pub fn render_interaction_prompt( | |||
| 248 | } | 320 | } |
| 249 | 321 | ||
| 250 | let list_chunk = chunks[idx]; | 322 | let list_chunk = chunks[idx]; |
| 251 | - let vmax = state.list_visible_max(); | ||
| 252 | let start = state | 323 | let start = state |
| 253 | .list_scroll | 324 | .list_scroll |
| 254 | .min(state.request.choices.len().saturating_sub(1)); | 325 | .min(state.request.choices.len().saturating_sub(1)); |
| @@ -308,10 +379,15 @@ pub fn render_interaction_prompt( | |||
| 308 | } else { | 379 | } else { |
| 309 | theme.border | 380 | theme.border |
| 310 | })) | 381 | })) |
| 311 | - .title(" 补充(可选) ") | 382 | + .title(if state.request.is_secret { |
| 383 | + " 密码输入 " | ||
| 384 | + } else { | ||
| 385 | + " 补充(可选) " | ||
| 386 | + }) | ||
| 312 | .padding(Padding::horizontal(1)); | 387 | .padding(Padding::horizontal(1)); |
| 313 | let sup_inner = sup_block.inner(sup_area); | 388 | let sup_inner = sup_block.inner(sup_area); |
| 314 | - let val = state.supplement.value().to_string(); | 389 | + // Use display_value for password masking |
| 390 | + let val = state.supplement.display_value(state.request.is_secret); | ||
| 315 | let p = Paragraph::new(val) | 391 | let p = Paragraph::new(val) |
| 316 | .style(Style::default().fg(theme.foreground).bg(theme.input_bg)) | 392 | .style(Style::default().fg(theme.foreground).bg(theme.input_bg)) |
| 317 | .block(sup_block); | 393 | .block(sup_block); |
| @@ -19,7 +19,7 @@ use crate::remote_sessions_service::{ | |||
| 19 | use crate::services::turn_delete::DeleteDialog; | 19 | use crate::services::turn_delete::DeleteDialog; |
| 20 | use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog}; | 20 | use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog}; |
| 21 | 21 | ||
| 22 | -use super::utils::{cursor_row_col, line_prefix_width, sanitize_terminal_text}; | 22 | +use super::utils::{line_prefix_width, sanitize_terminal_text}; |
| 23 | 23 | ||
| 24 | /// Flatten newlines and truncate `text` to fit within `max_width` terminal columns, | 24 | /// Flatten newlines and truncate `text` to fit within `max_width` terminal columns, |
| 25 | /// appending "..." when truncated. | 25 | /// appending "..." when truncated. |
| @@ -167,8 +167,10 @@ impl App { | |||
| 167 | } else { | 167 | } else { |
| 168 | available_width | 168 | available_width |
| 169 | }; | 169 | }; |
| 170 | - let available_height = area.height.saturating_sub(4).max(1); | 170 | + let inner_width = width.saturating_sub(2); |
| 171 | - let desired_height = interaction_prompt_outer_height(&prompt.request).max(6); | 171 | + let max_height = (area.height as f32 * 0.8).ceil() as u16; |
| 172 | + let available_height = area.height.saturating_sub(4).max(1).min(max_height); | ||
| 173 | + let desired_height = interaction_prompt_outer_height(&prompt.request, inner_width).max(6); | ||
| 172 | let height = desired_height.min(available_height); | 174 | let height = desired_height.min(available_height); |
| 173 | let x = area.x + (area.width.saturating_sub(width)) / 2; | 175 | let x = area.x + (area.width.saturating_sub(width)) / 2; |
| 174 | let y = area.y + (area.height.saturating_sub(height)) / 2; | 176 | let y = area.y + (area.height.saturating_sub(height)) / 2; |
| @@ -308,9 +310,9 @@ impl App { | |||
| 308 | } else if self.state.provider_dialog.is_some() { | 310 | } else if self.state.provider_dialog.is_some() { |
| 309 | " ↑↓ 切换 | ←→ 分栏 | Enter 选择 | Esc 关闭 " | 311 | " ↑↓ 切换 | ←→ 分栏 | Enter 选择 | Esc 关闭 " |
| 310 | } else if has_tool_cards { | 312 | } else if has_tool_cards { |
| 311 | - " Enter 发送 | / 命令 | Click 工具详情 | Ctrl+C 退出 " | 313 | + " Enter 发送 | Alt+Enter 换行 | / 命令 | Click 工具详情 | Ctrl+C 退出 " |
| 312 | } else { | 314 | } else { |
| 313 | - " Enter 发送 | / 命令 | Ctrl+C 退出 " | 315 | + " Enter 发送 | Alt+Enter 换行 | / 命令 | Ctrl+C 退出 " |
| 314 | }; | 316 | }; |
| 315 | let input_style = self.state.theme.default_style(); | 317 | let input_style = self.state.theme.default_style(); |
| 316 | let block = Block::default() | 318 | let block = Block::default() |
| @@ -325,16 +327,13 @@ impl App { | |||
| 325 | let value = self.state.chat_state.input.value(); | 327 | let value = self.state.chat_state.input.value(); |
| 326 | let cursor = self.state.chat_state.input.cursor(); | 328 | let cursor = self.state.chat_state.input.cursor(); |
| 327 | let selection = self.state.chat_state.input.selected_range(); | 329 | let selection = self.state.chat_state.input.selected_range(); |
| 328 | - let (row, col) = cursor_row_col(value, cursor); | ||
| 329 | - let lines: Vec<&str> = value.split('\n').collect(); | ||
| 330 | - let line = lines.get(row).copied().unwrap_or(""); | ||
| 331 | 330 | ||
| 332 | let inner_height = inner.height.max(1) as usize; | 331 | let inner_height = inner.height.max(1) as usize; |
| 333 | - let scroll_y = row.saturating_sub(inner_height.saturating_sub(1)); | 332 | + let max_width = inner.width.max(1) as usize; |
| 334 | 333 | ||
| 335 | - let max_width = inner.width.max(1).saturating_sub(1) as usize; | 334 | + // Calculate visual cursor position considering line wrapping |
| 336 | - let visual_x = line_prefix_width(line, col); | 335 | + let (visual_row, visual_col) = calculate_visual_cursor_position(value, cursor, max_width); |
| 337 | - let scroll_x = visual_x.max(max_width) - max_width; | 336 | + let scroll_y = visual_row.saturating_sub(inner_height.saturating_sub(1)); |
| 338 | 337 | ||
| 339 | let selection_style = Style::default() | 338 | let selection_style = Style::default() |
| 340 | .fg(self.state.theme.background) | 339 | .fg(self.state.theme.background) |
| @@ -346,12 +345,14 @@ impl App { | |||
| 346 | let text = | 345 | let text = |
| 347 | build_input_text_with_selection(value, &sel_range, input_style, selection_style); | 346 | build_input_text_with_selection(value, &sel_range, input_style, selection_style); |
| 348 | Paragraph::new(text) | 347 | Paragraph::new(text) |
| 349 | - .scroll((scroll_y as u16, scroll_x as u16)) | 348 | + .wrap(Wrap { trim: false }) |
| 349 | + .scroll((scroll_y as u16, 0)) | ||
| 350 | .block(block) | 350 | .block(block) |
| 351 | } else { | 351 | } else { |
| 352 | Paragraph::new(value) | 352 | Paragraph::new(value) |
| 353 | .style(input_style) | 353 | .style(input_style) |
| 354 | - .scroll((scroll_y as u16, scroll_x as u16)) | 354 | + .wrap(Wrap { trim: false }) |
| 355 | + .scroll((scroll_y as u16, 0)) | ||
| 355 | .block(block) | 356 | .block(block) |
| 356 | }; | 357 | }; |
| 357 | frame.render_widget(paragraph, area); | 358 | frame.render_widget(paragraph, area); |
| @@ -373,10 +374,14 @@ impl App { | |||
| 373 | | InputMode::SessionSnapshotSelection | 374 | | InputMode::SessionSnapshotSelection |
| 374 | ) | 375 | ) |
| 375 | { | 376 | { |
| 376 | - let y_on_screen = row - scroll_y; | 377 | + let y_on_screen = visual_row.saturating_sub(scroll_y); |
| 377 | if y_on_screen < inner_height { | 378 | if y_on_screen < inner_height { |
| 378 | - let x_on_screen = visual_x.saturating_sub(scroll_x); | 379 | + let adjusted_visual_col = if visual_col >= inner.width as usize { |
| 379 | - let cursor_x = inner.x.saturating_add(x_on_screen.min(max_width) as u16); | 380 | + inner.width.saturating_sub(1) as usize |
| 381 | + } else { | ||
| 382 | + visual_col | ||
| 383 | + }; | ||
| 384 | + let cursor_x = inner.x.saturating_add(adjusted_visual_col as u16); | ||
| 380 | let cursor_y = inner.y.saturating_add(y_on_screen as u16); | 385 | let cursor_y = inner.y.saturating_add(y_on_screen as u16); |
| 381 | frame.set_cursor_position((cursor_x, cursor_y)); | 386 | frame.set_cursor_position((cursor_x, cursor_y)); |
| 382 | } | 387 | } |
| @@ -1142,3 +1147,51 @@ fn truncate_chars(value: &str, max_chars: usize) -> String { | |||
| 1142 | truncated.push('…'); | 1147 | truncated.push('…'); |
| 1143 | truncated | 1148 | truncated |
| 1144 | } | 1149 | } |
| 1150 | + | ||
| 1151 | +/// Calculate the visual cursor position considering automatic line wrapping. | ||
| 1152 | +/// Returns (visual_row, visual_col) where visual_row is the row on screen | ||
| 1153 | +/// and visual_col is the column position within that row (visual width units). | ||
| 1154 | +fn calculate_visual_cursor_position( | ||
| 1155 | + value: &str, | ||
| 1156 | + cursor: usize, | ||
| 1157 | + max_width: usize, | ||
| 1158 | +) -> (usize, usize) { | ||
| 1159 | + if max_width == 0 { | ||
| 1160 | + return (0, 0); | ||
| 1161 | + } | ||
| 1162 | + | ||
| 1163 | + let chars: Vec<char> = value.chars().collect(); | ||
| 1164 | + let cursor = cursor.min(chars.len()); | ||
| 1165 | + | ||
| 1166 | + let mut visual_row = 0usize; | ||
| 1167 | + let mut visual_col = 0usize; | ||
| 1168 | + let mut current_row_width = 0usize; | ||
| 1169 | + | ||
| 1170 | + for (idx, &ch) in chars.iter().enumerate() { | ||
| 1171 | + if idx == cursor { | ||
| 1172 | + return (visual_row, visual_col); | ||
| 1173 | + } | ||
| 1174 | + | ||
| 1175 | + if ch == '\n' { | ||
| 1176 | + // Explicit newline: move to next line | ||
| 1177 | + visual_row += 1; | ||
| 1178 | + visual_col = 0; | ||
| 1179 | + current_row_width = 0; | ||
| 1180 | + } else { | ||
| 1181 | + let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); | ||
| 1182 | + | ||
| 1183 | + if current_row_width + char_width > max_width { | ||
| 1184 | + // Automatic line wrap: move to next line, then add character | ||
| 1185 | + visual_row += 1; | ||
| 1186 | + current_row_width = 0; | ||
| 1187 | + } | ||
| 1188 | + | ||
| 1189 | + // Add character width to current row | ||
| 1190 | + current_row_width += char_width; | ||
| 1191 | + visual_col = current_row_width; | ||
| 1192 | + } | ||
| 1193 | + } | ||
| 1194 | + | ||
| 1195 | + // Cursor at end of text | ||
| 1196 | + (visual_row, visual_col) | ||
| 1197 | +} | ||
| @@ -20,22 +20,6 @@ pub fn paste_into_input(input: &mut Input, text: &str) { | |||
| 20 | } | 20 | } |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | -pub(crate) fn cursor_row_col(value: &str, cursor: usize) -> (usize, usize) { | ||
| 24 | - let chars: Vec<char> = value.chars().collect(); | ||
| 25 | - let length = chars.len(); | ||
| 26 | - let cursor = cursor.min(length); | ||
| 27 | - let mut row = 0usize; | ||
| 28 | - let mut line_start = 0usize; | ||
| 29 | - for idx in 0..cursor { | ||
| 30 | - if chars[idx] == '\n' { | ||
| 31 | - row += 1; | ||
| 32 | - line_start = idx + 1; | ||
| 33 | - } | ||
| 34 | - } | ||
| 35 | - let col = cursor - line_start; | ||
| 36 | - (row, col) | ||
| 37 | -} | ||
| 38 | - | ||
| 39 | pub(crate) fn line_prefix_width(line: &str, col_chars: usize) -> usize { | 23 | pub(crate) fn line_prefix_width(line: &str, col_chars: usize) -> usize { |
| 40 | line.chars() | 24 | line.chars() |
| 41 | .take(col_chars) | 25 | .take(col_chars) |
| @@ -1254,6 +1254,7 @@ mod tests { | |||
| 1254 | }], | 1254 | }], |
| 1255 | allow_custom_input: true, | 1255 | allow_custom_input: true, |
| 1256 | multi_select: false, | 1256 | multi_select: false, |
| 1257 | + is_secret: false, | ||
| 1257 | default_index: Some(0), | 1258 | default_index: Some(0), |
| 1258 | } | 1259 | } |
| 1259 | } | 1260 | } |
| @@ -95,6 +95,38 @@ impl ConfiguredRuntimeResolver { | |||
| 95 | let agent = config.resolve_agent()?; | 95 | let agent = config.resolve_agent()?; |
| 96 | ensure_workspace_exists(&agent.workspace_root)?; | 96 | ensure_workspace_exists(&agent.workspace_root)?; |
| 97 | 97 | ||
| 98 | + let resolved_provider = resolve_config(ResolveInput { | ||
| 99 | + provider: Some(config.app.llm.provider.clone()), | ||
| 100 | + protocol: None, | ||
| 101 | + api_key: None, | ||
| 102 | + api_key_env: config.app.llm.api_key_env.clone(), | ||
| 103 | + base_url: config.app.llm.api_base.clone(), | ||
| 104 | + }) | ||
| 105 | + .context("failed to resolve llm provider config")?; | ||
| 106 | + let llm_provider = Arc::new( | ||
| 107 | + create_llm_provider_from_resolved( | ||
| 108 | + &resolved_provider, | ||
| 109 | + agent.model.clone(), | ||
| 110 | + Some(agent.id.clone()), | ||
| 111 | + None, | ||
| 112 | + ) | ||
| 113 | + .context("failed to create llm provider")?, | ||
| 114 | + ); | ||
| 115 | + let effective_context_window = resolve_effective_context_window( | ||
| 116 | + &resolved_provider, | ||
| 117 | + &agent.model, | ||
| 118 | + llm_provider.capabilities().max_context_window, | ||
| 119 | + ) | ||
| 120 | + .await; | ||
| 121 | + let token_budget = build_token_budget(effective_context_window, config.max_output_tokens()); | ||
| 122 | + | ||
| 123 | + validate_token_budget_config( | ||
| 124 | + &token_budget, | ||
| 125 | + config.max_output_tokens(), | ||
| 126 | + &agent.model, | ||
| 127 | + config.config_path(), | ||
| 128 | + ); | ||
| 129 | + | ||
| 98 | let trace = config.resolve_trace_config(); | 130 | let trace = config.resolve_trace_config(); |
| 99 | let skills_config = config.resolve_skills_config(); | 131 | let skills_config = config.resolve_skills_config(); |
| 100 | let skill_registry: Arc<dyn SkillRegistry> = | 132 | let skill_registry: Arc<dyn SkillRegistry> = |
| @@ -1,31 +1,31 @@ | |||
| 1 | use crate::channels::{AdapterResponse, ChannelError, ChannelResult, ChannelRuntime}; | 1 | use crate::channels::{AdapterResponse, ChannelError, ChannelResult, ChannelRuntime}; |
| 2 | +use crate::httpserver::GatewayServiceError; | ||
| 2 | use crate::httpserver::channel_ingress::GatewayChannelIngressError; | 3 | use crate::httpserver::channel_ingress::GatewayChannelIngressError; |
| 3 | use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor}; | 4 | use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor}; |
| 4 | use crate::httpserver::rate_limit::RateLimitConfig; | 5 | use crate::httpserver::rate_limit::RateLimitConfig; |
| 5 | -use crate::httpserver::sse_sink::{sse_stream_from_receiver, SseLoopEventSink, SseStreamEvent}; | 6 | +use crate::httpserver::sse_sink::{SseLoopEventSink, SseStreamEvent, sse_stream_from_receiver}; |
| 6 | -use crate::httpserver::GatewayServiceError; | ||
| 7 | use agent_contracts::InteractionHandle; | 7 | use agent_contracts::InteractionHandle; |
| 8 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; | 8 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; |
| 9 | use async_trait::async_trait; | 9 | use async_trait::async_trait; |
| 10 | use axum::{ | 10 | use axum::{ |
| 11 | + Json, Router, | ||
| 11 | body::Bytes, | 12 | body::Bytes, |
| 12 | extract::{Path, Query, State}, | 13 | extract::{Path, Query, State}, |
| 13 | http::{ | 14 | http::{ |
| 14 | - header::{AUTHORIZATION, WWW_AUTHENTICATE}, | ||
| 15 | HeaderMap, Request, StatusCode, | 15 | HeaderMap, Request, StatusCode, |
| 16 | + header::{AUTHORIZATION, WWW_AUTHENTICATE}, | ||
| 16 | }, | 17 | }, |
| 17 | middleware::{self, Next}, | 18 | middleware::{self, Next}, |
| 18 | response::{ | 19 | response::{ |
| 19 | - sse::{KeepAlive, Sse}, | ||
| 20 | IntoResponse, Response, | 20 | IntoResponse, Response, |
| 21 | + sse::{KeepAlive, Sse}, | ||
| 21 | }, | 22 | }, |
| 22 | routing::{get, post}, | 23 | routing::{get, post}, |
| 23 | - Json, Router, | ||
| 24 | }; | 24 | }; |
| 25 | use serde::{Deserialize, Serialize}; | 25 | use serde::{Deserialize, Serialize}; |
| 26 | use std::collections::HashMap; | 26 | use std::collections::HashMap; |
| 27 | use std::sync::Arc; | 27 | use std::sync::Arc; |
| 28 | -use tokio::sync::{oneshot, Mutex}; | 28 | +use tokio::sync::{Mutex, oneshot}; |
| 29 | use tracing::warn; | 29 | use tracing::warn; |
| 30 | use xiaoo_shared::gateway::SessionService; | 30 | use xiaoo_shared::gateway::SessionService; |
| 31 | 31 | ||
| @@ -162,7 +162,10 @@ impl InteractionHandle for RemoteSseInteractionHandle { | |||
| 162 | fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse { | 162 | fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse { |
| 163 | match request { | 163 | match request { |
| 164 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, | 164 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, |
| 165 | - InteractionRequest::TextInput { .. } => InteractionResponse::Text { value: None }, | 165 | + InteractionRequest::TextInput { .. } => InteractionResponse::Text { |
| 166 | + value: None, | ||
| 167 | + display_value: None, | ||
| 168 | + }, | ||
| 166 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, | 169 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, |
| 167 | } | 170 | } |
| 168 | } | 171 | } |
| @@ -230,16 +233,16 @@ fn create_router_from_state( | |||
| 230 | bearer_auth: Option<HttpBearerAuthConfig>, | 233 | bearer_auth: Option<HttpBearerAuthConfig>, |
| 231 | rate_limit: Option<RateLimitConfig>, | 234 | rate_limit: Option<RateLimitConfig>, |
| 232 | ) -> Router { | 235 | ) -> Router { |
| 233 | - let protected_session_routes = apply_http_bearer_auth( | 236 | + let protected_runtime_routes = apply_http_bearer_auth( |
| 234 | Router::new() | 237 | Router::new() |
| 235 | - .route("/api/v1/sessions/open", post(handle_session_open)) | 238 | + .route("/api/v1/runtimes/open", post(handle_session_open)) |
| 236 | - .route("/api/v1/sessions/input", post(handle_session_input)) | 239 | + .route("/api/v1/runtimes/input", post(handle_session_input)) |
| 237 | .route( | 240 | .route( |
| 238 | - "/api/v1/sessions/interaction", | 241 | + "/api/v1/runtimes/interaction", |
| 239 | post(handle_session_interaction), | 242 | post(handle_session_interaction), |
| 240 | ) | 243 | ) |
| 241 | - .route("/api/v1/sessions/cancel", post(handle_session_cancel)) | 244 | + .route("/api/v1/runtimes/cancel", post(handle_session_cancel)) |
| 242 | - .route("/api/v1/sessions/close", post(handle_session_close)) | 245 | + .route("/api/v1/runtimes/close", post(handle_session_close)) |
| 243 | .route( | 246 | .route( |
| 244 | "/api/v1/runtimes/checkpoint", | 247 | "/api/v1/runtimes/checkpoint", |
| 245 | post(handle_runtime_checkpoint), | 248 | post(handle_runtime_checkpoint), |
| @@ -258,7 +261,7 @@ fn create_router_from_state( | |||
| 258 | "/api/v1/channels/:channel_id/events", | 261 | "/api/v1/channels/:channel_id/events", |
| 259 | post(handle_channel_events), | 262 | post(handle_channel_events), |
| 260 | ) | 263 | ) |
| 261 | - .merge(protected_session_routes) | 264 | + .merge(protected_runtime_routes) |
| 262 | .with_state(Arc::new(state)); | 265 | .with_state(Arc::new(state)); |
| 263 | 266 | ||
| 264 | match rate_limit.and_then(|c| c.governor_layer()) { | 267 | match rate_limit.and_then(|c| c.governor_layer()) { |
| @@ -354,7 +357,7 @@ async fn health_check() -> Json<GatewayHealthResponse> { | |||
| 354 | 357 | ||
| 355 | async fn handle_session_open( | 358 | async fn handle_session_open( |
| 356 | State(state): State<Arc<GatewayAppState>>, | 359 | State(state): State<Arc<GatewayAppState>>, |
| 357 | - Json(payload): Json<xiaoo_shared::gateway::SessionOpenRequest>, | 360 | + Json(payload): Json<xiaoo_shared::gateway::RuntimeOpenRequest>, |
| 358 | ) -> Response { | 361 | ) -> Response { |
| 359 | let Some(control_plane) = state.session_control_plane.as_ref() else { | 362 | let Some(control_plane) = state.session_control_plane.as_ref() else { |
| 360 | return ( | 363 | return ( |
| @@ -374,7 +377,7 @@ async fn handle_session_open( | |||
| 374 | 377 | ||
| 375 | async fn handle_session_input( | 378 | async fn handle_session_input( |
| 376 | State(state): State<Arc<GatewayAppState>>, | 379 | State(state): State<Arc<GatewayAppState>>, |
| 377 | - Json(payload): Json<xiaoo_shared::gateway::AppTurnRequest>, | 380 | + Json(payload): Json<xiaoo_shared::gateway::RuntimeTurnRequest>, |
| 378 | ) -> Response { | 381 | ) -> Response { |
| 379 | stream_session_input(state, payload.session_id.clone(), payload).await | 382 | stream_session_input(state, payload.session_id.clone(), payload).await |
| 380 | } | 383 | } |
| @@ -382,7 +385,7 @@ async fn handle_session_input( | |||
| 382 | async fn stream_session_input( | 385 | async fn stream_session_input( |
| 383 | state: Arc<GatewayAppState>, | 386 | state: Arc<GatewayAppState>, |
| 384 | session_id: String, | 387 | session_id: String, |
| 385 | - payload: xiaoo_shared::gateway::AppTurnRequest, | 388 | + payload: xiaoo_shared::gateway::RuntimeTurnRequest, |
| 386 | ) -> Response { | 389 | ) -> Response { |
| 387 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseStreamEvent>(); | 390 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseStreamEvent>(); |
| 388 | let sink = Arc::new(SseLoopEventSink::new(tx.clone())); | 391 | let sink = Arc::new(SseLoopEventSink::new(tx.clone())); |
| @@ -401,6 +404,7 @@ async fn stream_session_input( | |||
| 401 | { | 404 | { |
| 402 | Ok(result) => { | 405 | Ok(result) => { |
| 403 | let summary = sink.take_loop_summary(); | 406 | let summary = sink.take_loop_summary(); |
| 407 | + let filtered_messages = filter_messages_for_display(&result.messages); | ||
| 404 | let _ = tx.send(SseStreamEvent::Done { | 408 | let _ = tx.send(SseStreamEvent::Done { |
| 405 | reply: result.visible_reply.clone(), | 409 | reply: result.visible_reply.clone(), |
| 406 | raw_reply: result.raw_reply, | 410 | raw_reply: result.raw_reply, |
| @@ -411,7 +415,7 @@ async fn stream_session_input( | |||
| 411 | prompt_tokens: result.prompt_tokens, | 415 | prompt_tokens: result.prompt_tokens, |
| 412 | completion_tokens: result.completion_tokens, | 416 | completion_tokens: result.completion_tokens, |
| 413 | estimated_input_tokens: result.estimated_input_tokens, | 417 | estimated_input_tokens: result.estimated_input_tokens, |
| 414 | - messages: result.messages, | 418 | + messages: filtered_messages, |
| 415 | stop_reason: summary.map(|s| s.stop_reason).unwrap_or_default(), | 419 | stop_reason: summary.map(|s| s.stop_reason).unwrap_or_default(), |
| 416 | }); | 420 | }); |
| 417 | } | 421 | } |
| @@ -430,7 +434,7 @@ async fn stream_session_input( | |||
| 430 | 434 | ||
| 431 | async fn handle_session_interaction( | 435 | async fn handle_session_interaction( |
| 432 | State(state): State<Arc<GatewayAppState>>, | 436 | State(state): State<Arc<GatewayAppState>>, |
| 433 | - Json(payload): Json<xiaoo_shared::gateway::SessionInteractionRequest>, | 437 | + Json(payload): Json<xiaoo_shared::gateway::RuntimeInteractionRequest>, |
| 434 | ) -> Response { | 438 | ) -> Response { |
| 435 | if state | 439 | if state |
| 436 | .remote_interactions | 440 | .remote_interactions |
| @@ -451,7 +455,7 @@ async fn handle_session_interaction( | |||
| 451 | 455 | ||
| 452 | async fn handle_session_cancel( | 456 | async fn handle_session_cancel( |
| 453 | State(state): State<Arc<GatewayAppState>>, | 457 | State(state): State<Arc<GatewayAppState>>, |
| 454 | - Json(payload): Json<xiaoo_shared::gateway::SessionCancelRequest>, | 458 | + Json(payload): Json<xiaoo_shared::gateway::RuntimeCancelRequest>, |
| 455 | ) -> Response { | 459 | ) -> Response { |
| 456 | let session_id = payload.session_id; | 460 | let session_id = payload.session_id; |
| 457 | let Some(control_plane) = state.session_control_plane.as_ref() else { | 461 | let Some(control_plane) = state.session_control_plane.as_ref() else { |
| @@ -482,7 +486,7 @@ async fn handle_session_cancel( | |||
| 482 | 486 | ||
| 483 | async fn handle_session_close( | 487 | async fn handle_session_close( |
| 484 | State(state): State<Arc<GatewayAppState>>, | 488 | State(state): State<Arc<GatewayAppState>>, |
| 485 | - Json(payload): Json<xiaoo_shared::gateway::SessionCloseRequest>, | 489 | + Json(payload): Json<xiaoo_shared::gateway::RuntimeCloseRequest>, |
| 486 | ) -> Response { | 490 | ) -> Response { |
| 487 | let Some(control_plane) = state.session_control_plane.as_ref() else { | 491 | let Some(control_plane) = state.session_control_plane.as_ref() else { |
| 488 | return ( | 492 | return ( |
| @@ -614,10 +618,7 @@ async fn handle_channel_events( | |||
| 614 | { | 618 | { |
| 615 | warn!( | 619 | warn!( |
| 616 | "failed to acknowledge channel message: channel={} id={} conversation={} error={}", | 620 | "failed to acknowledge channel message: channel={} id={} conversation={} error={}", |
| 617 | - runtime.meta.id, | 621 | + runtime.meta.id, message.message_id, message.conversation_id, error |
| 618 | - message.message_id, | ||
| 619 | - message.conversation_id, | ||
| 620 | - error | ||
| 621 | ); | 622 | ); |
| 622 | } | 623 | } |
| 623 | } | 624 | } |
| @@ -707,8 +708,8 @@ fn map_channel_message_processing_error(error: ChannelMessageProcessingError) -> | |||
| 707 | 708 | ||
| 708 | mod tests { | 709 | mod tests { |
| 709 | use super::{ | 710 | use super::{ |
| 710 | - create_router_with_auth, handle_channel_events, GatewayAppState, GatewayErrorResponse, | 711 | + GatewayAppState, GatewayErrorResponse, HttpBearerAuthConfig, create_router_with_auth, |
| 711 | - HttpBearerAuthConfig, | 712 | + handle_channel_events, |
| 712 | }; | 713 | }; |
| 713 | use crate::channels::{ | 714 | use crate::channels::{ |
| 714 | AdapterResponse, ChannelAdapter, ChannelCapabilities, ChannelMember, ChannelMention, | 715 | AdapterResponse, ChannelAdapter, ChannelCapabilities, ChannelMember, ChannelMention, |
| @@ -717,20 +718,20 @@ mod tests { | |||
| 717 | use agent_contracts::LoopEventSink; | 718 | use agent_contracts::LoopEventSink; |
| 718 | use async_trait::async_trait; | 719 | use async_trait::async_trait; |
| 719 | use axum::{ | 720 | use axum::{ |
| 720 | - body::{to_bytes, Body, Bytes}, | 721 | + body::{Body, Bytes, to_bytes}, |
| 721 | extract::{Path, Query, State}, | 722 | extract::{Path, Query, State}, |
| 722 | http::{HeaderMap, Request, StatusCode}, | 723 | http::{HeaderMap, Request, StatusCode}, |
| 723 | }; | 724 | }; |
| 724 | use std::collections::HashMap; | 725 | use std::collections::HashMap; |
| 725 | use std::sync::{Arc, Mutex}; | 726 | use std::sync::{Arc, Mutex}; |
| 726 | - use tokio::time::{sleep, timeout, Duration}; | 727 | + use tokio::time::{Duration, sleep, timeout}; |
| 727 | use tower::util::ServiceExt; | 728 | use tower::util::ServiceExt; |
| 728 | use xiaoo_shared::gateway::{ | 729 | use xiaoo_shared::gateway::{ |
| 729 | AppTurnRequest, AppTurnResult, SessionService, SessionServiceError, | 730 | AppTurnRequest, AppTurnResult, SessionService, SessionServiceError, |
| 730 | }; | 731 | }; |
| 731 | 732 | ||
| 732 | 733 | ||
| 733 | - async fn bearer_auth_rejects_missing_token_for_session_input() { | 734 | + async fn bearer_auth_rejects_missing_token_for_runtime_input() { |
| 734 | let router = create_router_with_auth( | 735 | let router = create_router_with_auth( |
| 735 | Arc::new(FakeSessionService::new("unused")), | 736 | Arc::new(FakeSessionService::new("unused")), |
| 736 | Some(HttpBearerAuthConfig::new("secret-token")), | 737 | Some(HttpBearerAuthConfig::new("secret-token")), |
| @@ -741,10 +742,10 @@ mod tests { | |||
| 741 | .oneshot( | 742 | .oneshot( |
| 742 | Request::builder() | 743 | Request::builder() |
| 743 | .method("POST") | 744 | .method("POST") |
| 744 | - .uri("/api/v1/sessions/input") | 745 | + .uri("/api/v1/runtimes/input") |
| 745 | .header("content-type", "application/json") | 746 | .header("content-type", "application/json") |
| 746 | .body(Body::from( | 747 | .body(Body::from( |
| 747 | - r#"{"session_id":"session-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#, | 748 | + r#"{"runtime_id":"runtime-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#, |
| 748 | )) | 749 | )) |
| 749 | .expect("request should build"), | 750 | .expect("request should build"), |
| 750 | ) | 751 | ) |
| @@ -769,7 +770,7 @@ mod tests { | |||
| 769 | } | 770 | } |
| 770 | 771 | ||
| 771 | 772 | ||
| 772 | - async fn bearer_auth_allows_valid_token_for_session_input() { | 773 | + async fn bearer_auth_allows_valid_token_for_runtime_input() { |
| 773 | let router = create_router_with_auth( | 774 | let router = create_router_with_auth( |
| 774 | Arc::new(FakeSessionService::new("unused")), | 775 | Arc::new(FakeSessionService::new("unused")), |
| 775 | Some(HttpBearerAuthConfig::new("secret-token")), | 776 | Some(HttpBearerAuthConfig::new("secret-token")), |
| @@ -780,11 +781,11 @@ mod tests { | |||
| 780 | .oneshot( | 781 | .oneshot( |
| 781 | Request::builder() | 782 | Request::builder() |
| 782 | .method("POST") | 783 | .method("POST") |
| 783 | - .uri("/api/v1/sessions/input") | 784 | + .uri("/api/v1/runtimes/input") |
| 784 | .header("authorization", "Bearer secret-token") | 785 | .header("authorization", "Bearer secret-token") |
| 785 | .header("content-type", "application/json") | 786 | .header("content-type", "application/json") |
| 786 | .body(Body::from( | 787 | .body(Body::from( |
| 787 | - r#"{"session_id":"session-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#, | 788 | + r#"{"runtime_id":"runtime-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#, |
| 788 | )) | 789 | )) |
| 789 | .expect("request should build"), | 790 | .expect("request should build"), |
| 790 | ) | 791 | ) |
| @@ -867,7 +868,7 @@ mod tests { | |||
| 867 | } | 868 | } |
| 868 | 869 | ||
| 869 | 870 | ||
| 870 | - async fn session_close_uses_body_session_id_route() { | 871 | + async fn runtime_close_uses_body_runtime_id_route() { |
| 871 | let router = create_router_with_auth( | 872 | let router = create_router_with_auth( |
| 872 | Arc::new(FakeSessionService::new("unused")), | 873 | Arc::new(FakeSessionService::new("unused")), |
| 873 | Some(HttpBearerAuthConfig::new("secret-token")), | 874 | Some(HttpBearerAuthConfig::new("secret-token")), |
| @@ -878,10 +879,10 @@ mod tests { | |||
| 878 | .oneshot( | 879 | .oneshot( |
| 879 | Request::builder() | 880 | Request::builder() |
| 880 | .method("POST") | 881 | .method("POST") |
| 881 | - .uri("/api/v1/sessions/close") | 882 | + .uri("/api/v1/runtimes/close") |
| 882 | .header("authorization", "Bearer secret-token") | 883 | .header("authorization", "Bearer secret-token") |
| 883 | .header("content-type", "application/json") | 884 | .header("content-type", "application/json") |
| 884 | - .body(Body::from(r#"{"session_id":"session-1"}"#)) | 885 | + .body(Body::from(r#"{"runtime_id":"runtime-1"}"#)) |
| 885 | .expect("request should build"), | 886 | .expect("request should build"), |
| 886 | ) | 887 | ) |
| 887 | .await | 888 | .await |
| @@ -891,26 +892,45 @@ mod tests { | |||
| 891 | } | 892 | } |
| 892 | 893 | ||
| 893 | 894 | ||
| 894 | - async fn session_close_old_path_is_not_registered() { | 895 | + async fn old_session_control_plane_routes_are_not_registered() { |
| 895 | let router = create_router_with_auth( | 896 | let router = create_router_with_auth( |
| 896 | Arc::new(FakeSessionService::new("unused")), | 897 | Arc::new(FakeSessionService::new("unused")), |
| 897 | Some(HttpBearerAuthConfig::new("secret-token")), | 898 | Some(HttpBearerAuthConfig::new("secret-token")), |
| 898 | None, | 899 | None, |
| 899 | ); | 900 | ); |
| 900 | 901 | ||
| 901 | - let response = router | 902 | + let input_response = router |
| 903 | + .clone() | ||
| 902 | .oneshot( | 904 | .oneshot( |
| 903 | Request::builder() | 905 | Request::builder() |
| 904 | .method("POST") | 906 | .method("POST") |
| 905 | - .uri("/api/v1/sessions/session-1/close") | 907 | + .uri("/api/v1/sessions/input") |
| 906 | .header("authorization", "Bearer secret-token") | 908 | .header("authorization", "Bearer secret-token") |
| 907 | - .body(Body::empty()) | 909 | + .header("content-type", "application/json") |
| 910 | + .body(Body::from( | ||
| 911 | + r#"{"session_id":"session-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#, | ||
| 912 | + )) | ||
| 908 | .expect("request should build"), | 913 | .expect("request should build"), |
| 909 | ) | 914 | ) |
| 910 | .await | 915 | .await |
| 911 | .expect("router should respond"); | 916 | .expect("router should respond"); |
| 912 | 917 | ||
| 913 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | 918 | + assert_eq!(input_response.status(), StatusCode::NOT_FOUND); |
| 919 | + | ||
| 920 | + let close_response = router | ||
| 921 | + .oneshot( | ||
| 922 | + Request::builder() | ||
| 923 | + .method("POST") | ||
| 924 | + .uri("/api/v1/sessions/close") | ||
| 925 | + .header("authorization", "Bearer secret-token") | ||
| 926 | + .header("content-type", "application/json") | ||
| 927 | + .body(Body::from(r#"{"runtime_id":"runtime-1"}"#)) | ||
| 928 | + .expect("request should build"), | ||
| 929 | + ) | ||
| 930 | + .await | ||
| 931 | + .expect("router should respond"); | ||
| 932 | + | ||
| 933 | + assert_eq!(close_response.status(), StatusCode::NOT_FOUND); | ||
| 914 | } | 934 | } |
| 915 | 935 | ||
| 916 | 936 | ||
| @@ -1190,11 +1210,13 @@ mod tests { | |||
| 1190 | .await; | 1210 | .await; |
| 1191 | 1211 | ||
| 1192 | assert_eq!(response.status(), StatusCode::OK); | 1212 | assert_eq!(response.status(), StatusCode::OK); |
| 1193 | - assert!(session_service | 1213 | + assert!( |
| 1194 | - .requests | 1214 | + session_service |
| 1195 | - .lock() | 1215 | + .requests |
| 1196 | - .expect("session service mutex poisoned") | 1216 | + .lock() |
| 1197 | - .is_empty()); | 1217 | + .expect("session service mutex poisoned") |
| 1218 | + .is_empty() | ||
| 1219 | + ); | ||
| 1198 | } | 1220 | } |
| 1199 | 1221 | ||
| 1200 | 1222 | ||
| @@ -1244,11 +1266,13 @@ mod tests { | |||
| 1244 | .expect("async webhook route should acknowledge immediately"); | 1266 | .expect("async webhook route should acknowledge immediately"); |
| 1245 | 1267 | ||
| 1246 | assert_eq!(response.status(), StatusCode::OK); | 1268 | assert_eq!(response.status(), StatusCode::OK); |
| 1247 | - assert!(session_service | 1269 | + assert!( |
| 1248 | - .requests | 1270 | + session_service |
| 1249 | - .lock() | 1271 | + .requests |
| 1250 | - .expect("session service mutex poisoned") | 1272 | + .lock() |
| 1251 | - .is_empty()); | 1273 | + .expect("session service mutex poisoned") |
| 1274 | + .is_empty() | ||
| 1275 | + ); | ||
| 1252 | 1276 | ||
| 1253 | sleep(Duration::from_millis(250)).await; | 1277 | sleep(Duration::from_millis(250)).await; |
| 1254 | 1278 | ||
| @@ -1267,3 +1291,76 @@ mod tests { | |||
| 1267 | assert_eq!(sent_texts[0].1, "处理完成"); | 1291 | assert_eq!(sent_texts[0].1, "处理完成"); |
| 1268 | } | 1292 | } |
| 1269 | } | 1293 | } |
| 1294 | + | ||
| 1295 | +fn filter_messages_for_display( | ||
| 1296 | + messages: &[llm_client::ChatMessage], | ||
| 1297 | +) -> Vec<llm_client::ChatMessage> { | ||
| 1298 | + messages.iter().map(filter_message_for_display).collect() | ||
| 1299 | +} | ||
| 1300 | + | ||
| 1301 | +fn filter_message_for_display(message: &llm_client::ChatMessage) -> llm_client::ChatMessage { | ||
| 1302 | + use agent_types::llm::ContentBlock; | ||
| 1303 | + let filtered_blocks: Vec<ContentBlock> = message | ||
| 1304 | + .blocks | ||
| 1305 | + .iter() | ||
| 1306 | + .map(|block| match block { | ||
| 1307 | + ContentBlock::ToolResult { | ||
| 1308 | + call_id, | ||
| 1309 | + tool_name, | ||
| 1310 | + output, | ||
| 1311 | + is_error, | ||
| 1312 | + } => { | ||
| 1313 | + if tool_name == "ask_user_question" { | ||
| 1314 | + let filtered_output = filter_ask_user_question_output(output); | ||
| 1315 | + ContentBlock::ToolResult { | ||
| 1316 | + call_id: call_id.clone(), | ||
| 1317 | + tool_name: tool_name.clone(), | ||
| 1318 | + output: filtered_output, | ||
| 1319 | + is_error: *is_error, | ||
| 1320 | + } | ||
| 1321 | + } else { | ||
| 1322 | + block.clone() | ||
| 1323 | + } | ||
| 1324 | + } | ||
| 1325 | + _ => block.clone(), | ||
| 1326 | + }) | ||
| 1327 | + .collect(); | ||
| 1328 | + | ||
| 1329 | + llm_client::ChatMessage { | ||
| 1330 | + role: message.role.clone(), | ||
| 1331 | + blocks: filtered_blocks, | ||
| 1332 | + message_id: message.message_id.clone(), | ||
| 1333 | + timestamp_ms: message.timestamp_ms, | ||
| 1334 | + api_usage_tokens: message.api_usage_tokens, | ||
| 1335 | + reasoning_content: message.reasoning_content.clone(), | ||
| 1336 | + estimated_tokens: message.estimated_tokens, | ||
| 1337 | + } | ||
| 1338 | +} | ||
| 1339 | + | ||
| 1340 | +fn filter_ask_user_question_output(output: &str) -> String { | ||
| 1341 | + if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(output) { | ||
| 1342 | + if let Some(answers) = json_value.get_mut("answers") { | ||
| 1343 | + if let Some(answers_array) = answers.as_array_mut() { | ||
| 1344 | + for answer in answers_array { | ||
| 1345 | + if let Some(kind) = answer.get("kind") { | ||
| 1346 | + if kind.as_str() == Some("text") { | ||
| 1347 | + let display_value = answer | ||
| 1348 | + .get("display_value") | ||
| 1349 | + .and_then(|v| if v.is_null() { None } else { Some(v.clone()) }); | ||
| 1350 | + if let Some(display_val) = display_value { | ||
| 1351 | + if let Some(obj) = answer.as_object_mut() { | ||
| 1352 | + obj["value"] = display_val; | ||
| 1353 | + obj.remove("display_value"); | ||
| 1354 | + } | ||
| 1355 | + } | ||
| 1356 | + } | ||
| 1357 | + } | ||
| 1358 | + } | ||
| 1359 | + } | ||
| 1360 | + } | ||
| 1361 | + if let Ok(filtered_output) = serde_json::to_string(&json_value) { | ||
| 1362 | + return filtered_output; | ||
| 1363 | + } | ||
| 1364 | + } | ||
| 1365 | + output.to_string() | ||
| 1366 | +} | ||
| @@ -39,6 +39,7 @@ pub enum SseStreamEvent { | |||
| 39 | reply: String, | 39 | reply: String, |
| 40 | raw_reply: String, | 40 | raw_reply: String, |
| 41 | conversation_id: String, | 41 | conversation_id: String, |
| 42 | + | ||
| 42 | session_id: String, | 43 | session_id: String, |
| 43 | turn_count: u32, | 44 | turn_count: u32, |
| 44 | total_tokens: usize, | 45 | total_tokens: usize, |
| @@ -52,6 +53,7 @@ pub enum SseStreamEvent { | |||
| 52 | error: String, | 53 | error: String, |
| 53 | }, | 54 | }, |
| 54 | Cancelled { | 55 | Cancelled { |
| 56 | + | ||
| 55 | session_id: String, | 57 | session_id: String, |
| 56 | }, | 58 | }, |
| 57 | } | 59 | } |
| @@ -176,3 +178,19 @@ pub fn sse_stream_from_receiver( | |||
| 176 | Ok(sse::Event::default().event(name).data(data)) | 178 | Ok(sse::Event::default().event(name).data(data)) |
| 177 | }) | 179 | }) |
| 178 | } | 180 | } |
| 181 | + | ||
| 182 | + | ||
| 183 | +mod tests { | ||
| 184 | + use super::*; | ||
| 185 | + | ||
| 186 | + | ||
| 187 | + fn cancelled_event_serializes_runtime_id() { | ||
| 188 | + let value = serde_json::to_value(SseStreamEvent::Cancelled { | ||
| 189 | + session_id: "runtime-1".to_string(), | ||
| 190 | + }) | ||
| 191 | + .expect("event should serialize"); | ||
| 192 | + | ||
| 193 | + assert_eq!(value["runtime_id"], "runtime-1"); | ||
| 194 | + assert!(value.get("session_id").is_none()); | ||
| 195 | + } | ||
| 196 | +} | ||
| @@ -23,6 +23,8 @@ pub enum InteractionRequest { | |||
| 23 | TextInput { | 23 | TextInput { |
| 24 | prompt: String, | 24 | prompt: String, |
| 25 | source: Option<InteractionSource>, | 25 | source: Option<InteractionSource>, |
| 26 | + | ||
| 27 | + is_secret: bool, | ||
| 26 | }, | 28 | }, |
| 27 | Choice { | 29 | Choice { |
| 28 | prompt: String, | 30 | prompt: String, |
| @@ -35,7 +37,15 @@ pub enum InteractionRequest { | |||
| 35 | 37 | ||
| 36 | 38 | ||
| 37 | pub enum InteractionResponse { | 39 | pub enum InteractionResponse { |
| 38 | - Confirmed { allowed: bool }, | 40 | + Confirmed { |
| 39 | - Text { value: Option<String> }, | 41 | + allowed: bool, |
| 40 | - Choice { value: Option<String> }, | 42 | + }, |
| 43 | + Text { | ||
| 44 | + value: Option<String>, | ||
| 45 | + | ||
| 46 | + display_value: Option<String>, | ||
| 47 | + }, | ||
| 48 | + Choice { | ||
| 49 | + value: Option<String>, | ||
| 50 | + }, | ||
| 41 | } | 51 | } |
| @@ -12,6 +12,8 @@ pub struct Usage { | |||
| 12 | pub prompt_tokens: usize, | 12 | pub prompt_tokens: usize, |
| 13 | pub completion_tokens: usize, | 13 | pub completion_tokens: usize, |
| 14 | pub total_tokens: usize, | 14 | pub total_tokens: usize, |
| 15 | + | ||
| 16 | + pub cached_tokens: usize, | ||
| 15 | } | 17 | } |
| 16 | 18 | ||
| 17 | 19 | ||
| @@ -404,15 +404,16 @@ async fn update_turn_span_after_llm(ctx: &mut LoopContext<'_>) { | |||
| 404 | let Some(span) = ctx.turn.turn_span.as_ref() else { | 404 | let Some(span) = ctx.turn.turn_span.as_ref() else { |
| 405 | return; | 405 | return; |
| 406 | }; | 406 | }; |
| 407 | - let (prompt_tokens, completion_tokens, total_tokens, has_tool_calls) = | 407 | + let (prompt_tokens, completion_tokens, total_tokens, cached_tokens, has_tool_calls) = |
| 408 | match ctx.turn.assistant_message.as_ref() { | 408 | match ctx.turn.assistant_message.as_ref() { |
| 409 | Some(msg) => ( | 409 | Some(msg) => ( |
| 410 | msg.usage.prompt_tokens, | 410 | msg.usage.prompt_tokens, |
| 411 | msg.usage.completion_tokens, | 411 | msg.usage.completion_tokens, |
| 412 | msg.usage.total_tokens, | 412 | msg.usage.total_tokens, |
| 413 | + msg.usage.cached_tokens, | ||
| 413 | msg.has_tool_calls(), | 414 | msg.has_tool_calls(), |
| 414 | ), | 415 | ), |
| 415 | - None => (0, 0, 0, false), | 416 | + None => (0, 0, 0, 0, false), |
| 416 | }; | 417 | }; |
| 417 | runtime_view | 418 | runtime_view |
| 418 | .trace_recorder() | 419 | .trace_recorder() |
| @@ -422,6 +423,7 @@ async fn update_turn_span_after_llm(ctx: &mut LoopContext<'_>) { | |||
| 422 | "prompt_tokens": prompt_tokens, | 423 | "prompt_tokens": prompt_tokens, |
| 423 | "completion_tokens": completion_tokens, | 424 | "completion_tokens": completion_tokens, |
| 424 | "total_tokens": total_tokens, | 425 | "total_tokens": total_tokens, |
| 426 | + "cached_tokens": cached_tokens, | ||
| 425 | "has_tool_calls": has_tool_calls, | 427 | "has_tool_calls": has_tool_calls, |
| 426 | }), | 428 | }), |
| 427 | ) | 429 | ) |
| @@ -556,7 +558,7 @@ async fn compress( | |||
| 556 | .map(|id| id.0.clone()) | 558 | .map(|id| id.0.clone()) |
| 557 | .unwrap_or_default(); | 559 | .unwrap_or_default(); |
| 558 | 560 | ||
| 559 | - // begin span — 记录开始时的基础元数据 | 561 | + // begin span — record baseline metadata at start |
| 560 | let compression_span = if let Some(rv) = ctx.input.runtime_view.clone() { | 562 | let compression_span = if let Some(rv) = ctx.input.runtime_view.clone() { |
| 561 | Some( | 563 | Some( |
| 562 | rv.trace_recorder() | 564 | rv.trace_recorder() |
| @@ -592,7 +594,7 @@ async fn compress( | |||
| 592 | "compression analysis" | 594 | "compression analysis" |
| 593 | ); | 595 | ); |
| 594 | 596 | ||
| 595 | - // update span — 记录分析结果 | 597 | + // update span — record analysis results |
| 596 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span.as_ref()) { | 598 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span.as_ref()) { |
| 597 | rv.trace_recorder() | 599 | rv.trace_recorder() |
| 598 | .update_span( | 600 | .update_span( |
| @@ -610,7 +612,7 @@ async fn compress( | |||
| 610 | } | 612 | } |
| 611 | 613 | ||
| 612 | if !trigger.is_forced() && !analysis.needs_compression() { | 614 | if !trigger.is_forced() && !analysis.needs_compression() { |
| 613 | - // end span — 无需压缩,正常结束 | 615 | + // end span — no compression needed, normal end |
| 614 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { | 616 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { |
| 615 | rv.trace_recorder() | 617 | rv.trace_recorder() |
| 616 | .end_span(span, TraceOutcome::Ok, json!({ "skipped": true })) | 618 | .end_span(span, TraceOutcome::Ok, json!({ "skipped": true })) |
| @@ -648,7 +650,7 @@ async fn compress( | |||
| 648 | "context compression triggered" | 650 | "context compression triggered" |
| 649 | ); | 651 | ); |
| 650 | 652 | ||
| 651 | - // end span — 压缩成功,记录输出信息 | 653 | + // end span — compression succeeded, record output info |
| 652 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { | 654 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { |
| 653 | rv.trace_recorder() | 655 | rv.trace_recorder() |
| 654 | .end_span( | 656 | .end_span( |
| @@ -674,7 +676,7 @@ async fn compress( | |||
| 674 | Ok(()) | 676 | Ok(()) |
| 675 | } | 677 | } |
| 676 | Err(e) => { | 678 | Err(e) => { |
| 677 | - // end span — 压缩失败,记录错误信息 | 679 | + // end span — compression failed, record error info |
| 678 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { | 680 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), compression_span) { |
| 679 | rv.trace_recorder() | 681 | rv.trace_recorder() |
| 680 | .end_span(span, TraceOutcome::Error, json!({ "error": e.to_string() })) | 682 | .end_span(span, TraceOutcome::Error, json!({ "error": e.to_string() })) |
| @@ -715,7 +717,7 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> { | |||
| 715 | .map(|id| id.0.clone()) | 717 | .map(|id| id.0.clone()) |
| 716 | .unwrap_or_default(); | 718 | .unwrap_or_default(); |
| 717 | 719 | ||
| 718 | - // begin span — 记录开始时的基础元数据 | 720 | + // begin span — record baseline metadata at start |
| 719 | let prompt_build_span = if let Some(rv) = ctx.input.runtime_view.clone() { | 721 | let prompt_build_span = if let Some(rv) = ctx.input.runtime_view.clone() { |
| 720 | Some( | 722 | Some( |
| 721 | rv.trace_recorder() | 723 | rv.trace_recorder() |
| @@ -751,7 +753,7 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> { | |||
| 751 | budget: ctx.snapshot.token_budget_config.clone(), | 753 | budget: ctx.snapshot.token_budget_config.clone(), |
| 752 | }; | 754 | }; |
| 753 | 755 | ||
| 754 | - // update span — 记录构建完成的 input 维度信息 | 756 | + // update span — record input dimension info after build completion |
| 755 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span.as_ref()) { | 757 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span.as_ref()) { |
| 756 | rv.trace_recorder() | 758 | rv.trace_recorder() |
| 757 | .update_span( | 759 | .update_span( |
| @@ -776,7 +778,7 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> { | |||
| 776 | match result { | 778 | match result { |
| 777 | Ok(mut result) => { | 779 | Ok(mut result) => { |
| 778 | result.request.reasoning_effort = ctx.input.reasoning_effort; | 780 | result.request.reasoning_effort = ctx.input.reasoning_effort; |
| 779 | - // end span — 成功,记录估算 token 数等输出信息 | 781 | + // end span — success, record estimated token count and other output info |
| 780 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span) { | 782 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span) { |
| 781 | rv.trace_recorder() | 783 | rv.trace_recorder() |
| 782 | .end_span( | 784 | .end_span( |
| @@ -794,7 +796,7 @@ async fn build_messages(ctx: &mut LoopContext<'_>) -> Result<(), AgentError> { | |||
| 794 | Ok(()) | 796 | Ok(()) |
| 795 | } | 797 | } |
| 796 | Err(e) => { | 798 | Err(e) => { |
| 797 | - // end span — 失败,记录错误信息 | 799 | + // end span — failure, record error info |
| 798 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span) { | 800 | if let (Some(rv), Some(span)) = (ctx.input.runtime_view.clone(), prompt_build_span) { |
| 799 | rv.trace_recorder() | 801 | rv.trace_recorder() |
| 800 | .end_span( | 802 | .end_span( |
| @@ -828,6 +830,11 @@ async fn llm_call(ctx: &mut LoopContext<'_>) -> Result<(), LlmError> { | |||
| 828 | let event_sink = ctx.input.event_sink.clone(); | 830 | let event_sink = ctx.input.event_sink.clone(); |
| 829 | let streamed_text = Mutex::new(String::new()); | 831 | let streamed_text = Mutex::new(String::new()); |
| 830 | let streamed_reasoning = Mutex::new(String::new()); | 832 | let streamed_reasoning = Mutex::new(String::new()); |
| 833 | + | ||
| 834 | + // Extract secrets from message history to filter in assistant messages | ||
| 835 | + let messages = ctx.state.messages.read().clone(); | ||
| 836 | + let secrets = extract_secrets_from_messages(&messages); | ||
| 837 | + | ||
| 831 | let response = if std::env::var("XIAOO_NON_STREAMING").is_ok() { | 838 | let response = if std::env::var("XIAOO_NON_STREAMING").is_ok() { |
| 832 | ctx.snapshot | 839 | ctx.snapshot |
| 833 | .llm_provider | 840 | .llm_provider |
| @@ -857,6 +864,7 @@ async fn llm_call(ctx: &mut LoopContext<'_>) -> Result<(), LlmError> { | |||
| 857 | &streamed_text, | 864 | &streamed_text, |
| 858 | &streamed_reasoning, | 865 | &streamed_reasoning, |
| 859 | chunk, | 866 | chunk, |
| 867 | + &secrets, | ||
| 860 | ); | 868 | ); |
| 861 | }) | 869 | }) |
| 862 | .await? | 870 | .await? |
| @@ -891,7 +899,8 @@ async fn llm_call(ctx: &mut LoopContext<'_>) -> Result<(), LlmError> { | |||
| 891 | if streamed_text != *text { | 899 | if streamed_text != *text { |
| 892 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); | 900 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); |
| 893 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); | 901 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); |
| 894 | - sink.on_assistant_message(agent_id, text); | 902 | + let filtered_text = filter_secrets_in_text(text, &secrets); |
| 903 | + sink.on_assistant_message(agent_id, &filtered_text); | ||
| 895 | } | 904 | } |
| 896 | } | 905 | } |
| 897 | } | 906 | } |
| @@ -1071,6 +1080,7 @@ fn stream_assistant_chunk( | |||
| 1071 | streamed_text: &Mutex<String>, | 1080 | streamed_text: &Mutex<String>, |
| 1072 | streamed_reasoning: &Mutex<String>, | 1081 | streamed_reasoning: &Mutex<String>, |
| 1073 | chunk: StreamChunk, | 1082 | chunk: StreamChunk, |
| 1083 | + secrets: &[String], | ||
| 1074 | ) { | 1084 | ) { |
| 1075 | if let Some(delta_reasoning) = chunk.delta_reasoning { | 1085 | if let Some(delta_reasoning) = chunk.delta_reasoning { |
| 1076 | let snapshot = { | 1086 | let snapshot = { |
| @@ -1081,7 +1091,8 @@ fn stream_assistant_chunk( | |||
| 1081 | full_reasoning.clone() | 1091 | full_reasoning.clone() |
| 1082 | }; | 1092 | }; |
| 1083 | if let Some(sink) = sink { | 1093 | if let Some(sink) = sink { |
| 1084 | - sink.on_assistant_reasoning(agent_id, &snapshot); | 1094 | + let filtered_reasoning = filter_secrets_in_text(&snapshot, secrets); |
| 1095 | + sink.on_assistant_reasoning(agent_id, &filtered_reasoning); | ||
| 1085 | } | 1096 | } |
| 1086 | } | 1097 | } |
| 1087 | 1098 | ||
| @@ -1095,7 +1106,8 @@ fn stream_assistant_chunk( | |||
| 1095 | }; | 1106 | }; |
| 1096 | 1107 | ||
| 1097 | if let Some(sink) = sink { | 1108 | if let Some(sink) = sink { |
| 1098 | - sink.on_assistant_message(agent_id, &snapshot); | 1109 | + let filtered_text = filter_secrets_in_text(&snapshot, secrets); |
| 1110 | + sink.on_assistant_message(agent_id, &filtered_text); | ||
| 1099 | } | 1111 | } |
| 1100 | } | 1112 | } |
| 1101 | } | 1113 | } |
| @@ -1216,6 +1228,17 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall | |||
| 1216 | if let Some(ref sink) = ctx.input.event_sink { | 1228 | if let Some(ref sink) = ctx.input.event_sink { |
| 1217 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); | 1229 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); |
| 1218 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); | 1230 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); |
| 1231 | + | ||
| 1232 | + let messages = ctx.state.messages.read().clone(); | ||
| 1233 | + let secrets = extract_secrets_from_messages(&messages); | ||
| 1234 | + let args_preview = | ||
| 1235 | + serde_json::to_string_pretty(&inv.input).unwrap_or_else(|_| inv.input.to_string()); | ||
| 1236 | + let filtered_args_preview = if inv.tool_name == "bash" { | ||
| 1237 | + filter_bash_args_preview(&args_preview, &secrets) | ||
| 1238 | + } else { | ||
| 1239 | + args_preview | ||
| 1240 | + }; | ||
| 1241 | + | ||
| 1219 | sink.on_tool_result( | 1242 | sink.on_tool_result( |
| 1220 | agent_id, | 1243 | agent_id, |
| 1221 | &ToolResultEvent { | 1244 | &ToolResultEvent { |
| @@ -1223,8 +1246,7 @@ async fn tool_exec(ctx: &mut LoopContext<'_>) -> Result<Option<SuspendedToolCall | |||
| 1223 | tool_name: inv.tool_name.clone(), | 1246 | tool_name: inv.tool_name.clone(), |
| 1224 | output_preview: invalid_tool_call_message(inv), | 1247 | output_preview: invalid_tool_call_message(inv), |
| 1225 | is_error: true, | 1248 | is_error: true, |
| 1226 | - args_preview: serde_json::to_string_pretty(&inv.input) | 1249 | + args_preview: filtered_args_preview, |
| 1227 | - .unwrap_or_else(|_| inv.input.to_string()), | ||
| 1228 | }, | 1250 | }, |
| 1229 | ); | 1251 | ); |
| 1230 | } | 1252 | } |
| @@ -1374,7 +1396,15 @@ fn emit_tool_result_event(ctx: &LoopContext<'_>, result: &ToolExecutionResult) { | |||
| 1374 | let (output_preview, is_error) = match result { | 1396 | let (output_preview, is_error) = match result { |
| 1375 | ToolExecutionResult::Completed { raw_outcome, .. } => { | 1397 | ToolExecutionResult::Completed { raw_outcome, .. } => { |
| 1376 | let preview = match raw_outcome { | 1398 | let preview = match raw_outcome { |
| 1377 | - RawToolOutcome::Success { output } => output.chars().take(200).collect(), | 1399 | + RawToolOutcome::Success { output } => { |
| 1400 | + // Filter password for ask_user_question tool | ||
| 1401 | + let tool_name = result.tool_name(); | ||
| 1402 | + if tool_name == "ask_user_question" { | ||
| 1403 | + filter_ask_user_question_output(output) | ||
| 1404 | + } else { | ||
| 1405 | + output.chars().take(200).collect() | ||
| 1406 | + } | ||
| 1407 | + } | ||
| 1378 | RawToolOutcome::Error { message } => message.chars().take(200).collect(), | 1408 | RawToolOutcome::Error { message } => message.chars().take(200).collect(), |
| 1379 | }; | 1409 | }; |
| 1380 | (preview, false) | 1410 | (preview, false) |
| @@ -1395,6 +1425,18 @@ fn emit_tool_result_event(ctx: &LoopContext<'_>, result: &ToolExecutionResult) { | |||
| 1395 | if should_emit { | 1425 | if should_emit { |
| 1396 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); | 1426 | let default_agent_id = agent_types::common::ids::AgentId(String::from("anonymous")); |
| 1397 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); | 1427 | let agent_id = ctx.input.agent_id.as_ref().unwrap_or(&default_agent_id); |
| 1428 | + | ||
| 1429 | + // Extract secrets and filter args_preview for bash commands | ||
| 1430 | + let messages = ctx.state.messages.read().clone(); | ||
| 1431 | + let secrets = extract_secrets_from_messages(&messages); | ||
| 1432 | + let args_preview = serde_json::to_string_pretty(&result.final_call().input) | ||
| 1433 | + .unwrap_or_else(|_| result.final_call().input.to_string()); | ||
| 1434 | + let filtered_args_preview = if result.tool_name() == "bash" { | ||
| 1435 | + filter_bash_args_preview(&args_preview, &secrets) | ||
| 1436 | + } else { | ||
| 1437 | + args_preview | ||
| 1438 | + }; | ||
| 1439 | + | ||
| 1398 | sink.on_tool_result( | 1440 | sink.on_tool_result( |
| 1399 | agent_id, | 1441 | agent_id, |
| 1400 | &ToolResultEvent { | 1442 | &ToolResultEvent { |
| @@ -1402,13 +1444,121 @@ fn emit_tool_result_event(ctx: &LoopContext<'_>, result: &ToolExecutionResult) { | |||
| 1402 | tool_name: result.tool_name().to_string(), | 1444 | tool_name: result.tool_name().to_string(), |
| 1403 | output_preview, | 1445 | output_preview, |
| 1404 | is_error, | 1446 | is_error, |
| 1405 | - args_preview: serde_json::to_string_pretty(&result.final_call().input) | 1447 | + args_preview: filtered_args_preview, |
| 1406 | - .unwrap_or_else(|_| result.final_call().input.to_string()), | ||
| 1407 | }, | 1448 | }, |
| 1408 | ); | 1449 | ); |
| 1409 | } | 1450 | } |
| 1410 | } | 1451 | } |
| 1411 | 1452 | ||
| 1453 | +/// Filter password in ask_user_question output for display | ||
| 1454 | +fn filter_ask_user_question_output(output: &str) -> String { | ||
| 1455 | + // Try to parse as AskUserQuestionOutput and filter display_value | ||
| 1456 | + if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(output) { | ||
| 1457 | + if let Some(answers) = json_value.get_mut("answers") { | ||
| 1458 | + if let Some(answers_array) = answers.as_array_mut() { | ||
| 1459 | + for answer in answers_array { | ||
| 1460 | + // For Text type answers with display_value, use display_value instead of value | ||
| 1461 | + if let Some(kind) = answer.get("kind") { | ||
| 1462 | + if kind.as_str() == Some("text") { | ||
| 1463 | + // Get display_value first | ||
| 1464 | + let display_value = answer.get("display_value").and_then(|v| { | ||
| 1465 | + if v.is_null() { | ||
| 1466 | + None | ||
| 1467 | + } else { | ||
| 1468 | + Some(v.clone()) | ||
| 1469 | + } | ||
| 1470 | + }); | ||
| 1471 | + | ||
| 1472 | + // If display_value exists, replace value | ||
| 1473 | + if let Some(display_val) = display_value { | ||
| 1474 | + if let Some(obj) = answer.as_object_mut() { | ||
| 1475 | + obj["value"] = display_val; | ||
| 1476 | + obj.remove("display_value"); | ||
| 1477 | + } | ||
| 1478 | + } | ||
| 1479 | + } | ||
| 1480 | + } | ||
| 1481 | + } | ||
| 1482 | + } | ||
| 1483 | + } | ||
| 1484 | + // Serialize back and take first 200 chars | ||
| 1485 | + if let Ok(filtered_output) = serde_json::to_string(&json_value) { | ||
| 1486 | + return filtered_output.chars().take(200).collect(); | ||
| 1487 | + } | ||
| 1488 | + } | ||
| 1489 | + // Fallback: original output (first 200 chars) | ||
| 1490 | + output.chars().take(200).collect() | ||
| 1491 | +} | ||
| 1492 | + | ||
| 1493 | +/// Extract secret values from message history for filtering in assistant messages | ||
| 1494 | +fn extract_secrets_from_messages(messages: &[ChatMessage]) -> Vec<String> { | ||
| 1495 | + use agent_types::llm::ContentBlock; | ||
| 1496 | + | ||
| 1497 | + messages | ||
| 1498 | + .iter() | ||
| 1499 | + .filter(|m| m.role == MessageRole::Tool) | ||
| 1500 | + .flat_map(|m| m.blocks.iter()) | ||
| 1501 | + .filter_map(|block| match block { | ||
| 1502 | + ContentBlock::ToolResult { | ||
| 1503 | + tool_name, output, .. | ||
| 1504 | + } => { | ||
| 1505 | + if tool_name == "ask_user_question" { | ||
| 1506 | + Some(output) | ||
| 1507 | + } else { | ||
| 1508 | + None | ||
| 1509 | + } | ||
| 1510 | + } | ||
| 1511 | + _ => None, | ||
| 1512 | + }) | ||
| 1513 | + .filter_map(|output| serde_json::from_str::<serde_json::Value>(output).ok()) | ||
| 1514 | + .filter_map(|json| json.get("answers").and_then(|a| a.as_array()).cloned()) | ||
| 1515 | + .flatten() | ||
| 1516 | + .filter_map(|answer| { | ||
| 1517 | + let is_text = answer.get("kind").and_then(|k| k.as_str()) == Some("text"); | ||
| 1518 | + let value = answer.get("value").and_then(|v| v.as_str()); | ||
| 1519 | + // Only extract as secret if has display_value field (is_secret=true was used) | ||
| 1520 | + let has_display_value = answer | ||
| 1521 | + .get("display_value") | ||
| 1522 | + .map(|v| !v.is_null()) | ||
| 1523 | + .unwrap_or(false); | ||
| 1524 | + | ||
| 1525 | + if is_text && has_display_value && value.map(|v| !v.is_empty()).unwrap_or(false) { | ||
| 1526 | + value.map(|v| v.to_string()) | ||
| 1527 | + } else { | ||
| 1528 | + None | ||
| 1529 | + } | ||
| 1530 | + }) | ||
| 1531 | + .collect() | ||
| 1532 | +} | ||
| 1533 | + | ||
| 1534 | +/// Filter secrets (passwords) in text by replacing them with <SECRET> | ||
| 1535 | +fn filter_secrets_in_text(text: &str, secrets: &[String]) -> String { | ||
| 1536 | + let mut filtered = text.to_string(); | ||
| 1537 | + for secret in secrets { | ||
| 1538 | + if filtered.contains(secret) { | ||
| 1539 | + filtered = filtered.replace(secret, "<SECRET>"); | ||
| 1540 | + } | ||
| 1541 | + } | ||
| 1542 | + filtered | ||
| 1543 | +} | ||
| 1544 | + | ||
| 1545 | +/// Filter password in bash args_preview (command field) | ||
| 1546 | +fn filter_bash_args_preview(args_preview: &str, secrets: &[String]) -> String { | ||
| 1547 | + if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(args_preview) { | ||
| 1548 | + if let Some(command) = json_value.get("command").and_then(|c| c.as_str()) { | ||
| 1549 | + let filtered_command = filter_secrets_in_text(command, secrets); | ||
| 1550 | + if let Some(obj) = json_value.as_object_mut() { | ||
| 1551 | + obj["command"] = serde_json::Value::String(filtered_command); | ||
| 1552 | + } | ||
| 1553 | + } | ||
| 1554 | + if let Ok(filtered) = serde_json::to_string_pretty(&json_value) { | ||
| 1555 | + return filtered; | ||
| 1556 | + } | ||
| 1557 | + } | ||
| 1558 | + // Fallback: filter entire string | ||
| 1559 | + filter_secrets_in_text(args_preview, secrets) | ||
| 1560 | +} | ||
| 1561 | + | ||
| 1412 | fn decide(ctx: &mut LoopContext<'_>) { | 1562 | fn decide(ctx: &mut LoopContext<'_>) { |
| 1413 | if ctx.state.cancel.is_cancelled() { | 1563 | if ctx.state.cancel.is_cancelled() { |
| 1414 | ctx.turn.decision = Some(LoopDecision::ReturnCancelled); | 1564 | ctx.turn.decision = Some(LoopDecision::ReturnCancelled); |
| @@ -1943,6 +2093,7 @@ mod tests { | |||
| 1943 | prompt_tokens: 3, | 2093 | prompt_tokens: 3, |
| 1944 | completion_tokens: 2, | 2094 | completion_tokens: 2, |
| 1945 | total_tokens: 5, | 2095 | total_tokens: 5, |
| 2096 | + cached_tokens: 0, | ||
| 1946 | }, | 2097 | }, |
| 1947 | stop_reason: StopReason::EndTurn, | 2098 | stop_reason: StopReason::EndTurn, |
| 1948 | }, | 2099 | }, |
| @@ -2002,6 +2153,7 @@ mod tests { | |||
| 2002 | prompt_tokens: 3, | 2153 | prompt_tokens: 3, |
| 2003 | completion_tokens: 2, | 2154 | completion_tokens: 2, |
| 2004 | total_tokens: 5, | 2155 | total_tokens: 5, |
| 2156 | + cached_tokens: 0, | ||
| 2005 | }, | 2157 | }, |
| 2006 | ) | 2158 | ) |
| 2007 | } else { | 2159 | } else { |
| @@ -2011,6 +2163,7 @@ mod tests { | |||
| 2011 | prompt_tokens: 7, | 2163 | prompt_tokens: 7, |
| 2012 | completion_tokens: 1, | 2164 | completion_tokens: 1, |
| 2013 | total_tokens: 8, | 2165 | total_tokens: 8, |
| 2166 | + cached_tokens: 0, | ||
| 2014 | }, | 2167 | }, |
| 2015 | ) | 2168 | ) |
| 2016 | }; | 2169 | }; |
| @@ -2426,6 +2579,7 @@ mod tests { | |||
| 2426 | prompt_tokens: 10, | 2579 | prompt_tokens: 10, |
| 2427 | completion_tokens: 2, | 2580 | completion_tokens: 2, |
| 2428 | total_tokens: 12, | 2581 | total_tokens: 12, |
| 2582 | + cached_tokens: 0, | ||
| 2429 | }, | 2583 | }, |
| 2430 | stop_reason: StopReason::EndTurn, | 2584 | stop_reason: StopReason::EndTurn, |
| 2431 | }, | 2585 | }, |
| @@ -2446,6 +2600,7 @@ mod tests { | |||
| 2446 | prompt_tokens: 10, | 2600 | prompt_tokens: 10, |
| 2447 | completion_tokens: 5, | 2601 | completion_tokens: 5, |
| 2448 | total_tokens: 15, | 2602 | total_tokens: 15, |
| 2603 | + cached_tokens: 0, | ||
| 2449 | }, | 2604 | }, |
| 2450 | stop_reason: StopReason::ToolUse, | 2605 | stop_reason: StopReason::ToolUse, |
| 2451 | }, | 2606 | }, |
| @@ -2510,4 +2665,172 @@ mod tests { | |||
| 2510 | "synthesized tool_use must pair with a tool_result on the same id" | 2665 | "synthesized tool_use must pair with a tool_result on the same id" |
| 2511 | ); | 2666 | ); |
| 2512 | } | 2667 | } |
| 2668 | + | ||
| 2669 | + | ||
| 2670 | + fn test_filter_ask_user_question_output() { | ||
| 2671 | + // Test with display_value | ||
| 2672 | + let input_with_display = json!({ | ||
| 2673 | + "answers": [{ | ||
| 2674 | + "kind": "text", | ||
| 2675 | + "prompt": "Enter password", | ||
| 2676 | + "value": "real_password_123", | ||
| 2677 | + "display_value": "<SECRET>" | ||
| 2678 | + }] | ||
| 2679 | + }) | ||
| 2680 | + .to_string(); | ||
| 2681 | + | ||
| 2682 | + let filtered = filter_ask_user_question_output(&input_with_display); | ||
| 2683 | + | ||
| 2684 | + // Should replace value with display_value | ||
| 2685 | + let filtered_json: serde_json::Value = serde_json::from_str(&filtered).unwrap(); | ||
| 2686 | + assert_eq!(filtered_json["answers"][0]["value"], "<SECRET>"); | ||
| 2687 | + assert!(filtered_json["answers"][0].get("display_value").is_none()); | ||
| 2688 | + | ||
| 2689 | + // Test without display_value | ||
| 2690 | + let input_without_display = json!({ | ||
| 2691 | + "answers": [{ | ||
| 2692 | + "kind": "text", | ||
| 2693 | + "prompt": "Enter name", | ||
| 2694 | + "value": "John" | ||
| 2695 | + }] | ||
| 2696 | + }) | ||
| 2697 | + .to_string(); | ||
| 2698 | + | ||
| 2699 | + let filtered2 = filter_ask_user_question_output(&input_without_display); | ||
| 2700 | + let filtered2_json: serde_json::Value = serde_json::from_str(&filtered2).unwrap(); | ||
| 2701 | + assert_eq!(filtered2_json["answers"][0]["value"], "John"); | ||
| 2702 | + | ||
| 2703 | + // Test with non-text type | ||
| 2704 | + let input_choice = json!({ | ||
| 2705 | + "answers": [{ | ||
| 2706 | + "kind": "choice", | ||
| 2707 | + "prompt": "Select option", | ||
| 2708 | + "value": "option1" | ||
| 2709 | + }] | ||
| 2710 | + }) | ||
| 2711 | + .to_string(); | ||
| 2712 | + | ||
| 2713 | + let filtered3 = filter_ask_user_question_output(&input_choice); | ||
| 2714 | + let filtered3_json: serde_json::Value = serde_json::from_str(&filtered3).unwrap(); | ||
| 2715 | + assert_eq!(filtered3_json["answers"][0]["value"], "option1"); | ||
| 2716 | + | ||
| 2717 | + // Test with invalid JSON | ||
| 2718 | + let invalid = "not a json"; | ||
| 2719 | + let filtered4 = filter_ask_user_question_output(invalid); | ||
| 2720 | + assert_eq!(filtered4, "not a json"); | ||
| 2721 | + } | ||
| 2722 | + | ||
| 2723 | + | ||
| 2724 | + fn test_extract_secrets_from_messages() { | ||
| 2725 | + use agent_types::llm::ContentBlock; | ||
| 2726 | + | ||
| 2727 | + // Test 1: Only extract secrets with display_value (is_secret=true) | ||
| 2728 | + let message_with_secret = ChatMessage { | ||
| 2729 | + role: MessageRole::Tool, | ||
| 2730 | + blocks: vec![ContentBlock::ToolResult { | ||
| 2731 | + call_id: "call_1".to_string(), | ||
| 2732 | + tool_name: "ask_user_question".to_string(), | ||
| 2733 | + output: json!({ | ||
| 2734 | + "answers": [{ | ||
| 2735 | + "kind": "text", | ||
| 2736 | + "prompt": "Password", | ||
| 2737 | + "value": "secret123", | ||
| 2738 | + "display_value": "<SECRET>" | ||
| 2739 | + }] | ||
| 2740 | + }) | ||
| 2741 | + .to_string(), | ||
| 2742 | + is_error: false, | ||
| 2743 | + }], | ||
| 2744 | + message_id: None, | ||
| 2745 | + timestamp_ms: 0, | ||
| 2746 | + api_usage_tokens: None, | ||
| 2747 | + reasoning_content: None, | ||
| 2748 | + estimated_tokens: None, | ||
| 2749 | + }; | ||
| 2750 | + | ||
| 2751 | + let message_with_normal_text = ChatMessage { | ||
| 2752 | + role: MessageRole::Tool, | ||
| 2753 | + blocks: vec![ContentBlock::ToolResult { | ||
| 2754 | + call_id: "call_2".to_string(), | ||
| 2755 | + tool_name: "ask_user_question".to_string(), | ||
| 2756 | + output: json!({ | ||
| 2757 | + "answers": [{ | ||
| 2758 | + "kind": "text", | ||
| 2759 | + "prompt": "Username", | ||
| 2760 | + "value": "john" | ||
| 2761 | + }] | ||
| 2762 | + }) | ||
| 2763 | + .to_string(), | ||
| 2764 | + is_error: false, | ||
| 2765 | + }], | ||
| 2766 | + message_id: None, | ||
| 2767 | + timestamp_ms: 0, | ||
| 2768 | + api_usage_tokens: None, | ||
| 2769 | + reasoning_content: None, | ||
| 2770 | + estimated_tokens: None, | ||
| 2771 | + }; | ||
| 2772 | + | ||
| 2773 | + let messages = vec![message_with_secret, message_with_normal_text]; | ||
| 2774 | + let secrets = extract_secrets_from_messages(&messages); | ||
| 2775 | + | ||
| 2776 | + // Should only extract the secret with display_value | ||
| 2777 | + assert_eq!(secrets.len(), 1); | ||
| 2778 | + assert_eq!(secrets[0], "secret123"); | ||
| 2779 | + assert!(!secrets.contains(&"john".to_string())); | ||
| 2780 | + | ||
| 2781 | + // Test 2: Multiple secrets in one answer | ||
| 2782 | + let message_multiple = ChatMessage { | ||
| 2783 | + role: MessageRole::Tool, | ||
| 2784 | + blocks: vec![ContentBlock::ToolResult { | ||
| 2785 | + call_id: "call_3".to_string(), | ||
| 2786 | + tool_name: "ask_user_question".to_string(), | ||
| 2787 | + output: json!({ | ||
| 2788 | + "answers": [ | ||
| 2789 | + { | ||
| 2790 | + "kind": "text", | ||
| 2791 | + "prompt": "Username", | ||
| 2792 | + "value": "admin" | ||
| 2793 | + }, | ||
| 2794 | + { | ||
| 2795 | + "kind": "text", | ||
| 2796 | + "prompt": "Password", | ||
| 2797 | + "value": "pass123", | ||
| 2798 | + "display_value": "<SECRET>" | ||
| 2799 | + } | ||
| 2800 | + ] | ||
| 2801 | + }) | ||
| 2802 | + .to_string(), | ||
| 2803 | + is_error: false, | ||
| 2804 | + }], | ||
| 2805 | + message_id: None, | ||
| 2806 | + timestamp_ms: 0, | ||
| 2807 | + api_usage_tokens: None, | ||
| 2808 | + reasoning_content: None, | ||
| 2809 | + estimated_tokens: None, | ||
| 2810 | + }; | ||
| 2811 | + | ||
| 2812 | + let secrets2 = extract_secrets_from_messages(&vec![message_multiple]); | ||
| 2813 | + assert_eq!(secrets2.len(), 1); | ||
| 2814 | + assert_eq!(secrets2[0], "pass123"); | ||
| 2815 | + assert!(!secrets2.contains(&"admin".to_string())); | ||
| 2816 | + | ||
| 2817 | + // Test 3: Non-ask_user_question tool results should not be extracted | ||
| 2818 | + let message_other_tool = ChatMessage { | ||
| 2819 | + role: MessageRole::Tool, | ||
| 2820 | + blocks: vec![ContentBlock::ToolResult { | ||
| 2821 | + call_id: "call_4".to_string(), | ||
| 2822 | + tool_name: "bash".to_string(), | ||
| 2823 | + output: "some output with password123".to_string(), | ||
| 2824 | + is_error: false, | ||
| 2825 | + }], | ||
| 2826 | + message_id: None, | ||
| 2827 | + timestamp_ms: 0, | ||
| 2828 | + api_usage_tokens: None, | ||
| 2829 | + reasoning_content: None, | ||
| 2830 | + estimated_tokens: None, | ||
| 2831 | + }; | ||
| 2832 | + | ||
| 2833 | + let secrets3 = extract_secrets_from_messages(&vec![message_other_tool]); | ||
| 2834 | + assert_eq!(secrets3.len(), 0); | ||
| 2835 | + } | ||
| 2513 | } | 2836 | } |
| @@ -48,7 +48,10 @@ impl InteractionHandle for NoopInteractionHandle { | |||
| 48 | async fn ask(&self, request: &InteractionRequest) -> InteractionResponse { | 48 | async fn ask(&self, request: &InteractionRequest) -> InteractionResponse { |
| 49 | match request { | 49 | match request { |
| 50 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, | 50 | InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false }, |
| 51 | - InteractionRequest::TextInput { .. } => InteractionResponse::Text { value: None }, | 51 | + InteractionRequest::TextInput { .. } => InteractionResponse::Text { |
| 52 | + value: None, | ||
| 53 | + display_value: None, | ||
| 54 | + }, | ||
| 52 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, | 55 | InteractionRequest::Choice { .. } => InteractionResponse::Choice { value: None }, |
| 53 | } | 56 | } |
| 54 | } | 57 | } |
| @@ -248,6 +248,7 @@ impl PluginLlmHookerAdaptor { | |||
| 248 | "prompt_tokens": response.message.usage.prompt_tokens, | 248 | "prompt_tokens": response.message.usage.prompt_tokens, |
| 249 | "completion_tokens": response.message.usage.completion_tokens, | 249 | "completion_tokens": response.message.usage.completion_tokens, |
| 250 | "total_tokens": response.message.usage.total_tokens, | 250 | "total_tokens": response.message.usage.total_tokens, |
| 251 | + "cached_tokens": response.message.usage.cached_tokens, | ||
| 251 | }, | 252 | }, |
| 252 | "stop_reason": match &response.message.stop_reason { | 253 | "stop_reason": match &response.message.stop_reason { |
| 253 | StopReason::EndTurn => "end_turn", | 254 | StopReason::EndTurn => "end_turn", |
| @@ -434,7 +435,11 @@ impl PluginLlmHookerAdaptor { | |||
| 434 | InteractionRequest::Confirm { prompt, source } | 435 | InteractionRequest::Confirm { prompt, source } |
| 435 | } | 436 | } |
| 436 | PluginAskUserRequest::TextInput { prompt, source: _ } => { | 437 | PluginAskUserRequest::TextInput { prompt, source: _ } => { |
| 437 | - InteractionRequest::TextInput { prompt, source } | 438 | + InteractionRequest::TextInput { |
| 439 | + prompt, | ||
| 440 | + source, | ||
| 441 | + is_secret: false, // Default to false for plugin requests | ||
| 442 | + } | ||
| 438 | } | 443 | } |
| 439 | PluginAskUserRequest::Choice { | 444 | PluginAskUserRequest::Choice { |
| 440 | prompt, | 445 | prompt, |
| @@ -652,6 +657,10 @@ impl PluginLlmHookerAdaptor { | |||
| 652 | .get("total_tokens") | 657 | .get("total_tokens") |
| 653 | .and_then(Value::as_u64) | 658 | .and_then(Value::as_u64) |
| 654 | .unwrap_or(0) as usize, | 659 | .unwrap_or(0) as usize, |
| 660 | + cached_tokens: usage_value | ||
| 661 | + .get("cached_tokens") | ||
| 662 | + .and_then(Value::as_u64) | ||
| 663 | + .unwrap_or(0) as usize, | ||
| 655 | }) | 664 | }) |
| 656 | } | 665 | } |
| 657 | 666 | ||
| @@ -176,7 +176,7 @@ impl PluginToolHookerAdaptor { | |||
| 176 | metadata: &HookInvokeMetadata, | 176 | metadata: &HookInvokeMetadata, |
| 177 | runtime: &dyn RuntimeView, | 177 | runtime: &dyn RuntimeView, |
| 178 | ) -> Result<Value, ToolExecutionError> { | 178 | ) -> Result<Value, ToolExecutionError> { |
| 179 | - // 获取 session_id(用于缓存 key) | 179 | + // Get session_id (for cache key) |
| 180 | let session_id = runtime | 180 | let session_id = runtime |
| 181 | .agent_context() | 181 | .agent_context() |
| 182 | .metadata() | 182 | .metadata() |
| @@ -184,10 +184,10 @@ impl PluginToolHookerAdaptor { | |||
| 184 | .clone() | 184 | .clone() |
| 185 | .unwrap_or_else(|| input.call.call_id.clone()); | 185 | .unwrap_or_else(|| input.call.call_id.clone()); |
| 186 | 186 | ||
| 187 | - // 从 runtime_view 获取 recent_messages | 187 | + // Get recent_messages from runtime_view |
| 188 | let recent_messages = runtime.agent_context().conversation().recent_messages(100); | 188 | let recent_messages = runtime.agent_context().conversation().recent_messages(100); |
| 189 | 189 | ||
| 190 | - // 获取第一条 user message 作为 prompt_session(用于意图一致性检测) | 190 | + // Get the first user message as prompt_session (for intent consistency check) |
| 191 | let prompt_session = recent_messages | 191 | let prompt_session = recent_messages |
| 192 | .iter() | 192 | .iter() |
| 193 | .find(|m| m.role == MessageRole::User) | 193 | .find(|m| m.role == MessageRole::User) |
| @@ -199,13 +199,13 @@ impl PluginToolHookerAdaptor { | |||
| 199 | }) | 199 | }) |
| 200 | .unwrap_or_default(); | 200 | .unwrap_or_default(); |
| 201 | 201 | ||
| 202 | - // 获取已完成的工具调用历史(用于 read_before_write 等规则) | 202 | + // Get completed tool call history (for read_before_write rules) |
| 203 | - // 收集 ToolUse(包含输入参数如文件路径)和 ToolResult(包含执行结果) | 203 | + // Collect ToolUse (with input params like file paths) and ToolResult (with execution results) |
| 204 | let messages = recent_messages; | 204 | let messages = recent_messages; |
| 205 | let mut tool_use_map: std::collections::HashMap<&String, Value> = | 205 | let mut tool_use_map: std::collections::HashMap<&String, Value> = |
| 206 | std::collections::HashMap::new(); | 206 | std::collections::HashMap::new(); |
| 207 | 207 | ||
| 208 | - // 先收集所有 ToolUse,记录 call_id -> input 映射 | 208 | + // First collect all ToolUse, record call_id -> input mapping |
| 209 | for m in messages.iter() { | 209 | for m in messages.iter() { |
| 210 | for block in &m.blocks { | 210 | for block in &m.blocks { |
| 211 | if let agent_types::llm::ContentBlock::ToolUse { | 211 | if let agent_types::llm::ContentBlock::ToolUse { |
| @@ -225,7 +225,7 @@ impl PluginToolHookerAdaptor { | |||
| 225 | } | 225 | } |
| 226 | } | 226 | } |
| 227 | 227 | ||
| 228 | - // 然后收集 ToolResult,合并输入和输出 | 228 | + // Then collect ToolResult, merge input and output |
| 229 | let action_history: Vec<Value> = messages | 229 | let action_history: Vec<Value> = messages |
| 230 | .iter() | 230 | .iter() |
| 231 | .flat_map(|m| m.blocks.iter()) | 231 | .flat_map(|m| m.blocks.iter()) |
| @@ -236,7 +236,7 @@ impl PluginToolHookerAdaptor { | |||
| 236 | output, | 236 | output, |
| 237 | is_error, | 237 | is_error, |
| 238 | } => { | 238 | } => { |
| 239 | - // 合并 ToolUse 的输入信息 | 239 | + // Merge ToolUse input info |
| 240 | let mut entry = tool_use_map.get(call_id).cloned().unwrap_or_else(|| { | 240 | let mut entry = tool_use_map.get(call_id).cloned().unwrap_or_else(|| { |
| 241 | json!({ | 241 | json!({ |
| 242 | "action_type": tool_name, | 242 | "action_type": tool_name, |
| @@ -475,7 +475,11 @@ impl PluginToolHookerAdaptor { | |||
| 475 | InteractionRequest::Confirm { prompt, source } | 475 | InteractionRequest::Confirm { prompt, source } |
| 476 | } | 476 | } |
| 477 | PluginAskUserRequest::TextInput { prompt, source: _ } => { | 477 | PluginAskUserRequest::TextInput { prompt, source: _ } => { |
| 478 | - InteractionRequest::TextInput { prompt, source } | 478 | + InteractionRequest::TextInput { |
| 479 | + prompt, | ||
| 480 | + source, | ||
| 481 | + is_secret: false, // Default to false for plugin requests | ||
| 482 | + } | ||
| 479 | } | 483 | } |
| 480 | PluginAskUserRequest::Choice { | 484 | PluginAskUserRequest::Choice { |
| 481 | prompt, | 485 | prompt, |
| @@ -932,6 +936,7 @@ else: | |||
| 932 | ); | 936 | ); |
| 933 | let runtime = TestRuntimeView::new(InteractionResponse::Text { | 937 | let runtime = TestRuntimeView::new(InteractionResponse::Text { |
| 934 | value: Some("alice".to_string()), | 938 | value: Some("alice".to_string()), |
| 939 | + display_value: None, | ||
| 935 | }); | 940 | }); |
| 936 | let input = PreToolHookInput { | 941 | let input = PreToolHookInput { |
| 937 | call: FinalToolCall { | 942 | call: FinalToolCall { |
| @@ -961,6 +966,7 @@ else: | |||
| 961 | hooker_name, | 966 | hooker_name, |
| 962 | hook_point, | 967 | hook_point, |
| 963 | }), | 968 | }), |
| 969 | + is_secret: _, // Ignore is_secret in test | ||
| 964 | } => { | 970 | } => { |
| 965 | assert_eq!(prompt, "who approved this?"); | 971 | assert_eq!(prompt, "who approved this?"); |
| 966 | assert_eq!(hooker_name, "plugin_pre_ask"); | 972 | assert_eq!(hooker_name, "plugin_pre_ask"); |
| @@ -241,6 +241,7 @@ pub(crate) fn wire_response_to_llm_response(wire: &WireResponse) -> LlmResponse | |||
| 241 | prompt_tokens: 0, | 241 | prompt_tokens: 0, |
| 242 | completion_tokens: 0, | 242 | completion_tokens: 0, |
| 243 | total_tokens: 0, | 243 | total_tokens: 0, |
| 244 | + cached_tokens: 0, | ||
| 244 | }, | 245 | }, |
| 245 | stop_reason: StopReason::EndTurn, | 246 | stop_reason: StopReason::EndTurn, |
| 246 | }, | 247 | }, |
| @@ -293,6 +294,7 @@ pub(crate) fn wire_choice_to_assistant_message(choice: &WireChoice) -> Assistant | |||
| 293 | prompt_tokens: 0, | 294 | prompt_tokens: 0, |
| 294 | completion_tokens: 0, | 295 | completion_tokens: 0, |
| 295 | total_tokens: 0, | 296 | total_tokens: 0, |
| 297 | + cached_tokens: 0, | ||
| 296 | }, | 298 | }, |
| 297 | stop_reason, | 299 | stop_reason, |
| 298 | } | 300 | } |
| @@ -303,6 +305,11 @@ pub(crate) fn wire_usage_to_usage(wire: &WireUsage) -> Usage { | |||
| 303 | prompt_tokens: wire.prompt_tokens as usize, | 305 | prompt_tokens: wire.prompt_tokens as usize, |
| 304 | completion_tokens: wire.completion_tokens as usize, | 306 | completion_tokens: wire.completion_tokens as usize, |
| 305 | total_tokens: wire.total_tokens as usize, | 307 | total_tokens: wire.total_tokens as usize, |
| 308 | + cached_tokens: wire | ||
| 309 | + .prompt_tokens_details | ||
| 310 | + .as_ref() | ||
| 311 | + .map(|d| d.cached_tokens as usize) | ||
| 312 | + .unwrap_or(0), | ||
| 306 | } | 313 | } |
| 307 | } | 314 | } |
| 308 | 315 | ||
| @@ -439,6 +446,7 @@ mod tests { | |||
| 439 | prompt_tokens: 10, | 446 | prompt_tokens: 10, |
| 440 | completion_tokens: 5, | 447 | completion_tokens: 5, |
| 441 | total_tokens: 15, | 448 | total_tokens: 15, |
| 449 | + prompt_tokens_details: None, | ||
| 442 | }, | 450 | }, |
| 443 | warnings: None, | 451 | warnings: None, |
| 444 | kv_transfer_params: None, | 452 | kv_transfer_params: None, |
| @@ -1,6 +1,10 @@ | |||
| 1 | +use std::fs::OpenOptions; | ||
| 2 | +use std::io::Write; | ||
| 3 | +use std::path::PathBuf; | ||
| 1 | use std::sync::OnceLock; | 4 | use std::sync::OnceLock; |
| 2 | 5 | ||
| 3 | pub use agent_types::LlmError; | 6 | pub use agent_types::LlmError; |
| 7 | +use chrono::Local; | ||
| 4 | use regex::Regex; | 8 | use regex::Regex; |
| 5 | use reqwest::StatusCode; | 9 | use reqwest::StatusCode; |
| 6 | use serde_json::Value; | 10 | use serde_json::Value; |
| @@ -184,6 +188,97 @@ fn overflow_patterns() -> &'static [Regex] { | |||
| 184 | static OVERFLOW_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new(); | 188 | static OVERFLOW_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new(); |
| 185 | static NO_BODY_OVERFLOW_RE: OnceLock<Regex> = OnceLock::new(); | 189 | static NO_BODY_OVERFLOW_RE: OnceLock<Regex> = OnceLock::new(); |
| 186 | 190 | ||
| 191 | +/// 记录 LLM 流式请求失败的详细信息到 error.log(完整记录,不截断) | ||
| 192 | +pub(crate) fn write_stream_error_log( | ||
| 193 | + url: &str, | ||
| 194 | + response_headers: Option<&reqwest::header::HeaderMap>, | ||
| 195 | + partial_response_buffer: &str, | ||
| 196 | + error_message: &str, | ||
| 197 | + http_status: Option<u16>, | ||
| 198 | +) { | ||
| 199 | + let log_path = get_error_log_path(); | ||
| 200 | + | ||
| 201 | + if let Some(parent) = log_path.parent() { | ||
| 202 | + if let Err(e) = std::fs::create_dir_all(parent) { | ||
| 203 | + tracing::error!("Failed to create error log directory: {}", e); | ||
| 204 | + return; | ||
| 205 | + } | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + match OpenOptions::new().create(true).append(true).open(&log_path) { | ||
| 209 | + Ok(mut file) => { | ||
| 210 | + let timestamp = Local::now().to_rfc3339(); | ||
| 211 | + | ||
| 212 | + writeln!(file).ok(); | ||
| 213 | + writeln!(file, "{}", "=".repeat(60)).ok(); | ||
| 214 | + writeln!(file, "===== {} source=llm_stream_error =====", timestamp).ok(); | ||
| 215 | + writeln!(file, "{}", "=".repeat(60)).ok(); | ||
| 216 | + | ||
| 217 | + writeln!(file).ok(); | ||
| 218 | + writeln!(file, "[Basic Info]").ok(); | ||
| 219 | + writeln!(file, "URL: {}", url).ok(); | ||
| 220 | + if let Some(status) = http_status { | ||
| 221 | + writeln!(file, "HTTP Status: {}", status).ok(); | ||
| 222 | + } | ||
| 223 | + writeln!(file, "Error: {}", error_message).ok(); | ||
| 224 | + | ||
| 225 | + if let Some(headers) = response_headers { | ||
| 226 | + writeln!(file).ok(); | ||
| 227 | + writeln!(file, "[Key Headers]").ok(); | ||
| 228 | + | ||
| 229 | + let essential_keys = ["content-type", "transfer-encoding"]; | ||
| 230 | + let debug_keys = [ | ||
| 231 | + "x-request-id", | ||
| 232 | + "retry-after", | ||
| 233 | + "x-ratelimit-limit", | ||
| 234 | + "x-ratelimit-remaining", | ||
| 235 | + "x-ratelimit-reset", | ||
| 236 | + "openai-model", | ||
| 237 | + "x-api-key", | ||
| 238 | + ]; | ||
| 239 | + | ||
| 240 | + for (key, value) in headers.iter() { | ||
| 241 | + let key_str = key.as_str(); | ||
| 242 | + if essential_keys.contains(&key_str) || debug_keys.contains(&key_str) { | ||
| 243 | + let value_str = value.to_str().unwrap_or("<binary>"); | ||
| 244 | + writeln!(file, " {}: {}", key, value_str).ok(); | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + | ||
| 249 | + writeln!(file).ok(); | ||
| 250 | + writeln!(file, "[Response Body]").ok(); | ||
| 251 | + writeln!(file, "Length: {} bytes", partial_response_buffer.len()).ok(); | ||
| 252 | + if !partial_response_buffer.is_empty() { | ||
| 253 | + writeln!(file, "{}", partial_response_buffer).ok(); | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + writeln!(file).ok(); | ||
| 257 | + writeln!(file, "{}", "=".repeat(60)).ok(); | ||
| 258 | + writeln!(file).ok(); | ||
| 259 | + | ||
| 260 | + | ||
| 261 | + { | ||
| 262 | + use std::os::unix::fs::PermissionsExt; | ||
| 263 | + if let Err(e) = | ||
| 264 | + std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o600)) | ||
| 265 | + { | ||
| 266 | + tracing::warn!("Failed to set error.log permissions: {}", e); | ||
| 267 | + } | ||
| 268 | + } | ||
| 269 | + } | ||
| 270 | + Err(e) => { | ||
| 271 | + tracing::error!("Failed to write stream error log: {}", e); | ||
| 272 | + } | ||
| 273 | + } | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +fn get_error_log_path() -> PathBuf { | ||
| 277 | + dirs::home_dir() | ||
| 278 | + .map(|h| h.join(".xiaoo").join("log").join("error.log")) | ||
| 279 | + .unwrap_or_else(|| PathBuf::from(".xiaoo_error.log")) | ||
| 280 | +} | ||
| 281 | + | ||
| 187 | 282 | ||
| 188 | mod tests { | 283 | mod tests { |
| 189 | use super::*; | 284 | use super::*; |
| @@ -100,6 +100,7 @@ mod wrapper_tests { | |||
| 100 | prompt_tokens: 10, | 100 | prompt_tokens: 10, |
| 101 | completion_tokens: 5, | 101 | completion_tokens: 5, |
| 102 | total_tokens: 15, | 102 | total_tokens: 15, |
| 103 | + cached_tokens: 0, | ||
| 103 | }, | 104 | }, |
| 104 | stop_reason: StopReason::EndTurn, | 105 | stop_reason: StopReason::EndTurn, |
| 105 | }, | 106 | }, |
| @@ -110,6 +110,7 @@ pub(super) fn response_trace_fields(response: &LlmResponse) -> Value { | |||
| 110 | "prompt_tokens": response.message.usage.prompt_tokens, | 110 | "prompt_tokens": response.message.usage.prompt_tokens, |
| 111 | "completion_tokens": response.message.usage.completion_tokens, | 111 | "completion_tokens": response.message.usage.completion_tokens, |
| 112 | "total_tokens": response.message.usage.total_tokens, | 112 | "total_tokens": response.message.usage.total_tokens, |
| 113 | + "cached_tokens": response.message.usage.cached_tokens, | ||
| 113 | "response_has_text": response.message.text.is_some(), | 114 | "response_has_text": response.message.text.is_some(), |
| 114 | "response_text_len": response.message.text.as_ref().map(|text| text.len()), | 115 | "response_text_len": response.message.text.as_ref().map(|text| text.len()), |
| 115 | "tool_call_count": response.message.tool_calls.len(), | 116 | "tool_call_count": response.message.tool_calls.len(), |
| @@ -40,14 +40,19 @@ pub(crate) fn extract_anthropic_tool_calls(content: &serde_json::Value) -> Vec<W | |||
| 40 | .collect() | 40 | .collect() |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | -pub(crate) fn anthropic_system_message(messages: &[ChatMessage]) -> String { | 43 | +pub(crate) fn anthropic_system_blocks(messages: &[ChatMessage]) -> Vec<String> { |
| 44 | messages | 44 | messages |
| 45 | .iter() | 45 | .iter() |
| 46 | .filter(|m| matches!(m.role, MessageRole::System)) | 46 | .filter(|m| matches!(m.role, MessageRole::System)) |
| 47 | - .flat_map(|m| m.blocks.iter()) | 47 | + .map(|m| { |
| 48 | - .filter_map(block_text_for_system) | 48 | + m.blocks |
| 49 | - .collect::<Vec<_>>() | 49 | + .iter() |
| 50 | - .join("\n\n") | 50 | + .filter_map(block_text_for_system) |
| 51 | + .collect::<Vec<_>>() | ||
| 52 | + .join("\n\n") | ||
| 53 | + }) | ||
| 54 | + .filter(|text| !text.is_empty()) | ||
| 55 | + .collect() | ||
| 51 | } | 56 | } |
| 52 | 57 | ||
| 53 | pub(crate) fn anthropic_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> { | 58 | pub(crate) fn anthropic_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> { |
| @@ -274,7 +279,7 @@ mod tests { | |||
| 274 | } | 279 | } |
| 275 | 280 | ||
| 276 | 281 | ||
| 277 | - fn test_anthropic_system_message_joins_text_blocks() { | 282 | + fn test_anthropic_system_blocks_one_entry_per_message() { |
| 278 | let messages = vec![ | 283 | let messages = vec![ |
| 279 | ChatMessage::system("base system"), | 284 | ChatMessage::system("base system"), |
| 280 | ChatMessage::new( | 285 | ChatMessage::new( |
| @@ -288,7 +293,7 @@ mod tests { | |||
| 288 | ), | 293 | ), |
| 289 | ]; | 294 | ]; |
| 290 | 295 | ||
| 291 | - let system = anthropic_system_message(&messages); | 296 | + let blocks = anthropic_system_blocks(&messages); |
| 292 | - assert_eq!(system, "base system\n\nworkspace doc"); | 297 | + assert_eq!(blocks, vec!["base system", "workspace doc"]); |
| 293 | } | 298 | } |
| 294 | } | 299 | } |
| @@ -16,7 +16,7 @@ use agent_types::{ | |||
| 16 | mod convert; | 16 | mod convert; |
| 17 | 17 | ||
| 18 | use convert::{ | 18 | use convert::{ |
| 19 | - anthropic_messages, anthropic_system_message, extract_anthropic_tool_calls, | 19 | + anthropic_messages, anthropic_system_blocks, extract_anthropic_tool_calls, |
| 20 | to_anthropic_output_format, to_anthropic_tool, to_anthropic_tool_choice, | 20 | to_anthropic_output_format, to_anthropic_tool, to_anthropic_tool_choice, |
| 21 | }; | 21 | }; |
| 22 | 22 | ||
| @@ -64,7 +64,7 @@ impl AnthropicProvider { | |||
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | fn build_body(&self, request: &LlmRequest, stream: bool) -> serde_json::Value { | 66 | fn build_body(&self, request: &LlmRequest, stream: bool) -> serde_json::Value { |
| 67 | - let system_message = anthropic_system_message(&request.messages); | 67 | + let system_blocks = anthropic_system_blocks(&request.messages); |
| 68 | let other_messages = anthropic_messages(&request.messages); | 68 | let other_messages = anthropic_messages(&request.messages); |
| 69 | 69 | ||
| 70 | let max_tokens = request.max_tokens.unwrap_or(16384); | 70 | let max_tokens = request.max_tokens.unwrap_or(16384); |
| @@ -81,8 +81,20 @@ impl AnthropicProvider { | |||
| 81 | body["stream"] = serde_json::json!(true); | 81 | body["stream"] = serde_json::json!(true); |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | - if !system_message.is_empty() { | 84 | + if !system_blocks.is_empty() { |
| 85 | - body["system"] = serde_json::json!(system_message); | 85 | + let block_count = system_blocks.len(); |
| 86 | + let blocks: Vec<serde_json::Value> = system_blocks | ||
| 87 | + .into_iter() | ||
| 88 | + .enumerate() | ||
| 89 | + .map(|(idx, text)| { | ||
| 90 | + let mut block = serde_json::json!({ "type": "text", "text": text }); | ||
| 91 | + if block_count == 1 || idx + 1 < block_count { | ||
| 92 | + block["cache_control"] = serde_json::json!({ "type": "ephemeral" }); | ||
| 93 | + } | ||
| 94 | + block | ||
| 95 | + }) | ||
| 96 | + .collect(); | ||
| 97 | + body["system"] = serde_json::json!(blocks); | ||
| 86 | } | 98 | } |
| 87 | 99 | ||
| 88 | if !request.tools.is_empty() { | 100 | if !request.tools.is_empty() { |
| @@ -194,6 +206,7 @@ impl LlmProvider for AnthropicProvider { | |||
| 194 | total_tokens: (usage_val["input_tokens"].as_u64().unwrap_or(0) | 206 | total_tokens: (usage_val["input_tokens"].as_u64().unwrap_or(0) |
| 195 | + usage_val["output_tokens"].as_u64().unwrap_or(0)) | 207 | + usage_val["output_tokens"].as_u64().unwrap_or(0)) |
| 196 | as usize, | 208 | as usize, |
| 209 | + cached_tokens: usage_val["cache_read_input_tokens"].as_u64().unwrap_or(0) as usize, | ||
| 197 | }; | 210 | }; |
| 198 | 211 | ||
| 199 | let finish_reason = anthropic_response["stop_reason"] | 212 | let finish_reason = anthropic_response["stop_reason"] |
| @@ -251,8 +264,8 @@ impl LlmProvider for AnthropicProvider { | |||
| 251 | .map_err(map_reqwest_error)?; | 264 | .map_err(map_reqwest_error)?; |
| 252 | 265 | ||
| 253 | let status = response.status(); | 266 | let status = response.status(); |
| 267 | + let headers = response.headers().clone(); | ||
| 254 | if !status.is_success() { | 268 | if !status.is_success() { |
| 255 | - let headers = response.headers().clone(); | ||
| 256 | let error_body = response.text().await.unwrap_or_default(); | 269 | let error_body = response.text().await.unwrap_or_default(); |
| 257 | return Err(map_api_status_error( | 270 | return Err(map_api_status_error( |
| 258 | status, | 271 | status, |
| @@ -272,8 +285,17 @@ impl LlmProvider for AnthropicProvider { | |||
| 272 | let mut byte_stream = response.bytes_stream(); | 285 | let mut byte_stream = response.bytes_stream(); |
| 273 | 286 | ||
| 274 | while let Some(chunk_result) = byte_stream.next().await { | 287 | while let Some(chunk_result) = byte_stream.next().await { |
| 275 | - let bytes = chunk_result.map_err(|e| LlmError::StreamError { | 288 | + let bytes = chunk_result.map_err(|e| { |
| 276 | - message: e.to_string(), | 289 | + crate::error::write_stream_error_log( |
| 290 | + &url, | ||
| 291 | + Some(&headers), | ||
| 292 | + &buffer, | ||
| 293 | + &e.to_string(), | ||
| 294 | + Some(status.as_u16()), | ||
| 295 | + ); | ||
| 296 | + LlmError::StreamError { | ||
| 297 | + message: format!("{} (详见 ~/.xiaoo/log/error.log)", e), | ||
| 298 | + } | ||
| 277 | })?; | 299 | })?; |
| 278 | let text = String::from_utf8_lossy(&bytes); | 300 | let text = String::from_utf8_lossy(&bytes); |
| 279 | buffer.push_str(&text); | 301 | buffer.push_str(&text); |
| @@ -378,6 +400,7 @@ impl AnthropicProvider { | |||
| 378 | prompt_tokens: t, | 400 | prompt_tokens: t, |
| 379 | completion_tokens: 0, | 401 | completion_tokens: 0, |
| 380 | total_tokens: t, | 402 | total_tokens: t, |
| 403 | + prompt_tokens_details: None, | ||
| 381 | }); | 404 | }); |
| 382 | Ok(Some(ParsedChunk { | 405 | Ok(Some(ParsedChunk { |
| 383 | content: None, | 406 | content: None, |
| @@ -448,6 +471,7 @@ impl AnthropicProvider { | |||
| 448 | prompt_tokens: 0, | 471 | prompt_tokens: 0, |
| 449 | completion_tokens: t, | 472 | completion_tokens: t, |
| 450 | total_tokens: t, | 473 | total_tokens: t, |
| 474 | + prompt_tokens_details: None, | ||
| 451 | }); | 475 | }); |
| 452 | Ok(Some(ParsedChunk { | 476 | Ok(Some(ParsedChunk { |
| 453 | content: None, | 477 | content: None, |
| @@ -468,6 +492,7 @@ fn merge_usage(existing: Option<Usage>, incoming: Usage) -> Usage { | |||
| 468 | let mut merged = existing.unwrap_or_default(); | 492 | let mut merged = existing.unwrap_or_default(); |
| 469 | merged.prompt_tokens = merged.prompt_tokens.max(incoming.prompt_tokens); | 493 | merged.prompt_tokens = merged.prompt_tokens.max(incoming.prompt_tokens); |
| 470 | merged.completion_tokens = merged.completion_tokens.max(incoming.completion_tokens); | 494 | merged.completion_tokens = merged.completion_tokens.max(incoming.completion_tokens); |
| 495 | + merged.cached_tokens = merged.cached_tokens.max(incoming.cached_tokens); | ||
| 471 | merged.total_tokens = merged.prompt_tokens + merged.completion_tokens; | 496 | merged.total_tokens = merged.prompt_tokens + merged.completion_tokens; |
| 472 | merged | 497 | merged |
| 473 | } | 498 | } |
| @@ -538,11 +563,13 @@ mod tests { | |||
| 538 | prompt_tokens: 21, | 563 | prompt_tokens: 21, |
| 539 | completion_tokens: 0, | 564 | completion_tokens: 0, |
| 540 | total_tokens: 21, | 565 | total_tokens: 21, |
| 566 | + cached_tokens: 0, | ||
| 541 | }), | 567 | }), |
| 542 | Usage { | 568 | Usage { |
| 543 | prompt_tokens: 0, | 569 | prompt_tokens: 0, |
| 544 | completion_tokens: 15, | 570 | completion_tokens: 15, |
| 545 | total_tokens: 15, | 571 | total_tokens: 15, |
| 572 | + cached_tokens: 0, | ||
| 546 | }, | 573 | }, |
| 547 | ); | 574 | ); |
| 548 | 575 | ||
| @@ -599,12 +626,12 @@ mod tests { | |||
| 599 | } | 626 | } |
| 600 | 627 | ||
| 601 | 628 | ||
| 602 | - fn build_body_concatenates_multiple_system_messages() { | 629 | + fn build_body_emits_system_blocks_caching_only_the_stable_prefix() { |
| 603 | let provider = make_provider(); | 630 | let provider = make_provider(); |
| 604 | let request = LlmRequest { | 631 | let request = LlmRequest { |
| 605 | messages: vec![ | 632 | messages: vec![ |
| 606 | agent_types::ChatMessage::system("base system"), | 633 | agent_types::ChatMessage::system("base system"), |
| 607 | - agent_types::ChatMessage::system("workspace rules"), | 634 | + agent_types::ChatMessage::system("# Context\n\nvolatile tail"), |
| 608 | agent_types::ChatMessage::user("hello"), | 635 | agent_types::ChatMessage::user("hello"), |
| 609 | ], | 636 | ], |
| 610 | tools: Vec::new(), | 637 | tools: Vec::new(), |
| @@ -617,10 +644,41 @@ mod tests { | |||
| 617 | 644 | ||
| 618 | let body = provider.build_body(&request, false); | 645 | let body = provider.build_body(&request, false); |
| 619 | 646 | ||
| 620 | - assert_eq!(body["system"], "base system\n\nworkspace rules"); | 647 | + let system = body["system"].as_array().expect("system should be an array"); |
| 648 | + assert_eq!(system.len(), 2); | ||
| 649 | + // Stable prefix carries the cache breakpoint; the volatile tail does not, | ||
| 650 | + // so a per-turn change to the tail never invalidates the cached prefix. | ||
| 651 | + assert_eq!(system[0]["text"], "base system"); | ||
| 652 | + assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); | ||
| 653 | + assert_eq!(system[1]["text"], "# Context\n\nvolatile tail"); | ||
| 654 | + assert!(system[1].get("cache_control").is_none()); | ||
| 621 | assert_eq!(body["messages"].as_array().unwrap().len(), 1); | 655 | assert_eq!(body["messages"].as_array().unwrap().len(), 1); |
| 622 | } | 656 | } |
| 623 | 657 | ||
| 658 | + | ||
| 659 | + fn build_body_caches_single_system_block() { | ||
| 660 | + let provider = make_provider(); | ||
| 661 | + let request = LlmRequest { | ||
| 662 | + messages: vec![ | ||
| 663 | + agent_types::ChatMessage::system("base system only"), | ||
| 664 | + agent_types::ChatMessage::user("hello"), | ||
| 665 | + ], | ||
| 666 | + tools: Vec::new(), | ||
| 667 | + tool_choice: agent_types::ToolChoice::Auto, | ||
| 668 | + max_tokens: None, | ||
| 669 | + temperature: None, | ||
| 670 | + response_format: agent_types::ResponseFormat::Text, | ||
| 671 | + reasoning_effort: agent_types::ReasoningEffort::Off, | ||
| 672 | + }; | ||
| 673 | + | ||
| 674 | + let body = provider.build_body(&request, false); | ||
| 675 | + | ||
| 676 | + let system = body["system"].as_array().expect("system should be an array"); | ||
| 677 | + assert_eq!(system.len(), 1); | ||
| 678 | + assert_eq!(system[0]["text"], "base system only"); | ||
| 679 | + assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); | ||
| 680 | + } | ||
| 681 | + | ||
| 624 | 682 | ||
| 625 | fn build_body_sets_thinking_budget_for_reasoning_effort() { | 683 | fn build_body_sets_thinking_budget_for_reasoning_effort() { |
| 626 | let provider = make_provider(); | 684 | let provider = make_provider(); |
| @@ -154,6 +154,7 @@ impl LlmProvider for GeminiProvider { | |||
| 154 | prompt_tokens, | 154 | prompt_tokens, |
| 155 | completion_tokens, | 155 | completion_tokens, |
| 156 | total_tokens: prompt_tokens + completion_tokens, | 156 | total_tokens: prompt_tokens + completion_tokens, |
| 157 | + cached_tokens: 0, | ||
| 157 | }, | 158 | }, |
| 158 | stop_reason, | 159 | stop_reason, |
| 159 | }, | 160 | }, |
| @@ -185,8 +186,8 @@ impl LlmProvider for GeminiProvider { | |||
| 185 | .map_err(map_reqwest_error)?; | 186 | .map_err(map_reqwest_error)?; |
| 186 | 187 | ||
| 187 | let status = response.status(); | 188 | let status = response.status(); |
| 189 | + let headers = response.headers().clone(); | ||
| 188 | if !status.is_success() { | 190 | if !status.is_success() { |
| 189 | - let headers = response.headers().clone(); | ||
| 190 | let error_body = response.text().await.unwrap_or_default(); | 191 | let error_body = response.text().await.unwrap_or_default(); |
| 191 | return Err(map_api_status_error( | 192 | return Err(map_api_status_error( |
| 192 | status, | 193 | status, |
| @@ -205,8 +206,17 @@ impl LlmProvider for GeminiProvider { | |||
| 205 | let mut byte_stream = response.bytes_stream(); | 206 | let mut byte_stream = response.bytes_stream(); |
| 206 | 207 | ||
| 207 | while let Some(chunk_result) = byte_stream.next().await { | 208 | while let Some(chunk_result) = byte_stream.next().await { |
| 208 | - let bytes = chunk_result.map_err(|e| LlmError::StreamError { | 209 | + let bytes = chunk_result.map_err(|e| { |
| 209 | - message: e.to_string(), | 210 | + crate::error::write_stream_error_log( |
| 211 | + &url, | ||
| 212 | + Some(&headers), | ||
| 213 | + &buffer, | ||
| 214 | + &e.to_string(), | ||
| 215 | + Some(status.as_u16()), | ||
| 216 | + ); | ||
| 217 | + LlmError::StreamError { | ||
| 218 | + message: format!("{} (详见 ~/.xiaoo/log/error.log)", e), | ||
| 219 | + } | ||
| 210 | })?; | 220 | })?; |
| 211 | buffer.push_str(&String::from_utf8_lossy(&bytes)); | 221 | buffer.push_str(&String::from_utf8_lossy(&bytes)); |
| 212 | 222 | ||
| @@ -261,6 +271,7 @@ impl LlmProvider for GeminiProvider { | |||
| 261 | completion_tokens: u.candidates_token_count.unwrap_or(0), | 271 | completion_tokens: u.candidates_token_count.unwrap_or(0), |
| 262 | total_tokens: u.prompt_token_count.unwrap_or(0) | 272 | total_tokens: u.prompt_token_count.unwrap_or(0) |
| 263 | + u.candidates_token_count.unwrap_or(0), | 273 | + u.candidates_token_count.unwrap_or(0), |
| 274 | + prompt_tokens_details: None, | ||
| 264 | }); | 275 | }); |
| 265 | if let Some(ref u) = usage { | 276 | if let Some(ref u) = usage { |
| 266 | final_usage = Some(wire_usage_to_usage(u)); | 277 | final_usage = Some(wire_usage_to_usage(u)); |
| @@ -141,8 +141,8 @@ impl LlmProvider for OllamaProvider { | |||
| 141 | .map_err(map_reqwest_error)?; | 141 | .map_err(map_reqwest_error)?; |
| 142 | 142 | ||
| 143 | let status = response.status(); | 143 | let status = response.status(); |
| 144 | + let headers = response.headers().clone(); | ||
| 144 | if !status.is_success() { | 145 | if !status.is_success() { |
| 145 | - let headers = response.headers().clone(); | ||
| 146 | let error_body = response.text().await.unwrap_or_default(); | 146 | let error_body = response.text().await.unwrap_or_default(); |
| 147 | return Err(map_api_status_error( | 147 | return Err(map_api_status_error( |
| 148 | status, | 148 | status, |
| @@ -160,8 +160,17 @@ impl LlmProvider for OllamaProvider { | |||
| 160 | let mut byte_stream = response.bytes_stream(); | 160 | let mut byte_stream = response.bytes_stream(); |
| 161 | 161 | ||
| 162 | while let Some(chunk_result) = byte_stream.next().await { | 162 | while let Some(chunk_result) = byte_stream.next().await { |
| 163 | - let bytes = chunk_result.map_err(|e| LlmError::StreamError { | 163 | + let bytes = chunk_result.map_err(|e| { |
| 164 | - message: e.to_string(), | 164 | + crate::error::write_stream_error_log( |
| 165 | + &url, | ||
| 166 | + Some(&headers), | ||
| 167 | + &buffer, | ||
| 168 | + &e.to_string(), | ||
| 169 | + Some(status.as_u16()), | ||
| 170 | + ); | ||
| 171 | + LlmError::StreamError { | ||
| 172 | + message: format!("{} (详见 ~/.xiaoo/log/error.log)", e), | ||
| 173 | + } | ||
| 165 | })?; | 174 | })?; |
| 166 | buffer.push_str(&String::from_utf8_lossy(&bytes)); | 175 | buffer.push_str(&String::from_utf8_lossy(&bytes)); |
| 167 | 176 | ||
| @@ -219,6 +228,7 @@ fn usage_from_ollama_json(json: &serde_json::Value) -> Usage { | |||
| 219 | prompt_tokens, | 228 | prompt_tokens, |
| 220 | completion_tokens, | 229 | completion_tokens, |
| 221 | total_tokens: prompt_tokens + completion_tokens, | 230 | total_tokens: prompt_tokens + completion_tokens, |
| 231 | + cached_tokens: 0, | ||
| 222 | } | 232 | } |
| 223 | } | 233 | } |
| 224 | 234 | ||
| @@ -251,6 +261,7 @@ fn parse_ollama_stream_line(line: &str) -> Result<Option<ParsedChunk>, LlmError> | |||
| 251 | prompt_tokens: usage.prompt_tokens as u32, | 261 | prompt_tokens: usage.prompt_tokens as u32, |
| 252 | completion_tokens: usage.completion_tokens as u32, | 262 | completion_tokens: usage.completion_tokens as u32, |
| 253 | total_tokens: usage.total_tokens as u32, | 263 | total_tokens: usage.total_tokens as u32, |
| 264 | + prompt_tokens_details: None, | ||
| 254 | }) | 265 | }) |
| 255 | } | 266 | } |
| 256 | }; | 267 | }; |
| @@ -198,16 +198,17 @@ impl OpenAiFamilyProvider { | |||
| 198 | } | 198 | } |
| 199 | 199 | ||
| 200 | if !content_type.to_lowercase().starts_with("text/event-stream") { | 200 | if !content_type.to_lowercase().starts_with("text/event-stream") { |
| 201 | - let preview = response | 201 | + let full_response = response.text().await.unwrap_or_default(); |
| 202 | - .text() | 202 | + crate::error::write_stream_error_log( |
| 203 | - .await | 203 | + url, |
| 204 | - .unwrap_or_default() | 204 | + Some(&headers), |
| 205 | - .chars() | 205 | + &full_response, |
| 206 | - .take(200) | 206 | + &format!("unexpected content type: {}", content_type), |
| 207 | - .collect::<String>(); | 207 | + Some(status.as_u16()), |
| 208 | + ); | ||
| 208 | return Err(LlmError::ApiError(format!( | 209 | return Err(LlmError::ApiError(format!( |
| 209 | - "unexpected content type: {content_type}; expected text/event-stream. \ | 210 | + "unexpected content type: {} (详见 ~/.xiaoo/log/error.log)", |
| 210 | - Response body preview: {preview}", | 211 | + content_type |
| 211 | ))); | 212 | ))); |
| 212 | } | 213 | } |
| 213 | 214 | ||
| @@ -222,8 +223,17 @@ impl OpenAiFamilyProvider { | |||
| 222 | let mut byte_stream = response.bytes_stream(); | 223 | let mut byte_stream = response.bytes_stream(); |
| 223 | 224 | ||
| 224 | while let Some(chunk_result) = byte_stream.next().await { | 225 | while let Some(chunk_result) = byte_stream.next().await { |
| 225 | - let bytes = chunk_result.map_err(|e| LlmError::StreamError { | 226 | + let bytes = chunk_result.map_err(|e| { |
| 226 | - message: e.to_string(), | 227 | + crate::error::write_stream_error_log( |
| 228 | + url, | ||
| 229 | + Some(&headers), | ||
| 230 | + &buffer, | ||
| 231 | + &e.to_string(), | ||
| 232 | + Some(status.as_u16()), | ||
| 233 | + ); | ||
| 234 | + LlmError::StreamError { | ||
| 235 | + message: format!("{} (详见 ~/.xiaoo/log/error.log)", e), | ||
| 236 | + } | ||
| 227 | })?; | 237 | })?; |
| 228 | let text = String::from_utf8_lossy(&bytes); | 238 | let text = String::from_utf8_lossy(&bytes); |
| 229 | buffer.push_str(&text); | 239 | buffer.push_str(&text); |
| @@ -922,11 +932,15 @@ fn accumulate_tool_call_deltas( | |||
| 922 | } | 932 | } |
| 923 | let tc = &mut full_tool_calls[idx]; | 933 | let tc = &mut full_tool_calls[idx]; |
| 924 | if let Some(ref id) = delta.id { | 934 | if let Some(ref id) = delta.id { |
| 925 | - tc.id = id.clone(); | 935 | + if !id.is_empty() { |
| 936 | + tc.id = id.clone(); | ||
| 937 | + } | ||
| 926 | } | 938 | } |
| 927 | if let Some(ref func) = delta.function { | 939 | if let Some(ref func) = delta.function { |
| 928 | if let Some(ref name) = func.name { | 940 | if let Some(ref name) = func.name { |
| 929 | - tc.function.name = name.clone(); | 941 | + if !name.is_empty() { |
| 942 | + tc.function.name = name.clone(); | ||
| 943 | + } | ||
| 930 | } | 944 | } |
| 931 | if let Some(ref args) = func.arguments { | 945 | if let Some(ref args) = func.arguments { |
| 932 | tc.function.arguments.push_str(args); | 946 | tc.function.arguments.push_str(args); |
| @@ -65,6 +65,14 @@ pub(crate) struct WireUsage { | |||
| 65 | pub prompt_tokens: u32, | 65 | pub prompt_tokens: u32, |
| 66 | pub completion_tokens: u32, | 66 | pub completion_tokens: u32, |
| 67 | pub total_tokens: u32, | 67 | pub total_tokens: u32, |
| 68 | + | ||
| 69 | + pub prompt_tokens_details: Option<WirePromptTokensDetails>, | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +pub(crate) struct WirePromptTokensDetails { | ||
| 74 | + | ||
| 75 | + pub cached_tokens: u32, | ||
| 68 | } | 76 | } |
| 69 | 77 | ||
| 70 | 78 | ||
| @@ -100,6 +108,7 @@ mod tests { | |||
| 100 | prompt_tokens: 10, | 108 | prompt_tokens: 10, |
| 101 | completion_tokens: 5, | 109 | completion_tokens: 5, |
| 102 | total_tokens: 15, | 110 | total_tokens: 15, |
| 111 | + prompt_tokens_details: None, | ||
| 103 | }, | 112 | }, |
| 104 | warnings: Some(vec![Warning::new( | 113 | warnings: Some(vec![Warning::new( |
| 105 | "response_format", | 114 | "response_format", |
| @@ -125,6 +134,7 @@ mod tests { | |||
| 125 | prompt_tokens: 10, | 134 | prompt_tokens: 10, |
| 126 | completion_tokens: 5, | 135 | completion_tokens: 5, |
| 127 | total_tokens: 15, | 136 | total_tokens: 15, |
| 137 | + prompt_tokens_details: None, | ||
| 128 | }, | 138 | }, |
| 129 | warnings: None, | 139 | warnings: None, |
| 130 | kv_transfer_params: None, | 140 | kv_transfer_params: None, |
| @@ -62,6 +62,7 @@ mod tests { | |||
| 62 | prompt_tokens: 10, | 62 | prompt_tokens: 10, |
| 63 | completion_tokens: 5, | 63 | completion_tokens: 5, |
| 64 | total_tokens: 15, | 64 | total_tokens: 15, |
| 65 | + prompt_tokens_details: None, | ||
| 65 | }; | 66 | }; |
| 66 | let chunk = ParsedChunk { | 67 | let chunk = ParsedChunk { |
| 67 | content: Some("Hello".to_string()), | 68 | content: Some("Hello".to_string()), |
| @@ -457,8 +457,9 @@ mod tests { | |||
| 457 | 457 | ||
| 458 | assert_eq!( | 458 | assert_eq!( |
| 459 | prompt, | 459 | prompt, |
| 460 | - "First priority: Load skill from xiaoo-guardian following the four-level priority system for security policy enforcement.\n\n\ | 460 | + "You are a subagent summoned by a parent agent.\n\n\ |
| 461 | -You are a subagent summoned by a parent agent. Your primary goal is:\n\ | 461 | +For general conversations and non-sensitive tasks, respond normally without enabling security verification. When tasks involve secrets, credentials, or sensitive files, enable the security verification mechanism.\n\n\ |
| 462 | +Your primary goal is:\n\ | ||
| 462 | Count files\n\n\ | 463 | Count files\n\n\ |
| 463 | Task Context:\n\ | 464 | Task Context:\n\ |
| 464 | Use find\n\n\ | 465 | Use find\n\n\ |
| @@ -473,8 +474,9 @@ You MUST conclude your task by producing a final result that strictly adheres to | |||
| 473 | 474 | ||
| 474 | assert_eq!( | 475 | assert_eq!( |
| 475 | prompt, | 476 | prompt, |
| 476 | - "First priority: Load skill from xiaoo-guardian following the four-level priority system for security policy enforcement.\n\n\ | 477 | + "You are a subagent summoned by a parent agent.\n\n\ |
| 477 | -You are a subagent summoned by a parent agent. Your primary goal is:\n\ | 478 | +For general conversations and non-sensitive tasks, respond normally without enabling security verification. When tasks involve secrets, credentials, or sensitive files, enable the security verification mechanism.\n\n\ |
| 479 | +Your primary goal is:\n\ | ||
| 478 | Summarize logs\n\n\ | 480 | Summarize logs\n\n\ |
| 479 | Task Context:\n\ | 481 | Task Context:\n\ |
| 480 | Check /var/log\n\n\ | 482 | Check /var/log\n\n\ |
| @@ -33,6 +33,8 @@ reqwest = { workspace = true, features = ["stream"] } | |||
| 33 | rustls-native-certs = "0.8" | 33 | rustls-native-certs = "0.8" |
| 34 | tempfile.workspace = true | 34 | tempfile.workspace = true |
| 35 | tracing.workspace = true | 35 | tracing.workspace = true |
| 36 | +regex = "1" | ||
| 37 | +lazy_static = "1.4" | ||
| 36 | 38 | ||
| 37 | [[bin]] | 39 | [[bin]] |
| 38 | name = "tool-cli" | 40 | name = "tool-cli" |
| @@ -38,7 +38,7 @@ impl ToolExecutor for AskUserQuestionExecutor { | |||
| 38 | message: format!("Failed to parse input: {}", e), | 38 | message: format!("Failed to parse input: {}", e), |
| 39 | })?; | 39 | })?; |
| 40 | 40 | ||
| 41 | - // 校验输入 | 41 | + // Validate input |
| 42 | let validation_result = validation::validate_input(&input); | 42 | let validation_result = validation::validate_input(&input); |
| 43 | if !validation_result.result { | 43 | if !validation_result.result { |
| 44 | let message = validation_result | 44 | let message = validation_result |
| @@ -67,10 +67,11 @@ impl ToolExecutor for AskUserQuestionExecutor { | |||
| 67 | }, | 67 | }, |
| 68 | prompt.clone(), | 68 | prompt.clone(), |
| 69 | ), | 69 | ), |
| 70 | - QuestionItem::TextInput { prompt } => ( | 70 | + QuestionItem::TextInput { prompt, is_secret } => ( |
| 71 | InteractionRequest::TextInput { | 71 | InteractionRequest::TextInput { |
| 72 | prompt: prompt.clone(), | 72 | prompt: prompt.clone(), |
| 73 | source: source.clone(), | 73 | source: source.clone(), |
| 74 | + is_secret: *is_secret, | ||
| 74 | }, | 75 | }, |
| 75 | prompt.clone(), | 76 | prompt.clone(), |
| 76 | ), | 77 | ), |
| @@ -95,7 +96,14 @@ impl ToolExecutor for AskUserQuestionExecutor { | |||
| 95 | InteractionResponse::Confirmed { allowed } => { | 96 | InteractionResponse::Confirmed { allowed } => { |
| 96 | AnswerItem::Confirmed { prompt, allowed } | 97 | AnswerItem::Confirmed { prompt, allowed } |
| 97 | } | 98 | } |
| 98 | - InteractionResponse::Text { value } => AnswerItem::Text { prompt, value }, | 99 | + InteractionResponse::Text { |
| 100 | + value, | ||
| 101 | + display_value, | ||
| 102 | + } => AnswerItem::Text { | ||
| 103 | + prompt, | ||
| 104 | + value, | ||
| 105 | + display_value, | ||
| 106 | + }, | ||
| 99 | InteractionResponse::Choice { value } => AnswerItem::Choice { prompt, value }, | 107 | InteractionResponse::Choice { value } => AnswerItem::Choice { prompt, value }, |
| 100 | }; | 108 | }; |
| 101 | answers.push(answer); | 109 | answers.push(answer); |
| @@ -1,14 +1,18 @@ | |||
| 1 | use serde::{Deserialize, Serialize}; | 1 | use serde::{Deserialize, Serialize}; |
| 2 | 2 | ||
| 3 | -/// 对应 InteractionRequest 的三种变体,用 serde tag 区分。 | 3 | +/// Three variants corresponding to InteractionRequest, differentiated by serde tag. |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | pub enum QuestionItem { | 6 | pub enum QuestionItem { |
| 7 | - /// 确认类问题(是/否) | 7 | + /// Confirmation question (yes/no) |
| 8 | Confirm { prompt: String }, | 8 | Confirm { prompt: String }, |
| 9 | - /// 文本输入类问题 | 9 | + /// Text input question |
| 10 | - TextInput { prompt: String }, | 10 | + TextInput { |
| 11 | - /// 单选/多选类问题 | 11 | + prompt: String, |
| 12 | + | ||
| 13 | + is_secret: bool, | ||
| 14 | + }, | ||
| 15 | + /// Single/multi-choice question | ||
| 12 | Choice { | 16 | Choice { |
| 13 | prompt: String, | 17 | prompt: String, |
| 14 | options: Vec<String>, | 18 | options: Vec<String>, |
| @@ -17,9 +21,9 @@ pub enum QuestionItem { | |||
| 17 | }, | 21 | }, |
| 18 | } | 22 | } |
| 19 | 23 | ||
| 20 | -/// AskUserQuestion 工具的输入结构。 | 24 | +/// Input structure for AskUserQuestion tool. |
| 21 | 25 | ||
| 22 | pub struct AskUserQuestionInput { | 26 | pub struct AskUserQuestionInput { |
| 23 | - /// 要向用户提出的问题列表(1–4 个)。 | 27 | + /// List of questions to ask the user (1–4 items). |
| 24 | pub questions: Vec<QuestionItem>, | 28 | pub questions: Vec<QuestionItem>, |
| 25 | } | 29 | } |
| @@ -1,26 +1,28 @@ | |||
| 1 | use serde::{Deserialize, Serialize}; | 1 | use serde::{Deserialize, Serialize}; |
| 2 | 2 | ||
| 3 | -/// 对应 InteractionResponse 的三种变体,同时携带原始 prompt 便于 AI 对应问答。 | 3 | +/// Three variants corresponding to InteractionResponse, carrying original prompt for AI reference. |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | pub enum AnswerItem { | 6 | pub enum AnswerItem { |
| 7 | - /// 对 Confirm 请求的回答 | 7 | + /// Response to Confirm request |
| 8 | Confirmed { prompt: String, allowed: bool }, | 8 | Confirmed { prompt: String, allowed: bool }, |
| 9 | - /// 对 TextInput 请求的回答 | 9 | + /// Response to TextInput request |
| 10 | Text { | 10 | Text { |
| 11 | prompt: String, | 11 | prompt: String, |
| 12 | value: Option<String>, | 12 | value: Option<String>, |
| 13 | + | ||
| 14 | + display_value: Option<String>, | ||
| 13 | }, | 15 | }, |
| 14 | - /// 对 Choice 请求的回答 | 16 | + /// Response to Choice request |
| 15 | Choice { | 17 | Choice { |
| 16 | prompt: String, | 18 | prompt: String, |
| 17 | value: Option<String>, | 19 | value: Option<String>, |
| 18 | }, | 20 | }, |
| 19 | } | 21 | } |
| 20 | 22 | ||
| 21 | -/// AskUserQuestion 工具的输出结构。 | 23 | +/// Output structure for AskUserQuestion tool. |
| 22 | 24 | ||
| 23 | pub struct AskUserQuestionOutput { | 25 | pub struct AskUserQuestionOutput { |
| 24 | - /// 与输入问题一一对应的回答列表。 | 26 | + /// List of answers corresponding one-to-one with input questions. |
| 25 | pub answers: Vec<AnswerItem>, | 27 | pub answers: Vec<AnswerItem>, |
| 26 | } | 28 | } |
| @@ -14,7 +14,7 @@ pub struct AskUserQuestionToolSpec { | |||
| 14 | 14 | ||
| 15 | impl AskUserQuestionToolSpec { | 15 | impl AskUserQuestionToolSpec { |
| 16 | pub fn new() -> Self { | 16 | pub fn new() -> Self { |
| 17 | - // JSON Schema 支持三种 kind 的 oneOf 变体 | 17 | + // JSON Schema supports three kind variants via oneOf |
| 18 | let schema = serde_json::json!({ | 18 | let schema = serde_json::json!({ |
| 19 | "type": "object", | 19 | "type": "object", |
| 20 | "properties": { | 20 | "properties": { |
| @@ -40,7 +40,12 @@ impl AskUserQuestionToolSpec { | |||
| 40 | "description": "文本输入类问题,用户可自由输入文本。", | 40 | "description": "文本输入类问题,用户可自由输入文本。", |
| 41 | "properties": { | 41 | "properties": { |
| 42 | "kind": { "type": "string", "enum": ["text_input"] }, | 42 | "kind": { "type": "string", "enum": ["text_input"] }, |
| 43 | - "prompt": { "type": "string", "description": "向用户展示的问题文本。" } | 43 | + "prompt": { "type": "string", "description": "向用户展示的问题文本。" }, |
| 44 | + "is_secret": { | ||
| 45 | + "type": "boolean", | ||
| 46 | + "description": "是否隐藏用户输入(如密码),默认false。设为true时输入内容用*遮蔽。", | ||
| 47 | + "default": false | ||
| 48 | + } | ||
| 44 | }, | 49 | }, |
| 45 | "required": ["kind", "prompt"], | 50 | "required": ["kind", "prompt"], |
| 46 | "additionalProperties": false | 51 | "additionalProperties": false |
| @@ -79,7 +84,7 @@ impl AskUserQuestionToolSpec { | |||
| 79 | description: "向用户提出一个或多个问题并收集回答。\n\ | 84 | description: "向用户提出一个或多个问题并收集回答。\n\ |
| 80 | 支持三种问题类型:\n\ | 85 | 支持三种问题类型:\n\ |
| 81 | - confirm:是/否确认\n\ | 86 | - confirm:是/否确认\n\ |
| 82 | - - text_input:自由文本输入\n\ | 87 | + - text_input:自由文本输入(is_secret=true 时输入内容用*遮蔽,适用于密码等敏感信息)\n\ |
| 83 | - choice:从给定选项中选择(可选允许自定义输入)\n\ | 88 | - choice:从给定选项中选择(可选允许自定义输入)\n\ |
| 84 | 每次调用最多可提出 4 个问题,按顺序依次与用户交互。" | 89 | 每次调用最多可提出 4 个问题,按顺序依次与用户交互。" |
| 85 | .to_string(), | 90 | .to_string(), |
| @@ -1,29 +1,29 @@ | |||
| 1 | //! Input validation for AskUserQuestionTool. | 1 | //! Input validation for AskUserQuestionTool. |
| 2 | //! | 2 | //! |
| 3 | //! Validates AskUserQuestionInput before processing to ensure: | 3 | //! Validates AskUserQuestionInput before processing to ensure: |
| 4 | -//! - questions 列表包含 1–4 个条目 | 4 | +//! - questions list contains 1–4 entries |
| 5 | -//! - 每个问题的 prompt 非空 | 5 | +//! - each question's prompt is non-empty |
| 6 | -//! - Choice 类型问题至少有 2 个选项,且每个选项非空 | 6 | +//! - Choice type questions have at least 2 options, each non-empty |
| 7 | -//! - questions 的 prompt 在列表内唯一(不重复) | 7 | +//! - questions' prompts are unique within the list (no duplicates) |
| 8 | -//! - Choice 类型问题的选项在该问题内唯一 | 8 | +//! - Choice type questions' options are unique within that question |
| 9 | 9 | ||
| 10 | use super::input::{AskUserQuestionInput, QuestionItem}; | 10 | use super::input::{AskUserQuestionInput, QuestionItem}; |
| 11 | 11 | ||
| 12 | /// Validation error codes. | 12 | /// Validation error codes. |
| 13 | pub mod error_code { | 13 | pub mod error_code { |
| 14 | - /// questions 列表为空(error_code = 1) | 14 | + /// questions list is empty (error_code = 1) |
| 15 | pub const QUESTIONS_EMPTY: u32 = 1; | 15 | pub const QUESTIONS_EMPTY: u32 = 1; |
| 16 | - /// questions 列表超过 4 个(error_code = 2) | 16 | + /// questions list exceeds 4 items (error_code = 2) |
| 17 | pub const QUESTIONS_TOO_MANY: u32 = 2; | 17 | pub const QUESTIONS_TOO_MANY: u32 = 2; |
| 18 | - /// 某个问题的 prompt 为空字符串(error_code = 3) | 18 | + /// a question's prompt is empty string (error_code = 3) |
| 19 | pub const PROMPT_EMPTY: u32 = 3; | 19 | pub const PROMPT_EMPTY: u32 = 3; |
| 20 | - /// Choice 选项数量不足 2 个(error_code = 4) | 20 | + /// Choice has fewer than 2 options (error_code = 4) |
| 21 | pub const CHOICE_TOO_FEW_OPTIONS: u32 = 4; | 21 | pub const CHOICE_TOO_FEW_OPTIONS: u32 = 4; |
| 22 | - /// Choice 某个选项为空字符串(error_code = 5) | 22 | + /// a Choice option is empty string (error_code = 5) |
| 23 | pub const CHOICE_OPTION_EMPTY: u32 = 5; | 23 | pub const CHOICE_OPTION_EMPTY: u32 = 5; |
| 24 | - /// questions 中存在重复的 prompt(error_code = 6) | 24 | + /// duplicate prompts in questions (error_code = 6) |
| 25 | pub const DUPLICATE_PROMPT: u32 = 6; | 25 | pub const DUPLICATE_PROMPT: u32 = 6; |
| 26 | - /// 同一 Choice 问题内存在重复选项(error_code = 7) | 26 | + /// duplicate options within a Choice question (error_code = 7) |
| 27 | pub const DUPLICATE_CHOICE_OPTION: u32 = 7; | 27 | pub const DUPLICATE_CHOICE_OPTION: u32 = 7; |
| 28 | } | 28 | } |
| 29 | 29 | ||
| @@ -58,7 +58,7 @@ impl ValidationResult { | |||
| 58 | 58 | ||
| 59 | /// Validates AskUserQuestionInput. | 59 | /// Validates AskUserQuestionInput. |
| 60 | pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | 60 | pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { |
| 61 | - // 1. 数量下限 | 61 | + // 1. Minimum count |
| 62 | if input.questions.is_empty() { | 62 | if input.questions.is_empty() { |
| 63 | return ValidationResult::error( | 63 | return ValidationResult::error( |
| 64 | "questions 不能为空,至少需要 1 个问题", | 64 | "questions 不能为空,至少需要 1 个问题", |
| @@ -66,7 +66,7 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 66 | ); | 66 | ); |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | - // 2. 数量上限 | 69 | + // 2. Maximum count |
| 70 | if input.questions.len() > 4 { | 70 | if input.questions.len() > 4 { |
| 71 | return ValidationResult::error( | 71 | return ValidationResult::error( |
| 72 | format!( | 72 | format!( |
| @@ -77,12 +77,12 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 77 | ); | 77 | ); |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | - // 3. 逐条校验每个问题 | 80 | + // 3. Validate each question |
| 81 | for (idx, question) in input.questions.iter().enumerate() { | 81 | for (idx, question) in input.questions.iter().enumerate() { |
| 82 | let pos = idx + 1; // 1-based index for user-facing messages | 82 | let pos = idx + 1; // 1-based index for user-facing messages |
| 83 | 83 | ||
| 84 | match question { | 84 | match question { |
| 85 | - QuestionItem::Confirm { prompt } | QuestionItem::TextInput { prompt } => { | 85 | + QuestionItem::Confirm { prompt } | QuestionItem::TextInput { prompt, .. } => { |
| 86 | if prompt.trim().is_empty() { | 86 | if prompt.trim().is_empty() { |
| 87 | return ValidationResult::error( | 87 | return ValidationResult::error( |
| 88 | format!("第 {} 个问题的 prompt 不能为空", pos), | 88 | format!("第 {} 个问题的 prompt 不能为空", pos), |
| @@ -93,7 +93,7 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 93 | QuestionItem::Choice { | 93 | QuestionItem::Choice { |
| 94 | prompt, options, .. | 94 | prompt, options, .. |
| 95 | } => { | 95 | } => { |
| 96 | - // prompt 非空 | 96 | + // prompt must be non-empty |
| 97 | if prompt.trim().is_empty() { | 97 | if prompt.trim().is_empty() { |
| 98 | return ValidationResult::error( | 98 | return ValidationResult::error( |
| 99 | format!("第 {} 个问题的 prompt 不能为空", pos), | 99 | format!("第 {} 个问题的 prompt 不能为空", pos), |
| @@ -101,7 +101,7 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 101 | ); | 101 | ); |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | - // options 数量下限 | 104 | + // options minimum count |
| 105 | if options.len() < 2 { | 105 | if options.len() < 2 { |
| 106 | return ValidationResult::error( | 106 | return ValidationResult::error( |
| 107 | format!( | 107 | format!( |
| @@ -113,7 +113,7 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 113 | ); | 113 | ); |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | - // 每个 option 非空 | 116 | + // each option must be non-empty |
| 117 | for (opt_idx, opt) in options.iter().enumerate() { | 117 | for (opt_idx, opt) in options.iter().enumerate() { |
| 118 | if opt.trim().is_empty() { | 118 | if opt.trim().is_empty() { |
| 119 | return ValidationResult::error( | 119 | return ValidationResult::error( |
| @@ -123,7 +123,7 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 123 | } | 123 | } |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | - // 选项唯一性(大小写敏感) | 126 | + // option uniqueness (case-sensitive) |
| 127 | let unique_options: std::collections::HashSet<&str> = | 127 | let unique_options: std::collections::HashSet<&str> = |
| 128 | options.iter().map(|o| o.as_str()).collect(); | 128 | options.iter().map(|o| o.as_str()).collect(); |
| 129 | if unique_options.len() != options.len() { | 129 | if unique_options.len() != options.len() { |
| @@ -136,13 +136,13 @@ pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult { | |||
| 136 | } | 136 | } |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | - // 4. questions prompt 唯一性 | 139 | + // 4. questions prompt uniqueness |
| 140 | let prompts: Vec<&str> = input | 140 | let prompts: Vec<&str> = input |
| 141 | .questions | 141 | .questions |
| 142 | .iter() | 142 | .iter() |
| 143 | .map(|q| match q { | 143 | .map(|q| match q { |
| 144 | QuestionItem::Confirm { prompt } | 144 | QuestionItem::Confirm { prompt } |
| 145 | - | QuestionItem::TextInput { prompt } | 145 | + | QuestionItem::TextInput { prompt, .. } |
| 146 | | QuestionItem::Choice { prompt, .. } => prompt.as_str(), | 146 | | QuestionItem::Choice { prompt, .. } => prompt.as_str(), |
| 147 | }) | 147 | }) |
| 148 | .collect(); | 148 | .collect(); |
| @@ -172,6 +172,7 @@ mod tests { | |||
| 172 | fn text_input(prompt: &str) -> QuestionItem { | 172 | fn text_input(prompt: &str) -> QuestionItem { |
| 173 | QuestionItem::TextInput { | 173 | QuestionItem::TextInput { |
| 174 | prompt: prompt.to_string(), | 174 | prompt: prompt.to_string(), |
| 175 | + is_secret: false, | ||
| 175 | } | 176 | } |
| 176 | } | 177 | } |
| 177 | 178 | ||
| @@ -10,6 +10,7 @@ use agent_types::tool::call_types::FinalToolCall; | |||
| 10 | use agent_types::tool::execution_types::{RawToolOutcome, ToolExecutionError, ToolExecutorOutput}; | 10 | use agent_types::tool::execution_types::{RawToolOutcome, ToolExecutionError, ToolExecutorOutput}; |
| 11 | 11 | ||
| 12 | use super::super::validation::backend as validation; | 12 | use super::super::validation::backend as validation; |
| 13 | +use super::super::validation::interactive; | ||
| 13 | use super::constants::{default_timeout_ms, MAX_OUTPUT_BYTES_PER_STREAM}; | 14 | use super::constants::{default_timeout_ms, MAX_OUTPUT_BYTES_PER_STREAM}; |
| 14 | use super::input::BashInput; | 15 | use super::input::BashInput; |
| 15 | use super::output::BashOutput; | 16 | use super::output::BashOutput; |
| @@ -129,6 +130,19 @@ impl ToolExecutor for BashExecutor { | |||
| 129 | }); | 130 | }); |
| 130 | } | 131 | } |
| 131 | 132 | ||
| 133 | + let validation_result = interactive::validate_interactive_command(&input); | ||
| 134 | + if !validation_result.result { | ||
| 135 | + let error_message = validation_result | ||
| 136 | + .message | ||
| 137 | + .unwrap_or_else(|| "Interactive command validation failed".to_string()); | ||
| 138 | + let error_code = validation_result.error_code.unwrap_or(0); | ||
| 139 | + return Ok(ToolExecutorOutput::Completed { | ||
| 140 | + raw_outcome: RawToolOutcome::Error { | ||
| 141 | + message: format!("[error_code={}] {}", error_code, error_message), | ||
| 142 | + }, | ||
| 143 | + }); | ||
| 144 | + } | ||
| 145 | + | ||
| 132 | let validation_result = validation::validate_timeout(&input); | 146 | let validation_result = validation::validate_timeout(&input); |
| 133 | if !validation_result.result { | 147 | if !validation_result.result { |
| 134 | let error_message = validation_result | 148 | let error_message = validation_result |
| @@ -4,6 +4,7 @@ pub mod error_code { | |||
| 4 | pub const CWD_NOT_DIRECTORY: u32 = 3; | 4 | pub const CWD_NOT_DIRECTORY: u32 = 3; |
| 5 | pub const TIMEOUT_INVALID: u32 = 4; | 5 | pub const TIMEOUT_INVALID: u32 = 4; |
| 6 | pub const TIMEOUT_EXCEEDS_MAX: u32 = 5; | 6 | pub const TIMEOUT_EXCEEDS_MAX: u32 = 5; |
| 7 | + pub const INTERACTIVE_COMMAND: u32 = 6; | ||
| 7 | } | 8 | } |
| 8 | 9 | ||
| 9 | use agent_contracts::backend::{PathKind, PathStat}; | 10 | use agent_contracts::backend::{PathKind, PathStat}; |
| @@ -0,0 +1,502 @@ | |||
| 1 | +//! Interactive command detection for bash tool. | ||
| 2 | +//! | ||
| 3 | +//! This module detects interactive bash commands (SSH, sudo, passwd, etc.) that require | ||
| 4 | +//! user input, and returns detailed error messages guiding the model to use ask_user_question | ||
| 5 | +//! tool instead of directly executing interactive commands. | ||
| 6 | + | ||
| 7 | +use lazy_static::lazy_static; | ||
| 8 | +use regex::Regex; | ||
| 9 | + | ||
| 10 | +use super::super::input::BashInput; | ||
| 11 | +use super::backend::error_code::INTERACTIVE_COMMAND; | ||
| 12 | +use super::backend::ValidationResult; | ||
| 13 | + | ||
| 14 | +/// Interactive command detection rule | ||
| 15 | +struct InteractiveCommandRule { | ||
| 16 | + /// Command type (e.g., "ssh", "sudo") | ||
| 17 | + command_type: &'static str, | ||
| 18 | + /// Regex patterns to match the command | ||
| 19 | + patterns: &'static [&'static str], | ||
| 20 | + /// Function to check if password is needed | ||
| 21 | + needs_password_check: fn(&str) -> bool, | ||
| 22 | + /// Function to check if hostkey confirmation is needed (optional) | ||
| 23 | + needs_hostkey_check: Option<fn(&str) -> bool>, | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// Lazy-static regex patterns for better performance | ||
| 27 | +lazy_static! { | ||
| 28 | + // SSH patterns | ||
| 29 | + static ref SSH_PATTERN: Regex = Regex::new(r"^ssh\b").unwrap(); | ||
| 30 | + static ref SCP_PATTERN: Regex = Regex::new(r"^scp\b").unwrap(); | ||
| 31 | + static ref RSYNC_SSH_PATTERN: Regex = Regex::new(r"^rsync\b.*ssh").unwrap(); | ||
| 32 | + static ref SSH_KEY_PARAM: Regex = Regex::new(r"\s+-i\s+").unwrap(); | ||
| 33 | + static ref SSH_BATCH_MODE: Regex = Regex::new(r"-o\s+BatchMode=yes").unwrap(); | ||
| 34 | + static ref SSH_STRICT_HOSTKEY: Regex = Regex::new(r"-o\s+StrictHostKeyChecking=no").unwrap(); | ||
| 35 | + static ref SSH_USER_HOSTS_FILE: Regex = Regex::new(r"-o\s+UserKnownHostsFile=/dev/null").unwrap(); | ||
| 36 | + | ||
| 37 | + // Sudo patterns | ||
| 38 | + static ref SUDO_PATTERN: Regex = Regex::new(r"^sudo\b").unwrap(); | ||
| 39 | + static ref SUDO_NO_PASSWORD: Regex = Regex::new(r"\s+-n\b").unwrap(); | ||
| 40 | + | ||
| 41 | + // Passwd patterns | ||
| 42 | + static ref PASSWD_PATTERN: Regex = Regex::new(r"^passwd\b").unwrap(); | ||
| 43 | + static ref CHPASSWD_PATTERN: Regex = Regex::new(r"^chpasswd\b").unwrap(); | ||
| 44 | + | ||
| 45 | + // Su patterns | ||
| 46 | + static ref SU_PATTERN: Regex = Regex::new(r"^su\b").unwrap(); | ||
| 47 | + static ref SU_NO_INTERACTIVE: Regex = Regex::new(r"\s+-\s+").unwrap(); | ||
| 48 | + | ||
| 49 | + // MySQL patterns | ||
| 50 | + static ref MYSQL_PATTERN: Regex = Regex::new(r"^mysql\b").unwrap(); | ||
| 51 | + static ref MYSQLDUMP_PATTERN: Regex = Regex::new(r"^mysqldump\b").unwrap(); | ||
| 52 | + static ref MYSQL_PASSWORD_PARAM: Regex = Regex::new(r"\s+-p\b").unwrap(); | ||
| 53 | + static ref MYSQL_PASSWORD_VALUE: Regex = Regex::new(r"-p\s*['\w]").unwrap(); | ||
| 54 | + | ||
| 55 | + // GPG patterns | ||
| 56 | + static ref GPG_PATTERN: Regex = Regex::new(r"^gpg\b").unwrap(); | ||
| 57 | + static ref GPG_NEEDS_PASSPHRASE: Regex = Regex::new(r"--decrypt|--sign|--clearsign").unwrap(); | ||
| 58 | + static ref GPG_BATCH_MODE: Regex = Regex::new(r"--batch").unwrap(); | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/// Check if SSH command needs password | ||
| 62 | +fn ssh_needs_password(command: &str) -> bool { | ||
| 63 | + // If has key file or BatchMode, no password needed | ||
| 64 | + !SSH_KEY_PARAM.is_match(command) && !SSH_BATCH_MODE.is_match(command) | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/// Check if SSH command needs hostkey confirmation | ||
| 68 | +fn ssh_needs_hostkey(command: &str) -> bool { | ||
| 69 | + !SSH_STRICT_HOSTKEY.is_match(command) && !SSH_USER_HOSTS_FILE.is_match(command) | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/// Check if sudo command needs password | ||
| 73 | +fn sudo_needs_password(command: &str) -> bool { | ||
| 74 | + !SUDO_NO_PASSWORD.is_match(command) | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +/// Check if passwd command needs password (always true) | ||
| 78 | +fn passwd_needs_password(_command: &str) -> bool { | ||
| 79 | + true | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +/// Check if su command needs password | ||
| 83 | +fn su_needs_password(command: &str) -> bool { | ||
| 84 | + !SU_NO_INTERACTIVE.is_match(command) | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +/// Check if mysql command needs password | ||
| 88 | +fn mysql_needs_password(command: &str) -> bool { | ||
| 89 | + MYSQL_PASSWORD_PARAM.is_match(command) && !MYSQL_PASSWORD_VALUE.is_match(command) | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/// Check if gpg command needs passphrase | ||
| 93 | +fn gpg_needs_password(command: &str) -> bool { | ||
| 94 | + GPG_NEEDS_PASSPHRASE.is_match(command) && !GPG_BATCH_MODE.is_match(command) | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +/// Predefined interactive command rules (Phase 1 + Phase 2) | ||
| 98 | +const INTERACTIVE_COMMAND_RULES: &[InteractiveCommandRule] = &[ | ||
| 99 | + // Phase 1: SSH, sudo, passwd | ||
| 100 | + InteractiveCommandRule { | ||
| 101 | + command_type: "ssh", | ||
| 102 | + patterns: &["^ssh\\b", "^scp\\b", "^rsync\\b.*ssh"], | ||
| 103 | + needs_password_check: ssh_needs_password, | ||
| 104 | + needs_hostkey_check: Some(ssh_needs_hostkey), | ||
| 105 | + }, | ||
| 106 | + InteractiveCommandRule { | ||
| 107 | + command_type: "sudo", | ||
| 108 | + patterns: &["^sudo\\b"], | ||
| 109 | + needs_password_check: sudo_needs_password, | ||
| 110 | + needs_hostkey_check: None, | ||
| 111 | + }, | ||
| 112 | + InteractiveCommandRule { | ||
| 113 | + command_type: "passwd", | ||
| 114 | + patterns: &["^passwd\\b", "^chpasswd\\b"], | ||
| 115 | + needs_password_check: passwd_needs_password, | ||
| 116 | + needs_hostkey_check: None, | ||
| 117 | + }, | ||
| 118 | + // Phase 2: su, mysql, gpg | ||
| 119 | + InteractiveCommandRule { | ||
| 120 | + command_type: "su", | ||
| 121 | + patterns: &["^su\\b"], | ||
| 122 | + needs_password_check: su_needs_password, | ||
| 123 | + needs_hostkey_check: None, | ||
| 124 | + }, | ||
| 125 | + InteractiveCommandRule { | ||
| 126 | + command_type: "mysql", | ||
| 127 | + patterns: &["^mysql\\b", "^mysqldump\\b"], | ||
| 128 | + needs_password_check: mysql_needs_password, | ||
| 129 | + needs_hostkey_check: None, | ||
| 130 | + }, | ||
| 131 | + InteractiveCommandRule { | ||
| 132 | + command_type: "gpg", | ||
| 133 | + patterns: &["^gpg\\b"], | ||
| 134 | + needs_password_check: gpg_needs_password, | ||
| 135 | + needs_hostkey_check: None, | ||
| 136 | + }, | ||
| 137 | +]; | ||
| 138 | + | ||
| 139 | +/// Detect if command is interactive | ||
| 140 | +fn detect_interactive_command(command: &str) -> Option<(String, bool, bool)> { | ||
| 141 | + let command_trimmed = command.trim(); | ||
| 142 | + | ||
| 143 | + for rule in INTERACTIVE_COMMAND_RULES { | ||
| 144 | + // Check command patterns | ||
| 145 | + for pattern_str in rule.patterns { | ||
| 146 | + let pattern = Regex::new(pattern_str).unwrap(); | ||
| 147 | + if pattern.is_match(command_trimmed) { | ||
| 148 | + // Check if password is needed | ||
| 149 | + let needs_password = (rule.needs_password_check)(command_trimmed); | ||
| 150 | + | ||
| 151 | + // Check if hostkey confirmation is needed | ||
| 152 | + let needs_hostkey = if let Some(check_fn) = rule.needs_hostkey_check { | ||
| 153 | + check_fn(command_trimmed) | ||
| 154 | + } else { | ||
| 155 | + false | ||
| 156 | + }; | ||
| 157 | + | ||
| 158 | + if needs_password || needs_hostkey { | ||
| 159 | + return Some((rule.command_type.to_string(), needs_password, needs_hostkey)); | ||
| 160 | + } | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + None | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +/// Build detailed error message for interactive command | ||
| 168 | +fn build_interactive_error_message( | ||
| 169 | + command_type: &str, | ||
| 170 | + needs_password: bool, | ||
| 171 | + needs_hostkey: bool, | ||
| 172 | + command: &str, | ||
| 173 | +) -> String { | ||
| 174 | + let mut message_parts = vec![ | ||
| 175 | + "❌ 不支持交互式 bash 命令。".to_string(), | ||
| 176 | + "".to_string(), | ||
| 177 | + format!("命令类型:{}", command_type), | ||
| 178 | + format!("原始命令:{}", command), | ||
| 179 | + "".to_string(), | ||
| 180 | + "需要用户提供:".to_string(), | ||
| 181 | + ]; | ||
| 182 | + | ||
| 183 | + if needs_password { | ||
| 184 | + message_parts.push(" - 密码".to_string()); | ||
| 185 | + } | ||
| 186 | + if needs_hostkey { | ||
| 187 | + message_parts.push(" - 主机密钥确认".to_string()); | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + message_parts.push("".to_string()); | ||
| 191 | + message_parts.push("非交互式命令示例:".to_string()); | ||
| 192 | + | ||
| 193 | + // Generate specific guidance based on needs | ||
| 194 | + match command_type { | ||
| 195 | + "ssh" => { | ||
| 196 | + if needs_password && needs_hostkey { | ||
| 197 | + message_parts.push(" sshpass -p '<password>' ssh -o StrictHostKeyChecking=no <user>@<host> <command>".to_string()); | ||
| 198 | + } else if needs_password { | ||
| 199 | + message_parts | ||
| 200 | + .push(" sshpass -p '<password>' ssh <user>@<host> <command>".to_string()); | ||
| 201 | + } else if needs_hostkey { | ||
| 202 | + message_parts | ||
| 203 | + .push(" ssh -o StrictHostKeyChecking=no <user>@<host> <command>".to_string()); | ||
| 204 | + } | ||
| 205 | + } | ||
| 206 | + "sudo" => { | ||
| 207 | + message_parts.push(" echo '<password>' | sudo -S <command>".to_string()); | ||
| 208 | + } | ||
| 209 | + "passwd" => { | ||
| 210 | + message_parts.push(" echo '<newpass>\\n<newpass>' | passwd".to_string()); | ||
| 211 | + } | ||
| 212 | + "su" => { | ||
| 213 | + message_parts.push(" echo '<password>' | su -c '<command>'".to_string()); | ||
| 214 | + } | ||
| 215 | + "mysql" => { | ||
| 216 | + message_parts.push(" mysql -u <user> -p'<password>'".to_string()); | ||
| 217 | + } | ||
| 218 | + "gpg" => { | ||
| 219 | + message_parts.push( | ||
| 220 | + " echo '<passphrase>' | gpg --batch --passphrase-fd 0 --decrypt <file>" | ||
| 221 | + .to_string(), | ||
| 222 | + ); | ||
| 223 | + } | ||
| 224 | + _ => { | ||
| 225 | + message_parts.push(" 请根据命令类型构造非交互式命令".to_string()); | ||
| 226 | + } | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + message_parts.extend(vec![ | ||
| 230 | + "".to_string(), | ||
| 231 | + "提示:使用 ask_user_question 时,每个问题只问一个事项,避免歧义。".to_string(), | ||
| 232 | + ]); | ||
| 233 | + | ||
| 234 | + // For commands that need password/passphrase, add guidance | ||
| 235 | + if needs_password { | ||
| 236 | + message_parts.push("".to_string()); | ||
| 237 | + message_parts.push("⚠ 收集密码/密钥时,请隐藏用户输入。".to_string()); | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + message_parts.join("\n") | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +/// Validate if command is interactive | ||
| 244 | +pub fn validate_interactive_command(input: &BashInput) -> ValidationResult { | ||
| 245 | + let command = input.command.trim(); | ||
| 246 | + | ||
| 247 | + // Detect if command needs interaction | ||
| 248 | + if let Some((command_type, needs_password, needs_hostkey)) = detect_interactive_command(command) | ||
| 249 | + { | ||
| 250 | + let error_message = | ||
| 251 | + build_interactive_error_message(&command_type, needs_password, needs_hostkey, command); | ||
| 252 | + | ||
| 253 | + return ValidationResult::error(error_message, INTERACTIVE_COMMAND); | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + ValidationResult::ok() | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | + | ||
| 260 | +mod tests { | ||
| 261 | + use super::*; | ||
| 262 | + | ||
| 263 | + | ||
| 264 | + fn test_error_message_quality() { | ||
| 265 | + let input = BashInput { | ||
| 266 | + command: "ssh root@192.168.1.1 ls /root".to_string(), | ||
| 267 | + cwd: None, | ||
| 268 | + timeout: None, | ||
| 269 | + }; | ||
| 270 | + let result = validate_interactive_command(&input); | ||
| 271 | + assert!(!result.result); | ||
| 272 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 273 | + | ||
| 274 | + let message = result.message.unwrap(); | ||
| 275 | + assert!(message.contains("❌ 不支持交互式 bash 命令")); | ||
| 276 | + assert!(message.contains("命令类型:ssh")); | ||
| 277 | + assert!(message.contains("原始命令:ssh root@192.168.1.1 ls /root")); | ||
| 278 | + assert!(message.contains("需要用户提供:")); | ||
| 279 | + assert!(message.contains("密码")); | ||
| 280 | + assert!(message.contains("非交互式命令示例:")); | ||
| 281 | + assert!(message.contains("sshpass")); | ||
| 282 | + assert!(message.contains("提示")); | ||
| 283 | + assert!(message.contains("隐藏用户输入")); | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + | ||
| 287 | + fn test_ssh_with_key_allowed() { | ||
| 288 | + let input = BashInput { | ||
| 289 | + command: "ssh -i ~/.ssh/id_rsa root@192.168.1.1 ls /root".to_string(), | ||
| 290 | + cwd: None, | ||
| 291 | + timeout: None, | ||
| 292 | + }; | ||
| 293 | + let result = validate_interactive_command(&input); | ||
| 294 | + // Has key parameter, no password needed, but still needs hostkey confirmation (first time) | ||
| 295 | + // Should be detected as interactive (needs hostkey confirmation) | ||
| 296 | + assert!(!result.result); | ||
| 297 | + let message = result.message.unwrap(); | ||
| 298 | + // Check that hostkey confirmation is listed in user interaction section | ||
| 299 | + assert!(message.contains("主机密钥确认")); | ||
| 300 | + // Since there's a key, password should not be listed | ||
| 301 | + // Check the "需要用户提供:" section | ||
| 302 | + let needs_section_start = message.find("需要用户提供:").unwrap(); | ||
| 303 | + let example_section_start = message.find("非交互式命令示例:").unwrap(); | ||
| 304 | + let needs_section = &message[needs_section_start..example_section_start]; | ||
| 305 | + assert!(needs_section.contains("主机密钥确认")); | ||
| 306 | + assert!(!needs_section.contains("密码")); | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + | ||
| 310 | + fn test_ssh_strict_hostkey_allowed() { | ||
| 311 | + let input = BashInput { | ||
| 312 | + command: "ssh -o StrictHostKeyChecking=no root@192.168.1.1 ls /root".to_string(), | ||
| 313 | + cwd: None, | ||
| 314 | + timeout: None, | ||
| 315 | + }; | ||
| 316 | + let result = validate_interactive_command(&input); | ||
| 317 | + // Has StrictHostKeyChecking=no, no hostkey needed, but still needs password (no key) | ||
| 318 | + // Should be detected as interactive (needs password) | ||
| 319 | + assert!(!result.result); | ||
| 320 | + let message = result.message.unwrap(); | ||
| 321 | + assert!(message.contains("密码")); | ||
| 322 | + assert!(!message.contains("主机密钥确认")); | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + | ||
| 326 | + fn test_ssh_with_key_and_no_hostkey_allowed() { | ||
| 327 | + let input = BashInput { | ||
| 328 | + command: "ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@192.168.1.1 ls /root" | ||
| 329 | + .to_string(), | ||
| 330 | + cwd: None, | ||
| 331 | + timeout: None, | ||
| 332 | + }; | ||
| 333 | + let result = validate_interactive_command(&input); | ||
| 334 | + // Has both key and StrictHostKeyChecking=no, fully non-interactive | ||
| 335 | + // Should be allowed | ||
| 336 | + assert!(result.result); | ||
| 337 | + } | ||
| 338 | + | ||
| 339 | + | ||
| 340 | + fn test_ssh_with_batch_and_no_hostkey_allowed() { | ||
| 341 | + let input = BashInput { | ||
| 342 | + command: "ssh -o BatchMode=yes -o StrictHostKeyChecking=no root@192.168.1.1 ls /root" | ||
| 343 | + .to_string(), | ||
| 344 | + cwd: None, | ||
| 345 | + timeout: None, | ||
| 346 | + }; | ||
| 347 | + let result = validate_interactive_command(&input); | ||
| 348 | + // Has both BatchMode and StrictHostKeyChecking=no, fully non-interactive | ||
| 349 | + // Should be allowed | ||
| 350 | + assert!(result.result); | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + | ||
| 354 | + fn test_sudo_needs_password_detection() { | ||
| 355 | + let input = BashInput { | ||
| 356 | + command: "sudo cat /var/log/syslog".to_string(), | ||
| 357 | + cwd: None, | ||
| 358 | + timeout: None, | ||
| 359 | + }; | ||
| 360 | + let result = validate_interactive_command(&input); | ||
| 361 | + assert!(!result.result); | ||
| 362 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 363 | + assert!(result.message.unwrap().contains("sudo")); | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + | ||
| 367 | + fn test_sudo_with_no_password_allowed() { | ||
| 368 | + let input = BashInput { | ||
| 369 | + command: "sudo -n cat /var/log/syslog".to_string(), | ||
| 370 | + cwd: None, | ||
| 371 | + timeout: None, | ||
| 372 | + }; | ||
| 373 | + let result = validate_interactive_command(&input); | ||
| 374 | + assert!(result.result); // Has -n parameter, allowed | ||
| 375 | + } | ||
| 376 | + | ||
| 377 | + | ||
| 378 | + fn test_passwd_detection() { | ||
| 379 | + let input = BashInput { | ||
| 380 | + command: "passwd".to_string(), | ||
| 381 | + cwd: None, | ||
| 382 | + timeout: None, | ||
| 383 | + }; | ||
| 384 | + let result = validate_interactive_command(&input); | ||
| 385 | + assert!(!result.result); | ||
| 386 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 387 | + } | ||
| 388 | + | ||
| 389 | + | ||
| 390 | + fn test_su_needs_password_detection() { | ||
| 391 | + let input = BashInput { | ||
| 392 | + command: "su -".to_string(), | ||
| 393 | + cwd: None, | ||
| 394 | + timeout: None, | ||
| 395 | + }; | ||
| 396 | + let result = validate_interactive_command(&input); | ||
| 397 | + assert!(!result.result); | ||
| 398 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + | ||
| 402 | + fn test_mysql_needs_password_detection() { | ||
| 403 | + let input = BashInput { | ||
| 404 | + command: "mysql -u root -p".to_string(), | ||
| 405 | + cwd: None, | ||
| 406 | + timeout: None, | ||
| 407 | + }; | ||
| 408 | + let result = validate_interactive_command(&input); | ||
| 409 | + assert!(!result.result); | ||
| 410 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 411 | + } | ||
| 412 | + | ||
| 413 | + | ||
| 414 | + fn test_mysql_with_password_allowed() { | ||
| 415 | + let input = BashInput { | ||
| 416 | + command: "mysql -u root -p'mypassword'".to_string(), | ||
| 417 | + cwd: None, | ||
| 418 | + timeout: None, | ||
| 419 | + }; | ||
| 420 | + let result = validate_interactive_command(&input); | ||
| 421 | + assert!(result.result); // Has password value, allowed | ||
| 422 | + } | ||
| 423 | + | ||
| 424 | + | ||
| 425 | + fn test_gpg_needs_passphrase_detection() { | ||
| 426 | + let input = BashInput { | ||
| 427 | + command: "gpg --decrypt file.gpg".to_string(), | ||
| 428 | + cwd: None, | ||
| 429 | + timeout: None, | ||
| 430 | + }; | ||
| 431 | + let result = validate_interactive_command(&input); | ||
| 432 | + assert!(!result.result); | ||
| 433 | + assert_eq!(result.error_code, Some(INTERACTIVE_COMMAND)); | ||
| 434 | + } | ||
| 435 | + | ||
| 436 | + | ||
| 437 | + fn test_gpg_with_batch_allowed() { | ||
| 438 | + let input = BashInput { | ||
| 439 | + command: "gpg --batch --passphrase-fd 0 --decrypt file.gpg".to_string(), | ||
| 440 | + cwd: None, | ||
| 441 | + timeout: None, | ||
| 442 | + }; | ||
| 443 | + let result = validate_interactive_command(&input); | ||
| 444 | + assert!(result.result); // Has batch mode, allowed | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + | ||
| 448 | + fn test_normal_command_allowed() { | ||
| 449 | + let input = BashInput { | ||
| 450 | + command: "ls -la".to_string(), | ||
| 451 | + cwd: None, | ||
| 452 | + timeout: None, | ||
| 453 | + }; | ||
| 454 | + let result = validate_interactive_command(&input); | ||
| 455 | + assert!(result.result); // Normal command, allowed | ||
| 456 | + } | ||
| 457 | + | ||
| 458 | + | ||
| 459 | + fn test_scp_detection() { | ||
| 460 | + let input = BashInput { | ||
| 461 | + command: "scp file.txt root@192.168.1.1:/tmp/".to_string(), | ||
| 462 | + cwd: None, | ||
| 463 | + timeout: None, | ||
| 464 | + }; | ||
| 465 | + let result = validate_interactive_command(&input); | ||
| 466 | + assert!(!result.result); // SCP needs password | ||
| 467 | + assert!(result.message.unwrap().contains("ssh")); | ||
| 468 | + } | ||
| 469 | + | ||
| 470 | + | ||
| 471 | + fn test_rsync_ssh_detection() { | ||
| 472 | + let input = BashInput { | ||
| 473 | + command: "rsync -avz -e ssh file.txt root@192.168.1.1:/tmp/".to_string(), | ||
| 474 | + cwd: None, | ||
| 475 | + timeout: None, | ||
| 476 | + }; | ||
| 477 | + let result = validate_interactive_command(&input); | ||
| 478 | + assert!(!result.result); // Rsync with ssh needs password | ||
| 479 | + } | ||
| 480 | + | ||
| 481 | + | ||
| 482 | + fn test_sshpass_command_allowed() { | ||
| 483 | + let input = BashInput { | ||
| 484 | + command: "sshpass -p 'password' ssh root@192.168.1.1 ls /root".to_string(), | ||
| 485 | + cwd: None, | ||
| 486 | + timeout: None, | ||
| 487 | + }; | ||
| 488 | + let result = validate_interactive_command(&input); | ||
| 489 | + assert!(result.result); // sshpass command, non-interactive | ||
| 490 | + } | ||
| 491 | + | ||
| 492 | + | ||
| 493 | + fn test_empty_command_allowed() { | ||
| 494 | + let input = BashInput { | ||
| 495 | + command: "".to_string(), | ||
| 496 | + cwd: None, | ||
| 497 | + timeout: None, | ||
| 498 | + }; | ||
| 499 | + let result = validate_interactive_command(&input); | ||
| 500 | + assert!(result.result); // Empty command won't be detected as interactive | ||
| 501 | + } | ||
| 502 | +} | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | pub(super) use super::{constants, input}; | 1 | pub(super) use super::{constants, input}; |
| 2 | 2 | ||
| 3 | pub(crate) mod backend; | 3 | pub(crate) mod backend; |
| 4 | +pub(crate) mod interactive; | ||
| @@ -255,20 +255,20 @@ Health check endpoint for liveness probes and load balancing. | |||
| 255 | 255 | ||
| 256 | #### Session And Runtime Control Plane | 256 | #### Session And Runtime Control Plane |
| 257 | 257 | ||
| 258 | -The daemon exposes session APIs for remote TUI and other first-class clients, | 258 | +The daemon exposes runtime APIs for remote TUI and other first-class clients, |
| 259 | -plus runtime checkpoint APIs for callers that need branching execution state. | 259 | +plus checkpoint APIs for callers that need branching execution state. |
| 260 | These endpoints are protected by HTTP Bearer auth when `[http]` auth is configured. | 260 | These endpoints are protected by HTTP Bearer auth when `[http]` auth is configured. |
| 261 | -LLM provider settings are resolved per session/turn: request payloads may pass an | 261 | +LLM provider settings are resolved per runtime/turn: request payloads may pass an |
| 262 | optional `llm` object, and omitted fields fall back to `[llm]` in the daemon | 262 | optional `llm` object, and omitted fields fall back to `[llm]` in the daemon |
| 263 | config. The daemon does not require the LLM API key at process startup. | 263 | config. The daemon does not require the LLM API key at process startup. |
| 264 | 264 | ||
| 265 | | Endpoint | Description | | 265 | | Endpoint | Description | |
| 266 | |----------|-------------| | 266 | |----------|-------------| |
| 267 | -| `POST /api/v1/sessions/open` | Open or resume a gateway session using `SessionOpenRequest` | | 267 | +| `POST /api/v1/runtimes/open` | Open or resume a runtime using `RuntimeOpenRequest` | |
| 268 | -| `POST /api/v1/sessions/input` | Submit one user input and stream SSE events | | 268 | +| `POST /api/v1/runtimes/input` | Submit one user input and stream SSE events | |
| 269 | -| `POST /api/v1/sessions/interaction` | Send a user interaction response back to the daemon | | 269 | +| `POST /api/v1/runtimes/interaction` | Send a user interaction response back to the daemon | |
| 270 | -| `POST /api/v1/sessions/cancel` | Request cancellation of the current turn | | 270 | +| `POST /api/v1/runtimes/cancel` | Request cancellation of the current turn | |
| 271 | -| `POST /api/v1/sessions/close` | Close the session, remove its record, and fire lifecycle hooks | | 271 | +| `POST /api/v1/runtimes/close` | Close the runtime, remove its record, and fire lifecycle hooks | |
| 272 | | `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint using `RuntimeCheckpointRequest` | | 272 | | `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint using `RuntimeCheckpointRequest` | |
| 273 | | `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Delete the provider snapshot/template referenced by a checkpoint | | 273 | | `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Delete the provider snapshot/template referenced by a checkpoint | |
| 274 | | `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint using `RuntimeCheckoutRequest` | | 274 | | `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint using `RuntimeCheckoutRequest` | |
| @@ -279,14 +279,14 @@ internal `session_id`; backend ids remain internal and are not returned in | |||
| 279 | `RuntimeRecord`. See [Runtime Checkpoint Control](./runtime_checkpoint.md) | 279 | `RuntimeRecord`. See [Runtime Checkpoint Control](./runtime_checkpoint.md) |
| 280 | for the current layering and checkpoint semantics. | 280 | for the current layering and checkpoint semantics. |
| 281 | 281 | ||
| 282 | -**Open session example:** | 282 | +**Open runtime example:** |
| 283 | 283 | ||
| 284 | ```bash | 284 | ```bash |
| 285 | -curl -X POST http://localhost:18080/api/v1/sessions/open \ | 285 | +curl -X POST http://localhost:18080/api/v1/runtimes/open \ |
| 286 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ | 286 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ |
| 287 | -H "Content-Type: application/json" \ | 287 | -H "Content-Type: application/json" \ |
| 288 | -d '{ | 288 | -d '{ |
| 289 | - "session_id": "tui-demo", | 289 | + "runtime_id": "tui-demo", |
| 290 | "conversation_id": "conv-demo", | 290 | "conversation_id": "conv-demo", |
| 291 | "sender_id": "user-1", | 291 | "sender_id": "user-1", |
| 292 | "entry": { "kind": "tui" }, | 292 | "entry": { "kind": "tui" }, |
| @@ -299,25 +299,25 @@ curl -X POST http://localhost:18080/api/v1/sessions/open \ | |||
| 299 | }' | 299 | }' |
| 300 | ``` | 300 | ``` |
| 301 | 301 | ||
| 302 | -**Close session example:** | 302 | +**Close runtime example:** |
| 303 | 303 | ||
| 304 | ```bash | 304 | ```bash |
| 305 | -curl -X POST http://localhost:18080/api/v1/sessions/close \ | 305 | +curl -X POST http://localhost:18080/api/v1/runtimes/close \ |
| 306 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ | 306 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ |
| 307 | -H "Content-Type: application/json" \ | 307 | -H "Content-Type: application/json" \ |
| 308 | -d '{ | 308 | -d '{ |
| 309 | - "session_id": "tui-demo" | 309 | + "runtime_id": "tui-demo" |
| 310 | }' | 310 | }' |
| 311 | ``` | 311 | ``` |
| 312 | 312 | ||
| 313 | **Submit input stream example:** | 313 | **Submit input stream example:** |
| 314 | 314 | ||
| 315 | ```bash | 315 | ```bash |
| 316 | -curl -N -X POST http://localhost:18080/api/v1/sessions/input \ | 316 | +curl -N -X POST http://localhost:18080/api/v1/runtimes/input \ |
| 317 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ | 317 | -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \ |
| 318 | -H "Content-Type: application/json" \ | 318 | -H "Content-Type: application/json" \ |
| 319 | -d '{ | 319 | -d '{ |
| 320 | - "session_id": "tui-demo", | 320 | + "runtime_id": "tui-demo", |
| 321 | "entry": { "kind": "tui" }, | 321 | "entry": { "kind": "tui" }, |
| 322 | "channel": "tui", | 322 | "channel": "tui", |
| 323 | "conversation_id": "conv-demo", | 323 | "conversation_id": "conv-demo", |
| @@ -335,10 +335,10 @@ curl -N -X POST http://localhost:18080/api/v1/sessions/input \ | |||
| 335 | **Runtime checkpoint / checkout example with timing:** | 335 | **Runtime checkpoint / checkout example with timing:** |
| 336 | 336 | ||
| 337 | The runtime checkpoint APIs require the source runtime to be idle. In the | 337 | The runtime checkpoint APIs require the source runtime to be idle. In the |
| 338 | -current v1 implementation, the `runtime_id` is the same value as the session id | 338 | +current v1 implementation, the `runtime_id` is the same value as the runtime id |
| 339 | -returned by `/api/v1/sessions/open`. The following flow creates a session, runs | 339 | +returned by `/api/v1/runtimes/open`. The following flow creates a runtime, runs |
| 340 | one turn to make the backend dirty, captures a checkpoint, checks out a child | 340 | one turn to make the backend dirty, captures a checkpoint, checks out a child |
| 341 | -runtime, runs both branches, and closes both sessions. | 341 | +runtime, runs both branches, and closes both runtimes. |
| 342 | 342 | ||
| 343 | The example requires Bash, `curl`, and `jq`. It only adds the `Authorization` | 343 | The example requires Bash, `curl`, and `jq`. It only adds the `Authorization` |
| 344 | header when `XIAOO_HTTP_BEARER_TOKEN` is present. It uses | 344 | header when `XIAOO_HTTP_BEARER_TOKEN` is present. It uses |
| @@ -347,8 +347,8 @@ checkpoint and checkout control-plane calls. | |||
| 347 | 347 | ||
| 348 | ```bash | 348 | ```bash |
| 349 | BASE_URL="http://localhost:18080" | 349 | BASE_URL="http://localhost:18080" |
| 350 | -SESSION="checkpoint-demo-$(date +%Y%m%d%H%M%S)" | 350 | +RUNTIME="checkpoint-demo-$(date +%Y%m%d%H%M%S)" |
| 351 | -CONV="conv-${SESSION}" | 351 | +CONV="conv-${RUNTIME}" |
| 352 | SENDER="checkpoint-demo-user" | 352 | SENDER="checkpoint-demo-user" |
| 353 | 353 | ||
| 354 | AUTH_HEADER=() | 354 | AUTH_HEADER=() |
| @@ -356,15 +356,15 @@ if [ -n "${XIAOO_HTTP_BEARER_TOKEN:-}" ]; then | |||
| 356 | AUTH_HEADER=(-H "Authorization: Bearer ${XIAOO_HTTP_BEARER_TOKEN}") | 356 | AUTH_HEADER=(-H "Authorization: Bearer ${XIAOO_HTTP_BEARER_TOKEN}") |
| 357 | fi | 357 | fi |
| 358 | 358 | ||
| 359 | -jq -n --arg session "$SESSION" --arg conv "$CONV" --arg sender "$SENDER" \ | 359 | +jq -n --arg runtime "$RUNTIME" --arg conv "$CONV" --arg sender "$SENDER" \ |
| 360 | '{ | 360 | '{ |
| 361 | - session_id: $session, | 361 | + runtime_id: $runtime, |
| 362 | conversation_id: $conv, | 362 | conversation_id: $conv, |
| 363 | sender_id: $sender, | 363 | sender_id: $sender, |
| 364 | entry: { kind: "http_api", instance_id: "checkpoint-demo" } | 364 | entry: { kind: "http_api", instance_id: "checkpoint-demo" } |
| 365 | }' > /tmp/xiaoo_open.json | 365 | }' > /tmp/xiaoo_open.json |
| 366 | 366 | ||
| 367 | -curl -sS -X POST "$BASE_URL/api/v1/sessions/open" \ | 367 | +curl -sS -X POST "$BASE_URL/api/v1/runtimes/open" \ |
| 368 | "${AUTH_HEADER[@]}" \ | 368 | "${AUTH_HEADER[@]}" \ |
| 369 | -H "Content-Type: application/json" \ | 369 | -H "Content-Type: application/json" \ |
| 370 | --data @/tmp/xiaoo_open.json \ | 370 | --data @/tmp/xiaoo_open.json \ |
| @@ -375,12 +375,12 @@ curl -sS -X POST "$BASE_URL/api/v1/sessions/open" \ | |||
| 375 | INIT_TEXT="请在当前 agent runtime 的工作区创建文件 /home/user/workspace/checkpoint_demo.txt,内容为两行:第一行 checkpoint base,第二行 runtime parent initialized。完成后读取该文件并回复其完整内容。" | 375 | INIT_TEXT="请在当前 agent runtime 的工作区创建文件 /home/user/workspace/checkpoint_demo.txt,内容为两行:第一行 checkpoint base,第二行 runtime parent initialized。完成后读取该文件并回复其完整内容。" |
| 376 | 376 | ||
| 377 | jq -n \ | 377 | jq -n \ |
| 378 | - --arg session "$SESSION" \ | 378 | + --arg runtime "$RUNTIME" \ |
| 379 | --arg conv "$CONV" \ | 379 | --arg conv "$CONV" \ |
| 380 | --arg sender "$SENDER" \ | 380 | --arg sender "$SENDER" \ |
| 381 | --arg text "$INIT_TEXT" \ | 381 | --arg text "$INIT_TEXT" \ |
| 382 | '{ | 382 | '{ |
| 383 | - session_id: $session, | 383 | + runtime_id: $runtime, |
| 384 | entry: { kind: "http_api", instance_id: "checkpoint-demo" }, | 384 | entry: { kind: "http_api", instance_id: "checkpoint-demo" }, |
| 385 | channel: null, | 385 | channel: null, |
| 386 | message_id: null, | 386 | message_id: null, |
| @@ -396,7 +396,7 @@ jq -n \ | |||
| 396 | llm: null | 396 | llm: null |
| 397 | }' > /tmp/xiaoo_initial_turn.json | 397 | }' > /tmp/xiaoo_initial_turn.json |
| 398 | 398 | ||
| 399 | -curl -sS -N -X POST "$BASE_URL/api/v1/sessions/input" \ | 399 | +curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \ |
| 400 | "${AUTH_HEADER[@]}" \ | 400 | "${AUTH_HEADER[@]}" \ |
| 401 | -H "Content-Type: application/json" \ | 401 | -H "Content-Type: application/json" \ |
| 402 | --data @/tmp/xiaoo_initial_turn.json \ | 402 | --data @/tmp/xiaoo_initial_turn.json \ |
| @@ -404,7 +404,7 @@ curl -sS -N -X POST "$BASE_URL/api/v1/sessions/input" \ | |||
| 404 | ``` | 404 | ``` |
| 405 | 405 | ||
| 406 | ```bash | 406 | ```bash |
| 407 | -jq -n --arg runtime "$SESSION" \ | 407 | +jq -n --arg runtime "$RUNTIME" \ |
| 408 | '{ | 408 | '{ |
| 409 | runtime_id: $runtime, | 409 | runtime_id: $runtime, |
| 410 | name: "fork-test-base", | 410 | name: "fork-test-base", |
| @@ -430,7 +430,7 @@ printf "checkpoint_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkpoint.time)" | |||
| 430 | ```bash | 430 | ```bash |
| 431 | jq -n \ | 431 | jq -n \ |
| 432 | --arg checkpoint "$CHECKPOINT_ID" \ | 432 | --arg checkpoint "$CHECKPOINT_ID" \ |
| 433 | - --arg child_conv "conv-${SESSION}-child" \ | 433 | + --arg child_conv "conv-${RUNTIME}-child" \ |
| 434 | '{ | 434 | '{ |
| 435 | checkpoint_id: $checkpoint, | 435 | checkpoint_id: $checkpoint, |
| 436 | conversation_id: $child_conv, | 436 | conversation_id: $child_conv, |
| @@ -450,7 +450,7 @@ curl -sS \ | |||
| 450 | --data @/tmp/xiaoo_checkout.json \ | 450 | --data @/tmp/xiaoo_checkout.json \ |
| 451 | > /tmp/xiaoo_checkout.time | 451 | > /tmp/xiaoo_checkout.time |
| 452 | 452 | ||
| 453 | -CHILD_SESSION="$(jq -r '.runtime.runtime_id' /tmp/xiaoo_checkout.out)" | 453 | +CHILD_RUNTIME="$(jq -r '.runtime.runtime_id' /tmp/xiaoo_checkout.out)" |
| 454 | printf "checkout_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkout.time)" | 454 | printf "checkout_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkout.time)" |
| 455 | ``` | 455 | ``` |
| 456 | 456 | ||
| @@ -474,9 +474,9 @@ curl -sS -X POST "$BASE_URL/api/v1/runtimes/checkpoint/delete-snapshot" \ | |||
| 474 | PARENT_TEXT="你是父 runtime。请不要写入“测试fork”。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:parent runtime complete。完成后读取该文件并回复完整内容。" | 474 | PARENT_TEXT="你是父 runtime。请不要写入“测试fork”。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:parent runtime complete。完成后读取该文件并回复完整内容。" |
| 475 | CHILD_TEXT="你是 checkpoint checkout 出来的子 runtime。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:测试fork。完成后读取该文件并回复完整内容。" | 475 | CHILD_TEXT="你是 checkpoint checkout 出来的子 runtime。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:测试fork。完成后读取该文件并回复完整内容。" |
| 476 | 476 | ||
| 477 | -jq -n --arg session "$SESSION" --arg conv "$CONV" --arg text "$PARENT_TEXT" \ | 477 | +jq -n --arg runtime "$RUNTIME" --arg conv "$CONV" --arg text "$PARENT_TEXT" \ |
| 478 | '{ | 478 | '{ |
| 479 | - session_id: $session, | 479 | + runtime_id: $runtime, |
| 480 | entry: { kind: "http_api", instance_id: "checkpoint-demo" }, | 480 | entry: { kind: "http_api", instance_id: "checkpoint-demo" }, |
| 481 | channel: null, | 481 | channel: null, |
| 482 | message_id: null, | 482 | message_id: null, |
| @@ -492,15 +492,15 @@ jq -n --arg session "$SESSION" --arg conv "$CONV" --arg text "$PARENT_TEXT" \ | |||
| 492 | llm: null | 492 | llm: null |
| 493 | }' > /tmp/xiaoo_parent_final.json | 493 | }' > /tmp/xiaoo_parent_final.json |
| 494 | 494 | ||
| 495 | -curl -sS -N -X POST "$BASE_URL/api/v1/sessions/input" \ | 495 | +curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \ |
| 496 | "${AUTH_HEADER[@]}" \ | 496 | "${AUTH_HEADER[@]}" \ |
| 497 | -H "Content-Type: application/json" \ | 497 | -H "Content-Type: application/json" \ |
| 498 | --data @/tmp/xiaoo_parent_final.json \ | 498 | --data @/tmp/xiaoo_parent_final.json \ |
| 499 | > /tmp/xiaoo_parent_final.sse | 499 | > /tmp/xiaoo_parent_final.sse |
| 500 | 500 | ||
| 501 | -jq -n --arg session "$CHILD_SESSION" --arg text "$CHILD_TEXT" \ | 501 | +jq -n --arg runtime "$CHILD_RUNTIME" --arg text "$CHILD_TEXT" \ |
| 502 | '{ | 502 | '{ |
| 503 | - session_id: $session, | 503 | + runtime_id: $runtime, |
| 504 | entry: { kind: "http_api", instance_id: "checkpoint-demo-child" }, | 504 | entry: { kind: "http_api", instance_id: "checkpoint-demo-child" }, |
| 505 | channel: null, | 505 | channel: null, |
| 506 | message_id: null, | 506 | message_id: null, |
| @@ -516,7 +516,7 @@ jq -n --arg session "$CHILD_SESSION" --arg text "$CHILD_TEXT" \ | |||
| 516 | llm: null | 516 | llm: null |
| 517 | }' > /tmp/xiaoo_child_final.json | 517 | }' > /tmp/xiaoo_child_final.json |
| 518 | 518 | ||
| 519 | -curl -sS -N -X POST "$BASE_URL/api/v1/sessions/input" \ | 519 | +curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \ |
| 520 | "${AUTH_HEADER[@]}" \ | 520 | "${AUTH_HEADER[@]}" \ |
| 521 | -H "Content-Type: application/json" \ | 521 | -H "Content-Type: application/json" \ |
| 522 | --data @/tmp/xiaoo_child_final.json \ | 522 | --data @/tmp/xiaoo_child_final.json \ |
| @@ -524,10 +524,10 @@ curl -sS -N -X POST "$BASE_URL/api/v1/sessions/input" \ | |||
| 524 | ``` | 524 | ``` |
| 525 | 525 | ||
| 526 | ```bash | 526 | ```bash |
| 527 | -for id in "$SESSION" "$CHILD_SESSION"; do | 527 | +for id in "$RUNTIME" "$CHILD_RUNTIME"; do |
| 528 | - jq -n --arg session "$id" '{ session_id: $session }' \ | 528 | + jq -n --arg runtime "$id" '{ runtime_id: $runtime }' \ |
| 529 | > "/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json" | 529 | > "/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json" |
| 530 | - curl -sS -X POST "$BASE_URL/api/v1/sessions/close" \ | 530 | + curl -sS -X POST "$BASE_URL/api/v1/runtimes/close" \ |
| 531 | "${AUTH_HEADER[@]}" \ | 531 | "${AUTH_HEADER[@]}" \ |
| 532 | -H "Content-Type: application/json" \ | 532 | -H "Content-Type: application/json" \ |
| 533 | --data @"/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json" | 533 | --data @"/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json" |
| @@ -545,7 +545,7 @@ measured: | |||
| 545 | These values are examples, not guarantees. They vary with E2B provider latency, | 545 | These values are examples, not guarantees. They vary with E2B provider latency, |
| 546 | network path, snapshot size, template cold/warm state, and daemon host load. The | 546 | network path, snapshot size, template cold/warm state, and daemon host load. The |
| 547 | numbers above do not include the LLM turns before or after the checkpoint, and | 547 | numbers above do not include the LLM turns before or after the checkpoint, and |
| 548 | -they do not include closing the sessions. Closing an E2B-backed session calls | 548 | +they do not include closing the runtimes. Closing an E2B-backed runtime calls |
| 549 | backend release, which deletes the corresponding E2B sandbox. | 549 | backend release, which deletes the corresponding E2B sandbox. |
| 550 | 550 | ||
| 551 | **SSE Event Types:** | 551 | **SSE Event Types:** |
| @@ -557,19 +557,19 @@ backend release, which deletes the corresponding E2B sandbox. | |||
| 557 | | `thinking_delta` | `delta`, `snapshot` | Emitted for assistant reasoning updates | | 557 | | `thinking_delta` | `delta`, `snapshot` | Emitted for assistant reasoning updates | |
| 558 | | `tool_result` | `call_id`, `tool_name`, `output_preview`, `is_error` | Emitted after each tool execution completes | | 558 | | `tool_result` | `call_id`, `tool_name`, `output_preview`, `is_error` | Emitted after each tool execution completes | |
| 559 | | `interaction_requested` | `request` | Emitted when the daemon needs a user confirmation/input/choice | | 559 | | `interaction_requested` | `request` | Emitted when the daemon needs a user confirmation/input/choice | |
| 560 | -| `done` | `reply`, `raw_reply`, `conversation_id`, `session_id`, `turn_count`, `total_tokens`, `messages`, `stop_reason` | Emitted when the agent loop finishes | | 560 | +| `done` | `reply`, `raw_reply`, `conversation_id`, `runtime_id`, `turn_count`, `total_tokens`, `messages`, `stop_reason` | Emitted when the agent loop finishes | |
| 561 | | `error` | `error` | Emitted on failure | | 561 | | `error` | `error` | Emitted on failure | |
| 562 | -| `cancelled` | `session_id` | Emitted as cancellation acknowledgement | | 562 | +| `cancelled` | `runtime_id` | Emitted as cancellation acknowledgement | |
| 563 | 563 | ||
| 564 | **Common Error Responses:** | 564 | **Common Error Responses:** |
| 565 | 565 | ||
| 566 | -- `400 Bad Request` — malformed request or path/session mismatch | 566 | +- `400 Bad Request` — malformed request or path/runtime mismatch |
| 567 | - `401 Unauthorized` — missing or invalid Bearer token when `[http]` auth is configured | 567 | - `401 Unauthorized` — missing or invalid Bearer token when `[http]` auth is configured |
| 568 | -- `404 Not Found` — session not found | 568 | +- `404 Not Found` — runtime not found |
| 569 | - `429 Too Many Requests` — rate limit exceeded when `[http.rate_limit]` is enabled | 569 | - `429 Too Many Requests` — rate limit exceeded when `[http.rate_limit]` is enabled |
| 570 | -- `500 Internal Server Error` — session service internal error | 570 | +- `500 Internal Server Error` — runtime service internal error |
| 571 | 571 | ||
| 572 | -> **Rate limiting applies globally** to all endpoints (`/api/v1/health`, `/api/v1/sessions/*`, `/api/v1/channels/{channel_id}/events`). Client identity is extracted from the `X-Forwarded-For` header (first IP) or `X-Real-Ip`, falling back to a shared `"unknown"` bucket. Ensure your reverse proxy (nginx / Caddy) forwards these headers. | 572 | +> **Rate limiting applies globally** to all endpoints (`/api/v1/health`, `/api/v1/runtimes/*`, `/api/v1/channels/{channel_id}/events`). Client identity is extracted from the `X-Forwarded-For` header (first IP) or `X-Real-Ip`, falling back to a shared `"unknown"` bucket. Ensure your reverse proxy (nginx / Caddy) forwards these headers. |
| 573 | 573 | ||
| 574 | --- | 574 | --- |
| 575 | 575 | ||
| @@ -14,14 +14,14 @@ Remote TUI lets one machine run the XiaoO gateway daemon while another machine r | |||
| 14 | Machine B Machine A | 14 | Machine B Machine A |
| 15 | xiaoo xiaoo-daemon | 15 | xiaoo xiaoo-daemon |
| 16 | --------- ---------------- | 16 | --------- ---------------- |
| 17 | -TUI input/rendering HTTP/SSE Gateway session APIs | 17 | +TUI input/rendering HTTP/SSE Gateway runtime APIs |
| 18 | /remote commands -----------> Agent loop | 18 | /remote commands -----------> Agent loop |
| 19 | Interaction prompt <----------> Tools / hooks / workspace | 19 | Interaction prompt <----------> Tools / hooks / workspace |
| 20 | ``` | 20 | ``` |
| 21 | 21 | ||
| 22 | Local TUI remains the default. Remote mode is opt-in: | 22 | Local TUI remains the default. Remote mode is opt-in: |
| 23 | 23 | ||
| 24 | -- `Local`: TUI opens sessions and runs the agent loop in the local process. | 24 | +- `Local`: TUI opens runtimes and runs the agent loop in the local process. |
| 25 | - `Remote`: TUI sends turns to the daemon and renders the daemon's SSE events. | 25 | - `Remote`: TUI sends turns to the daemon and renders the daemon's SSE events. |
| 26 | 26 | ||
| 27 | In remote mode, all tool execution happens on Machine A. The workspace shown in the TUI status bar is marked as remote to avoid confusing it with Machine B's local directory. | 27 | In remote mode, all tool execution happens on Machine A. The workspace shown in the TUI status bar is marked as remote to avoid confusing it with Machine B's local directory. |
| @@ -116,23 +116,22 @@ After `/remote <base_url>` succeeds, new turns go through Machine A's daemon. Th | |||
| 116 | 116 | ||
| 117 | ## 5. Remote Session And Runtime API | 117 | ## 5. Remote Session And Runtime API |
| 118 | 118 | ||
| 119 | -Remote TUI uses the daemon's session APIs. The same protected route group also | 119 | +Remote TUI uses the daemon's runtime control APIs. The same protected route |
| 120 | -contains runtime checkpoint APIs for programmatic clients that need branching | 120 | +group also contains checkpoint APIs for programmatic clients that need branching |
| 121 | runtime state. | 121 | runtime state. |
| 122 | 122 | ||
| 123 | | Endpoint | Description | | 123 | | Endpoint | Description | |
| 124 | |----------|-------------| | 124 | |----------|-------------| |
| 125 | -| `POST /api/v1/sessions/open` | Open or resume a gateway session using `SessionOpenRequest` | | 125 | +| `POST /api/v1/runtimes/open` | Open or resume a runtime using `RuntimeOpenRequest` | |
| 126 | -| `POST /api/v1/sessions/input` | Submit one user input and stream SSE events | | 126 | +| `POST /api/v1/runtimes/input` | Submit one user input and stream SSE events | |
| 127 | -| `POST /api/v1/sessions/interaction` | Send a user interaction response back to the daemon | | 127 | +| `POST /api/v1/runtimes/interaction` | Send a user interaction response back to the daemon | |
| 128 | -| `POST /api/v1/sessions/cancel` | Request cancellation of the current turn | | 128 | +| `POST /api/v1/runtimes/cancel` | Request cancellation of the current turn | |
| 129 | -| `POST /api/v1/sessions/close` | Close the session, remove its record, and fire lifecycle hooks | | 129 | +| `POST /api/v1/runtimes/close` | Close the runtime, remove its record, and fire lifecycle hooks | |
| 130 | | `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint | | 130 | | `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint | |
| 131 | | `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint | | 131 | | `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint | |
| 132 | 132 | ||
| 133 | -For the checkpoint API, callers use `runtime_id` and `checkpoint_id`. Current | 133 | +Runtime control payloads use `runtime_id` and `checkpoint_id` as their public |
| 134 | -v1 runtime ids are backed by internal session ids, while backend ids and | 134 | +vocabulary. |
| 135 | -provider-native instance ids stay internal to the daemon. | ||
| 136 | 135 | ||
| 137 | SSE event types: | 136 | SSE event types: |
| 138 | 137 | ||
| @@ -142,7 +141,7 @@ SSE event types: | |||
| 142 | | `text_delta` | Assistant text update; includes both incremental `delta` and cumulative `snapshot` | | 141 | | `text_delta` | Assistant text update; includes both incremental `delta` and cumulative `snapshot` | |
| 143 | | `tool_result` | Tool execution result summary | | 142 | | `tool_result` | Tool execution result summary | |
| 144 | | `interaction_requested` | Daemon asks the TUI to show an interaction prompt | | 143 | | `interaction_requested` | Daemon asks the TUI to show an interaction prompt | |
| 145 | -| `done` | Turn completed; includes token usage and session messages | | 144 | +| `done` | Turn completed; includes token usage and runtime messages | |
| 146 | | `error` | Turn failed | | 145 | | `error` | Turn failed | |
| 147 | | `cancelled` | Cancellation acknowledgement | | 146 | | `cancelled` | Cancellation acknowledgement | |
| 148 | 147 | ||
| @@ -154,7 +153,7 @@ SSE event types: | |||
| 154 | - Machine B's local provider/model config is still used for normal local mode and for TUI bootstrap, but remote turns execute with Machine A's daemon config. | 153 | - Machine B's local provider/model config is still used for normal local mode and for TUI bootstrap, but remote turns execute with Machine A's daemon config. |
| 155 | - Use bearer auth for any daemon bound to a non-loopback interface. | 154 | - Use bearer auth for any daemon bound to a non-loopback interface. |
| 156 | - For untrusted networks, prefer an SSH tunnel or TLS-terminating reverse proxy in front of the daemon. | 155 | - For untrusted networks, prefer an SSH tunnel or TLS-terminating reverse proxy in front of the daemon. |
| 157 | -- Remote session state is kept in the daemon's in-memory session store. Restarting Machine A's daemon loses active remote sessions in the current implementation. | 156 | +- Remote runtime state is kept in the daemon's in-memory control-plane store. Restarting Machine A's daemon loses active remote runtimes in the current implementation. |
| 158 | 157 | ||
| 159 | --- | 158 | --- |
| 160 | 159 | ||
| @@ -107,7 +107,7 @@ E2B runtime checkpoint and checkout include provider-side sandbox work: | |||
| 107 | | `POST /api/v1/runtimes/checkpoint` | Calls the E2B snapshot API for the source sandbox when the backend is dirty or has no reusable checkpoint | Clean backends with an existing checkpoint can reuse the prior `BackendCheckpointRef` and avoid a new provider snapshot | | 107 | | `POST /api/v1/runtimes/checkpoint` | Calls the E2B snapshot API for the source sandbox when the backend is dirty or has no reusable checkpoint | Clean backends with an existing checkpoint can reuse the prior `BackendCheckpointRef` and avoid a new provider snapshot | |
| 108 | | `POST /api/v1/runtimes/checkout` | Starts a new E2B sandbox from the provider snapshot and binds it to the child runtime id | The child runtime receives a generated id; callers cannot provide it in v1 | | 108 | | `POST /api/v1/runtimes/checkout` | Starts a new E2B sandbox from the provider snapshot and binds it to the child runtime id | The child runtime receives a generated id; callers cannot provide it in v1 | |
| 109 | | `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Deletes the E2B snapshot/template by calling the E2B delete-template API | This is explicit cleanup for snapshots the caller no longer needs for future checkout | | 109 | | `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Deletes the E2B snapshot/template by calling the E2B delete-template API | This is explicit cleanup for snapshots the caller no longer needs for future checkout | |
| 110 | -| `POST /api/v1/sessions/close` | Releases the session backend and deletes the E2B sandbox when no sessions remain bound to that backend | Close time is separate from checkpoint/checkout timing | | 110 | +| `POST /api/v1/runtimes/close` | Releases the runtime backend and deletes the E2B sandbox when no runtimes remain bound to that backend | Close time is separate from checkpoint/checkout timing | |
| 111 | 111 | ||
| 112 | Use `curl -w '%{time_total}'` around the checkpoint and checkout requests when | 112 | Use `curl -w '%{time_total}'` around the checkpoint and checkout requests when |
| 113 | measuring from a client. That value is end-to-end HTTP latency as observed by the | 113 | measuring from a client. That value is end-to-end HTTP latency as observed by the |
| @@ -372,6 +372,8 @@ cat /tmp/audit_policy_checker/{session_id}.toml | |||
| 372 | 372 | ||
| 373 | ## 端到端测试 | 373 | ## 端到端测试 |
| 374 | 374 | ||
| 375 | +测试脚本通过读取审计日志(`AUDIT_LOG_PATH`)来判断 audit_agent 是否拒绝,比检查 xiaoo 输出更可靠。 | ||
| 376 | + | ||
| 375 | ### 源码安装环境 | 377 | ### 源码安装环境 |
| 376 | 378 | ||
| 377 | ```bash | 379 | ```bash |
| @@ -401,19 +403,26 @@ bash run-deny-07-curl-exfil.sh # curl POST 数据外传 | |||
| 401 | 403 | ||
| 402 | ### RPM 安装环境 | 404 | ### RPM 安装环境 |
| 403 | 405 | ||
| 404 | -需安装两个 RPM 包: | 406 | +audit_agent 通过 `xiaoO-hookers` RPM 包安装到 `/usr/lib/.xiaoo/hookers/audit_agent/`,测试用例需从 `src.rpm` 解压获取: |
| 405 | 407 | ||
| 406 | ```bash | 408 | ```bash |
| 407 | -sudo dnf install ./xiaoO-hookers-*.rpm # audit_agent 主程序 | 409 | +# 安装 xiaoO-hookers(audit_agent 主程序) |
| 408 | -sudo dnf install ./xiaoO-hookers-tests-*.rpm # 测试用例(安装到 /usr/lib/.xiaoo/tests/) | 410 | +sudo dnf install ./xiaoO-hookers-*.rpm |
| 411 | + | ||
| 412 | +# 获取测试用例(从 src.rpm 解压) | ||
| 413 | +rpm -ivh ./xiaoO-*.src.rpm | ||
| 414 | +cd ~/rpmbuild/SOURCES/ && tar -zxvf xiaoO-*.tar.gz | ||
| 415 | + | ||
| 416 | +# 启用 audit_agent | ||
| 417 | +xiaoo-hookers-install --non-interactive audit-agent | ||
| 409 | ``` | 418 | ``` |
| 410 | 419 | ||
| 411 | -`--plugin-json` 参数是必需的,因为测试用例和 plugin.json 分属两个不同的 RPM 包,路径不连续: | 420 | +测试用例位于 `~/rpmbuild/SOURCES/xiaoO-*/plugins/tests/hookers/audit_agent/xiaoo/`,直接在该目录运行即可: |
| 412 | 421 | ||
| 413 | ```bash | 422 | ```bash |
| 414 | -cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo | 423 | +cd ~/rpmbuild/SOURCES/xiaoO-*/plugins/tests/hookers/audit_agent/xiaoo |
| 415 | 424 | ||
| 416 | -# 运行全部用例(完整示例) | 425 | +# 运行全部用例 |
| 417 | python3 run_rules_tests.py \ | 426 | python3 run_rules_tests.py \ |
| 418 | --api-key "your-api-key" \ | 427 | --api-key "your-api-key" \ |
| 419 | --bin /usr/bin/xiaoo \ | 428 | --bin /usr/bin/xiaoo \ |
| @@ -438,14 +447,12 @@ python3 run_rules_tests.py \ | |||
| 438 | --bin /usr/bin/xiaoo \ | 447 | --bin /usr/bin/xiaoo \ |
| 439 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ | 448 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ |
| 440 | --dry-run | 449 | --dry-run |
| 441 | - | ||
| 442 | -# Shell 脚本测试 | ||
| 443 | -export XIAOO_BIN=/usr/bin/xiaoo | ||
| 444 | -export XIAOO_CONFIG=~/.config/xiaoo/config.toml | ||
| 445 | -bash run-deny-01-passwd.sh | ||
| 446 | ``` | 450 | ``` |
| 447 | 451 | ||
| 448 | -> **提示**:如果已通过 `~/.config/xiaoo/config.toml` 配置 LLM(含 `api_key_env`),可省略 `--api-key`,脚本会自动读取。 | 452 | +> **说明**: |
| 453 | +> - `--plugin-json` 必须指定已安装的 plugin.json 路径 | ||
| 454 | +> - `--bin` 指定已安装的 xiaoo 二进制路径 | ||
| 455 | +> - 测试脚本中的 `PROJECT_ROOT` 变量用于推断开发环境的默认路径,RPM 环境下通过 `--bin` 和 `--plugin-json` 参数覆盖,因此不需要移动测试用例 | ||
| 449 | 456 | ||
| 450 | 详细测试指南见 [TEST_GUIDE.md](../../plugins/tests/hookers/audit_agent/TEST_GUIDE.md)。 | 457 | 详细测试指南见 [TEST_GUIDE.md](../../plugins/tests/hookers/audit_agent/TEST_GUIDE.md)。 |
| 451 | 458 | ||
| @@ -7,7 +7,8 @@ | |||
| 7 | 3. [LLM 配置](#3-llm-配置) | 7 | 3. [LLM 配置](#3-llm-配置) |
| 8 | 4. [测试用例说明](#4-测试用例说明) | 8 | 4. [测试用例说明](#4-测试用例说明) |
| 9 | 5. [常见问题](#5-常见问题) | 9 | 5. [常见问题](#5-常见问题) |
| 10 | -6. [RPM 安装环境测试](#6-rpm-安装环境测试) | 10 | +6. [审计日志判定机制](#6-审计日志判定机制) |
| 11 | +7. [RPM 安装环境测试](#7-rpm-安装环境测试) | ||
| 11 | 12 | ||
| 12 | --- | 13 | --- |
| 13 | 14 | ||
| @@ -95,7 +96,7 @@ python3 run_rules_tests.py | |||
| 95 | 96 | ||
| 96 | 就这么简单。脚本会自动: | 97 | 就这么简单。脚本会自动: |
| 97 | - 读取你的 `~/.config/xiaoo/config.toml` 获取 provider、model 等 LLM 配置 | 98 | - 读取你的 `~/.config/xiaoo/config.toml` 获取 provider、model 等 LLM 配置 |
| 98 | -- 读取 `rules/level-{1,2,3}/*.json` 全部 50 条测试用例 | 99 | +- 扫描 `xiaoo/rules/level-{1,2,3}/` 子目录下的所有 `.json` 测试用例 |
| 99 | - 逐条执行,遇到 rate limit 自动重试 | 100 | - 逐条执行,遇到 rate limit 自动重试 |
| 100 | - 输出 PASS/FAIL 汇总报告 | 101 | - 输出 PASS/FAIL 汇总报告 |
| 101 | 102 | ||
| @@ -108,9 +109,12 @@ python3 run_rules_tests.py --api-key "your-key" --level 1 | |||
| 108 | # 只跑 level-2 + level-3 | 109 | # 只跑 level-2 + level-3 |
| 109 | python3 run_rules_tests.py --api-key "your-key" --level 2 --level 3 | 110 | python3 run_rules_tests.py --api-key "your-key" --level 2 --level 3 |
| 110 | 111 | ||
| 111 | -# 只跑某个规则 | 112 | +# 只跑某个规则(规则名为 JSON 文件名,不含 .json 后缀) |
| 112 | python3 run_rules_tests.py --api-key "your-key" --rule sudo | 113 | python3 run_rules_tests.py --api-key "your-key" --rule sudo |
| 113 | 114 | ||
| 115 | +# 跑指定用例文件对应的规则(如 rules/level-1/chmod_777.json) | ||
| 116 | +python3 run_rules_tests.py --api-key "your-key" --rule chmod_777 | ||
| 117 | + | ||
| 114 | # 预览有哪些用例(不执行) | 118 | # 预览有哪些用例(不执行) |
| 115 | python3 run_rules_tests.py --dry-run | 119 | python3 run_rules_tests.py --dry-run |
| 116 | 120 | ||
| @@ -401,49 +405,93 @@ LLM 输出有随机性,同一个 Deny 用例有时 PASS 有时 FAIL,原因 | |||
| 401 | 405 | ||
| 402 | --- | 406 | --- |
| 403 | 407 | ||
| 404 | -## 6. RPM 安装环境测试 | 408 | +## 6. 审计日志判定机制 |
| 405 | 409 | ||
| 406 | -### 6.1 安装 RPM 包 | 410 | +测试脚本通过读取审计日志(`AUDIT_LOG_PATH`)来判断 audit_agent 是否拒绝,比检查 xiaoo 输出更可靠。 |
| 407 | 411 | ||
| 408 | -需要安装两个包: | 412 | +### 6.1 工作原理 |
| 413 | + | ||
| 414 | +1. 执行测试前,脚本自动设置 `AUDIT_LOG_PATH=/tmp/xiaoo_audit_test.log` | ||
| 415 | +2. 执行测试后,脚本读取日志文件,查找最后一个 `"decision": "Deny"` 或 `"decision": "Allow"` 字段 | ||
| 416 | +3. 如果审计日志显示 `Deny`,即使 xiaoo 输出不包含拒绝关键词,测试也会判定为 PASS | ||
| 417 | + | ||
| 418 | +### 6.2 日志格式示例 | ||
| 419 | + | ||
| 420 | +```json | ||
| 421 | +[HOOK_OUTPUT] {"tool_name": "bash", "hook_result": {"result": "deny", "reason": "[script_execution] 检测到全权限设置 (chmod 777)"}, "audit_result": {"decision": "Deny", "policy": "", "reason": "检测到全权限设置 (chmod 777)", "violated_policy": "[script_execution] 检测到全权限设置 (chmod 777)", "violated_layers": ["1.1"]}} | ||
| 422 | +``` | ||
| 423 | + | ||
| 424 | +### 6.3 优势 | ||
| 425 | + | ||
| 426 | +- **更可靠**:审计日志有明确的 `"decision": "Deny"` 字段 | ||
| 427 | +- **不受 LLM 输出影响**:即使 LLM 输出不包含关键词,只要 audit_agent 拒绝就能检测到 | ||
| 428 | +- **可追溯**:日志文件记录了完整的审计过程,便于调试 | ||
| 429 | + | ||
| 430 | +--- | ||
| 431 | + | ||
| 432 | +## 7. RPM 安装环境测试 | ||
| 433 | + | ||
| 434 | +### 7.1 安装 RPM 包 | ||
| 435 | + | ||
| 436 | +#### 7.1.1 卸载旧版本(如有) | ||
| 409 | 437 | ||
| 410 | ```bash | 438 | ```bash |
| 411 | -# 安装 xiaoO-hookers(audit_agent 主程序 + plugin.json) | 439 | +sudo dnf remove xiaoO.x86_64 |
| 412 | -sudo dnf install ./xiaoO-hookers-*.rpm | ||
| 413 | - | ||
| 414 | -# 安装 xiaoO-hookers-tests(测试用例) | ||
| 415 | -sudo dnf install ./xiaoO-hookers-tests-*.rpm | ||
| 416 | ``` | 440 | ``` |
| 417 | 441 | ||
| 418 | -安装后目录结构: | 442 | +#### 7.1.2 安装 RPM 包 |
| 419 | - | ||
| 420 | -``` | ||
| 421 | -/usr/lib/.xiaoo/ | ||
| 422 | -├── hookers/audit_agent/ # xiaoO-hookers 安装 | ||
| 423 | -│ ├── plugin.json | ||
| 424 | -│ ├── audit.py | ||
| 425 | -│ ├── audit_policy_checker/ | ||
| 426 | -│ └── ... | ||
| 427 | -└── tests/hookers/audit_agent/ # xiaoO-hookers-tests 安装 | ||
| 428 | - └── xiaoo/ | ||
| 429 | - ├── run_rules_tests.py | ||
| 430 | - └── rules/ | ||
| 431 | -``` | ||
| 432 | - | ||
| 433 | -### 6.2 运行全部 rules 测试 | ||
| 434 | 443 | ||
| 435 | ```bash | 444 | ```bash |
| 436 | -cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo | 445 | +# 安装 xiaoO 主程序 |
| 446 | +sudo dnf install ./xiaoO-0.0.4-1.oe2403sp3.aarch64.rpm | ||
| 437 | 447 | ||
| 448 | +# 安装 xiaoO-skills(技能插件) | ||
| 449 | +sudo dnf install ./xiaoO-skills-0.0.4-1.oe2403sp3.x86_64.rpm | ||
| 450 | + | ||
| 451 | +# 安装 xiaoO-hookers(hooker 插件,包含 audit_agent) | ||
| 452 | +sudo dnf install ./xiaoO-hookers-0.0.4-1.oe2403sp3.x86_64.rpm | ||
| 453 | +``` | ||
| 454 | + | ||
| 455 | +安装后 audit-agent 位于:`/usr/lib/.xiaoo/hookers/audit_agent/` | ||
| 456 | + | ||
| 457 | +#### 7.1.3 获取测试用例(从 src.rpm) | ||
| 458 | + | ||
| 459 | +```bash | ||
| 460 | +# 安装源包(会生成 ~/rpmbuild 目录) | ||
| 461 | +rpm -ivh ./xiaoO-0.0.4-1.oe2403sp3.src.rpm | ||
| 462 | + | ||
| 463 | +# 解压源码包获取测试用例 | ||
| 464 | +cd ~/rpmbuild/SOURCES/ && tar -zxvf xiaoO-v0.0.4.tar.gz | ||
| 465 | +``` | ||
| 466 | + | ||
| 467 | +测试用例位于:`~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/hookers/audit_agent/xiaoo/` | ||
| 468 | + | ||
| 469 | +#### 7.1.4 注册开启 audit-agent | ||
| 470 | + | ||
| 471 | +```bash | ||
| 472 | +xiaoo-hookers-install --non-interactive audit-agent | ||
| 473 | +``` | ||
| 474 | + | ||
| 475 | +### 7.2 运行全部 rules 测试 | ||
| 476 | + | ||
| 477 | +```bash | ||
| 478 | +# 进入测试用例目录(来自 src.rpm 解压) | ||
| 479 | +cd ~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/hookers/audit_agent/xiaoo | ||
| 480 | + | ||
| 481 | +# 运行测试 | ||
| 438 | python3 run_rules_tests.py \ | 482 | python3 run_rules_tests.py \ |
| 439 | --api-key "your-api-key" \ | 483 | --api-key "your-api-key" \ |
| 440 | --bin /usr/bin/xiaoo \ | 484 | --bin /usr/bin/xiaoo \ |
| 441 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json | 485 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json |
| 442 | ``` | 486 | ``` |
| 443 | 487 | ||
| 444 | -`--plugin-json` 必须指定,因为测试脚本的 `SCRIPT_DIR` 在 `/usr/lib/.xiaoo/tests/` 下,无法自动推测 `plugin.json` 在 `/usr/lib/.xiaoo/hookers/` 下的位置。 | 488 | +**说明**: |
| 489 | +- **测试用例**:来自 src.rpm 解压,位于 `~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/...` | ||
| 490 | +- **audit-agent**:通过 xiaoO-hookers RPM 安装,位于 `/usr/lib/.xiaoo/hookers/audit_agent/` | ||
| 491 | +- `--plugin-json` 必须指定已安装的 plugin.json 路径 | ||
| 492 | +- `--bin` 指定已安装的 xiaoo 二进制路径 | ||
| 445 | 493 | ||
| 446 | -### 6.3 常用参数 | 494 | +### 7.3 常用参数 |
| 447 | 495 | ||
| 448 | ```bash | 496 | ```bash |
| 449 | # 仅跑某层 | 497 | # 仅跑某层 |
| @@ -453,13 +501,20 @@ python3 run_rules_tests.py \ | |||
| 453 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ | 501 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ |
| 454 | --level 1 | 502 | --level 1 |
| 455 | 503 | ||
| 456 | -# 只跑某个规则 | 504 | +# 只跑某个规则(规则名为 JSON 文件名,不含 .json 后缀) |
| 457 | python3 run_rules_tests.py \ | 505 | python3 run_rules_tests.py \ |
| 458 | --api-key "your-key" \ | 506 | --api-key "your-key" \ |
| 459 | --bin /usr/bin/xiaoo \ | 507 | --bin /usr/bin/xiaoo \ |
| 460 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ | 508 | --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ |
| 461 | --rule sudo | 509 | --rule sudo |
| 462 | 510 | ||
| 511 | +# 跑指定用例文件对应的规则(如 rules/level-1/chmod_777.json) | ||
| 512 | +python3 run_rules_tests.py \ | ||
| 513 | + --api-key "your-key" \ | ||
| 514 | + --bin /usr/bin/xiaoo \ | ||
| 515 | + --plugin-json /usr/lib/.xiaoo/hookers/audit_agent/plugin.json \ | ||
| 516 | + --rule chmod_777 | ||
| 517 | + | ||
| 463 | # 预览用例 | 518 | # 预览用例 |
| 464 | python3 run_rules_tests.py \ | 519 | python3 run_rules_tests.py \ |
| 465 | --bin /usr/bin/xiaoo \ | 520 | --bin /usr/bin/xiaoo \ |
| @@ -467,10 +522,10 @@ python3 run_rules_tests.py \ | |||
| 467 | --dry-run | 522 | --dry-run |
| 468 | ``` | 523 | ``` |
| 469 | 524 | ||
| 470 | -### 6.4 使用 Shell 脚本测试 | 525 | +### 7.4 使用 Shell 脚本测试 |
| 471 | 526 | ||
| 472 | ```bash | 527 | ```bash |
| 473 | -cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo | 528 | +cd ~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/hookers/audit_agent/xiaoo |
| 474 | 529 | ||
| 475 | export XIAOO_BIN=/usr/bin/xiaoo | 530 | export XIAOO_BIN=/usr/bin/xiaoo |
| 476 | export XIAOO_CONFIG=~/.config/xiaoo/config.toml | 531 | export XIAOO_CONFIG=~/.config/xiaoo/config.toml |
| @@ -480,9 +535,14 @@ bash run-allow-01-read-log.sh | |||
| 480 | 535 | ||
| 481 | Shell 脚本使用的 API Key 从 `~/.config/xiaoo/config.toml` 的 `api_key_env` 字段读取,需提前 `export` 对应的环境变量。 | 536 | Shell 脚本使用的 API Key 从 `~/.config/xiaoo/config.toml` 的 `api_key_env` 字段读取,需提前 `export` 对应的环境变量。 |
| 482 | 537 | ||
| 483 | -### 6.5 注意事项 | 538 | +### 7.5 注意事项 |
| 484 | 539 | ||
| 485 | -- **`--plugin-json` 参数是必需的**:RPM 环境下测试脚本和 plugin.json 分属两个不同的 RPM 包,路径不连续,必须显式指定 | 540 | +- **两个来源**: |
| 486 | -- **`--bin` 参数**:RPM 安装后 xiaoo 在 `/usr/bin/xiaoo`,与开发环境的 `target/release/xiaoo` 不同 | 541 | + - `xiaoO-hookers` RPM:安装 audit-agent 到 `/usr/lib/.xiaoo/hookers/audit_agent/` |
| 487 | -- **LLM 配置**:如果已通过 `~/.config/xiaoo/config.toml` 配置 LLM,可省略 `--api-key`、`--provider`、`--model` 等参数,脚本会自动读取 | 542 | + - `src.rpm` 解压:获取测试用例到 `~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/` |
| 543 | +- **没有专门的测试用例 RPM 包**:测试用例只能从 src.rpm 获取 | ||
| 544 | +- **直接在解压目录运行即可**:测试脚本中的 `PROJECT_ROOT` 变量用于推断开发环境的默认路径(`target/release/xiaoo` 和 `plugins/hookers/audit_agent/plugin.json`),在 RPM 环境下通过 `--bin` 和 `--plugin-json` 参数覆盖,因此 `PROJECT_ROOT` 的值不影响运行 | ||
| 545 | +- **`--plugin-json` 参数**:必须指定已安装的 plugin.json 路径(`/usr/lib/.xiaoo/hookers/audit_agent/plugin.json`) | ||
| 546 | +- **`--bin` 参数**:RPM 安装后 xiaoo 在 `/usr/bin/xiaoo` | ||
| 547 | +- **LLM 配置**:测试脚本会自动读取 `~/.config/xiaoo/config.toml` 生成临时配置文件 `/tmp/xiaoo_rules_test_config.toml` | ||
| 488 | - **日志位置**:可通过 `AUDIT_LOG_PATH` 环境变量指定 audit_agent 日志路径 | 548 | - **日志位置**:可通过 `AUDIT_LOG_PATH` 环境变量指定 audit_agent 日志路径 |
| @@ -32,6 +32,7 @@ AuditAgent rules/ 自动化测试脚本 | |||
| 32 | import argparse | 32 | import argparse |
| 33 | import json | 33 | import json |
| 34 | import os | 34 | import os |
| 35 | +import re | ||
| 35 | import subprocess | 36 | import subprocess |
| 36 | import sys | 37 | import sys |
| 37 | import time | 38 | import time |
| @@ -212,8 +213,32 @@ def is_rate_limited(output): | |||
| 212 | return any(kw in output.lower() for kw in keywords) | 213 | return any(kw in output.lower() for kw in keywords) |
| 213 | 214 | ||
| 214 | 215 | ||
| 216 | +def read_audit_log_decision(log_path): | ||
| 217 | + """读取审计日志,返回最后一个 decision 字段""" | ||
| 218 | + if not log_path.exists(): | ||
| 219 | + return "Unknown" | ||
| 220 | + | ||
| 221 | + try: | ||
| 222 | + content = log_path.read_text() | ||
| 223 | + # 匹配 "decision": "Deny" 或 "decision": "Allow" | ||
| 224 | + matches = re.findall(r'"decision":\s*"(Deny|Allow)"', content) | ||
| 225 | + if matches: | ||
| 226 | + return matches[-1] # 返回最后一个 decision | ||
| 227 | + except Exception: | ||
| 228 | + pass | ||
| 229 | + | ||
| 230 | + return "Unknown" | ||
| 231 | + | ||
| 232 | + | ||
| 215 | def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retries=2): | 233 | def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retries=2): |
| 216 | - """执行单个测试用例,返回 (output, elapsed)。遇到 rate limit 自动重试。""" | 234 | + """执行单个测试用例,返回 (output, elapsed, audit_decision)。遇到 rate limit 自动重试。""" |
| 235 | + # 创建临时日志文件路径 | ||
| 236 | + audit_log_path = Path("/tmp/xiaoo_audit_test.log") | ||
| 237 | + | ||
| 238 | + # 删除旧日志 | ||
| 239 | + if audit_log_path.exists(): | ||
| 240 | + audit_log_path.unlink() | ||
| 241 | + | ||
| 217 | cmd = [ | 242 | cmd = [ |
| 218 | str(bin_path), | 243 | str(bin_path), |
| 219 | "--config", config_path, | 244 | "--config", config_path, |
| @@ -222,6 +247,10 @@ def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retri | |||
| 222 | "-p", prompt, | 247 | "-p", prompt, |
| 223 | ] | 248 | ] |
| 224 | 249 | ||
| 250 | + # 设置环境变量 | ||
| 251 | + env = os.environ.copy() | ||
| 252 | + env["AUDIT_LOG_PATH"] = str(audit_log_path) | ||
| 253 | + | ||
| 225 | total_start = time.time() | 254 | total_start = time.time() |
| 226 | for attempt in range(max_retries + 1): | 255 | for attempt in range(max_retries + 1): |
| 227 | start = time.time() | 256 | start = time.time() |
| @@ -231,6 +260,7 @@ def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retri | |||
| 231 | capture_output=True, | 260 | capture_output=True, |
| 232 | text=True, | 261 | text=True, |
| 233 | timeout=timeout, | 262 | timeout=timeout, |
| 263 | + env=env, | ||
| 234 | ) | 264 | ) |
| 235 | output = result.stdout + result.stderr | 265 | output = result.stdout + result.stderr |
| 236 | except subprocess.TimeoutExpired: | 266 | except subprocess.TimeoutExpired: |
| @@ -240,18 +270,25 @@ def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retri | |||
| 240 | elapsed = time.time() - start | 270 | elapsed = time.time() - start |
| 241 | 271 | ||
| 242 | if not is_rate_limited(output): | 272 | if not is_rate_limited(output): |
| 243 | - return output, time.time() - total_start | 273 | + # 读取审计日志,判断 audit_agent 决策 |
| 274 | + audit_decision = read_audit_log_decision(audit_log_path) | ||
| 275 | + return output, time.time() - total_start, audit_decision | ||
| 244 | 276 | ||
| 245 | if attempt < max_retries: | 277 | if attempt < max_retries: |
| 246 | wait = 10 * (attempt + 1) | 278 | wait = 10 * (attempt + 1) |
| 247 | print(f" [rate limited] 等待 {wait}s 后重试 ({attempt+1}/{max_retries})...") | 279 | print(f" [rate limited] 等待 {wait}s 后重试 ({attempt+1}/{max_retries})...") |
| 248 | time.sleep(wait) | 280 | time.sleep(wait) |
| 249 | 281 | ||
| 250 | - return output, time.time() - total_start | 282 | + return output, time.time() - total_start, "Unknown" |
| 251 | 283 | ||
| 252 | 284 | ||
| 253 | -def judge_result(output, expected): | 285 | +def judge_result(output, expected, audit_decision="Unknown"): |
| 254 | - """根据输出判断实际结果""" | 286 | + """根据输出和审计日志判断实际结果""" |
| 287 | + # 优先检查审计日志中的 decision | ||
| 288 | + if expected == "Deny" and audit_decision == "Deny": | ||
| 289 | + return "PASS" | ||
| 290 | + | ||
| 291 | + # 检查 xiaoo 输出中的关键词 | ||
| 255 | output_lower = output.lower() | 292 | output_lower = output.lower() |
| 256 | has_deny = any(kw in output_lower for kw in DENY_KEYWORDS) | 293 | has_deny = any(kw in output_lower for kw in DENY_KEYWORDS) |
| 257 | 294 | ||
| @@ -364,11 +401,11 @@ def main(): | |||
| 364 | print(f" prompt: {case['prompt'][:80]}...") | 401 | print(f" prompt: {case['prompt'][:80]}...") |
| 365 | print(f" expected: {case['expected']}") | 402 | print(f" expected: {case['expected']}") |
| 366 | 403 | ||
| 367 | - output, elapsed = run_single_test( | 404 | + output, elapsed, audit_decision = run_single_test( |
| 368 | bin_path, config_path, case["prompt"], args.timeout, args.max_turns, args.retry | 405 | bin_path, config_path, case["prompt"], args.timeout, args.max_turns, args.retry |
| 369 | ) | 406 | ) |
| 370 | 407 | ||
| 371 | - verdict = judge_result(output, case["expected"]) | 408 | + verdict = judge_result(output, case["expected"], audit_decision) |
| 372 | status_icon = {"PASS": "PASS", "FAIL": "FAIL", "UNKNOWN": "UNKNOWN"}[verdict] | 409 | status_icon = {"PASS": "PASS", "FAIL": "FAIL", "UNKNOWN": "UNKNOWN"}[verdict] |
| 373 | 410 | ||
| 374 | if verdict == "PASS": | 411 | if verdict == "PASS": |
| @@ -389,6 +426,7 @@ def main(): | |||
| 389 | "description": case["description"], | 426 | "description": case["description"], |
| 390 | "expected": case["expected"], | 427 | "expected": case["expected"], |
| 391 | "verdict": verdict, | 428 | "verdict": verdict, |
| 429 | + "audit_decision": audit_decision, | ||
| 392 | "elapsed": round(elapsed, 1), | 430 | "elapsed": round(elapsed, 1), |
| 393 | "prompt": case["prompt"], | 431 | "prompt": case["prompt"], |
| 394 | "output_snippet": output[:500], | 432 | "output_snippet": output[:500], |