Files
openhuman/tests/monitor_agent_e2e.rs
T

518 lines
16 KiB
Rust

#![cfg(not(windows))]
use anyhow::Result;
use async_trait::async_trait;
use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher;
use openhuman_core::openhuman::agent::harness::run_queue::RunQueue;
use openhuman_core::openhuman::agent::harness::session::Agent;
use openhuman_core::openhuman::agent::host_runtime::NativeRuntime;
use openhuman_core::openhuman::config::AgentConfig;
use openhuman_core::openhuman::inference::provider::thread_context::with_thread_id;
use openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities;
use openhuman_core::openhuman::inference::provider::{
ChatMessage, ChatRequest, ChatResponse, Provider, ToolCall,
};
use openhuman_core::openhuman::memory::{
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
};
use openhuman_core::openhuman::monitor::tools::{
MonitorListTool, MonitorReadTool, MonitorStopTool, MonitorTool,
};
use openhuman_core::openhuman::security::{AuditLogger, AutonomyLevel, SecurityPolicy};
use openhuman_core::openhuman::tools::Tool;
use parking_lot::Mutex;
use serde_json::json;
use std::collections::VecDeque;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use tempfile::TempDir;
use tokio::time::{sleep, Duration};
type ResponseFactory = Box<dyn Fn(&[ChatMessage]) -> ChatResponse + Send + Sync>;
enum ProviderStep {
Static(ChatResponse),
Delayed(Duration, ChatResponse),
FromHistory(ResponseFactory),
}
#[derive(Clone, Debug)]
struct CapturedRequest {
messages: Vec<ChatMessage>,
tool_names: Vec<String>,
}
struct ScriptedProvider {
steps: Mutex<VecDeque<ProviderStep>>,
requests: Mutex<Vec<CapturedRequest>>,
}
fn monitor_e2e_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
impl ScriptedProvider {
fn new(steps: Vec<ProviderStep>) -> Arc<Self> {
Arc::new(Self {
steps: Mutex::new(steps.into()),
requests: Mutex::new(Vec::new()),
})
}
fn requests(&self) -> Vec<CapturedRequest> {
self.requests.lock().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,
) -> Result<String> {
Ok(format!("summary:{message}"))
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<ChatResponse> {
let messages = request.messages.to_vec();
self.requests.lock().push(CapturedRequest {
messages: messages.clone(),
tool_names: request
.tools
.map(|tools| tools.iter().map(|tool| tool.name.clone()).collect())
.unwrap_or_default(),
});
let step = self.steps.lock().pop_front();
match step {
Some(ProviderStep::Static(response)) => Ok(response),
Some(ProviderStep::Delayed(delay, response)) => {
sleep(delay).await;
Ok(response)
}
Some(ProviderStep::FromHistory(factory)) => Ok(factory(&messages)),
None => Ok(text_response("default monitor final")),
}
}
}
#[derive(Default)]
struct StubMemory {
entries: Mutex<Vec<MemoryEntry>>,
}
#[async_trait]
impl Memory for StubMemory {
fn name(&self) -> &str {
"monitor-agent-e2e-memory"
}
async fn store(
&self,
namespace: &str,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> Result<()> {
let mut entries = self.entries.lock();
let id = format!("{namespace}:{key}:{}", entries.len());
entries.push(MemoryEntry {
id,
key: key.to_string(),
content: content.to_string(),
namespace: Some(namespace.to_string()),
category,
timestamp: "2026-06-04T00:00:00Z".to_string(),
session_id: session_id.map(str::to_string),
score: Some(0.9),
taint: Default::default(),
});
Ok(())
}
async fn recall(
&self,
_query: &str,
limit: usize,
_opts: RecallOpts<'_>,
) -> Result<Vec<MemoryEntry>> {
Ok(self.entries.lock().iter().take(limit).cloned().collect())
}
async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
Ok(self
.entries
.lock()
.iter()
.find(|entry| entry.namespace.as_deref() == Some(namespace) && entry.key == key)
.cloned())
}
async fn list(
&self,
namespace: Option<&str>,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> Result<Vec<MemoryEntry>> {
Ok(self
.entries
.lock()
.iter()
.filter(|entry| namespace.is_none_or(|ns| entry.namespace.as_deref() == Some(ns)))
.filter(|entry| category.is_none_or(|cat| &entry.category == cat))
.filter(|entry| session_id.is_none_or(|sid| entry.session_id.as_deref() == Some(sid)))
.cloned()
.collect())
}
async fn forget(&self, namespace: &str, key: &str) -> Result<bool> {
let mut entries = self.entries.lock();
let before = entries.len();
entries.retain(|entry| entry.namespace.as_deref() != Some(namespace) || entry.key != key);
Ok(entries.len() != before)
}
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
Ok(Vec::new())
}
async fn count(&self) -> Result<usize> {
Ok(self.entries.lock().len())
}
async fn health_check(&self) -> bool {
true
}
}
fn text_response(text: &str) -> ChatResponse {
ChatResponse {
text: Some(text.to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
}
}
fn tool_response(id: &str, name: &str, arguments: serde_json::Value) -> ChatResponse {
ChatResponse {
text: Some(format!("calling {name}")),
tool_calls: vec![ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: arguments.to_string(),
extra_content: None,
}],
usage: None,
reasoning_content: Some(format!("need {name}")),
}
}
fn all_messages_text(messages: &[ChatMessage]) -> String {
messages
.iter()
.map(|message| format!("{}:{}", message.role, message.content))
.collect::<Vec<_>>()
.join("\n")
}
fn first_monitor_id(messages: &[ChatMessage]) -> String {
let text = all_messages_text(messages);
let start = text
.find("mon_")
.unwrap_or_else(|| panic!("expected monitor id in messages:\n{text}"));
text[start..]
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.collect()
}
fn contains_tool_json_pair(text: &str, key: &str, value: &str) -> bool {
text.contains(&format!("\"{key}\":\"{value}\""))
|| text.contains(&format!("\\\"{key}\\\":\\\"{value}\\\""))
}
fn monitor_tools(workspace: &Path, autonomy: AutonomyLevel) -> Vec<Box<dyn Tool>> {
let security = Arc::new(SecurityPolicy {
autonomy,
workspace_dir: workspace.to_path_buf(),
action_dir: workspace.to_path_buf(),
..SecurityPolicy::default()
});
vec![
Box::new(MonitorTool::new(
security,
Arc::new(NativeRuntime::new()),
AuditLogger::disabled(),
)),
Box::new(MonitorListTool),
Box::new(MonitorStopTool),
Box::new(MonitorReadTool),
]
}
fn build_agent(
workspace: &Path,
provider: Arc<ScriptedProvider>,
tools: Vec<Box<dyn Tool>>,
max_tool_iterations: usize,
) -> Agent {
let mut agent = Agent::builder()
.provider_arc(provider)
.tools(tools)
.memory(Arc::new(StubMemory::default()))
.tool_dispatcher(Box::new(NativeToolDispatcher))
.config(AgentConfig {
max_tool_iterations,
max_history_messages: 16,
..AgentConfig::default()
})
.model_name("monitor-e2e-model".to_string())
.temperature(0.0)
.workspace_dir(workspace.to_path_buf())
.workflows(Vec::new())
.auto_save(false)
.event_context("monitor-e2e-session", "monitor-e2e-channel")
.agent_definition_name("orchestrator")
.omit_profile(true)
.omit_memory_md(true)
.explicit_preferences_enabled(false)
.unified_compaction_enabled(false)
.build()
.unwrap();
agent.set_connected_integrations(Vec::new());
agent
}
async fn run_monitor_turn(
tmp: &TempDir,
provider: Arc<ScriptedProvider>,
autonomy: AutonomyLevel,
max_tool_iterations: usize,
) -> String {
let mut agent = build_agent(
tmp.path(),
provider,
monitor_tools(tmp.path(), autonomy),
max_tool_iterations,
);
agent.set_run_queue(Some(RunQueue::new()));
with_thread_id("monitor-agent-e2e-thread", async move {
agent
.turn("Start a background monitor and react when it reports.")
.await
.unwrap()
})
.await
}
#[tokio::test]
async fn orchestrator_monitor_line_reaches_next_llm_call_as_collect_context() {
let _guard = monitor_e2e_lock().lock().await;
let tmp = tempfile::tempdir().unwrap();
let provider = ScriptedProvider::new(vec![
ProviderStep::Static(tool_response(
"call-monitor",
"monitor",
json!({
"command": "printf 'MONITOR_READY\\n'; sleep 0.1",
"description": "e2e line injection monitor",
"timeout_ms": 2_000,
"persistent": false
}),
)),
ProviderStep::Delayed(
Duration::from_millis(150),
tool_response("call-list", "monitor_list", json!({})),
),
ProviderStep::FromHistory(Box::new(|messages| {
let text = all_messages_text(messages);
assert!(
text.contains("[Additional context from user]: [Monitor mon_"),
"expected monitor collect injection in messages:\n{text}"
);
assert!(
text.contains("MONITOR_READY"),
"expected monitor line in messages:\n{text}"
);
text_response("orchestrator observed monitor event")
})),
]);
let answer = run_monitor_turn(&tmp, provider.clone(), AutonomyLevel::Supervised, 5).await;
assert_eq!(answer, "orchestrator observed monitor event");
let requests = provider.requests();
assert_eq!(requests.len(), 3);
assert!(requests[0].tool_names.contains(&"monitor".to_string()));
assert!(requests[0].tool_names.contains(&"monitor_list".to_string()));
assert!(requests[2]
.messages
.iter()
.any(|message| message.content.contains("MONITOR_READY")));
}
#[tokio::test]
async fn orchestrator_reads_monitor_output_after_registration() {
let _guard = monitor_e2e_lock().lock().await;
let tmp = tempfile::tempdir().unwrap();
let provider = ScriptedProvider::new(vec![
ProviderStep::Static(tool_response(
"call-monitor",
"monitor",
json!({
"command": "printf 'READBACK_LINE\\n'",
"description": "e2e monitor read",
"timeout_ms": 2_000,
"persistent": false
}),
)),
ProviderStep::Delayed(
Duration::from_millis(120),
tool_response("call-list", "monitor_list", json!({})),
),
ProviderStep::FromHistory(Box::new(|messages| {
let monitor_id = first_monitor_id(messages);
tool_response(
"call-read",
"monitor_read",
json!({ "monitor_id": monitor_id, "max_bytes": 4096 }),
)
})),
ProviderStep::FromHistory(Box::new(|messages| {
let text = all_messages_text(messages);
assert!(text.contains("READBACK_LINE"));
text_response("orchestrator read monitor output")
})),
]);
let answer = run_monitor_turn(&tmp, provider, AutonomyLevel::Supervised, 6).await;
assert_eq!(answer, "orchestrator read monitor output");
}
#[tokio::test]
async fn orchestrator_stops_a_running_monitor_by_id_from_tool_result() {
let _guard = monitor_e2e_lock().lock().await;
let tmp = tempfile::tempdir().unwrap();
let provider = ScriptedProvider::new(vec![
ProviderStep::Static(tool_response(
"call-monitor",
"monitor",
json!({
"command": "sleep 5",
"description": "e2e stoppable monitor",
"timeout_ms": 5_000,
"persistent": false
}),
)),
ProviderStep::FromHistory(Box::new(|messages| {
let monitor_id = first_monitor_id(messages);
tool_response(
"call-stop",
"monitor_stop",
json!({ "monitor_id": monitor_id }),
)
})),
ProviderStep::FromHistory(Box::new(|messages| {
let text = all_messages_text(messages);
assert!(
contains_tool_json_pair(&text, "status", "stopped"),
"expected stopped status in messages:\n{text}"
);
text_response("orchestrator stopped monitor")
})),
]);
let answer = run_monitor_turn(&tmp, provider, AutonomyLevel::Supervised, 5).await;
assert_eq!(answer, "orchestrator stopped monitor");
}
#[tokio::test]
async fn orchestrator_sees_monitor_timeout_status_through_list() {
let _guard = monitor_e2e_lock().lock().await;
let tmp = tempfile::tempdir().unwrap();
let provider = ScriptedProvider::new(vec![
ProviderStep::Static(tool_response(
"call-monitor",
"monitor",
json!({
"command": "sleep 1",
"description": "e2e timed monitor",
"timeout_ms": 40,
"persistent": false
}),
)),
ProviderStep::Delayed(
Duration::from_millis(120),
tool_response("call-list", "monitor_list", json!({})),
),
ProviderStep::FromHistory(Box::new(|messages| {
let text = all_messages_text(messages);
assert!(
text.contains("e2e timed monitor")
&& contains_tool_json_pair(&text, "status", "timed_out"),
"expected timed_out status in messages:\n{text}"
);
text_response("orchestrator saw monitor timeout")
})),
]);
let answer = run_monitor_turn(&tmp, provider, AutonomyLevel::Supervised, 5).await;
assert_eq!(answer, "orchestrator saw monitor timeout");
}
#[tokio::test]
async fn orchestrator_gets_denial_when_monitor_command_violates_policy() {
let _guard = monitor_e2e_lock().lock().await;
let tmp = tempfile::tempdir().unwrap();
let provider = ScriptedProvider::new(vec![
ProviderStep::Static(tool_response(
"call-monitor",
"monitor",
json!({
"command": "touch denied-monitor-file",
"description": "e2e denied monitor",
"timeout_ms": 2_000,
"persistent": false
}),
)),
ProviderStep::FromHistory(Box::new(|messages| {
let text = all_messages_text(messages);
assert!(
text.contains("[policy-blocked] Security policy"),
"expected policy denial in messages:\n{text}"
);
assert!(
!text.contains("\"monitorId\":\"mon_"),
"denied monitor must not return a monitor id:\n{text}"
);
text_response("orchestrator received monitor denial")
})),
]);
let answer = run_monitor_turn(&tmp, provider, AutonomyLevel::ReadOnly, 4).await;
assert_eq!(answer, "orchestrator received monitor denial");
assert!(!tmp.path().join("denied-monitor-file").exists());
}