use async_trait::async_trait; use openhuman_core::core::event_bus::{init_global, request_native_global, DEFAULT_CAPACITY}; use openhuman_core::openhuman::agent::bus::{ register_agent_handlers, AgentTurnRequest, AgentTurnResponse, AGENT_RUN_TURN_METHOD, }; use openhuman_core::openhuman::agent::progress::AgentProgress; use openhuman_core::openhuman::config::{MultimodalConfig, MultimodalFileConfig}; use openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities; use openhuman_core::openhuman::inference::provider::{ ChatMessage, ChatRequest, ChatResponse, Provider, ProviderDelta, UsageInfo, }; use openhuman_core::openhuman::security::POLICY_BLOCKED_MARKER; use openhuman_core::openhuman::tools::{PermissionLevel, Tool, ToolContent, ToolResult, ToolScope}; use serde_json::json; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; #[derive(Clone, Debug)] struct CapturedRequest { messages: Vec, tool_names: Vec, streamed: bool, } #[derive(Default)] struct ScriptedProvider { responses: Mutex>>, requests: Mutex>, stream_events: Vec, } impl ScriptedProvider { fn new(responses: Vec) -> Arc { Arc::new(Self { responses: Mutex::new(responses.into_iter().map(Ok).collect()), ..Self::default() }) } fn requests(&self) -> Vec { self.requests.lock().unwrap().clone() } } #[async_trait] impl Provider for ScriptedProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { native_tool_calling: true, vision: false, } } async fn chat_with_system( &self, _system_prompt: Option<&str>, message: &str, _model: &str, _temperature: f64, ) -> anyhow::Result { Ok(message.to_string()) } async fn chat( &self, request: ChatRequest<'_>, _model: &str, _temperature: f64, ) -> anyhow::Result { self.requests.lock().unwrap().push(CapturedRequest { messages: request.messages.to_vec(), tool_names: request .tools .map(|tools| tools.iter().map(|tool| tool.name.clone()).collect()) .unwrap_or_default(), streamed: request.stream.is_some(), }); if let Some(stream) = request.stream { for event in &self.stream_events { stream.send(event.clone()).await.ok(); } } self.responses .lock() .unwrap() .pop_front() .unwrap_or_else(|| Ok(text_response("script exhausted fallback"))) } } struct Round22Tool { name: &'static str, output: &'static str, is_error: bool, } impl Round22Tool { fn ok(name: &'static str, output: &'static str) -> Box { Box::new(Self { name, output, is_error: false, }) } fn err(name: &'static str, output: &'static str) -> Box { Box::new(Self { name, output, is_error: true, }) } } #[async_trait] impl Tool for Round22Tool { fn name(&self) -> &str { self.name } fn description(&self) -> &str { "round22 deterministic coverage tool" } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "value": { "type": "string" }, "command": { "type": "string" } } }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let suffix = args .get("value") .or_else(|| args.get("command")) .and_then(serde_json::Value::as_str) .unwrap_or_default(); let body = if suffix.is_empty() { self.output.to_string() } else { format!("{}:{suffix}", self.output) }; Ok(ToolResult { content: vec![ToolContent::Text { text: body }], is_error: self.is_error, markdown_formatted: None, }) } fn permission_level(&self) -> PermissionLevel { PermissionLevel::ReadOnly } fn scope(&self) -> ToolScope { ToolScope::All } } fn text_response(text: &str) -> ChatResponse { ChatResponse { text: Some(text.to_string()), tool_calls: vec![], usage: Some(UsageInfo { input_tokens: 3, output_tokens: 2, context_window: 16_000, cached_input_tokens: 1, charged_amount_usd: 0.00001, }), reasoning_content: None, } } fn xml_tool_response(name: &str, args: serde_json::Value) -> ChatResponse { ChatResponse { text: Some(format!( "before {{\"name\":\"{name}\",\"arguments\":{args}}}" )), tool_calls: vec![], usage: None, reasoning_content: None, } } fn glm_response(line: &str) -> ChatResponse { ChatResponse { text: Some(line.to_string()), tool_calls: vec![], usage: None, reasoning_content: None, } } async fn run_turn( provider: Arc, tools: Vec>, max_tool_iterations: usize, on_delta: Option>, on_progress: Option>, ) -> Result { init_global(DEFAULT_CAPACITY); register_agent_handlers(); request_native_global::( AGENT_RUN_TURN_METHOD, AgentTurnRequest { provider, history: vec![ ChatMessage::system("round22 system"), ChatMessage::user("round22 run"), ], tools_registry: Arc::new(tools), provider_name: "round22".to_string(), model: "gpt-4o-mini".to_string(), temperature: 0.0, silent: true, channel_name: "round22".to_string(), multimodal: MultimodalConfig::default(), multimodal_files: MultimodalFileConfig::default(), max_tool_iterations, on_delta, target_agent_id: Some("orchestrator".to_string()), visible_tool_names: None, extra_tools: Vec::new(), on_progress, origin: openhuman_core::openhuman::agent::turn_origin::AgentTurnOrigin::Cli, }, ) .await .map_err(|err| err.to_string()) } #[tokio::test] async fn no_progress_guard_uses_default_iteration_fallback_when_zero() { let provider = ScriptedProvider::new(vec![ xml_tool_response("fail", json!({ "value": "one" })), xml_tool_response("fail", json!({ "value": "two" })), xml_tool_response("fail", json!({ "value": "three" })), xml_tool_response("fail", json!({ "value": "four" })), xml_tool_response("fail", json!({ "value": "five" })), xml_tool_response("fail", json!({ "value": "six" })), ]); let response = run_turn( provider.clone(), vec![Round22Tool::err("fail", "round22 failure")], 0, None, None, ) .await .unwrap(); assert!(response.text.contains("6 tool calls in a row failed")); assert!(response.text.contains("round22 failure:six")); assert_eq!( provider.requests().len(), 6, "max_tool_iterations=0 should use the default cap, allowing the no-progress guard to halt first" ); } #[tokio::test] async fn hard_policy_block_repeat_halts_on_second_identical_call() { let provider = ScriptedProvider::new(vec![ xml_tool_response("blocked", json!({ "value": "same" })), xml_tool_response("blocked", json!({ "value": "same" })), ]); let output = format!("{POLICY_BLOCKED_MARKER} read-only policy blocked this write"); let response = run_turn( provider.clone(), vec![Round22Tool::err( "blocked", Box::leak(output.into_boxed_str()), )], 8, None, None, ) .await .unwrap(); assert!(response.text.contains("blocked by the security policy")); assert!(response.text.contains("re-issued with identical arguments")); assert_eq!(provider.requests().len(), 2); } #[tokio::test] async fn glm_style_tool_call_executes_then_final_streams_in_chunks_and_progress() { let provider = Arc::new(ScriptedProvider { responses: Mutex::new( vec![ Ok(glm_response("browser_open/url>https://example.com/data")), Ok(text_response( "This is a deliberately long final response from the scripted provider so the on_delta path emits more than one deterministic chunk for channel draft updates.", )), ] .into(), ), requests: Mutex::new(Vec::new()), stream_events: vec![ProviderDelta::TextDelta { delta: "draft from provider".to_string(), }], }); let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel(8); let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(16); let response = run_turn( provider.clone(), vec![Round22Tool::ok("shell", "shell-output")], 4, Some(delta_tx), Some(progress_tx), ) .await .unwrap(); assert!(response .text .starts_with("This is a deliberately long final response")); let mut deltas = Vec::new(); while let Ok(delta) = delta_rx.try_recv() { deltas.push(delta); } assert!( deltas.len() >= 2, "long final response should stream in at least two chunks, got {deltas:?}" ); let mut progress = Vec::new(); while let Ok(event) = progress_rx.try_recv() { progress.push(event); } assert!(progress .iter() .any(|event| matches!(event, AgentProgress::TextDelta { delta, iteration: 1 } if delta == "draft from provider"))); assert!(progress.iter().any(|event| matches!( event, AgentProgress::ToolCallCompleted { tool_name, success, .. } if tool_name == "shell" && *success ))); assert!(progress .iter() .any(|event| matches!(event, AgentProgress::TurnCompleted { iterations: 2 }))); let requests = provider.requests(); assert!(requests.iter().all(|request| request.streamed)); assert_eq!(requests[0].tool_names, vec!["shell"]); let second_request_text = requests[1] .messages .iter() .map(|message| message.content.as_str()) .collect::>() .join("\n"); assert!(second_request_text.contains("curl -s 'https://example.com/data'")); assert!(second_request_text.contains("shell-output:curl -s 'https://example.com/data'")); }