mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
1031 lines
33 KiB
Rust
1031 lines
33 KiB
Rust
use async_trait::async_trait;
|
|
use openhuman_core::openhuman::agent::dispatcher::{NativeToolDispatcher, XmlToolDispatcher};
|
|
use openhuman_core::openhuman::agent::harness::definition::AgentTier;
|
|
use openhuman_core::openhuman::agent::harness::subagent_runner::run_subagent;
|
|
use openhuman_core::openhuman::agent::harness::{
|
|
with_parent_context, AgentDefinition, DefinitionSource, ModelSpec, ParentExecutionContext,
|
|
PromptSource, SandboxMode, SubagentRunError, SubagentRunOptions, ToolScope,
|
|
};
|
|
use openhuman_core::openhuman::agent::hooks::{PostTurnHook, TurnContext};
|
|
use openhuman_core::openhuman::agent::memory_loader::MemoryLoader;
|
|
use openhuman_core::openhuman::agent::progress::AgentProgress;
|
|
use openhuman_core::openhuman::agent::tool_policy::{
|
|
ToolPolicy, ToolPolicyDecision, ToolPolicyRequest,
|
|
};
|
|
use openhuman_core::openhuman::agent::Agent;
|
|
use openhuman_core::openhuman::config::{AgentConfig, ContextConfig, MemoryConfig};
|
|
use openhuman_core::openhuman::inference::provider::{
|
|
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ProviderDelta, ToolCall,
|
|
UsageInfo,
|
|
};
|
|
use openhuman_core::openhuman::memory::{
|
|
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
|
};
|
|
use openhuman_core::openhuman::memory_store;
|
|
use openhuman_core::openhuman::tokenjuice::AgentTokenjuiceCompression;
|
|
use openhuman_core::openhuman::tools::traits::ToolCallOptions;
|
|
use openhuman_core::openhuman::tools::{
|
|
PermissionLevel, Tool, ToolContent, ToolResult, ToolScope as RuntimeToolScope,
|
|
};
|
|
use serde_json::json;
|
|
use std::collections::{HashSet, VecDeque};
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use tempfile::TempDir;
|
|
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
|
use tokio::time::{timeout, Duration};
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
unsafe { std::env::set_var(key, value) };
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match self.previous.take() {
|
|
Some(value) => unsafe { std::env::set_var(self.key, value) },
|
|
None => unsafe { std::env::remove_var(self.key) },
|
|
}
|
|
}
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
|
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct CapturedRequest {
|
|
model: String,
|
|
temperature: f64,
|
|
messages: Vec<ChatMessage>,
|
|
tool_names: Vec<String>,
|
|
stream_was_requested: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ScriptedProvider {
|
|
responses: Mutex<VecDeque<anyhow::Result<ChatResponse>>>,
|
|
requests: Mutex<Vec<CapturedRequest>>,
|
|
stream_events: Vec<ProviderDelta>,
|
|
native_tools: bool,
|
|
}
|
|
|
|
impl ScriptedProvider {
|
|
fn new(responses: Vec<ChatResponse>) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
|
..Self::default()
|
|
})
|
|
}
|
|
|
|
fn failing(message: &'static str) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
responses: Mutex::new(VecDeque::from([Err(anyhow::anyhow!(message))])),
|
|
..Self::default()
|
|
})
|
|
}
|
|
|
|
fn requests(&self) -> Vec<CapturedRequest> {
|
|
self.requests.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for ScriptedProvider {
|
|
fn capabilities(
|
|
&self,
|
|
) -> openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities {
|
|
openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities {
|
|
native_tool_calling: self.native_tools,
|
|
vision: false,
|
|
}
|
|
}
|
|
|
|
async fn chat_with_system(
|
|
&self,
|
|
_system_prompt: Option<&str>,
|
|
message: &str,
|
|
_model: &str,
|
|
_temperature: f64,
|
|
) -> anyhow::Result<String> {
|
|
Ok(format!("summary: {message}"))
|
|
}
|
|
|
|
async fn chat(
|
|
&self,
|
|
request: ChatRequest<'_>,
|
|
model: &str,
|
|
temperature: f64,
|
|
) -> anyhow::Result<ChatResponse> {
|
|
self.requests.lock().unwrap().push(CapturedRequest {
|
|
model: model.to_string(),
|
|
temperature,
|
|
messages: request.messages.to_vec(),
|
|
tool_names: request
|
|
.tools
|
|
.map(|tools| tools.iter().map(|tool| tool.name.clone()).collect())
|
|
.unwrap_or_default(),
|
|
stream_was_requested: 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("default scripted final")))
|
|
}
|
|
}
|
|
|
|
struct StaticMemory {
|
|
entries: Mutex<Vec<MemoryEntry>>,
|
|
fail_recall: bool,
|
|
}
|
|
|
|
impl Default for StaticMemory {
|
|
fn default() -> Self {
|
|
Self {
|
|
entries: Mutex::new(Vec::new()),
|
|
fail_recall: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Memory for StaticMemory {
|
|
fn name(&self) -> &str {
|
|
"round17-memory"
|
|
}
|
|
|
|
async fn store(
|
|
&self,
|
|
namespace: &str,
|
|
key: &str,
|
|
content: &str,
|
|
category: MemoryCategory,
|
|
session_id: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
let mut entries = self.entries.lock().unwrap();
|
|
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-05-29T00:00:00Z".to_string(),
|
|
session_id: session_id.map(str::to_string),
|
|
score: Some(0.95),
|
|
taint: Default::default(),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn recall(
|
|
&self,
|
|
query: &str,
|
|
limit: usize,
|
|
_opts: RecallOpts<'_>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>> {
|
|
if self.fail_recall {
|
|
anyhow::bail!("forced recall failure for {query}");
|
|
}
|
|
Ok(self
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.take(limit)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
|
|
async fn get(&self, namespace: &str, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
|
Ok(self
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.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>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>> {
|
|
Ok(self
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.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) -> anyhow::Result<bool> {
|
|
let mut entries = self.entries.lock().unwrap();
|
|
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) -> anyhow::Result<Vec<NamespaceSummary>> {
|
|
Ok(vec![NamespaceSummary {
|
|
namespace: "round17".to_string(),
|
|
count: self.entries.lock().unwrap().len(),
|
|
last_updated: Some("2026-05-29T00:00:00Z".to_string()),
|
|
}])
|
|
}
|
|
|
|
async fn count(&self) -> anyhow::Result<usize> {
|
|
Ok(self.entries.lock().unwrap().len())
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
struct StaticMemoryLoader {
|
|
context: String,
|
|
fail: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MemoryLoader for StaticMemoryLoader {
|
|
async fn load_context(
|
|
&self,
|
|
_memory: &dyn Memory,
|
|
_user_message: &str,
|
|
) -> anyhow::Result<String> {
|
|
if self.fail {
|
|
anyhow::bail!("forced loader failure");
|
|
}
|
|
Ok(self.context.clone())
|
|
}
|
|
}
|
|
|
|
struct RecordingHook {
|
|
calls: Arc<AsyncMutex<Vec<TurnContext>>>,
|
|
notify: Arc<Notify>,
|
|
fail: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PostTurnHook for RecordingHook {
|
|
fn name(&self) -> &str {
|
|
"round17-recording"
|
|
}
|
|
|
|
async fn on_turn_complete(&self, ctx: &TurnContext) -> anyhow::Result<()> {
|
|
self.calls.lock().await.push(ctx.clone());
|
|
self.notify.notify_waiters();
|
|
if self.fail {
|
|
anyhow::bail!("hook failure is non-blocking");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct Round17Tool {
|
|
name: &'static str,
|
|
output: &'static str,
|
|
calls: Arc<AtomicUsize>,
|
|
error_result: bool,
|
|
execution_error: bool,
|
|
permission: PermissionLevel,
|
|
scope: RuntimeToolScope,
|
|
}
|
|
|
|
impl Round17Tool {
|
|
fn boxed(name: &'static str, output: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self {
|
|
name,
|
|
output,
|
|
calls,
|
|
error_result: false,
|
|
execution_error: false,
|
|
permission: PermissionLevel::ReadOnly,
|
|
scope: RuntimeToolScope::All,
|
|
})
|
|
}
|
|
|
|
fn write(name: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self {
|
|
name,
|
|
output: "write-output",
|
|
calls,
|
|
error_result: false,
|
|
execution_error: false,
|
|
permission: PermissionLevel::Write,
|
|
scope: RuntimeToolScope::All,
|
|
})
|
|
}
|
|
|
|
fn failing_execute(name: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self {
|
|
name,
|
|
output: "not used",
|
|
calls,
|
|
error_result: false,
|
|
execution_error: true,
|
|
permission: PermissionLevel::ReadOnly,
|
|
scope: RuntimeToolScope::All,
|
|
})
|
|
}
|
|
|
|
fn tool_error(name: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self {
|
|
name,
|
|
output: "semantic failure",
|
|
calls,
|
|
error_result: true,
|
|
execution_error: false,
|
|
permission: PermissionLevel::ReadOnly,
|
|
scope: RuntimeToolScope::All,
|
|
})
|
|
}
|
|
|
|
fn cli_only(name: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self {
|
|
name,
|
|
output: "cli-only",
|
|
calls,
|
|
error_result: false,
|
|
execution_error: false,
|
|
permission: PermissionLevel::ReadOnly,
|
|
scope: RuntimeToolScope::CliRpcOnly,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for Round17Tool {
|
|
fn name(&self) -> &str {
|
|
self.name
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"round17 deterministic tool"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"value": { "type": "string" }
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
self.execute_with_options(args, ToolCallOptions::default())
|
|
.await
|
|
}
|
|
|
|
async fn execute_with_options(
|
|
&self,
|
|
args: serde_json::Value,
|
|
options: ToolCallOptions,
|
|
) -> anyhow::Result<ToolResult> {
|
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
if self.execution_error {
|
|
anyhow::bail!("execution exploded");
|
|
}
|
|
let suffix = args
|
|
.get("value")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or_default();
|
|
let text = if suffix.is_empty() {
|
|
self.output.to_string()
|
|
} else {
|
|
format!("{}:{suffix}", self.output)
|
|
};
|
|
Ok(ToolResult {
|
|
content: vec![ToolContent::Text { text: text.clone() }],
|
|
is_error: self.error_result,
|
|
markdown_formatted: options.prefer_markdown.then(|| format!("**{text}**")),
|
|
})
|
|
}
|
|
|
|
fn permission_level(&self) -> PermissionLevel {
|
|
self.permission
|
|
}
|
|
|
|
fn scope(&self) -> RuntimeToolScope {
|
|
self.scope
|
|
}
|
|
}
|
|
|
|
struct DenyNamedPolicy(&'static str);
|
|
|
|
#[async_trait]
|
|
impl ToolPolicy for DenyNamedPolicy {
|
|
fn name(&self) -> &str {
|
|
"round17-deny"
|
|
}
|
|
|
|
async fn check(&self, request: &ToolPolicyRequest) -> ToolPolicyDecision {
|
|
if request.tool_name == self.0 {
|
|
ToolPolicyDecision::deny("round17 policy says no")
|
|
} else {
|
|
ToolPolicyDecision::Allow
|
|
}
|
|
}
|
|
}
|
|
|
|
fn text_response(text: &str) -> ChatResponse {
|
|
ChatResponse {
|
|
text: Some(text.to_string()),
|
|
tool_calls: vec![],
|
|
usage: Some(UsageInfo {
|
|
input_tokens: 17,
|
|
output_tokens: 9,
|
|
context_window: 16_000,
|
|
cached_input_tokens: 4,
|
|
charged_amount_usd: 0.0003,
|
|
}),
|
|
reasoning_content: None,
|
|
}
|
|
}
|
|
|
|
fn xml_tool_response(name: &str, args: serde_json::Value) -> ChatResponse {
|
|
ChatResponse {
|
|
text: Some(format!(
|
|
"pre-tool <tool_call>{{\"name\":\"{name}\",\"arguments\":{args}}}</tool_call>"
|
|
)),
|
|
tool_calls: vec![],
|
|
usage: None,
|
|
reasoning_content: Some("tool reasoning".to_string()),
|
|
}
|
|
}
|
|
|
|
fn native_tool_response(id: &str, name: &str, args: serde_json::Value) -> ChatResponse {
|
|
ChatResponse {
|
|
text: Some("native preamble".to_string()),
|
|
tool_calls: vec![ToolCall {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
arguments: args.to_string(),
|
|
extra_content: None,
|
|
}],
|
|
usage: Some(UsageInfo {
|
|
input_tokens: 21,
|
|
output_tokens: 6,
|
|
context_window: 16_000,
|
|
cached_input_tokens: 5,
|
|
charged_amount_usd: 0.0004,
|
|
}),
|
|
reasoning_content: Some("native reasoning".to_string()),
|
|
}
|
|
}
|
|
|
|
fn workspace(label: &str) -> (TempDir, PathBuf) {
|
|
let root = std::env::current_dir()
|
|
.unwrap()
|
|
.join("target")
|
|
.join(format!(
|
|
"agent-session-turn-round17-{label}-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let temp = TempDir::new_in(root.parent().unwrap()).unwrap();
|
|
let path = temp.path().join(label);
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
(temp, path)
|
|
}
|
|
|
|
fn memory_for_workspace(path: &PathBuf) -> Arc<dyn Memory> {
|
|
let cfg = MemoryConfig {
|
|
backend: "none".to_string(),
|
|
..MemoryConfig::default()
|
|
};
|
|
Arc::from(memory_store::create_memory(&cfg, path).unwrap())
|
|
}
|
|
|
|
fn agent_with(
|
|
provider: Arc<dyn Provider>,
|
|
tools: Vec<Box<dyn Tool>>,
|
|
workspace_path: PathBuf,
|
|
dispatcher: Box<dyn openhuman_core::openhuman::agent::dispatcher::ToolDispatcher>,
|
|
config: AgentConfig,
|
|
context_config: ContextConfig,
|
|
) -> Agent {
|
|
Agent::builder()
|
|
.provider_arc(provider)
|
|
.tools(tools)
|
|
.memory(memory_for_workspace(&workspace_path))
|
|
.memory_loader(Box::new(StaticMemoryLoader {
|
|
context: String::new(),
|
|
fail: false,
|
|
}))
|
|
.tool_dispatcher(dispatcher)
|
|
.workspace_dir(workspace_path)
|
|
.event_context("round17-session", "round17-channel")
|
|
.agent_definition_name("round17/orchestrator")
|
|
.config(config)
|
|
.context_config(context_config)
|
|
.auto_save(true)
|
|
.explicit_preferences_enabled(false)
|
|
.unified_compaction_enabled(false)
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_native_tool_progress_reasoning_usage_and_resume_seed_paths() {
|
|
let _env = env_lock();
|
|
let (_temp, workspace_path) = workspace("native-progress");
|
|
let _workspace_guard = EnvGuard::set_path("OPENHUMAN_WORKSPACE", &workspace_path);
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let provider = Arc::new(ScriptedProvider {
|
|
responses: Mutex::new(
|
|
vec![
|
|
Ok(native_tool_response(
|
|
"native-1",
|
|
"round17_echo",
|
|
json!({ "value": "alpha" }),
|
|
)),
|
|
Ok(ChatResponse {
|
|
text: Some("native final".to_string()),
|
|
tool_calls: vec![],
|
|
usage: Some(UsageInfo {
|
|
input_tokens: 5,
|
|
output_tokens: 3,
|
|
context_window: 16_000,
|
|
cached_input_tokens: 2,
|
|
charged_amount_usd: 0.0001,
|
|
}),
|
|
reasoning_content: Some("final hidden reasoning".to_string()),
|
|
}),
|
|
]
|
|
.into(),
|
|
),
|
|
requests: Mutex::new(Vec::new()),
|
|
stream_events: vec![
|
|
ProviderDelta::TextDelta {
|
|
delta: "stream text".to_string(),
|
|
},
|
|
ProviderDelta::ThinkingDelta {
|
|
delta: "stream thought".to_string(),
|
|
},
|
|
ProviderDelta::ToolCallStart {
|
|
call_id: "native-1".to_string(),
|
|
tool_name: "round17_echo".to_string(),
|
|
},
|
|
ProviderDelta::ToolCallArgsDelta {
|
|
call_id: "native-1".to_string(),
|
|
delta: "{\"value\":\"alpha\"}".to_string(),
|
|
},
|
|
],
|
|
native_tools: true,
|
|
});
|
|
let mut agent = agent_with(
|
|
provider.clone(),
|
|
vec![Round17Tool::boxed(
|
|
"round17_echo",
|
|
"echo-output",
|
|
calls.clone(),
|
|
)],
|
|
workspace_path,
|
|
Box::new(NativeToolDispatcher),
|
|
AgentConfig {
|
|
max_tool_iterations: 4,
|
|
max_history_messages: 12,
|
|
..AgentConfig::default()
|
|
},
|
|
ContextConfig {
|
|
prefer_markdown_tool_output: true,
|
|
..ContextConfig::default()
|
|
},
|
|
);
|
|
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(64);
|
|
agent.set_on_progress(Some(progress_tx));
|
|
|
|
let answer = agent.turn("use the native tool").await.unwrap();
|
|
assert_eq!(answer, "native final");
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
assert!(agent.history().iter().any(|message| matches!(
|
|
message,
|
|
ConversationMessage::AssistantToolCalls {
|
|
tool_calls,
|
|
reasoning_content,
|
|
..
|
|
} if tool_calls[0].id == "native-1" && reasoning_content.as_deref() == Some("native reasoning")
|
|
)));
|
|
assert!(agent.history().iter().any(|message| matches!(
|
|
message,
|
|
ConversationMessage::Chat(chat)
|
|
if chat.role == "assistant"
|
|
&& chat.extra_metadata.as_ref()
|
|
.and_then(|m| m.get("reasoning_content"))
|
|
.and_then(|v| v.as_str()) == Some("final hidden reasoning")
|
|
)));
|
|
|
|
let mut progress = Vec::new();
|
|
while let Ok(event) = progress_rx.try_recv() {
|
|
progress.push(event);
|
|
}
|
|
assert!(progress
|
|
.iter()
|
|
.any(|event| matches!(event, AgentProgress::TurnStarted)));
|
|
assert!(progress.iter().any(|event| matches!(
|
|
event,
|
|
AgentProgress::TextDelta { delta, iteration: 1 } if delta == "stream text"
|
|
)));
|
|
assert!(progress.iter().any(|event| matches!(
|
|
event,
|
|
AgentProgress::ThinkingDelta { delta, iteration: 1 } if delta == "stream thought"
|
|
)));
|
|
assert!(progress.iter().any(|event| matches!(
|
|
event,
|
|
AgentProgress::ToolCallCompleted { tool_name, success, .. }
|
|
if tool_name == "round17_echo" && *success
|
|
)));
|
|
|
|
let requests = provider.requests();
|
|
assert!(requests[0].stream_was_requested);
|
|
assert_eq!(requests[0].tool_names, vec!["round17_echo"]);
|
|
assert!(
|
|
requests[1]
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == "tool"
|
|
&& message.content.contains("**echo-output:alpha**"))
|
|
);
|
|
|
|
let (_seeded_tmp, seeded_workspace) = workspace("seeded-resume");
|
|
let mut seeded = agent_with(
|
|
ScriptedProvider::new(vec![text_response("seeded final")]),
|
|
vec![Round17Tool::boxed(
|
|
"round17_echo",
|
|
"unused",
|
|
Arc::new(AtomicUsize::new(0)),
|
|
)],
|
|
seeded_workspace,
|
|
Box::new(XmlToolDispatcher),
|
|
AgentConfig {
|
|
max_history_messages: 3,
|
|
..AgentConfig::default()
|
|
},
|
|
ContextConfig::default(),
|
|
);
|
|
seeded
|
|
.seed_resume_from_messages(
|
|
vec![
|
|
("user".to_string(), "old one".to_string()),
|
|
("assistant".to_string(), "old two".to_string()),
|
|
("user".to_string(), "current".to_string()),
|
|
],
|
|
"current",
|
|
)
|
|
.unwrap();
|
|
let seeded_answer = seeded.run_single("current").await.unwrap();
|
|
assert_eq!(seeded_answer, "seeded final");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_xml_failures_checkpoint_policy_visibility_and_hooks_are_publicly_exercised() {
|
|
let _env = env_lock();
|
|
let (_temp, workspace_path) = workspace("xml-failures");
|
|
let _workspace_guard = EnvGuard::set_path("OPENHUMAN_WORKSPACE", &workspace_path);
|
|
let ok_calls = Arc::new(AtomicUsize::new(0));
|
|
let err_calls = Arc::new(AtomicUsize::new(0));
|
|
let boom_calls = Arc::new(AtomicUsize::new(0));
|
|
let write_calls = Arc::new(AtomicUsize::new(0));
|
|
let provider = Arc::new(ScriptedProvider {
|
|
responses: Mutex::new(
|
|
vec![
|
|
Ok(xml_tool_response("hidden_tool", json!({ "value": "h" }))),
|
|
Ok(xml_tool_response("cli_only", json!({ "value": "c" }))),
|
|
Ok(xml_tool_response("round17_error", json!({ "value": "e" }))),
|
|
Ok(xml_tool_response("round17_boom", json!({ "value": "b" }))),
|
|
Ok(xml_tool_response("round17_write", json!({ "value": "w" }))),
|
|
Ok(xml_tool_response("round17_ok", json!({ "value": "o" }))),
|
|
Ok(ChatResponse {
|
|
text: Some(String::new()),
|
|
tool_calls: vec![],
|
|
usage: None,
|
|
reasoning_content: None,
|
|
}),
|
|
]
|
|
.into(),
|
|
),
|
|
requests: Mutex::new(Vec::new()),
|
|
..ScriptedProvider::default()
|
|
});
|
|
let hook_calls = Arc::new(AsyncMutex::new(Vec::<TurnContext>::new()));
|
|
let hook_notify = Arc::new(Notify::new());
|
|
let mut channel_permissions = std::collections::HashMap::new();
|
|
channel_permissions.insert("round17-channel".to_string(), "read_only".to_string());
|
|
let mut agent = Agent::builder()
|
|
.provider_arc(provider.clone())
|
|
.tools(vec![
|
|
Round17Tool::boxed("round17_ok", "ok-output", ok_calls.clone()),
|
|
Round17Tool::tool_error("round17_error", err_calls.clone()),
|
|
Round17Tool::failing_execute("round17_boom", boom_calls.clone()),
|
|
Round17Tool::write("round17_write", write_calls.clone()),
|
|
Round17Tool::cli_only("cli_only", Arc::new(AtomicUsize::new(0))),
|
|
])
|
|
.memory(Arc::new(StaticMemory {
|
|
entries: Mutex::new(vec![MemoryEntry {
|
|
id: "m1".to_string(),
|
|
key: "k1".to_string(),
|
|
content: "remembered citation".to_string(),
|
|
namespace: Some("round17".to_string()),
|
|
category: MemoryCategory::Conversation,
|
|
timestamp: "2026-05-29T00:00:00Z".to_string(),
|
|
session_id: None,
|
|
score: Some(0.9),
|
|
taint: Default::default(),
|
|
}]),
|
|
fail_recall: true,
|
|
}))
|
|
.memory_loader(Box::new(StaticMemoryLoader {
|
|
context: "[round17 injected context]\n".to_string(),
|
|
fail: true,
|
|
}))
|
|
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
|
.workspace_dir(workspace_path)
|
|
.event_context("round17-session", "round17-channel")
|
|
.agent_definition_name("round17/orchestrator")
|
|
.config(AgentConfig {
|
|
max_tool_iterations: 6,
|
|
channel_permissions,
|
|
..AgentConfig::default()
|
|
})
|
|
.context_config(ContextConfig {
|
|
tool_result_budget_bytes: 96,
|
|
..ContextConfig::default()
|
|
})
|
|
.post_turn_hooks(vec![Arc::new(RecordingHook {
|
|
calls: hook_calls.clone(),
|
|
notify: hook_notify.clone(),
|
|
fail: true,
|
|
})])
|
|
.tool_policy(Arc::new(DenyNamedPolicy("round17_ok")))
|
|
.explicit_preferences_enabled(false)
|
|
.unified_compaction_enabled(false)
|
|
.build()
|
|
.unwrap();
|
|
let mut visible = HashSet::new();
|
|
visible.insert("round17_ok".to_string());
|
|
visible.insert("round17_error".to_string());
|
|
visible.insert("round17_boom".to_string());
|
|
visible.insert("round17_write".to_string());
|
|
visible.insert("cli_only".to_string());
|
|
agent.set_visible_tool_names(visible);
|
|
|
|
let checkpoint = agent.turn("exercise failure branches").await.unwrap();
|
|
assert!(
|
|
checkpoint.contains("Done so far") || checkpoint.contains("Need next"),
|
|
"fallback checkpoint should be deterministic, got {checkpoint}"
|
|
);
|
|
assert_eq!(ok_calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(err_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(boom_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(write_calls.load(Ordering::SeqCst), 0);
|
|
assert!(agent.take_last_turn_citations().is_empty());
|
|
|
|
timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
if !hook_calls.lock().await.is_empty() {
|
|
break;
|
|
}
|
|
hook_notify.notified().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let hooks = hook_calls.lock().await;
|
|
assert_eq!(hooks[0].assistant_response, checkpoint);
|
|
assert_eq!(hooks[0].tool_calls.len(), 6);
|
|
|
|
let joined = provider
|
|
.requests()
|
|
.into_iter()
|
|
.flat_map(|request| request.messages)
|
|
.map(|message| message.content)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(joined.contains("not available to this agent"));
|
|
assert!(joined.contains("semantic failure"));
|
|
assert!(joined.contains("Error executing round17_boom"));
|
|
assert!(joined.contains("denied by policy 'round17-deny'"));
|
|
|
|
let (_failing_tmp, failing_workspace) = workspace("provider-error");
|
|
let provider_error = ScriptedProvider::failing("provider offline");
|
|
let mut failing_agent = agent_with(
|
|
provider_error,
|
|
vec![],
|
|
failing_workspace,
|
|
Box::new(XmlToolDispatcher),
|
|
AgentConfig::default(),
|
|
ContextConfig::default(),
|
|
);
|
|
let err = failing_agent.run_single("fail now").await.unwrap_err();
|
|
assert!(err.to_string().contains("provider offline"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_errors() {
|
|
let _env = env_lock();
|
|
let no_parent = run_subagent(
|
|
&definition("round17_child", ToolScope::Wildcard, None, 3),
|
|
"outside turn",
|
|
SubagentRunOptions::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(no_parent, SubagentRunError::NoParentContext));
|
|
|
|
let (_temp, workspace_path) = workspace("subagent");
|
|
let _workspace_guard = EnvGuard::set_path("OPENHUMAN_WORKSPACE", &workspace_path);
|
|
let echo_calls = Arc::new(AtomicUsize::new(0));
|
|
let hidden_calls = Arc::new(AtomicUsize::new(0));
|
|
let provider = Arc::new(ScriptedProvider {
|
|
responses: Mutex::new(
|
|
vec![
|
|
Ok(native_tool_response(
|
|
"child-1",
|
|
"round17_echo",
|
|
json!({ "value": "child" }),
|
|
)),
|
|
Ok(text_response("child final response that should be capped")),
|
|
]
|
|
.into(),
|
|
),
|
|
requests: Mutex::new(Vec::new()),
|
|
native_tools: true,
|
|
..ScriptedProvider::default()
|
|
});
|
|
let all_tools = vec![
|
|
Round17Tool::boxed("round17_echo", "child-tool", echo_calls.clone()),
|
|
Round17Tool::boxed("round17_hidden", "hidden-tool", hidden_calls.clone()),
|
|
Round17Tool::boxed(
|
|
"spawn_subagent",
|
|
"must-strip",
|
|
Arc::new(AtomicUsize::new(0)),
|
|
),
|
|
];
|
|
let all_specs = all_tools.iter().map(|tool| tool.spec()).collect::<Vec<_>>();
|
|
let parent = ParentExecutionContext {
|
|
agent_definition_id: "orchestrator".into(),
|
|
allowed_subagent_ids: [
|
|
"round17_child".to_string(),
|
|
"round17_provider_error".to_string(),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
provider: provider.clone(),
|
|
all_tools: Arc::new(all_tools),
|
|
all_tool_specs: Arc::new(all_specs),
|
|
visible_tool_names: std::collections::HashSet::new(),
|
|
model_name: "parent-model".to_string(),
|
|
temperature: 0.22,
|
|
workspace_dir: workspace_path.clone(),
|
|
memory: Arc::new(StaticMemory::default()),
|
|
agent_config: AgentConfig {
|
|
max_tool_iterations: 5,
|
|
..AgentConfig::default()
|
|
},
|
|
workflows: Arc::new(Vec::new()),
|
|
memory_context: Arc::new(Some("parent memory context".to_string())),
|
|
session_id: "round17-parent-session".to_string(),
|
|
channel: "round17-parent-channel".to_string(),
|
|
connected_integrations: Vec::new(),
|
|
tool_call_format: openhuman_core::openhuman::context::prompt::ToolCallFormat::Json,
|
|
session_key: "123_parent".to_string(),
|
|
session_parent_prefix: Some("root_ancestor".to_string()),
|
|
on_progress: None,
|
|
run_queue: None,
|
|
};
|
|
|
|
let outcome = with_parent_context(parent.clone(), async {
|
|
run_subagent(
|
|
&definition(
|
|
"round17_child",
|
|
ToolScope::Named(vec![
|
|
"round17_echo".to_string(),
|
|
"round17_hidden".to_string(),
|
|
"spawn_subagent".to_string(),
|
|
]),
|
|
Some(18),
|
|
4,
|
|
),
|
|
"delegate this",
|
|
SubagentRunOptions {
|
|
context: Some("spawn context".to_string()),
|
|
model_override: Some("override-model".to_string()),
|
|
task_id: Some("task-round17".to_string()),
|
|
..SubagentRunOptions::default()
|
|
},
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.task_id, "task-round17");
|
|
assert_eq!(outcome.agent_id, "round17_child");
|
|
assert_eq!(outcome.iterations, 2);
|
|
assert!(outcome.output.ends_with("[...truncated]"));
|
|
assert_eq!(echo_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(hidden_calls.load(Ordering::SeqCst), 0);
|
|
|
|
let requests = provider.requests();
|
|
assert_eq!(requests[0].model, "override-model");
|
|
assert_eq!(requests[0].temperature, 0.4);
|
|
assert_eq!(requests[0].tool_names, vec!["round17_echo"]);
|
|
assert!(requests[0]
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == "system"
|
|
&& message.content.contains("Sub-agent Role Contract")
|
|
&& message.content.contains("round17 child prompt")));
|
|
assert!(requests[0]
|
|
.messages
|
|
.iter()
|
|
.any(|message| message.role == "user"
|
|
&& message.content.contains("spawn context")
|
|
&& message.content.contains("delegate this")));
|
|
|
|
let error_parent = ParentExecutionContext {
|
|
provider: ScriptedProvider::failing("subagent provider offline"),
|
|
..parent
|
|
};
|
|
let provider_err = with_parent_context(error_parent, async {
|
|
run_subagent(
|
|
&definition("round17_provider_error", ToolScope::Wildcard, None, 1),
|
|
"provider error",
|
|
SubagentRunOptions::default(),
|
|
)
|
|
.await
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(provider_err, SubagentRunError::Provider(_)));
|
|
assert!(provider_err
|
|
.to_string()
|
|
.contains("subagent provider offline"));
|
|
}
|
|
|
|
fn definition(
|
|
id: &str,
|
|
tools: ToolScope,
|
|
max_result_chars: Option<usize>,
|
|
max_iterations: usize,
|
|
) -> AgentDefinition {
|
|
AgentDefinition {
|
|
id: id.to_string(),
|
|
when_to_use: "round17 test definition".to_string(),
|
|
display_name: Some(id.to_string()),
|
|
system_prompt: PromptSource::Inline("round17 child prompt".to_string()),
|
|
omit_identity: true,
|
|
omit_memory_context: false,
|
|
omit_safety_preamble: true,
|
|
omit_skills_catalog: true,
|
|
omit_profile: true,
|
|
omit_memory_md: true,
|
|
model: ModelSpec::Inherit,
|
|
temperature: 0.4,
|
|
tools,
|
|
disallowed_tools: vec!["round17_hidden".to_string()],
|
|
skill_filter: None,
|
|
extra_tools: Vec::new(),
|
|
max_iterations,
|
|
iteration_policy: Default::default(),
|
|
max_result_chars,
|
|
timeout_secs: None,
|
|
sandbox_mode: SandboxMode::None,
|
|
background: false,
|
|
trigger_memory_agent: Default::default(),
|
|
tokenjuice_compression: AgentTokenjuiceCompression::Auto,
|
|
subagents: Vec::new(),
|
|
delegate_name: None,
|
|
agent_tier: AgentTier::Worker,
|
|
source: DefinitionSource::Builtin,
|
|
}
|
|
}
|