已合并
Merge remote-tracking branch 'upstream/dev' into dev #187
hypothesier创建于 6月13日
Merge remote-tracking branch 'upstream/dev' into dev #187
已合并
hypothesier创建于 6月13日
55 个文件变更+2150-393
MCargo.lock+2-0
@@ -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",
Mapps/endside/src/cli/entry.rs+9-3
@@ -80,6 +80,10 @@ enum Command {
80 #[arg(long)]80 #[arg(long)]
81 no_tools: bool,81 no_tools: bool,
82 82 
83+ /// Restrict to a comma-separated allowlist of tools
84+ #[arg(long, value_delimiter = ',')]
85+ tools: Option<Vec<String>>,
86+ 
83 /// Reasoning effort: off, high, or max87 /// Reasoning effort: off, high, or max
84 #[arg(long, value_parser = clap::value_parser!(ReasoningEffort))]88 #[arg(long, value_parser = clap::value_parser!(ReasoningEffort))]
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),
Mapps/endside/src/cli/mod.rs+2-0
@@ -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(),
Mapps/endside/src/gateway_api/remote.rs+50-28
@@ -1,15 +1,15 @@
1use futures_util::StreamExt;1use futures_util::StreamExt;
2use serde::{Deserialize, Serialize};2use serde::{Deserialize, Serialize};
3-use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};3+use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
4 4 
5use agent_types::common::ids::AgentId;5use agent_types::common::ids::AgentId;
6use agent_types::interaction::{InteractionRequest, InteractionResponse};6use 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};
9use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate};9use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate};
10use crate::gateway::{10use crate::gateway::{
11- AppTurnRequest, GatewayEntryContext, SessionCancelRequest, SessionInteractionRequest,11+ GatewayEntryContext, RuntimeCancelRequest, RuntimeCloseRequest, RuntimeInteractionRequest,
12- SessionOpenRequest,12+ RuntimeOpenRequest, RuntimeTurnRequest,
13};13};
14use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult};14use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult};
15use crate::remote_sessions_service::record_remote_session;15use crate::remote_sessions_service::record_remote_session;
@@ -58,6 +58,7 @@ enum RemoteSseEvent {
58 raw_reply: String,58 raw_reply: String,
59 #[allow(dead_code)]59 #[allow(dead_code)]
60 conversation_id: String,60 conversation_id: String,
61+ #[serde(rename = "runtime_id", alias = "session_id")]
61 #[allow(dead_code)]62 #[allow(dead_code)]
62 session_id: String,63 session_id: String,
63 #[allow(dead_code)]64 #[allow(dead_code)]
@@ -74,6 +75,7 @@ enum RemoteSseEvent {
74 error: String,75 error: String,
75 },76 },
76 Cancelled {77 Cancelled {
78+ #[serde(rename = "runtime_id", alias = "session_id")]
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(
653fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse {672fn 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#[cfg(test)]683#[cfg(test)]
662mod tests {684mod tests {
663- use super::{parse_sse_frame, take_sse_frame, RemoteSseEvent};685+ use super::{RemoteSseEvent, parse_sse_frame, take_sse_frame};
664 686 
665 #[test]687 #[test]
666 fn parses_sse_frame_from_split_buffer() {688 fn parses_sse_frame_from_split_buffer() {
Mapps/endside/src/gateway_api/session_interaction.rs+29-9
@@ -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 }
Mapps/endside/src/input/core.rs+91-0
@@ -38,6 +38,15 @@ impl Input {
38 self.cursor38 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.value51 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 = before373 let line_start = before
Mapps/endside/src/input/event_key.rs+26-3
@@ -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 }
Mapps/endside/src/prompts/cli_default_system_prompt.txt+45-2
@@ -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 System30## 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.
Mapps/endside/src/render/interaction_prompt.rs+101-25
@@ -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 
9use crate::input::Input;9use crate::input::Input;
10use ratatui::{10use 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};
19use super::theme::Theme;19use super::theme::Theme;
20use super::utils::sanitize_terminal_text;20use super::utils::sanitize_terminal_text;
21 21 
22-/// 单个可选项(可与 JSON 对齐)。22+/// Single selectable option (can align with JSON).
23#[derive(Debug, Clone, Serialize, Deserialize)]23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PromptChoice {24pub 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#[derive(Debug, Clone, Serialize, Deserialize)]31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PromptRequest {32pub 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 #[serde(default)]37 #[serde(default)]
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 #[serde(default)]40 #[serde(default)]
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+ #[serde(default)]
45+ pub is_secret: bool,
43}46}
44 47 
45-/// 用户操作结果(TUI → 后端 / 会话)。48+/// User operation result (TUI → backend / session).
46#[derive(Debug, Clone, Serialize, Deserialize)]49#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct UserPromptResult {50pub 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).
73pub struct InteractionPromptState {76pub 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 1195 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 
198pub fn render_interaction_prompt(241pub 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 = state323 let start = state
253 .list_scroll324 .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.border380 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);
Mapps/endside/src/render/overlay.rs+70-17
@@ -19,7 +19,7 @@ use crate::remote_sessions_service::{
19use crate::services::turn_delete::DeleteDialog;19use crate::services::turn_delete::DeleteDialog;
20use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog};20use 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_width168 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::SessionSnapshotSelection374 | 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 truncated1148 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+}
Mapps/endside/src/render/utils.rs+0-16
@@ -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- 
39pub(crate) fn line_prefix_width(line: &str, col_chars: usize) -> usize {23pub(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)
Mapps/endside/src/state/app_state.rs+1-0
@@ -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 }
Mapps/serverside/src/daemon_runtime.rs+32-0
@@ -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> =
Mapps/serverside/src/httpserver/router.rs+150-53
@@ -1,31 +1,31 @@
1use crate::channels::{AdapterResponse, ChannelError, ChannelResult, ChannelRuntime};1use crate::channels::{AdapterResponse, ChannelError, ChannelResult, ChannelRuntime};
2+use crate::httpserver::GatewayServiceError;
2use crate::httpserver::channel_ingress::GatewayChannelIngressError;3use crate::httpserver::channel_ingress::GatewayChannelIngressError;
3use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor};4use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor};
4use crate::httpserver::rate_limit::RateLimitConfig;5use 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;
7use agent_contracts::InteractionHandle;7use agent_contracts::InteractionHandle;
8use agent_types::interaction::{InteractionRequest, InteractionResponse};8use agent_types::interaction::{InteractionRequest, InteractionResponse};
9use async_trait::async_trait;9use async_trait::async_trait;
10use axum::{10use 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};
25use serde::{Deserialize, Serialize};25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;26use std::collections::HashMap;
27use std::sync::Arc;27use std::sync::Arc;
28-use tokio::sync::{oneshot, Mutex};28+use tokio::sync::{Mutex, oneshot};
29use tracing::warn;29use tracing::warn;
30use xiaoo_shared::gateway::SessionService;30use xiaoo_shared::gateway::SessionService;
31 31 
@@ -162,7 +162,10 @@ impl InteractionHandle for RemoteSseInteractionHandle {
162fn default_interaction_response(request: &InteractionRequest) -> InteractionResponse {162fn 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 
355async fn handle_session_open(358async 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 
375async fn handle_session_input(378async 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).await382 stream_session_input(state, payload.session_id.clone(), payload).await
380}383}
@@ -382,7 +385,7 @@ async fn handle_session_input(
382async fn stream_session_input(385async 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 
431async fn handle_session_interaction(435async 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 state439 if state
436 .remote_interactions440 .remote_interactions
@@ -451,7 +455,7 @@ async fn handle_session_interaction(
451 455 
452async fn handle_session_cancel(456async 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 
483async fn handle_session_close(487async 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#[cfg(test)]708#[cfg(test)]
708mod tests {709mod 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 #[tokio::test(flavor = "current_thread")]733 #[tokio::test(flavor = "current_thread")]
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 #[tokio::test(flavor = "current_thread")]772 #[tokio::test(flavor = "current_thread")]
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 #[tokio::test(flavor = "current_thread")]870 #[tokio::test(flavor = "current_thread")]
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 .await888 .await
@@ -891,26 +892,45 @@ mod tests {
891 }892 }
892 893 
893 #[tokio::test(flavor = "current_thread")]894 #[tokio::test(flavor = "current_thread")]
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 = router902+ 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 .await915 .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 #[tokio::test(flavor = "current_thread")]936 #[tokio::test(flavor = "current_thread")]
@@ -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_service1213+ assert!(
1194- .requests1214+ 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 #[tokio::test(flavor = "current_thread")]1222 #[tokio::test(flavor = "current_thread")]
@@ -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_service1269+ assert!(
1248- .requests1270+ 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+}
Mapps/serverside/src/httpserver/sse_sink.rs+18-0
@@ -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+ #[serde(rename = "runtime_id")]
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+ #[serde(rename = "runtime_id")]
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+#[cfg(test)]
183+mod tests {
184+ use super::*;
185+ 
186+ #[test]
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+}
Mapps/shared/src/gateway/channel_interaction.rs+2-0
@@ -169,6 +169,7 @@ impl ChannelInteractionHandle {
169 InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false },169 InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false },
170 InteractionRequest::TextInput { .. } => InteractionResponse::Text {170 InteractionRequest::TextInput { .. } => InteractionResponse::Text {
171 value: Some(sentinel),171 value: Some(sentinel),
172+ display_value: None,
172 },173 },
173 InteractionRequest::Choice { .. } => InteractionResponse::Choice {174 InteractionRequest::Choice { .. } => InteractionResponse::Choice {
174 value: Some(sentinel),175 value: Some(sentinel),
@@ -198,6 +199,7 @@ pub fn resolve_interaction_from_text(
198 }199 }
199 InteractionRequest::TextInput { .. } => InteractionResponse::Text {200 InteractionRequest::TextInput { .. } => InteractionResponse::Text {
200 value: Some(trimmed.to_string()),201 value: Some(trimmed.to_string()),
202+ display_value: None,
201 },203 },
202 InteractionRequest::Choice { options, .. } => {204 InteractionRequest::Choice { options, .. } => {
203 if let Ok(index) = trimmed.parse::<usize>() {205 if let Ok(index) = trimmed.parse::<usize>() {
Mapps/shared/src/gateway/mod.rs+6-5
@@ -20,7 +20,7 @@ pub mod subagent_interaction;
20pub mod turns;20pub mod turns;
21pub mod workspace_prompt;21pub mod workspace_prompt;
22 22 
23-pub use decrypted_api_keys::{get_decrypted_api_key, init_secret_provider, SecretProvider};23+pub use decrypted_api_keys::{SecretProvider, get_decrypted_api_key, init_secret_provider};
24 24 
25pub use bootstrap::{AppBootstrap, AppBootstrapError, AppDependencies};25pub use bootstrap::{AppBootstrap, AppBootstrapError, AppDependencies};
26pub use hosted_runtime_resolver::{26pub use hosted_runtime_resolver::{
@@ -28,9 +28,10 @@ pub use hosted_runtime_resolver::{
28};28};
29pub use progress_updates::ChannelProgressRelayHandle;29pub use progress_updates::ChannelProgressRelayHandle;
30pub use session_base::{30pub use session_base::{
31- channel_session_id, SessionCancelRequest, SessionCloseRequest, SessionForkRequest,31+ RuntimeCancelRequest, RuntimeCloseRequest, RuntimeInteractionRequest, RuntimeOpenRequest,
32- SessionForkResult, SessionInput, SessionInputKind, SessionInteractionRequest,32+ SessionCancelRequest, SessionCloseRequest, SessionForkRequest, SessionForkResult, SessionInput,
33- SessionOpenRequest, SessionSubmitReceipt,33+ SessionInputKind, SessionInteractionRequest, SessionOpenRequest, SessionSubmitReceipt,
34+ channel_session_id,
34};35};
35pub use session_record::{SessionLifecycleStatus, SessionRecord};36pub use session_record::{SessionLifecycleStatus, SessionRecord};
36pub use session_runtime::{37pub use session_runtime::{
@@ -43,6 +44,6 @@ pub use session_service_impl::CoreBackedSessionService;
43pub use session_store::{InMemorySessionStore, SessionStore, SessionStoreError};44pub use session_store::{InMemorySessionStore, SessionStore, SessionStoreError};
44pub use turns::{45pub use turns::{
45 AppTurnRequest, AppTurnResult, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig,46 AppTurnRequest, AppTurnResult, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig,
46- TurnMention,47+ RuntimeTurnRequest, TurnMention,
47};48};
48pub use workspace_prompt::compose_workspace_system_prompt;49pub use workspace_prompt::compose_workspace_system_prompt;
Mapps/shared/src/gateway/session_base.rs+36-0
@@ -14,6 +14,7 @@ pub fn channel_session_id(
14 14 
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SessionOpenRequest {16pub struct SessionOpenRequest {
17+ #[serde(rename = "runtime_id", alias = "session_id")]
17 pub session_id: String,18 pub session_id: String,
18 pub conversation_id: String,19 pub conversation_id: String,
19 pub sender_id: String,20 pub sender_id: String,
@@ -50,11 +51,13 @@ impl SessionOpenRequest {
50 51 
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52pub struct SessionCloseRequest {53pub struct SessionCloseRequest {
54+ #[serde(rename = "runtime_id", alias = "session_id")]
53 pub session_id: String,55 pub session_id: String,
54}56}
55 57 
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct SessionCancelRequest {59pub struct SessionCancelRequest {
60+ #[serde(rename = "runtime_id", alias = "session_id")]
58 pub session_id: String,61 pub session_id: String,
59}62}
60 63 
@@ -78,10 +81,16 @@ pub struct SessionForkResult {
78 81 
79#[derive(Debug, Clone, Serialize, Deserialize)]82#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SessionInteractionRequest {83pub struct SessionInteractionRequest {
84+ #[serde(rename = "runtime_id", alias = "session_id")]
81 pub session_id: String,85 pub session_id: String,
82 pub response: InteractionResponse,86 pub response: InteractionResponse,
83}87}
84 88 
89+pub type RuntimeOpenRequest = SessionOpenRequest;
90+pub type RuntimeCloseRequest = SessionCloseRequest;
91+pub type RuntimeCancelRequest = SessionCancelRequest;
92+pub type RuntimeInteractionRequest = SessionInteractionRequest;
93+ 
85#[derive(Debug, Clone, Serialize, Deserialize)]94#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]95#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum SessionInput {96pub enum SessionInput {
@@ -114,3 +123,30 @@ pub struct SessionSubmitReceipt {
114 pub session_id: String,123 pub session_id: String,
115 pub accepted_kind: SessionInputKind,124 pub accepted_kind: SessionInputKind,
116}125}
126+ 
127+#[cfg(test)]
128+mod tests {
129+ use super::*;
130+ 
131+ #[test]
132+ fn runtime_open_request_serializes_runtime_id_and_accepts_legacy_session_id() {
133+ let request = RuntimeOpenRequest {
134+ session_id: "runtime-1".to_string(),
135+ conversation_id: "conv-1".to_string(),
136+ sender_id: "user-1".to_string(),
137+ entry: GatewayEntryContext::default(),
138+ channel: None,
139+ channel_instance_id: None,
140+ llm: None,
141+ };
142+ 
143+ let value = serde_json::to_value(&request).expect("request should serialize");
144+ assert_eq!(value["runtime_id"], "runtime-1");
145+ assert!(value.get("session_id").is_none());
146+ 
147+ let legacy: RuntimeCloseRequest =
148+ serde_json::from_str(r#"{"session_id":"legacy-runtime"}"#)
149+ .expect("legacy session_id should deserialize");
150+ assert_eq!(legacy.session_id, "legacy-runtime");
151+ }
152+}
Mapps/shared/src/gateway/session_record.rs+1-0
@@ -48,6 +48,7 @@ pub struct SessionRuntimeSnapshot {
48 48 
49#[derive(Debug, Clone, Serialize, Deserialize)]49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SessionRecord {50pub struct SessionRecord {
51+ #[serde(rename = "runtime_id", alias = "session_id")]
51 pub session_id: String,52 pub session_id: String,
52 pub conversation_id: String,53 pub conversation_id: String,
53 pub sender_id: String,54 pub sender_id: String,
Mapps/shared/src/gateway/subagent_interaction.rs+1-0
@@ -56,6 +56,7 @@ fn default_timeout_response(request: &InteractionRequest) -> InteractionResponse
56 InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false },56 InteractionRequest::Confirm { .. } => InteractionResponse::Confirmed { allowed: false },
57 InteractionRequest::TextInput { .. } => InteractionResponse::Text {57 InteractionRequest::TextInput { .. } => InteractionResponse::Text {
58 value: Some(sentinel.to_string()),58 value: Some(sentinel.to_string()),
59+ display_value: None,
59 },60 },
60 InteractionRequest::Choice { .. } => InteractionResponse::Choice {61 InteractionRequest::Choice { .. } => InteractionResponse::Choice {
61 value: Some(sentinel.to_string()),62 value: Some(sentinel.to_string()),
Mapps/shared/src/gateway/turns.rs+32-0
@@ -83,6 +83,7 @@ pub struct TurnMention {
83 83 
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct AppTurnRequest {85pub struct AppTurnRequest {
86+ #[serde(rename = "runtime_id", alias = "session_id")]
86 pub session_id: String,87 pub session_id: String,
87 #[serde(default)]88 #[serde(default)]
88 pub entry: GatewayEntryContext,89 pub entry: GatewayEntryContext,
@@ -102,3 +103,34 @@ pub struct AppTurnRequest {
102 #[serde(default)]103 #[serde(default)]
103 pub llm: Option<LlmRuntimeConfig>,104 pub llm: Option<LlmRuntimeConfig>,
104}105}
106+ 
107+pub type RuntimeTurnRequest = AppTurnRequest;
108+ 
109+#[cfg(test)]
110+mod tests {
111+ use super::*;
112+ 
113+ #[test]
114+ fn runtime_turn_request_serializes_runtime_id() {
115+ let request = RuntimeTurnRequest {
116+ session_id: "runtime-1".to_string(),
117+ entry: GatewayEntryContext::default(),
118+ channel: None,
119+ message_id: None,
120+ conversation_id: "conv-1".to_string(),
121+ sender_id: "user-1".to_string(),
122+ text: "hello".to_string(),
123+ channel_instance_id: None,
124+ channel_identity_prompt: None,
125+ reply_to_message_id: None,
126+ root_message_id: None,
127+ mentions: Vec::new(),
128+ reasoning_effort: ReasoningEffort::default(),
129+ llm: None,
130+ };
131+ 
132+ let value = serde_json::to_value(&request).expect("request should serialize");
133+ assert_eq!(value["runtime_id"], "runtime-1");
134+ assert!(value.get("session_id").is_none());
135+ }
136+}
Mcrates/agent-types/src/interaction/types.rs+13-3
@@ -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+ #[serde(default)]
27+ is_secret: bool,
26 },28 },
27 Choice {29 Choice {
28 prompt: String,30 prompt: String,
@@ -35,7 +37,15 @@ pub enum InteractionRequest {
35#[derive(Clone, Debug, Serialize, Deserialize)]37#[derive(Clone, Debug, Serialize, Deserialize)]
36#[serde(tag = "kind", rename_all = "snake_case")]38#[serde(tag = "kind", rename_all = "snake_case")]
37pub enum InteractionResponse {39pub 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+ #[serde(default, skip_serializing_if = "Option::is_none")]
46+ display_value: Option<String>,
47+ },
48+ Choice {
49+ value: Option<String>,
50+ },
41}51}
Mcrates/agent-types/src/llm/response.rs+2-0
@@ -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+ #[serde(default)]
16+ pub cached_tokens: usize,
15}17}
16 18 
17#[derive(Clone, Debug, Serialize, Deserialize)]19#[derive(Clone, Debug, Serialize, Deserialize)]
Mcrates/core/src/agent_loop.rs+342-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_view418 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.snapshot839 ctx.snapshot
833 .llm_provider840 .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+ 
1412fn decide(ctx: &mut LoopContext<'_>) {1562fn 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+ #[test]
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+ #[test]
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}
Mcrates/core/src/runtime_support.rs+4-1
@@ -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 }
Mcrates/hook/src/hookers/plugin/llm/adaptor.rs+10-1
@@ -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 
Mcrates/hook/src/hookers/plugin/tool/adaptor.rs+15-9
@@ -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(用于缓存 key179+ // Get session_id (for cache key)
180 let session_id = runtime180 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_messages187+ // 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_messages191 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> = messages229 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");
Mcrates/llm-client/src/convert.rs+8-0
@@ -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,
Mcrates/llm-client/src/error.rs+95-0
@@ -1,6 +1,10 @@
1+use std::fs::OpenOptions;
2+use std::io::Write;
3+use std::path::PathBuf;
1use std::sync::OnceLock;4use std::sync::OnceLock;
2 5 
3pub use agent_types::LlmError;6pub use agent_types::LlmError;
7+use chrono::Local;
4use regex::Regex;8use regex::Regex;
5use reqwest::StatusCode;9use reqwest::StatusCode;
6use serde_json::Value;10use serde_json::Value;
@@ -184,6 +188,97 @@ fn overflow_patterns() -> &'static [Regex] {
184static OVERFLOW_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();188static OVERFLOW_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
185static NO_BODY_OVERFLOW_RE: OnceLock<Regex> = OnceLock::new();189static 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+ #[cfg(unix)]
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#[cfg(test)]282#[cfg(test)]
188mod tests {283mod tests {
189 use super::*;284 use super::*;
Mcrates/llm-client/src/factory/tests.rs+1-0
@@ -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 },
Mcrates/llm-client/src/factory/trace.rs+1-0
@@ -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(),
Mcrates/llm-client/src/providers/anthropic/convert.rs+13-8
@@ -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 messages44 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 
53pub(crate) fn anthropic_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> {58pub(crate) fn anthropic_messages(messages: &[ChatMessage]) -> Vec<serde_json::Value> {
@@ -274,7 +279,7 @@ mod tests {
274 }279 }
275 280 
276 #[test]281 #[test]
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}
Mcrates/llm-client/src/providers/anthropic/mod.rs+68-10
@@ -16,7 +16,7 @@ use agent_types::{
16mod convert;16mod convert;
17 17 
18use convert::{18use 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 merged497 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 #[test]628 #[test]
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+ #[test]
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 #[test]682 #[test]
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();
Mcrates/llm-client/src/providers/gemini/mod.rs+14-3
@@ -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));
Mcrates/llm-client/src/providers/ollama.rs+14-3
@@ -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 };
Mcrates/llm-client/src/providers/openai_family.rs+27-13
@@ -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 = response201+ let full_response = response.text().await.unwrap_or_default();
202- .text()202+ crate::error::write_stream_error_log(
203- .await203+ 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);
Mcrates/llm-client/src/wire_types/response.rs+10-0
@@ -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+ #[serde(default)]
69+ pub prompt_tokens_details: Option<WirePromptTokensDetails>,
70+}
71+ 
72+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73+pub(crate) struct WirePromptTokensDetails {
74+ #[serde(default)]
75+ pub cached_tokens: u32,
68}76}
69 77 
70#[cfg(test)]78#[cfg(test)]
@@ -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,
Mcrates/llm-client/src/wire_types/stream.rs+1-0
@@ -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()),
Mcrates/subagent/src/coordinator.rs+6-4
@@ -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\
462Count files\n\n\463Count files\n\n\
463Task Context:\n\464Task Context:\n\
464Use find\n\n\465Use 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\
478Summarize logs\n\n\480Summarize logs\n\n\
479Task Context:\n\481Task Context:\n\
480Check /var/log\n\n\482Check /var/log\n\n\
Mcrates/tool/Cargo.toml+2-0
@@ -33,6 +33,8 @@ reqwest = { workspace = true, features = ["stream"] }
33rustls-native-certs = "0.8"33rustls-native-certs = "0.8"
34tempfile.workspace = true34tempfile.workspace = true
35tracing.workspace = true35tracing.workspace = true
36+regex = "1"
37+lazy_static = "1.4"
36 38 
37[[bin]]39[[bin]]
38name = "tool-cli"40name = "tool-cli"
Mcrates/tool/src/impl/builtin/ask_user_question/executor.rs+11-3
@@ -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_result44 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);
Mcrates/tool/src/impl/builtin/ask_user_question/input.rs+11-7
@@ -1,14 +1,18 @@
1use serde::{Deserialize, Serialize};1use serde::{Deserialize, Serialize};
2 2 
3-/// 对应 InteractionRequest 的三种变体,用 serde tag 区分。3+/// Three variants corresponding to InteractionRequest, differentiated by serde tag.
4#[derive(Debug, Clone, Serialize, Deserialize)]4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(tag = "kind", rename_all = "snake_case")]5#[serde(tag = "kind", rename_all = "snake_case")]
6pub enum QuestionItem {6pub 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+ #[serde(default)]
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#[derive(Debug, Clone, Serialize, Deserialize)]25#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AskUserQuestionInput {26pub 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}
Mcrates/tool/src/impl/builtin/ask_user_question/output.rs+8-6
@@ -1,26 +1,28 @@
1use serde::{Deserialize, Serialize};1use serde::{Deserialize, Serialize};
2 2 
3-/// 对应 InteractionResponse 的三种变体,同时携带原始 prompt 便于 AI 对应问答。3+/// Three variants corresponding to InteractionResponse, carrying original prompt for AI reference.
4#[derive(Debug, Clone, Serialize, Deserialize)]4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(tag = "kind", rename_all = "snake_case")]5#[serde(tag = "kind", rename_all = "snake_case")]
6pub enum AnswerItem {6pub 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+ #[serde(default, skip_serializing_if = "Option::is_none")]
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#[derive(Debug, Clone, Serialize, Deserialize)]24#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AskUserQuestionOutput {25pub 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}
Mcrates/tool/src/impl/builtin/ask_user_question/spec.rs+8-3
@@ -14,7 +14,7 @@ pub struct AskUserQuestionToolSpec {
14 14 
15impl AskUserQuestionToolSpec {15impl 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": false51 "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(),
Mcrates/tool/src/impl/builtin/ask_user_question/validation.rs+23-22
@@ -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 
10use super::input::{AskUserQuestionInput, QuestionItem};10use super::input::{AskUserQuestionInput, QuestionItem};
11 11 
12/// Validation error codes.12/// Validation error codes.
13pub mod error_code {13pub mod error_code {
14- /// questions 列表为空(error_code = 114+ /// 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 = 216+ /// 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 = 318+ /// 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 = 420+ /// 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 = 522+ /// 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 = 624+ /// 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 = 726+ /// 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.
60pub fn validate_input(input: &AskUserQuestionInput) -> ValidationResult {60pub 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 messages82 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> = input140 let prompts: Vec<&str> = input
141 .questions141 .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 
Mcrates/tool/src/impl/builtin/bash/executor/backend.rs+14-0
@@ -10,6 +10,7 @@ use agent_types::tool::call_types::FinalToolCall;
10use agent_types::tool::execution_types::{RawToolOutcome, ToolExecutionError, ToolExecutorOutput};10use agent_types::tool::execution_types::{RawToolOutcome, ToolExecutionError, ToolExecutorOutput};
11 11 
12use super::super::validation::backend as validation;12use super::super::validation::backend as validation;
13+use super::super::validation::interactive;
13use super::constants::{default_timeout_ms, MAX_OUTPUT_BYTES_PER_STREAM};14use super::constants::{default_timeout_ms, MAX_OUTPUT_BYTES_PER_STREAM};
14use super::input::BashInput;15use super::input::BashInput;
15use super::output::BashOutput;16use 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_result148 let error_message = validation_result
Mcrates/tool/src/impl/builtin/bash/validation/backend.rs+1-0
@@ -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 
9use agent_contracts::backend::{PathKind, PathStat};10use agent_contracts::backend::{PathKind, PathStat};
Acrates/tool/src/impl/builtin/bash/validation/interactive.rs+502-0
@@ -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+#[cfg(test)]
260+mod tests {
261+ use super::*;
262+ 
263+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+ #[test]
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+}
Mcrates/tool/src/impl/builtin/bash/validation/mod.rs+1-0
@@ -1,3 +1,4 @@
1pub(super) use super::{constants, input};1pub(super) use super::{constants, input};
2 2 
3pub(crate) mod backend;3pub(crate) mod backend;
4+pub(crate) mod interactive;
Mdocs/daemon_config.md+46-46
@@ -255,20 +255,20 @@ Health check endpoint for liveness probes and load balancing.
255 255 
256#### Session And Runtime Control Plane256#### 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.
260These endpoints are protected by HTTP Bearer auth when `[http]` auth is configured.260These 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 an261+LLM provider settings are resolved per runtime/turn: request payloads may pass an
262optional `llm` object, and omitted fields fall back to `[llm]` in the daemon262optional `llm` object, and omitted fields fall back to `[llm]` in the daemon
263config. The daemon does not require the LLM API key at process startup.263config. 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)
280for the current layering and checkpoint semantics.280for the current layering and checkpoint semantics.
281 281 
282-**Open session example:**282+**Open runtime example:**
283 283 
284```bash284```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```bash304```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```bash315```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 
337The runtime checkpoint APIs require the source runtime to be idle. In the337The 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 id338+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, runs339+returned by `/api/v1/runtimes/open`. The following flow creates a runtime, runs
340one turn to make the backend dirty, captures a checkpoint, checks out a child340one 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 
343The example requires Bash, `curl`, and `jq`. It only adds the `Authorization`343The example requires Bash, `curl`, and `jq`. It only adds the `Authorization`
344header when `XIAOO_HTTP_BEARER_TOKEN` is present. It uses344header when `XIAOO_HTTP_BEARER_TOKEN` is present. It uses
@@ -347,8 +347,8 @@ checkpoint and checkout control-plane calls.
347 347 
348```bash348```bash
349BASE_URL="http://localhost:18080"349BASE_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}"
352SENDER="checkpoint-demo-user"352SENDER="checkpoint-demo-user"
353 353 
354AUTH_HEADER=()354AUTH_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}")
357fi357fi
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.json365 }' > /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" \
375INIT_TEXT="请在当前 agent runtime 的工作区创建文件 /home/user/workspace/checkpoint_demo.txt,内容为两行:第一行 checkpoint base,第二行 runtime parent initialized。完成后读取该文件并回复其完整内容。"375INIT_TEXT="请在当前 agent runtime 的工作区创建文件 /home/user/workspace/checkpoint_demo.txt,内容为两行:第一行 checkpoint base,第二行 runtime parent initialized。完成后读取该文件并回复其完整内容。"
376 376 
377jq -n \377jq -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: null396 llm: null
397 }' > /tmp/xiaoo_initial_turn.json397 }' > /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```bash406```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```bash430```bash
431jq -n \431jq -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.time451 > /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)"
454printf "checkout_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkout.time)"454printf "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" \
474PARENT_TEXT="你是父 runtime。请不要写入“测试fork”。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:parent runtime complete。完成后读取该文件并回复完整内容。"474PARENT_TEXT="你是父 runtime。请不要写入“测试fork”。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:parent runtime complete。完成后读取该文件并回复完整内容。"
475CHILD_TEXT="你是 checkpoint checkout 出来的子 runtime。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:测试fork。完成后读取该文件并回复完整内容。"475CHILD_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: null492 llm: null
493 }' > /tmp/xiaoo_parent_final.json493 }' > /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.sse499 > /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: null516 llm: null
517 }' > /tmp/xiaoo_child_final.json517 }' > /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```bash526```bash
527-for id in "$SESSION" "$CHILD_SESSION"; do527+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:
545These values are examples, not guarantees. They vary with E2B provider latency,545These values are examples, not guarantees. They vary with E2B provider latency,
546network path, snapshot size, template cold/warm state, and daemon host load. The546network path, snapshot size, template cold/warm state, and daemon host load. The
547numbers above do not include the LLM turns before or after the checkpoint, and547numbers 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 calls548+they do not include closing the runtimes. Closing an E2B-backed runtime calls
549backend release, which deletes the corresponding E2B sandbox.549backend 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 mismatch566+- `400 Bad Request` — malformed request or path/runtime mismatch
567- `401 Unauthorized` — missing or invalid Bearer token when `[http]` auth is configured567- `401 Unauthorized` — missing or invalid Bearer token when `[http]` auth is configured
568-- `404 Not Found` — session not found568+- `404 Not Found` — runtime not found
569- `429 Too Many Requests` — rate limit exceeded when `[http.rate_limit]` is enabled569- `429 Too Many Requests` — rate limit exceeded when `[http.rate_limit]` is enabled
570-- `500 Internal Server Error` — session service internal error570+- `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 
Mdocs/remote_tui.md+13-14
@@ -14,14 +14,14 @@ Remote TUI lets one machine run the XiaoO gateway daemon while another machine r
14Machine B Machine A14Machine B Machine A
15xiaoo xiaoo-daemon15xiaoo xiaoo-daemon
16--------- ----------------16--------- ----------------
17-TUI input/rendering HTTP/SSE Gateway session APIs17+TUI input/rendering HTTP/SSE Gateway runtime APIs
18/remote commands -----------> Agent loop18/remote commands -----------> Agent loop
19Interaction prompt <----------> Tools / hooks / workspace19Interaction prompt <----------> Tools / hooks / workspace
20```20```
21 21 
22Local TUI remains the default. Remote mode is opt-in:22Local 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 
27In 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.27In 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 API117## 5. Remote Session And Runtime API
118 118 
119-Remote TUI uses the daemon's session APIs. The same protected route group also119+Remote TUI uses the daemon's runtime control APIs. The same protected route
120-contains runtime checkpoint APIs for programmatic clients that need branching120+group also contains checkpoint APIs for programmatic clients that need branching
121runtime state.121runtime 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`. Current133+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 and134+vocabulary.
135-provider-native instance ids stay internal to the daemon.
136 135 
137SSE event types:136SSE 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 
Mdocs/runtime_checkpoint.md+1-1
@@ -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 
112Use `curl -w '%{time_total}'` around the checkpoint and checkout requests when112Use `curl -w '%{time_total}'` around the checkpoint and checkout requests when
113measuring from a client. That value is end-to-end HTTP latency as observed by the113measuring from a client. That value is end-to-end HTTP latency as observed by the
Mplugins/hookers/audit_agent/README.md+19-12
@@ -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```bash379```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```bash408```bash
407-sudo dnf install ./xiaoO-hookers-*.rpm # audit_agent 主程序409+# 安装 xiaoO-hookersaudit_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```bash422```bash
414-cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo423+cd ~/rpmbuild/SOURCES/xiaoO-*/plugins/tests/hookers/audit_agent/xiaoo
415 424 
416-# 运行全部用例(完整示例)425+# 运行全部用例
417python3 run_rules_tests.py \426python3 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-run449 --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 
Mplugins/tests/hookers/audit_agent/TEST_GUIDE.md+97-37
@@ -7,7 +7,8 @@
73. [LLM 配置](#3-llm-配置)73. [LLM 配置](#3-llm-配置)
84. [测试用例说明](#4-测试用例说明)84. [测试用例说明](#4-测试用例说明)
95. [常见问题](#5-常见问题)95. [常见问题](#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-3109# 只跑 level-2 + level-3
109python3 run_rules_tests.py --api-key "your-key" --level 2 --level 3110python3 run_rules_tests.py --api-key "your-key" --level 2 --level 3
110 111 
111-# 只跑某个规则112+# 只跑某个规则(规则名为 JSON 文件名,不含 .json 后缀)
112python3 run_rules_tests.py --api-key "your-key" --rule sudo113python3 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# 预览有哪些用例(不执行)
115python3 run_rules_tests.py --dry-run119python3 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```bash438```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```bash444```bash
436-cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo445+# 安装 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+# 运行测试
438python3 run_rules_tests.py \482python3 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.json485 --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```bash496```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 1502 --level 1
455 503 
456-# 只跑某个规则504+# 只跑某个规则(规则名为 JSON 文件名,不含 .json 后缀)
457python3 run_rules_tests.py \505python3 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 sudo509 --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# 预览用例
464python3 run_rules_tests.py \519python3 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-run522 --dry-run
468```523```
469 524 
470-### 6.4 使用 Shell 脚本测试525+### 7.4 使用 Shell 脚本测试
471 526 
472```bash527```bash
473-cd /usr/lib/.xiaoo/tests/hookers/audit_agent/xiaoo528+cd ~/rpmbuild/SOURCES/xiaoO-v0.0.4/plugins/tests/hookers/audit_agent/xiaoo
474 529 
475export XIAOO_BIN=/usr/bin/xiaoo530export XIAOO_BIN=/usr/bin/xiaoo
476export XIAOO_CONFIG=~/.config/xiaoo/config.toml531export XIAOO_CONFIG=~/.config/xiaoo/config.toml
@@ -480,9 +535,14 @@ bash run-allow-01-read-log.sh
480 535 
481Shell 脚本使用的 API Key 从 `~/.config/xiaoo/config.toml``api_key_env` 字段读取,需提前 `export` 对应的环境变量。536Shell 脚本使用的 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 日志路径
Mplugins/tests/hookers/audit_agent/xiaoo/run_rules_tests.py+45-7
@@ -32,6 +32,7 @@ AuditAgent rules/ 自动化测试脚本
32import argparse32import argparse
33import json33import json
34import os34import os
35+import re
35import subprocess36import subprocess
36import sys37import sys
37import time38import 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+ 
215def run_single_test(bin_path, config_path, prompt, timeout, max_turns, max_retries=2):233def 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.stderr265 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() - start270 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_start273+ # 读取审计日志,判断 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_start282+ 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.retry405 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],