mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
+16




![github-actions[bot] <github-actions[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)




Mega Mind
GitHub
YellowSnnowmann
Steven Enamakel
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Cyrus Gray
Horst1993
Cursor
James Gentes
Sam
Sami Rusani
oxoxDev
Muhammad Ismail
Claude Fable 5
nb213
binyangzhu000-sudo
Steven Enamakel
CodeGhost21
sanil-23
M3gA-Mind
oxoxDev
mysma-9403
mwakidenis
NgoQuocViet2001
viet.ngo
Maciej Myszkiewicz
2e5b5e7b23
Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Co-authored-by: Horst1993 <horst.w@gmicloud.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: James Gentes <jgentes@users.noreply.github.com> Co-authored-by: Sam <samrusani@users.noreply.github.com> Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: Muhammad Ismail <78064250+myi1@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nb213 <binyangzhu000@gmail.com> Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai> Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Co-authored-by: sanil-23 <sanil@tinyhumans.ai> Co-authored-by: M3gA-Mind <elvin@mahadao.com> Co-authored-by: oxoxDev <oxoxdev@users.noreply.github.com> Co-authored-by: mysma-9403 <64923976+mysma-9403@users.noreply.github.com> Co-authored-by: mwakidenis <mwakidenice@gmail.com> Co-authored-by: NgoQuocViet2001 <123613986+NgoQuocViet2001@users.noreply.github.com> Co-authored-by: viet.ngo <viet.ngo@sotatek.com> Co-authored-by: Maciej Myszkiewicz <mmyszkiewicz@bwcoders.com>
502 lines
16 KiB
Rust
502 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::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::tinyagents::thread_context::with_thread_id;
|
|
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 tinyagents::harness::message::{AssistantMessage, Message};
|
|
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
|
use tinyagents::harness::tool::ToolCall;
|
|
use tokio::time::{sleep, Duration};
|
|
|
|
type ResponseFactory = Box<dyn Fn(&[Message]) -> ModelResponse + Send + Sync>;
|
|
|
|
enum ModelStep {
|
|
Static(ModelResponse),
|
|
Delayed(Duration, ModelResponse),
|
|
FromHistory(ResponseFactory),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct CapturedRequest {
|
|
messages: Vec<Message>,
|
|
tool_names: Vec<String>,
|
|
}
|
|
|
|
struct ScriptedModel {
|
|
steps: Mutex<VecDeque<ModelStep>>,
|
|
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 ScriptedModel {
|
|
fn new(steps: Vec<ModelStep>) -> 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 ChatModel<()> for ScriptedModel {
|
|
fn profile(&self) -> Option<&ModelProfile> {
|
|
static PROFILE: OnceLock<ModelProfile> = OnceLock::new();
|
|
Some(PROFILE.get_or_init(|| {
|
|
let mut profile = ModelProfile::default();
|
|
profile.tool_calling = true;
|
|
profile.parallel_tool_calls = true;
|
|
profile
|
|
}))
|
|
}
|
|
|
|
async fn invoke(
|
|
&self,
|
|
_state: &(),
|
|
request: ModelRequest,
|
|
) -> tinyagents::Result<ModelResponse> {
|
|
let messages = request.messages;
|
|
self.requests.lock().push(CapturedRequest {
|
|
messages: messages.clone(),
|
|
tool_names: request.tools.iter().map(|tool| tool.name.clone()).collect(),
|
|
});
|
|
|
|
let step = self.steps.lock().pop_front();
|
|
match step {
|
|
Some(ModelStep::Static(response)) => Ok(response),
|
|
Some(ModelStep::Delayed(delay, response)) => {
|
|
sleep(delay).await;
|
|
Ok(response)
|
|
}
|
|
Some(ModelStep::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) -> ModelResponse {
|
|
ModelResponse::assistant(text)
|
|
}
|
|
|
|
fn tool_response(id: &str, name: &str, arguments: serde_json::Value) -> ModelResponse {
|
|
ModelResponse {
|
|
message: AssistantMessage {
|
|
id: None,
|
|
content: Vec::new(),
|
|
tool_calls: vec![ToolCall::new(id, name, arguments)],
|
|
usage: None,
|
|
},
|
|
usage: None,
|
|
finish_reason: Some("tool_calls".to_string()),
|
|
raw: None,
|
|
resolved_model: None,
|
|
continue_turn: None,
|
|
}
|
|
}
|
|
|
|
fn all_messages_text(messages: &[Message]) -> String {
|
|
messages
|
|
.iter()
|
|
.map(Message::text)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn first_monitor_id(messages: &[Message]) -> 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<ScriptedModel>,
|
|
tools: Vec<Box<dyn Tool>>,
|
|
max_tool_iterations: usize,
|
|
) -> Agent {
|
|
let mut agent = Agent::builder()
|
|
.chat_model(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)
|
|
.build()
|
|
.unwrap();
|
|
agent.set_connected_integrations(Vec::new());
|
|
agent
|
|
}
|
|
|
|
async fn run_monitor_turn(
|
|
tmp: &TempDir,
|
|
provider: Arc<ScriptedModel>,
|
|
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 = ScriptedModel::new(vec![
|
|
ModelStep::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
|
|
}),
|
|
)),
|
|
ModelStep::Delayed(
|
|
Duration::from_millis(150),
|
|
tool_response("call-list", "monitor_list", json!({})),
|
|
),
|
|
ModelStep::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.text().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 = ScriptedModel::new(vec![
|
|
ModelStep::Static(tool_response(
|
|
"call-monitor",
|
|
"monitor",
|
|
json!({
|
|
"command": "printf 'READBACK_LINE\\n'",
|
|
"description": "e2e monitor read",
|
|
"timeout_ms": 2_000,
|
|
"persistent": false
|
|
}),
|
|
)),
|
|
ModelStep::Delayed(
|
|
Duration::from_millis(120),
|
|
tool_response("call-list", "monitor_list", json!({})),
|
|
),
|
|
ModelStep::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 }),
|
|
)
|
|
})),
|
|
ModelStep::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 = ScriptedModel::new(vec![
|
|
ModelStep::Static(tool_response(
|
|
"call-monitor",
|
|
"monitor",
|
|
json!({
|
|
"command": "sleep 5",
|
|
"description": "e2e stoppable monitor",
|
|
"timeout_ms": 5_000,
|
|
"persistent": false
|
|
}),
|
|
)),
|
|
ModelStep::FromHistory(Box::new(|messages| {
|
|
let monitor_id = first_monitor_id(messages);
|
|
tool_response(
|
|
"call-stop",
|
|
"monitor_stop",
|
|
json!({ "monitor_id": monitor_id }),
|
|
)
|
|
})),
|
|
ModelStep::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 = ScriptedModel::new(vec![
|
|
ModelStep::Static(tool_response(
|
|
"call-monitor",
|
|
"monitor",
|
|
json!({
|
|
"command": "sleep 1",
|
|
"description": "e2e timed monitor",
|
|
"timeout_ms": 40,
|
|
"persistent": false
|
|
}),
|
|
)),
|
|
ModelStep::Delayed(
|
|
Duration::from_millis(120),
|
|
tool_response("call-list", "monitor_list", json!({})),
|
|
),
|
|
ModelStep::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 = ScriptedModel::new(vec![
|
|
ModelStep::Static(tool_response(
|
|
"call-monitor",
|
|
"monitor",
|
|
json!({
|
|
"command": "touch denied-monitor-file",
|
|
"description": "e2e denied monitor",
|
|
"timeout_ms": 2_000,
|
|
"persistent": false
|
|
}),
|
|
)),
|
|
ModelStep::FromHistory(Box::new(|messages| {
|
|
let text = all_messages_text(messages);
|
|
assert!(
|
|
text.contains("[policy-blocked] Tool 'monitor' was blocked by the 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());
|
|
}
|