mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
refactor(tinyagents): complete provider cutover and lifecycle consolidation (#5143)
This commit is contained in:
+82
-139
@@ -1,16 +1,15 @@
|
||||
//! Deterministic offline `Provider` mocks installed via
|
||||
//! Deterministic offline `ChatModel` mocks installed via
|
||||
//! `test_provider_override` (honoured only under the `rss-bench` feature).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use openhuman_core::openhuman::inference::provider::traits::{
|
||||
ChatRequest, ChatResponse, ProviderCapabilities, ToolCall,
|
||||
};
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::inference::provider::types::{ChatResponse, ToolCall};
|
||||
use tinyagents::harness::message::{AssistantMessage, ContentBlock, Message};
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
use tinyagents::harness::tool::ToolCall as TinyAgentsToolCall;
|
||||
|
||||
/// A plain `ChatResponse` carrying only text (no tool calls).
|
||||
pub fn response(text: &str) -> ChatResponse {
|
||||
@@ -22,6 +21,55 @@ pub fn response(text: &str) -> ChatResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn joined_request(request: &ModelRequest) -> String {
|
||||
request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
let role = match message {
|
||||
Message::System(_) => "system",
|
||||
Message::User(_) => "user",
|
||||
Message::Assistant(_) => "assistant",
|
||||
Message::Tool(_) => "tool",
|
||||
};
|
||||
format!("{role}: {}", message.text())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn model_response(response: ChatResponse) -> ModelResponse {
|
||||
let content = response
|
||||
.text
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(|text| vec![ContentBlock::Text(text)])
|
||||
.unwrap_or_default();
|
||||
let tool_calls = response
|
||||
.tool_calls
|
||||
.into_iter()
|
||||
.map(|call| {
|
||||
TinyAgentsToolCall::new(
|
||||
call.id,
|
||||
call.name,
|
||||
serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ModelResponse {
|
||||
finish_reason: (!tool_calls.is_empty()).then(|| "tool_calls".to_string()),
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every prompt it sees so scenarios can assert what ran.
|
||||
fn record(prompts: &Mutex<Vec<String>>, joined: &str) {
|
||||
prompts
|
||||
@@ -281,38 +329,15 @@ impl SubagentMock {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SubagentMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for SubagentMock {
|
||||
async fn invoke(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
let joined = format!("{}\n{message}", system_prompt.unwrap_or(""));
|
||||
Ok(self.dispatch(&joined).await.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(self.dispatch(&joined).await)
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(model_response(
|
||||
self.dispatch(&joined_request(&request)).await,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,44 +386,16 @@ impl LatencyMock {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for LatencyMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for LatencyMock {
|
||||
async fn invoke(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.latency.sleep_sampled().await;
|
||||
record(
|
||||
&self.prompts,
|
||||
&format!("{}\n{message}", system_prompt.unwrap_or("")),
|
||||
);
|
||||
Ok(self.text.clone())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.latency.sleep_sampled().await;
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let joined = joined_request(&request);
|
||||
record(&self.prompts, &joined);
|
||||
Ok(response(&self.text))
|
||||
Ok(model_response(response(&self.text)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,42 +416,15 @@ impl PlainTextMock {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PlainTextMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for PlainTextMock {
|
||||
async fn invoke(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
record(
|
||||
&self.prompts,
|
||||
&format!("{}\n{message}", system_prompt.unwrap_or("")),
|
||||
);
|
||||
Ok(self.text.clone())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let joined = joined_request(&request);
|
||||
record(&self.prompts, &joined);
|
||||
Ok(response(&self.text))
|
||||
Ok(model_response(response(&self.text)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,39 +502,12 @@ impl SkillRunMock {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SkillRunMock {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for SkillRunMock {
|
||||
async fn invoke(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok(self
|
||||
.reply(&format!("{}\n{message}", system_prompt.unwrap_or("")))
|
||||
.text
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
let joined = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(self.reply(&joined))
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(model_response(self.reply(&joined_request(&request))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
//! cold agent turn built directly from config, with a plain-text mock provider
|
||||
//! (no tool calls, no delegation).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
@@ -20,8 +17,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("The Phoenix migration is healthy and on track.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
eprintln!("[library-profile] agent-turn: registries ready, mock installed");
|
||||
|
||||
measure("agent-turn", 1, None, |_rec| async {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! inside one measured region. Each phase is sampled right after it completes
|
||||
//! so the JSON `checkpoints` series attributes the cold-start cost per phase.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -10,7 +9,6 @@ use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::memory_store::MemoryClient;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
@@ -47,10 +45,9 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
rec.checkpoint("memory-store")?;
|
||||
|
||||
// Provider mock for the two turns below (not itself a phase).
|
||||
// Model mock for the two turns below (not itself a phase).
|
||||
let mock = PlainTextMock::new("Phoenix migration is healthy and on track.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
|
||||
// f. agent-build.
|
||||
let mut agent = Agent::from_config_for_agent(&fixture.config, "subconscious")?;
|
||||
|
||||
@@ -23,7 +23,6 @@ use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::proc_metrics;
|
||||
|
||||
use crate::harness::{fixture, measure, FleetBudget, ProfileResult, Recorder, TurnLatency};
|
||||
@@ -149,8 +148,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = LatencyMock::from_env("Fleet agent: nothing needs your attention.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
eprintln!(
|
||||
"[library-profile] fleet: agents={agents_requested} turns={turns} \
|
||||
target={target_agents} budget_mib={ram_budget_mib}"
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
//! BEFORE the measured region, then runs N sequential turns inside it, pushing
|
||||
//! a per-turn checkpoint so the plateau/leak curve is visible.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
use crate::mock::PlainTextMock;
|
||||
@@ -37,8 +34,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration is healthy; no action needed.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
|
||||
let mut agent = Agent::from_config_for_agent(&fixture.config, "subconscious")?;
|
||||
eprintln!("[library-profile] long-agent: warming agent with one pre-measure turn");
|
||||
|
||||
@@ -40,15 +40,12 @@
|
||||
//! The measured cost lands in `result.tree` (`tree_rss_kib`, `child_count`,
|
||||
//! per-child RSS), captured at the workload peak.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::config::Config;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::security::AutonomyLevel;
|
||||
|
||||
use crate::harness::{fixture, measure_with_tree, EnvGuard, ProfileResult};
|
||||
@@ -135,8 +132,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
|
||||
let mock = SkillRunMock::new();
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
eprintln!(
|
||||
"[library-profile] skill-run: registries ready, node_exec mock installed \
|
||||
(agent={CODE_AGENT}, concurrency={concurrency}, pool={}, pool_workers={pool_workers})",
|
||||
|
||||
@@ -33,14 +33,11 @@
|
||||
//! `turn_latency_ms` (percentiles across the K researcher child executions).
|
||||
//! The workload asserts all K researcher subagents actually executed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult, TurnLatency};
|
||||
use crate::mock::{subagent_marker, SubagentMock};
|
||||
@@ -100,8 +97,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
|
||||
let mock = SubagentMock::with_width(width);
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
eprintln!("[library-profile] subagent-storm: width={width} — single cold width-K fan-out");
|
||||
|
||||
// We drive the `orchestrator` agent directly: it owns `spawn_parallel_agents`
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! `subagents`: run one real orchestrator chat turn that spawns two real
|
||||
//! researcher subagents through the parallel-delegation tool.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::config::schema::SubconsciousMode;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::subconscious::LongLivedSession;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
@@ -20,8 +17,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = SubagentMock::new();
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_PREWARM_SUBAGENTS").is_some() {
|
||||
eprintln!("[library-profile] subagents: prewarming one full turn");
|
||||
let warmup = LongLivedSession::with_thread(
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
//! `LongLivedSession` path as `subagents`, but the mock returns a direct text
|
||||
//! response (no `spawn_parallel_agents` tool call). Complements `subagents`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::config::schema::SubconsciousMode;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use openhuman_core::openhuman::subconscious::LongLivedSession;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
@@ -21,8 +18,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration is on track; nothing needs your attention.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
|
||||
if std::env::var_os("OPENHUMAN_PROFILE_PREWARM_SUBAGENTS").is_some() {
|
||||
eprintln!("[library-profile] subconscious: prewarming one full turn");
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
//! (recorded as a checkpoint), then `flows_run` is measured as the workload.
|
||||
//! The agent node's LLM routes through the plain-text mock provider.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use openhuman_core::core::event_bus::init_global;
|
||||
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use openhuman_core::openhuman::flows::ops::{flows_create, flows_run};
|
||||
use openhuman_core::openhuman::flows::FlowRunTrigger;
|
||||
use openhuman_core::openhuman::inference::provider::factory::test_provider_override;
|
||||
use openhuman_core::openhuman::inference::provider::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::harness::{fixture, measure, ProfileResult};
|
||||
@@ -23,8 +20,7 @@ pub async fn run() -> Result<ProfileResult> {
|
||||
openhuman_core::openhuman::agent::bus::register_agent_handlers();
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let mock = PlainTextMock::new("Phoenix migration status: healthy, ramp on Friday.");
|
||||
let provider: Arc<dyn Provider> = mock.clone();
|
||||
let _provider = test_provider_override::install(provider);
|
||||
let _provider = test_provider_override::install_model(mock.clone());
|
||||
|
||||
let graph = json!({
|
||||
"name": "profile-workflow",
|
||||
|
||||
+14
-38
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! Mirrors the OpenCompany embedding contract: a bare [`Agent`] built directly
|
||||
//! via [`Agent::builder`] (no `CoreBuilder`, no RPC, no background services)
|
||||
//! with an injected mock provider, an in-process `"none"` memory backend, and a
|
||||
//! with an injected mock model, an in-process `"none"` memory backend, and a
|
||||
//! per-agent temp workspace. Builds a 1-agent and an 8-agent roster, runs one
|
||||
//! deterministic warm-up turn per agent to fault in lazy allocations, settles,
|
||||
//! then samples `/proc/self/{status,smaps_rollup}`.
|
||||
@@ -28,9 +28,6 @@ use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher;
|
||||
use openhuman_core::openhuman::agent::Agent;
|
||||
use openhuman_core::openhuman::inference::provider::{
|
||||
ChatRequest, ChatResponse, Provider, UsageInfo,
|
||||
};
|
||||
use openhuman_core::openhuman::memory::{
|
||||
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
||||
};
|
||||
@@ -44,6 +41,7 @@ use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
|
||||
/// Roster sizes measured by default: the 1-agent baseline and the
|
||||
/// representative 8-agent company roster from #5046.
|
||||
@@ -56,45 +54,23 @@ const DEFAULT_REPEAT: usize = 5;
|
||||
/// the outer CI job timeout.
|
||||
const CHILD_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Provider that never touches the network: returns a fixed assistant message
|
||||
/// Model that never touches the network: returns a fixed assistant message
|
||||
/// with a `stop` shape (no tool calls) so a turn completes in one round-trip.
|
||||
struct MockProvider;
|
||||
struct MockModel;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for MockModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
text: Some("ok".into()),
|
||||
tool_calls: vec![],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 8,
|
||||
output_tokens: 2,
|
||||
context_window: 8000,
|
||||
charged_amount_usd: 0.0,
|
||||
..Default::default()
|
||||
}),
|
||||
reasoning_content: None,
|
||||
})
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant("ok"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trivial host-supplied tool so the roster mirrors a real embedding (the host
|
||||
/// injects its own tools). Never invoked — the provider returns no tool calls.
|
||||
/// injects its own tools). Never invoked — the model returns no tool calls.
|
||||
struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
@@ -182,7 +158,7 @@ struct Roster {
|
||||
_workspaces: Vec<TempDir>,
|
||||
}
|
||||
|
||||
/// Build `n` bare agents, each with its own temp workspace, mock provider,
|
||||
/// Build `n` bare agents, each with its own temp workspace, mock model,
|
||||
/// `"none"` memory backend, and a single host-supplied tool.
|
||||
fn build_roster(n: usize) -> Result<Roster> {
|
||||
let mut agents = Vec::with_capacity(n);
|
||||
@@ -194,7 +170,7 @@ fn build_roster(n: usize) -> Result<Roster> {
|
||||
let memory: Arc<dyn Memory> = Arc::new(NoopMemory);
|
||||
|
||||
let agent = Agent::builder()
|
||||
.provider(Box::new(MockProvider))
|
||||
.chat_model(Arc::new(MockModel))
|
||||
.tools(vec![Box::new(EchoTool)])
|
||||
.memory(memory)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -215,7 +191,7 @@ fn build_roster(n: usize) -> Result<Roster> {
|
||||
}
|
||||
|
||||
/// One deterministic warm-up turn per agent, forcing first-touch allocations
|
||||
/// (prompt build, tokenizer, provider adapter) to fault in before measuring.
|
||||
/// (prompt build, tokenizer, model seam) to fault in before measuring.
|
||||
async fn warm_up(roster: &mut Roster) -> Result<()> {
|
||||
for agent in &mut roster.agents {
|
||||
let _ = agent.turn("warmup").await.context("warm-up turn")?;
|
||||
|
||||
@@ -21,15 +21,16 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::core::event_bus::register_native_global;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::agent::turn_origin::{self, AgentTurnOrigin};
|
||||
use crate::openhuman::config::MultimodalConfig;
|
||||
use crate::openhuman::inference::provider::{
|
||||
current_resolved_provider_route, with_resolved_provider_route_scope, ChatMessage,
|
||||
};
|
||||
use crate::openhuman::prompt_injection::{
|
||||
enforce_prompt_input, PromptEnforcementAction, PromptEnforcementContext,
|
||||
};
|
||||
use crate::openhuman::tinyagents::{
|
||||
current_resolved_provider_route, with_resolved_provider_route_scope,
|
||||
};
|
||||
use crate::openhuman::tools::Tool;
|
||||
|
||||
use super::harness::definition::{AgentDefinitionRegistry, SandboxMode};
|
||||
@@ -471,40 +472,15 @@ pub async fn use_real_agent_handler() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::event_bus::NativeRegistry;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Minimal `Provider` implementation used only to build the
|
||||
/// [`TurnModelSource`] in [`AgentTurnRequest`]. The tests below
|
||||
/// override the bus handler with a stub that never calls any
|
||||
/// provider methods, so this no-op is sufficient — the only required
|
||||
/// trait method is `chat_with_system`, everything else has a default.
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!(
|
||||
"NoopProvider::chat_with_system should not be invoked by tests that \
|
||||
override the agent.run_turn handler"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a canonical test request. The bus handler is always stubbed
|
||||
/// in these tests, so the provider trait object is never actually
|
||||
/// invoked — it only needs to satisfy the type.
|
||||
/// invoked — an empty native scripted model only satisfies the type.
|
||||
fn test_request() -> AgentTurnRequest {
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::new(Vec::new()));
|
||||
AgentTurnRequest {
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(
|
||||
NoopProvider,
|
||||
)),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
history: vec![
|
||||
ChatMessage::system("you are a test bot"),
|
||||
ChatMessage::user("hello"),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::openhuman::agent::harness::parse_tool_calls;
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage, ToolResultMessage};
|
||||
use crate::openhuman::agent::pformat::{self, PFormatRegistry};
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatResponse;
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
use serde_json::Value;
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -277,7 +277,7 @@ fn assistant_tool_calls(id: &str) -> ConversationMessage {
|
||||
}
|
||||
|
||||
fn tool_results(id: &str) -> ConversationMessage {
|
||||
use crate::openhuman::inference::provider::ToolResultMessage;
|
||||
use crate::openhuman::agent::messages::ToolResultMessage;
|
||||
ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: id.into(),
|
||||
content: "ok".into(),
|
||||
@@ -285,13 +285,13 @@ fn tool_results(id: &str) -> ConversationMessage {
|
||||
}
|
||||
|
||||
fn user_chat(text: &str) -> ConversationMessage {
|
||||
ConversationMessage::Chat(crate::openhuman::inference::provider::ChatMessage::user(
|
||||
text,
|
||||
))
|
||||
ConversationMessage::Chat(crate::openhuman::agent::messages::ChatMessage::user(text))
|
||||
}
|
||||
|
||||
fn assistant_chat(text: &str) -> ConversationMessage {
|
||||
ConversationMessage::Chat(crate::openhuman::inference::provider::ChatMessage::assistant(text))
|
||||
ConversationMessage::Chat(crate::openhuman::agent::messages::ChatMessage::assistant(
|
||||
text,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -458,7 +458,7 @@ fn native_dispatcher_omits_reasoning_content_when_absent() {
|
||||
}
|
||||
|
||||
fn tool_results_multi(ids: &[&str]) -> ConversationMessage {
|
||||
use crate::openhuman::inference::provider::ToolResultMessage;
|
||||
use crate::openhuman::agent::messages::ToolResultMessage;
|
||||
ConversationMessage::ToolResults(
|
||||
ids.iter()
|
||||
.map(|id| ToolResultMessage {
|
||||
|
||||
@@ -27,8 +27,8 @@ use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::openhuman::agent::harness::run_queue::RunQueue;
|
||||
use crate::openhuman::agent::harness::subagent_runner::SubagentRunError;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::tinyagents::TurnModelSource;
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ pub(crate) fn test_main_def() -> AgentDefinition {
|
||||
/// which is exactly what the full-path spawn test needs to assert the
|
||||
/// dispatch → run_subagent → result-threading chain end to end.
|
||||
/// Provider *routing* for `Hint` sub-agents is covered separately by
|
||||
/// `subagent_runner::ops::tests::resolve_subagent_provider_*`.
|
||||
/// `subagent_runner::ops::tests::resolve_subagent_source_*`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_inherit_echo_def() -> AgentDefinition {
|
||||
use super::definition::{ModelSpec, PromptSource, SandboxMode, ToolScope};
|
||||
|
||||
@@ -30,9 +30,9 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::tinyagents::run_turn_via_tinyagents_shared;
|
||||
use crate::openhuman::tinyagents::TurnModelSource;
|
||||
use crate::openhuman::tools::Tool;
|
||||
@@ -179,10 +179,12 @@ pub(crate) async fn run_channel_turn_via_graph(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::inference::provider::{ChatResponse, Provider, ToolCall};
|
||||
use crate::openhuman::tools::ToolResult;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tinyagents::harness::message::AssistantMessage;
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelResponse};
|
||||
use tinyagents::harness::testkit::ScriptedModel;
|
||||
use tinyagents::harness::tool::ToolCall;
|
||||
|
||||
struct PingTool;
|
||||
#[async_trait]
|
||||
@@ -201,57 +203,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct PingThenDone {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Provider for PingThenDone {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let n = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
Ok(ChatResponse {
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "p".to_string(),
|
||||
name: "ping".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Ok(ChatResponse {
|
||||
text: Some("channel done".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_turn_runs_through_the_graph() {
|
||||
let registry: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(PingTool)]);
|
||||
let mut history = vec![ChatMessage::user("ping please")];
|
||||
let scripted: Arc<dyn ChatModel<()>> = Arc::new(ScriptedModel::new(vec![
|
||||
ModelResponse {
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content: Vec::new(),
|
||||
tool_calls: vec![ToolCall::new("p", "ping", serde_json::json!({}))],
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
},
|
||||
ModelResponse::assistant("channel done"),
|
||||
]));
|
||||
let mut profile = ModelProfile::default();
|
||||
profile.tool_calling = true;
|
||||
profile.parallel_tool_calls = true;
|
||||
let text = run_channel_turn_via_graph(
|
||||
TurnModelSource::new(Arc::new(PingThenDone {
|
||||
calls: AtomicUsize::new(0),
|
||||
})),
|
||||
TurnModelSource::from_model_with_profile(scripted, profile),
|
||||
&mut history,
|
||||
registry,
|
||||
vec![],
|
||||
|
||||
@@ -23,92 +23,7 @@
|
||||
//! - `<invoke tool=…>` XML attribute form — the parser does not parse attributes;
|
||||
//! only the tag body (JSON) is used.
|
||||
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse};
|
||||
use crate::openhuman::tool_timeout::parse_tool_timeout_secs;
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared test doubles
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct ScriptedProvider {
|
||||
responses: Mutex<Vec<anyhow::Result<ChatResponse>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ScriptedProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("fallback".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let mut guard = self.responses.lock();
|
||||
guard.remove(0)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult::success("echo-out"))
|
||||
}
|
||||
}
|
||||
|
||||
struct PingTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for PingTool {
|
||||
fn name(&self) -> &str {
|
||||
"ping"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"ping"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult::success("pong"))
|
||||
}
|
||||
}
|
||||
|
||||
fn multimodal_cfg() -> crate::openhuman::config::MultimodalConfig {
|
||||
crate::openhuman::config::MultimodalConfig::default()
|
||||
}
|
||||
|
||||
fn multimodal_file_cfg() -> crate::openhuman::config::MultimodalFileConfig {
|
||||
crate::openhuman::config::MultimodalFileConfig::default()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Item 1 — Full turn cycle: user → LLM emits tool call → tool executes →
|
||||
|
||||
@@ -118,8 +118,7 @@ pub(crate) async fn build_context(
|
||||
// enforced at the SQLite layer (one DB per workspace == one user);
|
||||
// the current chat is excluded by passing `session_id` so the block
|
||||
// never duplicates the same-chat history.
|
||||
let current_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
let current_thread_id = crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
let cross_session_opts = crate::openhuman::memory::RecallOpts {
|
||||
session_id: current_thread_id.as_deref(),
|
||||
cross_session: true,
|
||||
|
||||
@@ -60,36 +60,20 @@ impl AgentBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the AI provider for the agent.
|
||||
///
|
||||
/// Accepts a `Box<dyn Provider>` for backward compatibility but wraps it in
|
||||
/// the seam [`TurnModelSource`](crate::openhuman::tinyagents::TurnModelSource)
|
||||
/// internally (issue #4249, Phase 3 / Motion A) so the agent + sub-agents
|
||||
/// spawned from it share the same source.
|
||||
pub fn provider(
|
||||
mut self,
|
||||
provider: Box<dyn crate::openhuman::inference::provider::Provider>,
|
||||
) -> Self {
|
||||
self.turn_model_source = Some(crate::openhuman::tinyagents::TurnModelSource::new(
|
||||
Arc::from(provider),
|
||||
/// Sets an already-constructed TinyAgents chat model. This is the native
|
||||
/// injection seam for tests and embedders; no legacy `Provider` adapter is
|
||||
/// constructed.
|
||||
pub fn chat_model(mut self, model: Arc<dyn tinyagents::harness::model::ChatModel<()>>) -> Self {
|
||||
self.turn_model_source = Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the AI provider from an existing `Arc`. Use this when sharing
|
||||
/// a provider instance across multiple agents.
|
||||
pub fn provider_arc(
|
||||
mut self,
|
||||
provider: Arc<dyn crate::openhuman::inference::provider::Provider>,
|
||||
) -> Self {
|
||||
self.turn_model_source = Some(crate::openhuman::tinyagents::TurnModelSource::new(provider));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the AI provider as a **crate-native** turn-model source (Phase 3 P3-B):
|
||||
/// `build`/`build_summarizer` construct crate `ChatModel`s from `(role, config)`
|
||||
/// via `create_turn_chat_model` (managed → `OpenHumanBackendModel`, local/cloud →
|
||||
/// crate `OpenAiModel`) instead of wrapping `provider` in `ProviderModel`s.
|
||||
/// crate `OpenAiModel`) instead of wrapping `provider` in `native model adapters.
|
||||
/// Used by the production session factory; the plain
|
||||
/// [`provider`](Self::provider) setter (Provider path) stays for tests that
|
||||
/// inject a mock they observe.
|
||||
|
||||
@@ -11,8 +11,9 @@ use super::types::{Agent, AgentBuilder};
|
||||
use crate::core::event_bus::{publish_global, DomainEvent};
|
||||
use crate::openhuman::agent::dispatcher::ParsedToolCall;
|
||||
use crate::openhuman::agent::error::AgentError;
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
use crate::openhuman::agent_tool_policy::ToolPolicyEngine;
|
||||
use crate::openhuman::inference::provider::{self, ConversationMessage, ToolCall};
|
||||
use crate::openhuman::inference::provider::{self, ToolCall};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::prompt_injection::{
|
||||
enforce_prompt_input, PromptEnforcementAction, PromptEnforcementContext,
|
||||
@@ -356,21 +357,21 @@ impl Agent {
|
||||
let learned = crate::openhuman::agent::prompts::LearnedContextData::default();
|
||||
let system_prompt = self.build_system_prompt(learned)?;
|
||||
|
||||
let mut cached: Vec<crate::openhuman::inference::provider::ChatMessage> =
|
||||
let mut cached: Vec<crate::openhuman::agent::messages::ChatMessage> =
|
||||
Vec::with_capacity(prior.len() + 1);
|
||||
cached.push(crate::openhuman::inference::provider::ChatMessage::system(
|
||||
cached.push(crate::openhuman::agent::messages::ChatMessage::system(
|
||||
system_prompt,
|
||||
));
|
||||
for (role, content) in prior {
|
||||
let chat = match role.as_str() {
|
||||
"user" => crate::openhuman::inference::provider::ChatMessage::user(content),
|
||||
"user" => crate::openhuman::agent::messages::ChatMessage::user(content),
|
||||
"agent" | "assistant" => {
|
||||
crate::openhuman::inference::provider::ChatMessage::assistant(content)
|
||||
crate::openhuman::agent::messages::ChatMessage::assistant(content)
|
||||
}
|
||||
// Fall back to user role for unknown senders rather than
|
||||
// dropping the message — losing context is worse than
|
||||
// mislabelling a system/tool message.
|
||||
_ => crate::openhuman::inference::provider::ChatMessage::user(content),
|
||||
_ => crate::openhuman::agent::messages::ChatMessage::user(content),
|
||||
};
|
||||
cached.push(chat);
|
||||
}
|
||||
|
||||
@@ -2,60 +2,66 @@ use super::*;
|
||||
use crate::core::event_bus::{global, init_global, DomainEvent};
|
||||
use crate::openhuman::agent::dispatcher::XmlToolDispatcher;
|
||||
use crate::openhuman::agent::error::AgentError;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Provider, UsageInfo,
|
||||
};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::inference::provider::{ChatResponse, UsageInfo};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
struct StaticProvider {
|
||||
struct StaticModel {
|
||||
response: Mutex<Option<anyhow::Result<ChatResponse>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StaticProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for StaticModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("unused".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.response.lock().take().unwrap_or_else(|| {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let response = self.response.lock().take().unwrap_or_else(|| {
|
||||
Ok(ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
})
|
||||
});
|
||||
match response {
|
||||
Ok(response) => Ok(
|
||||
crate::openhuman::tinyagents::model::native_model_response_for_request(
|
||||
&response, &request,
|
||||
),
|
||||
),
|
||||
Err(error) => Err(tinyagents::TinyAgentsError::Model(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream(&self, state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
let response = self.invoke(state, request).await?;
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
ModelStreamItem::Started,
|
||||
ModelStreamItem::Completed(response),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider that fails on EVERY call with a freshly-built typed [`AgentError`].
|
||||
/// Model that fails every call with the rendered form of a host [`AgentError`].
|
||||
///
|
||||
/// The default turn model (`chat-v1`) now carries a same-family cross-route
|
||||
/// fallback chain (`chat-v1 → burst-v1`, issue #4249 Workstream 02.2). A mock
|
||||
/// that errors only once (via `StaticProvider`'s `take()`) would fail the primary
|
||||
/// that errors only once (via `StaticModel`'s `take()`) would fail the primary
|
||||
/// route and then succeed on the fallback route, masking the terminal error. To
|
||||
/// exercise `run_single`'s error-surfacing path we need a provider that fails on
|
||||
/// every route so the harness exhausts the chain and surfaces the typed error
|
||||
/// (recovered from the primary route's error slot).
|
||||
struct PersistentErrProvider {
|
||||
/// exercise `run_single`'s error-surfacing path we need a model that fails on
|
||||
/// every route so the harness exhausts the chain and surfaces the message.
|
||||
struct PersistentErrModel {
|
||||
kind: PersistentErrKind,
|
||||
}
|
||||
|
||||
@@ -65,7 +71,7 @@ enum PersistentErrKind {
|
||||
PermissionDenied,
|
||||
}
|
||||
|
||||
impl PersistentErrProvider {
|
||||
impl PersistentErrModel {
|
||||
fn build_error(&self) -> anyhow::Error {
|
||||
match self.kind {
|
||||
PersistentErrKind::MaxIterations { max } => {
|
||||
@@ -81,28 +87,19 @@ impl PersistentErrProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PersistentErrProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for PersistentErrModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Err(self.build_error())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
Err(self.build_error())
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Err(tinyagents::TinyAgentsError::Model(
|
||||
self.build_error().to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(provider: Arc<dyn Provider>) -> Agent {
|
||||
fn make_agent(model: Arc<dyn ChatModel<()>>) -> Agent {
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
std::mem::forget(workspace);
|
||||
@@ -115,7 +112,7 @@ fn make_agent(provider: Arc<dyn Provider>) -> Agent {
|
||||
);
|
||||
|
||||
Agent::builder()
|
||||
.provider_arc(provider)
|
||||
.chat_model(model)
|
||||
.tools(vec![])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
@@ -209,17 +206,25 @@ fn sanitizers_and_tool_call_helpers_cover_fallback_paths() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_single_preserves_typed_max_iterations_error_for_sentry_skip() {
|
||||
// OPENHUMAN-TAURI-99 regression guard: when the agent hits its tool
|
||||
// iteration cap, `run_single` MUST surface the typed
|
||||
// `AgentError::MaxIterationsExceeded` variant so the call site can
|
||||
// downcast and skip `report_error`. If the error reaches the funnel as
|
||||
// a plain `anyhow::Error::msg(..)` (e.g. someone reverts to
|
||||
// `anyhow::bail!`), the downcast fails and Sentry re-floods with the
|
||||
// exact noise this fix removes.
|
||||
async fn run_single_preserves_native_model_error_text() {
|
||||
// Host-generated user-state errors remain typed and therefore retain the
|
||||
// Sentry-suppression contract at their source.
|
||||
let typed = anyhow!(AgentError::MaxIterationsExceeded { max: 8 });
|
||||
assert!(matches!(
|
||||
typed.downcast_ref::<AgentError>(),
|
||||
Some(AgentError::MaxIterationsExceeded { max: 8 })
|
||||
));
|
||||
assert_eq!(
|
||||
Agent::sanitize_event_error_message(&typed),
|
||||
"max_iterations_exceeded"
|
||||
);
|
||||
|
||||
// A crate-native model error crosses the TinyAgents boundary as its
|
||||
// provider-neutral error type rather than a downcastable host error. Its
|
||||
// user-visible text must still remain intact.
|
||||
let _ = init_global(64);
|
||||
|
||||
let err_provider: Arc<dyn Provider> = Arc::new(PersistentErrProvider {
|
||||
let err_provider: Arc<dyn ChatModel<()>> = Arc::new(PersistentErrModel {
|
||||
kind: PersistentErrKind::MaxIterations { max: 8 },
|
||||
});
|
||||
let mut agent = make_agent(err_provider);
|
||||
@@ -236,23 +241,14 @@ async fn run_single_preserves_typed_max_iterations_error_for_sentry_skip() {
|
||||
"canonical phrase missing: {err}"
|
||||
);
|
||||
|
||||
// The downcast is the load-bearing condition for the Sentry skip in
|
||||
// `Agent::run_single` (matches!(err.downcast_ref::<AgentError>(),
|
||||
// Some(AgentError::MaxIterationsExceeded { .. }))). If this assertion
|
||||
// ever fails the suppression silently regresses to error-level
|
||||
// emission.
|
||||
let downcast = err.downcast_ref::<AgentError>();
|
||||
assert!(
|
||||
matches!(downcast, Some(AgentError::MaxIterationsExceeded { max: 8 })),
|
||||
"expected MaxIterationsExceeded {{ max: 8 }}, got {downcast:?}"
|
||||
err.downcast_ref::<AgentError>().is_none(),
|
||||
"model-boundary errors must not pretend to retain host types"
|
||||
);
|
||||
|
||||
// Sanitized event message round-trips to the stable kind tag so the
|
||||
// structured `log::info!` we emit instead of `report_error` carries
|
||||
// the right `error_kind` for log-side filtering.
|
||||
assert_eq!(
|
||||
Agent::sanitize_event_error_message(&err),
|
||||
"max_iterations_exceeded"
|
||||
assert!(
|
||||
Agent::sanitize_event_error_message(&err)
|
||||
.contains("Agent exceeded maximum tool iterations"),
|
||||
"native error text should survive sanitization: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,7 +265,7 @@ async fn run_single_publishes_completed_and_error_events() {
|
||||
})
|
||||
});
|
||||
|
||||
let ok_provider: Arc<dyn Provider> = Arc::new(StaticProvider {
|
||||
let ok_provider: Arc<dyn ChatModel<()>> = Arc::new(StaticModel {
|
||||
response: Mutex::new(Some(Ok(ChatResponse {
|
||||
text: Some("ok".into()),
|
||||
tool_calls: vec![],
|
||||
@@ -281,7 +277,7 @@ async fn run_single_publishes_completed_and_error_events() {
|
||||
let response = ok_agent.run_single("hello").await.expect("run_single ok");
|
||||
assert_eq!(response, "ok");
|
||||
|
||||
let err_provider: Arc<dyn Provider> = Arc::new(PersistentErrProvider {
|
||||
let err_provider: Arc<dyn ChatModel<()>> = Arc::new(PersistentErrModel {
|
||||
kind: PersistentErrKind::PermissionDenied,
|
||||
});
|
||||
let mut err_agent = make_agent(err_provider);
|
||||
@@ -313,14 +309,14 @@ async fn run_single_publishes_completed_and_error_events() {
|
||||
message,
|
||||
recoverable,
|
||||
} if session_id == "runtime-test-session"
|
||||
&& message == "permission_denied"
|
||||
&& message.contains("Permission denied")
|
||||
&& !recoverable
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_history_reset_expose_agent_runtime_state() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(StaticProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(StaticModel {
|
||||
response: Mutex::new(None),
|
||||
});
|
||||
let mut agent = make_agent(provider);
|
||||
|
||||
@@ -8,46 +8,60 @@
|
||||
use super::types::{Agent, AgentBuilder};
|
||||
use crate::core::event_bus::DomainEvent;
|
||||
use crate::openhuman::agent::dispatcher::{NativeToolDispatcher, XmlToolDispatcher};
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ConversationMessage, Provider};
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
use crate::openhuman::inference::provider::ChatResponse;
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::tools::Tool;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelProfile, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
|
||||
struct MockProvider {
|
||||
responses: Mutex<Vec<crate::openhuman::inference::provider::ChatResponse>>,
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("ok".into())
|
||||
impl ChatModel<()> for MockProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(ModelProfile::default);
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<crate::openhuman::inference::provider::ChatResponse> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let mut guard = self.responses.lock();
|
||||
if guard.is_empty() {
|
||||
return Ok(crate::openhuman::inference::provider::ChatResponse {
|
||||
let response = if guard.is_empty() {
|
||||
ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
} else {
|
||||
guard.remove(0)
|
||||
};
|
||||
Ok(
|
||||
crate::openhuman::tinyagents::model::native_model_response_for_request(
|
||||
&response, &request,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async fn stream(&self, state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
let response = self.invoke(state, request).await?;
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
ModelStreamItem::Started,
|
||||
ModelStreamItem::Completed(response),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +72,7 @@ impl Provider for MockProvider {
|
||||
#[derive(Default)]
|
||||
struct RecordingProvider {
|
||||
captures: Mutex<Vec<CapturedCall>>,
|
||||
responses: Mutex<Vec<crate::openhuman::inference::provider::ChatResponse>>,
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -68,43 +82,51 @@ struct CapturedCall {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for RecordingProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("ok".into())
|
||||
impl ChatModel<()> for RecordingProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(ModelProfile::default);
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<crate::openhuman::inference::provider::ChatResponse> {
|
||||
let system_prompt = request
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.clone());
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let system_prompt = request.messages.iter().find_map(|message| match message {
|
||||
Message::System(_) => Some(message.text()),
|
||||
_ => None,
|
||||
});
|
||||
self.captures.lock().push(CapturedCall {
|
||||
system_prompt,
|
||||
model: model.to_string(),
|
||||
model: request.model.clone().unwrap_or_default(),
|
||||
});
|
||||
|
||||
let mut guard = self.responses.lock();
|
||||
if guard.is_empty() {
|
||||
return Ok(crate::openhuman::inference::provider::ChatResponse {
|
||||
let response = if guard.is_empty() {
|
||||
ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
} else {
|
||||
guard.remove(0)
|
||||
};
|
||||
Ok(
|
||||
crate::openhuman::tinyagents::model::native_model_response_for_request(
|
||||
&response, &request,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async fn stream(&self, state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
let response = self.invoke(state, request).await?;
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
ModelStreamItem::Started,
|
||||
ModelStreamItem::Completed(response),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +171,7 @@ fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Ag
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
|
||||
@@ -162,7 +184,7 @@ fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Ag
|
||||
);
|
||||
|
||||
let mut builder = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -515,11 +537,11 @@ fn refresh_workflows_picks_up_skill_installed_on_disk() {
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory_store::create_memory(&memory_cfg, &wsp).unwrap());
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -585,11 +607,11 @@ fn refresh_workflows_retracts_skill_removed_from_disk() {
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory_store::create_memory(&memory_cfg, &wsp).unwrap());
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -669,7 +691,7 @@ async fn turn_without_tools_returns_text() {
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some("hello".into()),
|
||||
tool_calls: vec![],
|
||||
@@ -687,7 +709,7 @@ async fn turn_without_tools_returns_text() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
@@ -709,7 +731,7 @@ async fn last_turn_usage_is_public_and_non_draining() {
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some("hello".into()),
|
||||
tool_calls: vec![],
|
||||
@@ -733,7 +755,7 @@ async fn last_turn_usage_is_public_and_non_draining() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
@@ -782,7 +804,7 @@ async fn turn_with_native_dispatcher_handles_tool_results_variant() {
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
@@ -813,7 +835,7 @@ async fn turn_with_native_dispatcher_handles_tool_results_variant() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -834,7 +856,7 @@ async fn turn_with_native_dispatcher_persists_fallback_tool_calls() {
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some(
|
||||
@@ -863,7 +885,7 @@ async fn turn_with_native_dispatcher_persists_fallback_tool_calls() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -906,7 +928,7 @@ async fn turn_with_native_dispatcher_persists_fallback_tool_calls() {
|
||||
/// chain. `Inherit` keeps `parent.provider`, which is exactly the
|
||||
/// plumbing this test asserts. Provider *routing* for Hint sub-agents
|
||||
/// is covered independently by
|
||||
/// `subagent_runner::ops::tests::resolve_subagent_provider_*`.
|
||||
/// `subagent_runner::ops::tests::resolve_subagent_source_*`.
|
||||
#[tokio::test]
|
||||
async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
@@ -922,7 +944,7 @@ async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
// 1. Parent turn iter 0 — emit a spawn_subagent tool call.
|
||||
// 2. Sub-agent (researcher) iter 0 — return final text "X is Y".
|
||||
// 3. Parent turn iter 1 — fold sub-agent result into "Based on the research, X is Y."
|
||||
let provider = Box::new(MockProvider {
|
||||
let provider = Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
@@ -967,7 +989,7 @@ async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(SpawnSubagentTool::new())];
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -1056,7 +1078,7 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider_arc(provider.clone() as Arc<dyn Provider>)
|
||||
.chat_model(provider.clone() as Arc<dyn ChatModel<()>>)
|
||||
.tools(vec![])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -1209,8 +1231,8 @@ fn seed_resume_from_messages_primes_cached_transcript() {
|
||||
fn seed_resume_from_messages_is_noop_on_warm_agent() {
|
||||
let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator"));
|
||||
agent.cached_transcript_messages = Some(vec![
|
||||
crate::openhuman::inference::provider::ChatMessage::system("warm prefix"),
|
||||
crate::openhuman::inference::provider::ChatMessage::user("hi"),
|
||||
crate::openhuman::agent::messages::ChatMessage::system("warm prefix"),
|
||||
crate::openhuman::agent::messages::ChatMessage::user("hi"),
|
||||
]);
|
||||
agent
|
||||
.seed_resume_from_messages(vec![("user".into(), "different".into())], "different")
|
||||
@@ -1285,11 +1307,11 @@ fn bound_cached_transcript_messages_without_system_prefix_keeps_tail() {
|
||||
agent.config.max_history_messages = 3;
|
||||
|
||||
let messages = vec![
|
||||
crate::openhuman::inference::provider::ChatMessage::user("u1"),
|
||||
crate::openhuman::inference::provider::ChatMessage::assistant("a1"),
|
||||
crate::openhuman::inference::provider::ChatMessage::user("u2"),
|
||||
crate::openhuman::inference::provider::ChatMessage::assistant("a2"),
|
||||
crate::openhuman::inference::provider::ChatMessage::user("u3"),
|
||||
crate::openhuman::agent::messages::ChatMessage::user("u1"),
|
||||
crate::openhuman::agent::messages::ChatMessage::assistant("a1"),
|
||||
crate::openhuman::agent::messages::ChatMessage::user("u2"),
|
||||
crate::openhuman::agent::messages::ChatMessage::assistant("a2"),
|
||||
crate::openhuman::agent::messages::ChatMessage::user("u3"),
|
||||
];
|
||||
let bounded = agent.bound_cached_transcript_messages(messages);
|
||||
assert_eq!(bounded.len(), 3);
|
||||
@@ -1305,7 +1327,7 @@ fn bound_cached_transcript_messages_without_system_prefix_keeps_tail() {
|
||||
/// provider 400s (surfacing as "Something went wrong").
|
||||
#[test]
|
||||
fn bound_cached_transcript_messages_snaps_past_leading_orphan_tool() {
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator"));
|
||||
agent.config.max_history_messages = 3;
|
||||
@@ -1351,7 +1373,8 @@ fn bound_cached_transcript_messages_snaps_past_leading_orphan_tool() {
|
||||
#[test]
|
||||
fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
|
||||
use super::transcript::{self, MessageUsage, TranscriptMeta, TurnUsage};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ToolCall};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::inference::provider::ToolCall;
|
||||
|
||||
let ws = tempfile::TempDir::new().expect("temp workspace");
|
||||
let wsp = ws.path().to_path_buf();
|
||||
@@ -1424,7 +1447,7 @@ fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory_store::create_memory(&memory_cfg, &wsp).unwrap());
|
||||
let mut agent = Agent::builder()
|
||||
.provider(Box::new(MockProvider {
|
||||
.chat_model(Arc::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
}))
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
@@ -1488,7 +1511,7 @@ fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
|
||||
#[test]
|
||||
fn seed_resume_replays_compaction_to_reduced_context() {
|
||||
use super::transcript::{self, TranscriptMeta};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
let ws = tempfile::TempDir::new().expect("temp workspace");
|
||||
let wsp = ws.path().to_path_buf();
|
||||
@@ -1571,7 +1594,7 @@ fn seed_resume_from_thread_transcript_returns_false_without_transcript() {
|
||||
fn seed_resume_from_thread_transcript_is_noop_on_warm_agent() {
|
||||
let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator"));
|
||||
agent.cached_transcript_messages = Some(vec![
|
||||
crate::openhuman::inference::provider::ChatMessage::system("warm prefix"),
|
||||
crate::openhuman::agent::messages::ChatMessage::system("warm prefix"),
|
||||
]);
|
||||
assert!(!agent.seed_resume_from_thread_transcript("thr_x"));
|
||||
let cached = agent
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
//! the session transcript can eventually replace the separate thread
|
||||
//! message log without losing message-level addressing.
|
||||
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::inference::provider::ToolCall;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use super::super::turn_checkpoint::assistant_message_has_tool_calls;
|
||||
use super::super::types::Agent;
|
||||
use super::{collect_tree_root_summaries, sanitize_learned_entry};
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::agent_tool_policy::render_tool_policy_boundary;
|
||||
use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::memory::MemoryCategory;
|
||||
use crate::openhuman::tools::Tool;
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ use crate::openhuman::agent::harness;
|
||||
use crate::openhuman::agent::harness::definition::TriggerMemoryAgent;
|
||||
use crate::openhuman::agent::harness::fork_context::ParentExecutionContext;
|
||||
use crate::openhuman::agent::hooks::{self, TurnContext};
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::agent_experience::{
|
||||
prepend_experience_block, render_experience_hits, retrieve_across_stores, AgentExperienceStore,
|
||||
ExperienceQuery,
|
||||
};
|
||||
use crate::openhuman::agent_memory::memory_loader::collect_recall_citations;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::memory::MemoryCategory;
|
||||
use crate::openhuman::util::truncate_with_ellipsis;
|
||||
|
||||
@@ -656,7 +656,7 @@ impl Agent {
|
||||
let autosave_key = format!("user_msg:{}", uuid::Uuid::new_v4());
|
||||
let chars = user_msg.chars().count();
|
||||
// Captured *before* `tokio::spawn` — the ambient thread id is a
|
||||
// `tokio::task_local` (see `inference::provider::thread_context`)
|
||||
// `tokio::task_local` (see `tinyagents::thread_context`)
|
||||
// and does not propagate into a spawned task, so it must be read
|
||||
// on this (still-scoped) task and moved in explicitly. Tagging
|
||||
// this document with the live chat thread id is what lets the
|
||||
@@ -665,7 +665,7 @@ impl Agent {
|
||||
// turn, so the agent's own on-demand memory search doesn't echo
|
||||
// its own triggering request back as a "relevant" result.
|
||||
let session_id_for_autosave =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
log::debug!(
|
||||
"[agent_autosave] enqueue user-message store key={autosave_key} chars={chars} \
|
||||
session_id={}",
|
||||
|
||||
@@ -30,8 +30,9 @@ use anyhow::Result;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::openhuman::agent::harness::run_queue::RunQueue;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, AGENT_TURN_MAX_OUTPUT_TOKENS};
|
||||
use crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS;
|
||||
use crate::openhuman::tinyagents::{
|
||||
run_turn_via_tinyagents_shared, TinyagentsTurnOutcome, TurnContextMiddleware,
|
||||
};
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
use super::super::transcript;
|
||||
use super::super::types::Agent;
|
||||
use crate::openhuman::agent::harness;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS,
|
||||
ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
|
||||
@@ -547,7 +548,7 @@ impl Agent {
|
||||
output_tokens,
|
||||
cached_input_tokens,
|
||||
charged_amount_usd,
|
||||
thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(),
|
||||
thread_id: crate::openhuman::tinyagents::thread_context::current_thread_id(),
|
||||
task_id: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::openhuman::agent::hooks::ToolCallRecord;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
pub(crate) fn assistant_message_has_tool_calls(msg: &ChatMessage) -> bool {
|
||||
if msg.role != "assistant" {
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::openhuman::agent::dispatcher::{
|
||||
PFormatToolDispatcher, ToolDispatcher, XmlToolDispatcher,
|
||||
};
|
||||
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::agent::tool_policy::{
|
||||
GeneratedToolRuntimeContext, GeneratedToolRuntimeRisk, ToolPolicy, ToolPolicyDecision,
|
||||
ToolPolicyRequest,
|
||||
@@ -11,9 +12,7 @@ use crate::openhuman::agent_experience::{
|
||||
AgentExperience, AgentExperienceStore, ExperienceOutcome, ExperienceSource,
|
||||
};
|
||||
use crate::openhuman::agent_memory::memory_loader::MemoryLoader;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo,
|
||||
};
|
||||
use crate::openhuman::inference::provider::{ChatResponse, UsageInfo};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::tools::ToolResult;
|
||||
use crate::openhuman::tools::{PermissionLevel, Tool};
|
||||
@@ -21,6 +20,10 @@ use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelProfile, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::{timeout, Duration};
|
||||
@@ -28,29 +31,19 @@ use tokio::time::{timeout, Duration};
|
||||
struct DummyProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for DummyProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("unused".into())
|
||||
impl ChatModel<()> for DummyProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(ModelProfile::default);
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
text: Some("unused".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant("unused"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,25 +53,63 @@ struct SequenceProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for SequenceProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("unused".into())
|
||||
impl ChatModel<()> for SequenceProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(ModelProfile::default);
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.requests.lock().await.push(request.messages.to_vec());
|
||||
self.responses.lock().await.remove(0)
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.requests.lock().await.push(
|
||||
request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| ChatMessage {
|
||||
id: None,
|
||||
role: match message {
|
||||
Message::System(_) => "system",
|
||||
Message::User(_) => "user",
|
||||
Message::Assistant(_) => "assistant",
|
||||
// SequenceProvider replaces the old prompt-guided
|
||||
// Provider fixture. Its wire adapter flattened tool
|
||||
// results into a user turn rather than sending the
|
||||
// native `tool` role.
|
||||
Message::Tool(_) => "user",
|
||||
}
|
||||
.to_string(),
|
||||
content: match message {
|
||||
Message::Tool(_) => format!("[Tool results]\n{}", message.text()),
|
||||
_ => message.text(),
|
||||
},
|
||||
extra_metadata: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
match self.responses.lock().await.remove(0) {
|
||||
Ok(response) => Ok(
|
||||
crate::openhuman::tinyagents::model::native_model_response_for_request(
|
||||
&response, &request,
|
||||
),
|
||||
),
|
||||
Err(error) => Err(tinyagents::TinyAgentsError::Model(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream(&self, state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
// The legacy fixture implemented `chat` but did not write provider
|
||||
// deltas. Preserve that non-streaming wire behavior: the harness still
|
||||
// receives the authoritative completed response, while turn-owned
|
||||
// continuation deltas remain independently observable.
|
||||
let response = self.invoke(state, request).await?;
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
ModelStreamItem::Started,
|
||||
ModelStreamItem::Completed(response),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +351,7 @@ fn make_agent(visible_tool_names: Option<HashSet<String>>) -> Agent {
|
||||
);
|
||||
|
||||
let mut builder = Agent::builder()
|
||||
.provider(Box::new(DummyProvider))
|
||||
.chat_model(Arc::new(DummyProvider))
|
||||
.tools(vec![Box::new(EchoTool)])
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
@@ -339,7 +370,7 @@ fn make_agent(visible_tool_names: Option<HashSet<String>>) -> Agent {
|
||||
}
|
||||
|
||||
fn make_agent_with_builder(
|
||||
provider: Arc<dyn Provider>,
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
memory_loader: Box<dyn MemoryLoader>,
|
||||
post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
|
||||
@@ -358,7 +389,7 @@ fn make_agent_with_builder(
|
||||
}
|
||||
|
||||
fn make_agent_with_builder_and_dispatcher(
|
||||
provider: Arc<dyn Provider>,
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
memory_loader: Box<dyn MemoryLoader>,
|
||||
post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
|
||||
@@ -378,7 +409,7 @@ fn make_agent_with_builder_and_dispatcher(
|
||||
);
|
||||
|
||||
Agent::builder()
|
||||
.provider_arc(provider)
|
||||
.chat_model(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.memory_loader(memory_loader)
|
||||
@@ -425,7 +456,8 @@ fn trim_history_preserves_system_and_keeps_latest_non_system_entries() {
|
||||
/// `trim_history` must snap past the orphan so the window starts on a clean turn.
|
||||
#[test]
|
||||
fn trim_history_snaps_past_orphaned_tool_results() {
|
||||
use crate::openhuman::inference::provider::{ToolCall, ToolResultMessage};
|
||||
use crate::openhuman::agent::messages::ToolResultMessage;
|
||||
use crate::openhuman::inference::provider::ToolCall;
|
||||
|
||||
let mut agent = make_agent(None); // max_history_messages = 3
|
||||
agent.history = vec![
|
||||
@@ -734,7 +766,7 @@ async fn transcript_resume_uses_profile_scoped_raw_directory() {
|
||||
|
||||
#[test]
|
||||
fn system_prompt_includes_tool_policy_boundary() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(DummyProvider);
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(DummyProvider);
|
||||
let mut config = crate::openhuman::config::AgentConfig::default();
|
||||
config
|
||||
.channel_permissions
|
||||
@@ -767,7 +799,7 @@ fn system_prompt_includes_tool_policy_boundary() {
|
||||
|
||||
#[test]
|
||||
fn set_agent_definition_name_refreshes_tool_policy_identity() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(DummyProvider);
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(DummyProvider);
|
||||
let mut config = crate::openhuman::config::AgentConfig::default();
|
||||
config
|
||||
.channel_permissions
|
||||
@@ -823,7 +855,7 @@ async fn turn_runs_full_tool_cycle_with_context_and_hooks() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
let hook_calls = Arc::new(AsyncMutex::new(Vec::<TurnContext>::new()));
|
||||
let hook_notify = Arc::new(Notify::new());
|
||||
let hooks: Vec<Arc<dyn PostTurnHook>> = vec![Arc::new(RecordingHook {
|
||||
@@ -923,7 +955,7 @@ async fn turn_triggers_configured_memory_agent_before_parent_prompt() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
let memory_cfg = crate::openhuman::config::MemoryConfig {
|
||||
@@ -935,7 +967,7 @@ async fn turn_triggers_configured_memory_agent_before_parent_prompt() {
|
||||
);
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider_arc(provider)
|
||||
.chat_model(provider)
|
||||
.tools(vec![Box::new(EchoTool)])
|
||||
.memory(mem)
|
||||
.memory_loader(Box::new(FixedMemoryLoader {
|
||||
@@ -992,7 +1024,7 @@ async fn turn_uses_cached_transcript_prefix_on_first_iteration() {
|
||||
})]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
let mut agent = make_agent_with_builder(
|
||||
provider,
|
||||
vec![Box::new(EchoTool)],
|
||||
@@ -1046,7 +1078,7 @@ async fn turn_accepts_valid_required_output_without_repair() {
|
||||
})]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1105,7 +1137,7 @@ async fn turn_repairs_missing_required_output_via_reprompt() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1170,7 +1202,7 @@ async fn turn_synthesizes_required_output_when_reprompt_also_omits() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1239,7 +1271,7 @@ async fn turn_appends_required_output_block_when_streamed_to_preserve_consistenc
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1347,7 +1379,7 @@ async fn turn_appends_only_block_not_restated_answer_when_streamed() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1441,7 +1473,7 @@ async fn turn_appends_synthesized_block_when_streamed_reprompt_also_omits() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1518,7 +1550,7 @@ async fn turn_synthesizes_required_output_when_reprompt_call_fails() {
|
||||
]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
|
||||
let config = crate::openhuman::config::AgentConfig {
|
||||
max_tool_iterations: 3,
|
||||
@@ -1563,7 +1595,7 @@ async fn turn_emits_checkpoint_when_max_tool_iterations_are_exceeded() {
|
||||
// error anymore — it returns a resumable checkpoint so the thread stays
|
||||
// well-formed and the user can continue on their next message
|
||||
// (bug-report-2026-05-26 A1).
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
@@ -1629,7 +1661,7 @@ async fn turn_errors_on_empty_provider_response() {
|
||||
// answer — surface it as an error instead of accepting a blank reply,
|
||||
// which previously rendered as silence and wedged the thread
|
||||
// (bug-report-2026-05-26 A1, defect B).
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![Ok(ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![],
|
||||
@@ -1665,7 +1697,7 @@ async fn turn_checkpoint_falls_back_to_deterministic_summary_when_model_summary_
|
||||
// comes back empty. The harness must fall back to a deterministic
|
||||
// done/next summary so the turn never returns blank — the safety net
|
||||
// that guarantees the thread can't re-wedge (bug-report-2026-05-26 A1).
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
@@ -1712,7 +1744,7 @@ async fn turn_checkpoint_falls_back_to_deterministic_summary_when_model_summary_
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_checkpoint_rejects_pformat_wrapup_without_streaming_it() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
@@ -1775,7 +1807,7 @@ async fn turn_synthesizes_final_answer_when_tool_turn_yields_no_text() {
|
||||
// harness must enforce the "must produce a final response" terminal step by
|
||||
// re-prompting the model (tools disabled) for a closing summary and
|
||||
// returning that instead of a blank reply.
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
// Tool iteration (well under the cap).
|
||||
Ok(ChatResponse {
|
||||
@@ -1859,7 +1891,7 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp
|
||||
// back to a deterministic summary of the tool calls so the turn is never
|
||||
// blank — and, unlike the cap path, it must read as a completed summary
|
||||
// rather than a paused "tool-call limit" checkpoint.
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
@@ -1928,7 +1960,7 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarize_turn_wrapup_rejects_prompt_tool_call_and_preserves_usage() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
tool_calls: vec![],
|
||||
@@ -1974,7 +2006,7 @@ async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() {
|
||||
// The extra checkpoint provider call costs tokens; those must land in
|
||||
// the persisted transcript's cumulative accounting rather than being
|
||||
// silently dropped (CodeRabbit review on bug-report-2026-05-26 A1).
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![
|
||||
// Tool iteration — provider reports no usage.
|
||||
Ok(ChatResponse {
|
||||
@@ -2058,7 +2090,7 @@ fn make_agent_with_memory(
|
||||
explicit_preferences_enabled: bool,
|
||||
) -> Agent {
|
||||
Agent::builder()
|
||||
.provider(Box::new(DummyProvider))
|
||||
.chat_model(Arc::new(DummyProvider))
|
||||
.tools(vec![])
|
||||
.memory(memory)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
@@ -2108,7 +2140,7 @@ async fn dedicated_profile_experience_recall_merges_shared_legacy_store() {
|
||||
.unwrap();
|
||||
|
||||
let agent = Agent::builder()
|
||||
.provider(Box::new(DummyProvider))
|
||||
.chat_model(Arc::new(DummyProvider))
|
||||
.tools(vec![])
|
||||
.memory(dedicated)
|
||||
.shared_experience_memory(Some(shared))
|
||||
|
||||
@@ -10,13 +10,13 @@ use crate::openhuman::agent::dispatcher::ToolDispatcher;
|
||||
use crate::openhuman::agent::harness::archivist::ArchivistHook;
|
||||
use crate::openhuman::agent::harness::definition::TriggerMemoryAgent;
|
||||
use crate::openhuman::agent::hooks::PostTurnHook;
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::agent::tool_policy::ToolPolicy;
|
||||
use crate::openhuman::agent_memory::memory_loader::MemoryLoader;
|
||||
use crate::openhuman::agent_tool_policy::ToolPolicySession;
|
||||
use crate::openhuman::context::prompt::SystemPromptBuilder;
|
||||
use crate::openhuman::context::ContextManager;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::tinyagents::TurnModelSource;
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
|
||||
@@ -32,7 +32,7 @@ use super::handoff::{chunk_content, ResultHandoffCache, HANDOFF_MAX_ENTRIES};
|
||||
use crate::openhuman::agent::harness::session::transcript::{
|
||||
resolve_keyed_transcript_path, write_transcript, MessageUsage, TranscriptMeta, TurnUsage,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::tinyagents::TurnModelSource;
|
||||
use crate::openhuman::tools::{Tool, ToolCategory, ToolResult};
|
||||
use tinyagents::harness::message::Message;
|
||||
@@ -571,7 +571,7 @@ fn write_extract_transcript(
|
||||
output_tokens: 0,
|
||||
cached_input_tokens: 0,
|
||||
charged_amount_usd: 0.0,
|
||||
thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(),
|
||||
thread_id: crate::openhuman::tinyagents::thread_context::current_thread_id(),
|
||||
task_id: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ use crate::openhuman::agent::harness::agent_graph::{
|
||||
AgentTurnRequest, AgentTurnResult, AgentTurnUsage,
|
||||
};
|
||||
use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError;
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
|
||||
use crate::openhuman::tinyagents::{run_turn_via_tinyagents_shared, SubagentScope};
|
||||
use crate::openhuman::tokenjuice::AgentTokenjuiceCompression;
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
@@ -661,7 +661,7 @@ fn persist_subagent_transcript(
|
||||
output_tokens: usage.output_tokens,
|
||||
cached_input_tokens: usage.cached_input_tokens,
|
||||
charged_amount_usd: usage.charged_amount_usd,
|
||||
thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(),
|
||||
thread_id: crate::openhuman::tinyagents::thread_context::current_thread_id(),
|
||||
task_id: Some(task_id.to_string()),
|
||||
};
|
||||
if let Err(err) = transcript::write_transcript(&path, history, &meta, Some(&turn_usage)) {
|
||||
@@ -997,10 +997,42 @@ fn build_cap_digest(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::inference::provider::{ChatResponse, Provider, ToolCall};
|
||||
use crate::openhuman::tools::ToolResult;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tinyagents::harness::message::{AssistantMessage, MessageDelta};
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelProfile, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
use tinyagents::harness::tool::ToolCall;
|
||||
|
||||
fn native_tool_profile() -> &'static ModelProfile {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(|| ModelProfile {
|
||||
provider: Some("subagent-graph-test".to_string()),
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
streaming: true,
|
||||
..ModelProfile::default()
|
||||
});
|
||||
&PROFILE
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
@@ -1024,43 +1056,23 @@ mod tests {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Provider for TwoStepProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
impl ChatModel<()> for TwoStepProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
async fn chat(
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let n = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
Ok(ChatResponse {
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: r#"{"msg":"hi"}"#.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
Ok(tool_response("1", "echo", serde_json::json!({"msg": "hi"})))
|
||||
} else {
|
||||
Ok(ChatResponse {
|
||||
text: Some("all done".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
Ok(ModelResponse::assistant("all done"))
|
||||
}
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1074,7 +1086,7 @@ mod tests {
|
||||
let mut history = vec![ChatMessage::user("please echo hi")];
|
||||
|
||||
let (output, iterations, usage, early_exit, hit_cap, _breaker) = run_subagent_via_graph(
|
||||
crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(provider),
|
||||
"mock-model",
|
||||
0.0,
|
||||
&mut history,
|
||||
@@ -1115,44 +1127,36 @@ mod tests {
|
||||
/// delta sender, exercising the child-progress bridge end to end.
|
||||
struct ThinkingStreamProvider;
|
||||
#[async_trait]
|
||||
impl Provider for ThinkingStreamProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
impl ChatModel<()> for ThinkingStreamProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
async fn chat(
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
r: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
use crate::openhuman::inference::provider::ProviderDelta;
|
||||
if let Some(tx) = r.stream {
|
||||
let _ = tx
|
||||
.send(ProviderDelta::ThinkingDelta {
|
||||
delta: "let me think".into(),
|
||||
})
|
||||
.await;
|
||||
for chunk in ["Hel", "lo"] {
|
||||
let _ = tx
|
||||
.send(ProviderDelta::TextDelta {
|
||||
delta: chunk.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(ChatResponse {
|
||||
text: Some("Hello".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant("Hello"))
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelStream> {
|
||||
let response = ModelResponse::assistant("Hello");
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
ModelStreamItem::Started,
|
||||
ModelStreamItem::MessageDelta(MessageDelta {
|
||||
text: String::new(),
|
||||
reasoning: "let me think".to_string(),
|
||||
tool_call: None,
|
||||
}),
|
||||
ModelStreamItem::MessageDelta(MessageDelta::text("Hel")),
|
||||
ModelStreamItem::MessageDelta(MessageDelta::text("lo")),
|
||||
ModelStreamItem::Completed(response),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,7 +1167,9 @@ mod tests {
|
||||
let mut history = vec![ChatMessage::user("hi")];
|
||||
|
||||
let (output, _iters, _usage, _early, _hit_cap, _breaker) = run_subagent_via_graph(
|
||||
crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(ThinkingStreamProvider)),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::new(
|
||||
ThinkingStreamProvider,
|
||||
)),
|
||||
"mock-model",
|
||||
0.0,
|
||||
&mut history,
|
||||
@@ -1260,43 +1266,27 @@ mod tests {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Provider for AskThenAnswer {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
impl ChatModel<()> for AskThenAnswer {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
async fn chat(
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let n = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
Ok(ChatResponse {
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "ask-1".to_string(),
|
||||
name: "ask_user_clarification".to_string(),
|
||||
arguments: r#"{"question":"which file?"}"#.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
Ok(tool_response(
|
||||
"ask-1",
|
||||
"ask_user_clarification",
|
||||
serde_json::json!({"question": "which file?"}),
|
||||
))
|
||||
} else {
|
||||
Ok(ChatResponse {
|
||||
text: Some("should not be reached".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
Ok(ModelResponse::assistant("should not be reached"))
|
||||
}
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1310,7 +1300,7 @@ mod tests {
|
||||
let mut history = vec![ChatMessage::user("help me")];
|
||||
|
||||
let (output, iterations, _usage, early_exit, _hit_cap, _breaker) = run_subagent_via_graph(
|
||||
crate::openhuman::tinyagents::TurnModelSource::new(provider.clone()),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(provider.clone()),
|
||||
"mock-model",
|
||||
0.0,
|
||||
&mut history,
|
||||
@@ -1370,43 +1360,23 @@ mod tests {
|
||||
/// A request with no tools is the cap-hit summary call — it returns prose.
|
||||
struct LoopForeverProvider;
|
||||
#[async_trait]
|
||||
impl Provider for LoopForeverProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
impl ChatModel<()> for LoopForeverProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
async fn chat(
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
r: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
if r.tools.is_some() {
|
||||
Ok(ChatResponse {
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "n".to_string(),
|
||||
name: "noop".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
if !request.tools.is_empty() {
|
||||
Ok(tool_response("n", "noop", serde_json::json!({})))
|
||||
} else {
|
||||
// The summary call (tools=None): return a progress checkpoint.
|
||||
Ok(ChatResponse {
|
||||
text: Some("progress: explored two leads".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
Ok(ModelResponse::assistant("progress: explored two leads"))
|
||||
}
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1417,7 +1387,9 @@ mod tests {
|
||||
let mut history = vec![ChatMessage::user("do a big task")];
|
||||
|
||||
let (output, iterations, _usage, early_exit, hit_cap, _breaker) = run_subagent_via_graph(
|
||||
crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(LoopForeverProvider)),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::new(
|
||||
LoopForeverProvider,
|
||||
)),
|
||||
"mock-model",
|
||||
0.0,
|
||||
&mut history,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//!
|
||||
//! | File | Contents |
|
||||
//! | ------------------- | -------------------------------------------------------------- |
|
||||
//! | `provider.rs` | `resolve_subagent_provider`, `user_is_signed_in_to_composio`, `LazyToolkitResolver` |
|
||||
//! | `provider.rs` | `resolve_subagent_source`, `user_is_signed_in_to_composio`, `LazyToolkitResolver` |
|
||||
//! | `prompt.rs` | Role-contract suffix, `append_subagent_role_contract`, `dedup_tool_specs_by_name` |
|
||||
//! | `runner.rs` | `run_subagent`, `run_typed_mode` |
|
||||
//! | `graph.rs` | `run_subagent_via_graph` — the sub-agent turn graph + tools |
|
||||
@@ -35,12 +35,7 @@ pub use runner::run_subagent;
|
||||
// without reaching into a private sibling module.
|
||||
pub(crate) use provider::user_is_signed_in_to_composio;
|
||||
|
||||
// `resolve_subagent_provider` is called from tests via
|
||||
// `super::resolve_subagent_provider`. Keep it accessible at the ops
|
||||
// module boundary.
|
||||
pub(crate) use prompt::append_subagent_role_contract;
|
||||
#[cfg(test)]
|
||||
pub(crate) use provider::resolve_subagent_provider;
|
||||
pub(crate) use provider::resolve_subagent_source;
|
||||
|
||||
// Re-exports for test companion modules that use `use super::*`.
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
|
||||
pub(crate) fn resolve_subagent_source(
|
||||
spec: &crate::openhuman::agent::harness::definition::ModelSpec,
|
||||
agent_id: &str,
|
||||
@@ -87,118 +85,6 @@ pub(crate) fn resolve_subagent_source(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider / model resolution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a sub-agent's `(provider, model)` based on its declarative
|
||||
/// `[model]` spec.
|
||||
///
|
||||
/// - inline `model` override — highest precedence for one call.
|
||||
/// - config-level pin — `[orchestrator] model` or `[teams.*]`
|
||||
/// `lead_model` / `agent_model`, when present.
|
||||
/// - `Inherit` — use the parent's provider AND model. Literally
|
||||
/// "do what the parent does".
|
||||
/// - `Hint(workload)` — build a fresh provider via the per-workload
|
||||
/// factory (e.g. `integrations_agent`'s `[model] hint = "agentic"`
|
||||
/// resolves to whatever `agentic_provider` is routed to in
|
||||
/// AI Settings). The factory returns the *exact* model id for that
|
||||
/// workload — the OpenHuman backend and every third-party provider
|
||||
/// accept exact model names, so there's no `{hint}-v1` synthesis
|
||||
/// anywhere on this path.
|
||||
/// - `Exact(name)` — escape hatch: use the parent's provider with
|
||||
/// this model name overriding the parent's. Callers are expected
|
||||
/// to know the model is valid for the parent's provider; the enum
|
||||
/// is the wrong place to encode provider switching, which belongs
|
||||
/// to `Hint` + AI-settings routing.
|
||||
///
|
||||
/// `config` is `None` when the live `Config::load_or_init()` failed
|
||||
/// (rare — transient I/O). Both `None` config and factory build errors
|
||||
/// fall back to `(parent_provider, parent_model)` so a config glitch
|
||||
/// can't sink sub-agent execution entirely.
|
||||
///
|
||||
/// The async part (config load) is hoisted out of the caller so this
|
||||
/// helper stays sync and can be exercised by a focused unit test
|
||||
/// without spinning up a `tokio::test` runtime per case.
|
||||
pub(crate) fn resolve_subagent_provider(
|
||||
spec: &crate::openhuman::agent::harness::definition::ModelSpec,
|
||||
agent_id: &str,
|
||||
config: Option<&crate::openhuman::config::Config>,
|
||||
parent_provider: Arc<dyn Provider>,
|
||||
parent_model: String,
|
||||
is_team_lead: bool,
|
||||
model_override: Option<&str>,
|
||||
) -> (Arc<dyn Provider>, String) {
|
||||
use crate::openhuman::agent::harness::definition::ModelSpec;
|
||||
if let Some(model) = model_override
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
log::debug!(
|
||||
"[subagent_runner] agent_id={} using inline model override model={}",
|
||||
agent_id,
|
||||
model
|
||||
);
|
||||
return (parent_provider, model.to_string());
|
||||
}
|
||||
|
||||
if let Some(model) = config.and_then(|cfg| cfg.configured_agent_model(agent_id, is_team_lead)) {
|
||||
log::debug!(
|
||||
"[subagent_runner] agent_id={} using config-level model pin model={}",
|
||||
agent_id,
|
||||
model
|
||||
);
|
||||
return (parent_provider, model.to_string());
|
||||
}
|
||||
|
||||
match spec {
|
||||
ModelSpec::Hint(workload) => match config {
|
||||
Some(cfg) => {
|
||||
match crate::openhuman::inference::provider::create_chat_provider(workload, cfg) {
|
||||
Ok((p, m)) => {
|
||||
log::info!(
|
||||
"[subagent_runner] role={} agent_id={} resolved via workload factory model={}",
|
||||
workload,
|
||||
agent_id,
|
||||
m
|
||||
);
|
||||
(std::sync::Arc::from(p), m)
|
||||
}
|
||||
Err(e) => {
|
||||
let suggested_key = match workload.as_str() {
|
||||
"summarization" | "memory" => "memory_provider".to_string(),
|
||||
_ => format!("{workload}_provider"),
|
||||
};
|
||||
log::warn!(
|
||||
"[subagent_runner] workload='{}' provider build failed for agent_id={} error='{}' \
|
||||
falling back to parent provider (parent_model='{}'). \
|
||||
Consider setting {} in config.",
|
||||
workload,
|
||||
agent_id,
|
||||
e,
|
||||
parent_model,
|
||||
suggested_key
|
||||
);
|
||||
(parent_provider, parent_model)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"[subagent_runner] config load failed for workload '{}' (agent_id={}) — \
|
||||
falling back to parent provider + parent model '{}'",
|
||||
workload,
|
||||
agent_id,
|
||||
parent_model
|
||||
);
|
||||
(parent_provider, parent_model)
|
||||
}
|
||||
},
|
||||
ModelSpec::Inherit => (parent_provider, parent_model),
|
||||
ModelSpec::Exact(name) => (parent_provider, name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Composio sign-in probe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1086,7 +1086,7 @@ async fn run_typed_mode(
|
||||
if let Some(ref ctx) = options.context {
|
||||
context_parts.push(ctx);
|
||||
}
|
||||
let mut history: Vec<crate::openhuman::inference::provider::ChatMessage> =
|
||||
let mut history: Vec<crate::openhuman::agent::messages::ChatMessage> =
|
||||
if let Some(ref initial) = options.initial_history {
|
||||
tracing::info!(
|
||||
agent_id = %definition.id,
|
||||
@@ -1102,8 +1102,8 @@ async fn run_typed_mode(
|
||||
format!("[Context]\n{}\n\n{task_prompt}", context_parts.join("\n\n"))
|
||||
};
|
||||
vec![
|
||||
crate::openhuman::inference::provider::ChatMessage::system(system_prompt),
|
||||
crate::openhuman::inference::provider::ChatMessage::user(user_message),
|
||||
crate::openhuman::agent::messages::ChatMessage::system(system_prompt),
|
||||
crate::openhuman::agent::messages::ChatMessage::user(user_message),
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -200,139 +200,169 @@ fn append_subagent_role_contract_is_idempotent() {
|
||||
|
||||
use crate::openhuman::agent::harness::fork_context::with_parent_context;
|
||||
use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue};
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatRequest as PChatRequest, ChatResponse, Provider, ProviderDelta, ToolCall,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::{AssistantMessage, ContentBlock, Message, MessageDelta};
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelProfile, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
use tinyagents::harness::tool::ToolCall;
|
||||
|
||||
/// Mock provider whose response queue can be inspected by the test
|
||||
/// to verify the bytes that arrive at the model.
|
||||
#[derive(Clone)]
|
||||
struct CapturedRequest {
|
||||
messages: Vec<crate::openhuman::inference::provider::ChatMessage>,
|
||||
messages: Vec<CapturedMessage>,
|
||||
tool_count: usize,
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CapturedMessage {
|
||||
role: &'static str,
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct ScriptedProvider {
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
responses: Mutex<Vec<ModelResponse>>,
|
||||
captured: Mutex<Vec<CapturedRequest>>,
|
||||
}
|
||||
|
||||
impl ScriptedProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Arc<Self> {
|
||||
fn new(responses: Vec<ModelResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses),
|
||||
captured: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn take_response(&self, request: ModelRequest) -> ModelResponse {
|
||||
self.captured.lock().push(CapturedRequest {
|
||||
messages: request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| CapturedMessage {
|
||||
role: match message {
|
||||
Message::System(_) => "system",
|
||||
Message::User(_) => "user",
|
||||
Message::Assistant(_) => "assistant",
|
||||
Message::Tool(_) => "tool",
|
||||
},
|
||||
content: message.text(),
|
||||
})
|
||||
.collect(),
|
||||
tool_count: request.tools.len(),
|
||||
model: request.model.unwrap_or_default(),
|
||||
});
|
||||
let mut responses = self.responses.lock();
|
||||
if responses.is_empty() {
|
||||
ModelResponse::assistant("")
|
||||
} else {
|
||||
responses.remove(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ScriptedProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("noop".into())
|
||||
impl ChatModel<()> for ScriptedProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(|| ModelProfile {
|
||||
provider: Some("subagent-runner-test".to_string()),
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
streaming: true,
|
||||
..ModelProfile::default()
|
||||
});
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
request: PChatRequest<'_>,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.captured.lock().push(CapturedRequest {
|
||||
messages: request.messages.to_vec(),
|
||||
tool_count: request.tools.map_or(0, |tools| tools.len()),
|
||||
model: model.to_string(),
|
||||
});
|
||||
let response = {
|
||||
let mut q = self.responses.lock();
|
||||
if q.is_empty() {
|
||||
ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
} else {
|
||||
q.remove(0)
|
||||
}
|
||||
};
|
||||
// Mirror a real streaming provider: when the caller attached a
|
||||
// `stream` sink, forward this response's reasoning then visible
|
||||
// text as `ProviderDelta`s before returning the aggregate. Lets
|
||||
// the subagent runner's per-iteration sink exercise the
|
||||
// `SubagentThinkingDelta` / `SubagentTextDelta` forwarding path.
|
||||
if let Some(sink) = request.stream {
|
||||
if let Some(reasoning) = response.reasoning_content.as_deref() {
|
||||
if !reasoning.is_empty() {
|
||||
let _ = sink
|
||||
.send(ProviderDelta::ThinkingDelta {
|
||||
delta: reasoning.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(text) = response.text.as_deref() {
|
||||
if !text.is_empty() {
|
||||
let _ = sink
|
||||
.send(ProviderDelta::TextDelta {
|
||||
delta: text.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(self.take_response(request))
|
||||
}
|
||||
|
||||
async fn stream(&self, _state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
let response = self.take_response(request);
|
||||
let reasoning = response
|
||||
.message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Thinking { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
let text = response.text();
|
||||
let mut items = vec![ModelStreamItem::Started];
|
||||
if !reasoning.is_empty() {
|
||||
items.push(ModelStreamItem::MessageDelta(MessageDelta::reasoning(
|
||||
reasoning,
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
if !text.is_empty() {
|
||||
items.push(ModelStreamItem::MessageDelta(MessageDelta::text(text)));
|
||||
}
|
||||
items.push(ModelStreamItem::Completed(response));
|
||||
Ok(Box::pin(futures::stream::iter(items)))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(text: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: vec![],
|
||||
fn text_response(text: &str) -> ModelResponse {
|
||||
ModelResponse::assistant(text)
|
||||
}
|
||||
|
||||
fn text_response_with_reasoning(text: &str, reasoning: &str) -> ModelResponse {
|
||||
ModelResponse {
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
text: reasoning.to_string(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text(text.to_string()),
|
||||
],
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
finish_reason: None,
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response_with_reasoning(text: &str, reasoning: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: vec![],
|
||||
fn tool_response(name: &str, args: &str) -> ModelResponse {
|
||||
ModelResponse {
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content: Vec::new(),
|
||||
tool_calls: vec![ToolCall::new(
|
||||
"call-1",
|
||||
name,
|
||||
serde_json::from_str(args).expect("valid scripted tool arguments"),
|
||||
)],
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
reasoning_content: Some(reasoning.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_response(name: &str, args: &str) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "call-1".into(),
|
||||
name: name.into(),
|
||||
arguments: args.into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal `ParentExecutionContext` suitable for runner tests.
|
||||
/// Uses a no-op memory backend so we don't have to spin up a real one.
|
||||
fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> ParentExecutionContext {
|
||||
fn make_parent(
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
) -> ParentExecutionContext {
|
||||
let tool_specs: Vec<crate::openhuman::tools::ToolSpec> =
|
||||
tools.iter().map(|t| t.spec()).collect();
|
||||
ParentExecutionContext {
|
||||
@@ -341,7 +371,7 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
|
||||
allowed_subagent_ids: ["test".to_string(), "child".to_string(), "inner".to_string()]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(provider),
|
||||
all_tools: Arc::new(tools),
|
||||
all_tool_specs: Arc::new(tool_specs),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -1158,77 +1188,62 @@ async fn typed_mode_progress_emission_is_a_noop_without_sink() {
|
||||
// Truncation tests live in ops_truncation_tests.rs to keep this file
|
||||
// under the ~500-line guideline.
|
||||
|
||||
// ── resolve_subagent_provider ─────────────────────────────────────────
|
||||
|
||||
/// `Arc<dyn Provider>` identity helper — every test below uses a fresh
|
||||
/// `ScriptedProvider` and we want to assert "is this the *same* Arc as
|
||||
/// the parent's" without leaning on `PartialEq` on dyn trait objects.
|
||||
fn arc_ptr_eq<P: ?Sized>(a: &std::sync::Arc<P>, b: &std::sync::Arc<P>) -> bool {
|
||||
std::sync::Arc::ptr_eq(a, b)
|
||||
}
|
||||
// ── resolve_subagent_source ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_inherit_uses_parent_provider_and_model() {
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
fn resolve_subagent_source_inherit_uses_parent_source_and_model() {
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let parent_source = crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone());
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Inherit,
|
||||
"test_agent",
|
||||
None,
|
||||
parent.clone(),
|
||||
parent_source,
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"Inherit must return the parent's Arc unchanged"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "parent-model-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_exact_overrides_only_model() {
|
||||
fn resolve_subagent_source_exact_overrides_only_model() {
|
||||
// Exact keeps the parent's provider but replaces the model name.
|
||||
// This is the explicit "I want a cheaper tier on the same backend"
|
||||
// escape hatch.
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Exact("haiku-mini".to_string()),
|
||||
"test_agent",
|
||||
None,
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone()),
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"Exact must keep the parent's provider — only the model name changes"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "haiku-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_spawn_override_wins_over_definition_model() {
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
fn resolve_subagent_source_spawn_override_wins_over_definition_model() {
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Exact("definition-model".to_string()),
|
||||
"test_agent",
|
||||
None,
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone()),
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
Some("spawn-model-y"),
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"inline spawn override should not change the provider"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "spawn-model-y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_config_model_wins_over_definition_model() {
|
||||
fn resolve_subagent_source_config_model_wins_over_definition_model() {
|
||||
use crate::openhuman::config::{Config, TeamModelConfig};
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -1240,25 +1255,22 @@ fn resolve_subagent_provider_config_model_wins_over_definition_model() {
|
||||
},
|
||||
);
|
||||
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Exact("definition-model".to_string()),
|
||||
"test_agent",
|
||||
Some(&config),
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone()),
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"config model pin should not change the provider"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "configured-agent-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_inline_override_wins_over_config_model() {
|
||||
fn resolve_subagent_source_inline_override_wins_over_config_model() {
|
||||
use crate::openhuman::config::{Config, TeamModelConfig};
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -1270,21 +1282,22 @@ fn resolve_subagent_provider_inline_override_wins_over_config_model() {
|
||||
},
|
||||
);
|
||||
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Exact("definition-model".to_string()),
|
||||
"test_agent",
|
||||
Some(&config),
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent),
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
Some("inline-model"),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "inline-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_config_alias_matches_issue_team_examples() {
|
||||
fn resolve_subagent_source_config_alias_matches_issue_team_examples() {
|
||||
use crate::openhuman::config::{Config, TeamModelConfig};
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -1296,39 +1309,37 @@ fn resolve_subagent_provider_config_alias_matches_issue_team_examples() {
|
||||
},
|
||||
);
|
||||
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Hint("agentic".to_string()),
|
||||
"researcher",
|
||||
Some(&config),
|
||||
parent,
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent),
|
||||
"parent-model-x".to_string(),
|
||||
false,
|
||||
None,
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "research-agent-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_hint_with_no_config_falls_back() {
|
||||
fn resolve_subagent_source_hint_with_no_config_falls_back() {
|
||||
// The async config load failed (transient I/O, missing file, etc.).
|
||||
// The Hint arm must NOT silently swallow the failure and synthesise
|
||||
// `{workload}-v1` — that's the OpenHuman-only naming that breaks
|
||||
// Anthropic/OpenAI. Fall back to the parent's known-good
|
||||
// (provider, model) instead.
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Hint("agentic".to_string()),
|
||||
"test_agent",
|
||||
None, // no config loaded
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone()),
|
||||
"real-claude-id".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"config-load failure must fall back to parent provider, not synthesize a new one"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_model, "real-claude-id",
|
||||
@@ -1337,7 +1348,7 @@ fn resolve_subagent_provider_hint_with_no_config_falls_back() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_hint_with_config_routes_via_factory() {
|
||||
fn resolve_subagent_source_hint_with_config_routes_via_factory() {
|
||||
// The Hint arm with a real config takes the workload-factory path.
|
||||
// We don't assert the *resulting* provider identity here (the
|
||||
// factory may return a fresh OpenHuman backend or whatever
|
||||
@@ -1358,15 +1369,16 @@ fn resolve_subagent_provider_hint_with_config_routes_via_factory() {
|
||||
config.agentic_provider = Some("openhuman".to_string());
|
||||
config.default_model = Some("chat-v1".to_string());
|
||||
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Hint("agentic".to_string()),
|
||||
"test_agent",
|
||||
Some(&config),
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent),
|
||||
"parent-model-ignored-on-hint".to_string(),
|
||||
false,
|
||||
None,
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_model, "agentic-v1",
|
||||
@@ -1376,7 +1388,7 @@ fn resolve_subagent_provider_hint_with_config_routes_via_factory() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_subagent_provider_hint_falls_back_on_factory_error() {
|
||||
fn resolve_subagent_source_hint_falls_back_on_factory_error() {
|
||||
// An invalid provider string in the workload config (e.g. a typo
|
||||
// like "groq:something") makes the factory return Err. The Hint
|
||||
// arm must fall back to the parent provider rather than
|
||||
@@ -1386,19 +1398,16 @@ fn resolve_subagent_provider_hint_falls_back_on_factory_error() {
|
||||
let mut config = Config::default();
|
||||
config.agentic_provider = Some("groq:not-a-real-prefix".to_string());
|
||||
|
||||
let parent: Arc<dyn Provider> = ScriptedProvider::new(vec![]);
|
||||
let (resolved_provider, resolved_model) = super::resolve_subagent_provider(
|
||||
let parent: Arc<dyn ChatModel<()>> = ScriptedProvider::new(vec![]);
|
||||
let (_resolved_source, resolved_model) = super::resolve_subagent_source(
|
||||
&ModelSpec::Hint("agentic".to_string()),
|
||||
"test_agent",
|
||||
Some(&config),
|
||||
parent.clone(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(parent.clone()),
|
||||
"fallback-model".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
arc_ptr_eq(&parent, &resolved_provider),
|
||||
"factory error must fall back to parent provider"
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(resolved_model, "fallback-model");
|
||||
}
|
||||
@@ -1514,7 +1523,7 @@ fn nested_subagent_dispatch_runs_on_a_constrained_worker_stack() {
|
||||
let inner_def = make_def_named_tools(&[]);
|
||||
let delegate_tool: Box<dyn Tool> = Box::new(RecursiveDelegateTool { inner_def });
|
||||
let parent = make_parent(
|
||||
Arc::clone(&(provider.clone() as Arc<dyn Provider>)),
|
||||
Arc::clone(&(provider.clone() as Arc<dyn ChatModel<()>>)),
|
||||
vec![delegate_tool],
|
||||
);
|
||||
let outer_def = make_def_named_tools(&["delegate_inner"]);
|
||||
|
||||
@@ -9,7 +9,7 @@ use thiserror::Error;
|
||||
use tinyagents::harness::workspace::WorkspaceDescriptor;
|
||||
|
||||
use crate::openhuman::agent::harness::definition::AgentTier;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
/// Per-spawn options that override or augment what the
|
||||
/// [`AgentDefinition`] specifies. Built by `SpawnSubagentTool::execute`
|
||||
|
||||
@@ -4,11 +4,7 @@ use super::parse::{
|
||||
extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_tool_call_value,
|
||||
parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format,
|
||||
};
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::tools;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
@@ -30,74 +26,6 @@ fn test_scrub_credentials_json() {
|
||||
assert!(scrubbed.contains("public"));
|
||||
}
|
||||
|
||||
struct NonVisionProvider {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NonVisionProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct VisionProvider {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for VisionProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: false,
|
||||
vision: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let marker_count =
|
||||
crate::openhuman::agent::multimodal::count_image_markers(request.messages);
|
||||
if marker_count == 0 {
|
||||
anyhow::bail!("expected image markers in request messages");
|
||||
}
|
||||
|
||||
if request.tools.is_some() {
|
||||
anyhow::bail!("no tools should be attached for this test");
|
||||
}
|
||||
|
||||
Ok(ChatResponse {
|
||||
text: Some("vision-ok".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_extracts_single_call() {
|
||||
let response = r#"Let me check that.
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
//! Persistence-boundary conversions between OpenHuman transcript records and
|
||||
//! TinyAgents' rich [`Message`]/[`TaToolCall`] types.
|
||||
//!
|
||||
//! The two sides model the same concepts with different shapes:
|
||||
//!
|
||||
//! - openhuman `ChatMessage` is `{ role: String, content: String }` — tool
|
||||
//! calls and tool-result correlation ids are not first-class fields; the
|
||||
//! legacy loop threads them through provider-native encoding instead.
|
||||
//! - `tinyagents::harness::message::Message` is a typed enum
|
||||
//! (`System`/`User`/`Assistant`/`Tool`) whose `Assistant` arm carries
|
||||
//! structured `tool_calls` and whose `Tool` arm carries a `tool_call_id`.
|
||||
//!
|
||||
//! These helpers bridge the seed history into the harness and the harness'
|
||||
//! resulting transcript back out, so a turn can run on the `tinyagents`
|
||||
//! agent-loop while callers keep speaking openhuman's `ChatMessage` vocabulary.
|
||||
|
||||
use tinyagents::harness::message::{
|
||||
AssistantMessage, ContentBlock, Message, SystemMessage, ToolMessage, UserMessage,
|
||||
};
|
||||
use tinyagents::harness::tool::ToolCall as TaToolCall;
|
||||
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage, ToolResultMessage};
|
||||
|
||||
/// Key under which a thinking model's `reasoning_content` is echoed through
|
||||
/// openhuman [`ChatMessage::extra_metadata`]. New harness transcripts carry
|
||||
/// reasoning as [`ContentBlock::Thinking`]; legacy persisted transcripts may
|
||||
/// still have the same key inside [`ContentBlock::ProviderExtension`].
|
||||
pub(crate) const REASONING_EXT_KEY: &str = "reasoning_content";
|
||||
|
||||
/// Build the [`ContentBlock`] that carries a response's `reasoning_content` on
|
||||
/// an assistant message, if any.
|
||||
pub(crate) fn reasoning_content_block(reasoning: Option<&str>) -> Option<ContentBlock> {
|
||||
let reasoning = reasoning?;
|
||||
// Store verbatim (only gate on non-empty after a trim): thinking-mode
|
||||
// providers validate the prior reasoning block byte-for-byte on a resumed
|
||||
// multi-turn request, so trimming boundary whitespace could break replay.
|
||||
(!reasoning.trim().is_empty()).then(|| ContentBlock::Thinking {
|
||||
text: reasoning.to_string(),
|
||||
signature: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recover `reasoning_content` from an assistant message's content blocks.
|
||||
pub(crate) fn reasoning_from_content(content: &[ContentBlock]) -> Option<String> {
|
||||
content.iter().find_map(|block| match block {
|
||||
ContentBlock::Thinking { text, .. } => Some(text.clone()),
|
||||
ContentBlock::ProviderExtension(value) => value
|
||||
.get(REASONING_EXT_KEY)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `extra_metadata` an assistant [`ChatMessage`] should carry so
|
||||
/// `reasoning_content` replays on the next provider request.
|
||||
fn reasoning_extra_metadata(content: &[ContentBlock]) -> Option<serde_json::Value> {
|
||||
reasoning_from_content(content)
|
||||
.map(|reasoning| serde_json::json!({ REASONING_EXT_KEY: reasoning }))
|
||||
}
|
||||
|
||||
/// Convert one openhuman [`ChatMessage`] into a harness [`Message`].
|
||||
///
|
||||
/// Role strings map onto the typed arms. A seeded **native** tool round is
|
||||
/// serialized by [`NativeToolDispatcher::to_provider_messages`] as a
|
||||
/// `{ "content", "tool_calls" }` assistant envelope followed by
|
||||
/// `{ "tool_call_id", "content" }` tool envelopes; we unwrap those back into the
|
||||
/// structured [`AssistantMessage::tool_calls`] / [`ToolMessage::tool_call_id`]
|
||||
/// the harness needs. Without this, the seeded assistant loses its tool calls
|
||||
/// while the following tool rows survive, so the harness re-sends orphan `tool`
|
||||
/// messages and native providers reject the request (`assistant message with
|
||||
/// 'tool_calls' must be followed by tool messages`). A plain assistant/tool
|
||||
/// message that isn't an envelope maps straight through as text.
|
||||
pub(crate) fn chat_message_to_message(msg: &ChatMessage) -> Message {
|
||||
let text = msg.content.clone();
|
||||
match msg.role.as_str() {
|
||||
"system" => Message::System(SystemMessage {
|
||||
content: vec![ContentBlock::Text(text)],
|
||||
}),
|
||||
"assistant" => {
|
||||
// Restore any `reasoning_content` stashed on the persisted message so a
|
||||
// multi-turn thinking-mode conversation replays it verbatim (see
|
||||
// [`reasoning_content_block`]).
|
||||
let reasoning = msg
|
||||
.extra_metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(REASONING_EXT_KEY))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
if let Some((inner, tool_calls)) = parse_native_assistant_envelope(&text) {
|
||||
let mut content = vec![ContentBlock::Text(inner)];
|
||||
content.extend(reasoning_content_block(reasoning));
|
||||
Message::Assistant(AssistantMessage {
|
||||
id: msg.id.clone(),
|
||||
content,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
})
|
||||
} else {
|
||||
let mut content = vec![ContentBlock::Text(text)];
|
||||
content.extend(reasoning_content_block(reasoning));
|
||||
Message::Assistant(AssistantMessage {
|
||||
id: msg.id.clone(),
|
||||
content,
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
// Prefer the envelope's `tool_call_id` (the native seed shape); fall
|
||||
// back to the message id, then an empty id for a bare tool message.
|
||||
let (tool_call_id, content) = parse_native_tool_envelope(&text)
|
||||
.unwrap_or_else(|| (msg.id.clone().unwrap_or_default(), text.clone()));
|
||||
Message::Tool(ToolMessage {
|
||||
tool_call_id,
|
||||
content: vec![ContentBlock::Text(content)],
|
||||
})
|
||||
}
|
||||
// "user" and any unrecognized role default to a user turn — the safest
|
||||
// mapping for a free-form inbound message.
|
||||
_ => Message::User(UserMessage {
|
||||
content: vec![ContentBlock::Text(text)],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a native assistant tool-call envelope (`{ "content", "tool_calls" }`, as
|
||||
/// [`NativeToolDispatcher::to_provider_messages`] emits) back into its inner
|
||||
/// visible text and structured [`TaToolCall`]s. Returns `None` when `text` is not
|
||||
/// such an envelope (plain assistant prose), so the caller can fall back to text.
|
||||
fn parse_native_assistant_envelope(text: &str) -> Option<(String, Vec<TaToolCall>)> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let obj = value.as_object()?;
|
||||
let calls_val = obj.get("tool_calls")?;
|
||||
// Require a non-empty, parseable tool-call array so ordinary JSON-looking
|
||||
// assistant prose isn't misread as a tool round.
|
||||
if calls_val.as_array().is_none_or(|a| a.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let oh_calls: Vec<crate::openhuman::inference::provider::ToolCall> =
|
||||
serde_json::from_value(calls_val.clone()).ok()?;
|
||||
if oh_calls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let inner = obj
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Some((inner, oh_calls.iter().map(oh_call_to_ta_call).collect()))
|
||||
}
|
||||
|
||||
/// Parse a native tool-result envelope (`{ "tool_call_id", "content" }`) back into
|
||||
/// its correlation id and payload. Returns `None` for a bare tool message.
|
||||
fn parse_native_tool_envelope(text: &str) -> Option<(String, String)> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let obj = value.as_object()?;
|
||||
let id = obj.get("tool_call_id")?.as_str()?.to_string();
|
||||
let content = obj
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Some((id, content))
|
||||
}
|
||||
|
||||
/// Inverse of [`ta_call_to_oh_call`]: rebuild a harness [`TaToolCall`] from an
|
||||
/// openhuman [`ToolCall`] (whose `arguments` is a serialized JSON string).
|
||||
fn oh_call_to_ta_call(oh: &crate::openhuman::inference::provider::ToolCall) -> TaToolCall {
|
||||
TaToolCall {
|
||||
id: oh.id.clone(),
|
||||
name: oh.name.clone(),
|
||||
arguments: serde_json::from_str(&oh.arguments).unwrap_or(serde_json::Value::Null),
|
||||
invalid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a seed history into the harness `input` transcript.
|
||||
pub(crate) fn history_to_messages(history: &[ChatMessage]) -> Vec<Message> {
|
||||
history.iter().map(chat_message_to_message).collect()
|
||||
}
|
||||
|
||||
/// Convert a harness [`Message`] back into an openhuman [`ChatMessage`].
|
||||
///
|
||||
/// Assistant tool calls are flattened to their text (the loop already executed
|
||||
/// them and appended `Tool` result messages), and a tool message preserves its
|
||||
/// correlation id on [`ChatMessage::id`] so downstream persistence keeps it.
|
||||
pub(crate) fn message_to_chat_message(msg: &Message) -> ChatMessage {
|
||||
match msg {
|
||||
Message::System(_) => ChatMessage::system(msg.text()),
|
||||
Message::User(_) => ChatMessage::user(msg.text()),
|
||||
Message::Assistant(a) => {
|
||||
let mut cm = ChatMessage::assistant(msg.text());
|
||||
cm.extra_metadata = reasoning_extra_metadata(&a.content);
|
||||
cm
|
||||
}
|
||||
Message::Tool(t) => {
|
||||
let mut cm = ChatMessage::tool(msg.text());
|
||||
cm.id = Some(t.tool_call_id.clone());
|
||||
cm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a harness transcript back into openhuman history.
|
||||
pub(crate) fn messages_to_history(messages: &[Message]) -> Vec<ChatMessage> {
|
||||
messages.iter().map(message_to_chat_message).collect()
|
||||
}
|
||||
|
||||
/// Convert one harness [`Message`] into a [`ChatMessage`] for a **native**
|
||||
/// tool-calling provider request, preserving the structure the provider needs to
|
||||
/// round-trip a tool round: an assistant turn that made tool calls is encoded as
|
||||
/// the `{ "content", "tool_calls" }` JSON envelope (matching the dispatcher's
|
||||
/// native `to_provider_messages`), and a tool result as `{ "tool_call_id",
|
||||
/// "content" }`. Without this the provider sees an assistant with no `tool_calls`
|
||||
/// followed by an orphan tool message and drops the round — breaking multi-turn
|
||||
/// native tool calling (e.g. the orchestrator's `spawn_parallel_agents` →
|
||||
/// synthesis hop).
|
||||
pub(crate) fn message_to_native_chat_message(msg: &Message) -> ChatMessage {
|
||||
match msg {
|
||||
Message::System(_) => ChatMessage::system(msg.text()),
|
||||
Message::User(_) => ChatMessage::user(msg.text()),
|
||||
Message::Assistant(a) if !a.tool_calls.is_empty() => {
|
||||
let tool_calls: Vec<_> = a.tool_calls.iter().map(ta_call_to_oh_call).collect();
|
||||
let payload = serde_json::json!({
|
||||
"content": msg.text(),
|
||||
"tool_calls": tool_calls,
|
||||
});
|
||||
let mut cm = ChatMessage::assistant(payload.to_string());
|
||||
cm.extra_metadata = reasoning_extra_metadata(&a.content);
|
||||
cm
|
||||
}
|
||||
Message::Assistant(a) => {
|
||||
let mut cm = ChatMessage::assistant(msg.text());
|
||||
cm.extra_metadata = reasoning_extra_metadata(&a.content);
|
||||
cm
|
||||
}
|
||||
Message::Tool(t) => {
|
||||
let payload = serde_json::json!({
|
||||
"tool_call_id": t.tool_call_id,
|
||||
"content": msg.text(),
|
||||
});
|
||||
let mut cm = ChatMessage::tool(payload.to_string());
|
||||
cm.id = Some(t.tool_call_id.clone());
|
||||
cm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a harness transcript into the **typed** [`ConversationMessage`] shape
|
||||
/// the chat session persists, preserving assistant tool-call structure
|
||||
/// (`AssistantToolCalls`) and tool results (`ToolResults`) — unlike
|
||||
/// [`messages_to_history`], which flattens tool calls to text.
|
||||
///
|
||||
/// Consecutive `Tool` messages are coalesced into one `ToolResults` batch (the
|
||||
/// shape a single assistant tool-call round produces), matching the legacy
|
||||
/// `turn_engine_adapter` persistence.
|
||||
pub(crate) fn messages_to_conversation(messages: &[Message]) -> Vec<ConversationMessage> {
|
||||
let mut out: Vec<ConversationMessage> = Vec::new();
|
||||
let mut pending: Vec<ToolResultMessage> = Vec::new();
|
||||
|
||||
fn flush(out: &mut Vec<ConversationMessage>, pending: &mut Vec<ToolResultMessage>) {
|
||||
if !pending.is_empty() {
|
||||
out.push(ConversationMessage::ToolResults(std::mem::take(pending)));
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::Tool(t) => {
|
||||
pending.push(ToolResultMessage {
|
||||
tool_call_id: t.tool_call_id.clone(),
|
||||
content: msg.text(),
|
||||
});
|
||||
}
|
||||
Message::System(_) => {
|
||||
flush(&mut out, &mut pending);
|
||||
out.push(ConversationMessage::Chat(ChatMessage::system(msg.text())));
|
||||
}
|
||||
Message::User(_) => {
|
||||
flush(&mut out, &mut pending);
|
||||
out.push(ConversationMessage::Chat(ChatMessage::user(msg.text())));
|
||||
}
|
||||
Message::Assistant(a) => {
|
||||
flush(&mut out, &mut pending);
|
||||
if a.tool_calls.is_empty() {
|
||||
let mut chat = ChatMessage::assistant(msg.text());
|
||||
chat.extra_metadata = reasoning_extra_metadata(&a.content);
|
||||
out.push(ConversationMessage::Chat(chat));
|
||||
} else {
|
||||
let text = msg.text();
|
||||
out.push(ConversationMessage::AssistantToolCalls {
|
||||
text: (!text.is_empty()).then_some(text),
|
||||
tool_calls: a.tool_calls.iter().map(ta_call_to_oh_call).collect(),
|
||||
reasoning_content: reasoning_from_content(&a.content),
|
||||
extra_metadata: reasoning_extra_metadata(&a.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut out, &mut pending);
|
||||
out
|
||||
}
|
||||
|
||||
/// The suffix of `messages` produced *after* the most recent user turn — i.e.
|
||||
/// the assistant/tool messages a single turn appended. Robust to front-trimming
|
||||
/// middleware (which drops old messages but keeps the current user turn).
|
||||
///
|
||||
/// Retired from the persistence path in favour of [`messages_since_request`]
|
||||
/// (issue #4455) because an injected mid-turn steer moves the last-user boundary
|
||||
/// and truncates persisted history; kept only as a documented, test-covered
|
||||
/// reference to the legacy convention. `allow(dead_code)` off the test build
|
||||
/// since it now has no non-test caller.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn messages_since_last_user(messages: &[Message]) -> &[Message] {
|
||||
let start = messages
|
||||
.iter()
|
||||
.rposition(|m| matches!(m, Message::User(_)))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
&messages[start..]
|
||||
}
|
||||
|
||||
/// The transcript suffix appended during a single turn, sliced at an **explicit
|
||||
/// boundary** captured *before* the run — `base_len` is the length of the
|
||||
/// request's `input` transcript (`history_to_messages(&history).len()`).
|
||||
///
|
||||
/// This replaces the fragile "suffix after the last `Message::User`" convention
|
||||
/// ([`messages_since_last_user`]) on the persistence path. Mid-turn steer/collect
|
||||
/// messages are injected as `Message::user(...)` (`forward_steers` /
|
||||
/// `forward_collects`), which *moves* the last-user boundary — so slicing on it
|
||||
/// silently dropped every pre-steer assistant/tool round **and** the steer text
|
||||
/// itself from persisted history, the next-turn KV-cache prefix, and subagent
|
||||
/// checkpoints (issue #4455). Anchoring on the pre-run request length instead
|
||||
/// captures the full post-request transcript, injected steers included, in
|
||||
/// execution order.
|
||||
///
|
||||
/// The crate returns the full transcript in `run.messages`: the agent loop seeds
|
||||
/// its working transcript from `input` (`messages = input`) and only ever
|
||||
/// *appends* (assistant/tool rounds + applied steers); the compression/trim
|
||||
/// middleware rewrites the per-call `request.messages.clone()`, never the loop's
|
||||
/// working transcript. So `messages` always starts with the `base_len` request
|
||||
/// messages as a prefix. `base_len` is clamped defensively in case a future
|
||||
/// crate change ever front-trims the persisted transcript.
|
||||
pub(crate) fn messages_since_request(messages: &[Message], base_len: usize) -> &[Message] {
|
||||
let start = base_len.min(messages.len());
|
||||
if start != base_len {
|
||||
tracing::warn!(
|
||||
base_len,
|
||||
transcript_len = messages.len(),
|
||||
"[tinyagents] messages_since_request boundary exceeds transcript length; \
|
||||
clamping (transcript may have been front-trimmed) — persisting full transcript"
|
||||
);
|
||||
}
|
||||
&messages[start..]
|
||||
}
|
||||
|
||||
/// Convert a harness transcript into openhuman [`ChatMessage`]s for a provider
|
||||
/// that does **not** support native tool calls (text/prompt-guided mode).
|
||||
///
|
||||
/// Consecutive `Tool` result messages are coalesced into a single
|
||||
/// `[Tool results]` user turn — the shape prompt-guided models are taught to
|
||||
/// read — instead of native `tool`-role messages they wouldn't understand.
|
||||
/// Other messages convert as usual (assistant tool calls already rode the
|
||||
/// visible text in this mode).
|
||||
pub(crate) fn messages_to_text_mode_chat(messages: &[Message]) -> Vec<ChatMessage> {
|
||||
let mut out: Vec<ChatMessage> = Vec::new();
|
||||
let mut pending: Vec<String> = Vec::new();
|
||||
|
||||
fn flush(out: &mut Vec<ChatMessage>, pending: &mut Vec<String>) {
|
||||
if !pending.is_empty() {
|
||||
out.push(ChatMessage::user(format!(
|
||||
"[Tool results]\n{}",
|
||||
std::mem::take(pending).join("\n")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::Tool(_) => pending.push(msg.text()),
|
||||
_ => {
|
||||
flush(&mut out, &mut pending);
|
||||
out.push(message_to_chat_message(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut out, &mut pending);
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a harness [`TaToolCall`] into an openhuman [`ToolCall`].
|
||||
///
|
||||
/// The harness models arguments as parsed JSON; openhuman carries them as the
|
||||
/// raw JSON string the provider emitted, so we re-serialize.
|
||||
pub(crate) fn ta_call_to_oh_call(
|
||||
call: &TaToolCall,
|
||||
) -> crate::openhuman::inference::provider::ToolCall {
|
||||
crate::openhuman::inference::provider::ToolCall {
|
||||
id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
arguments: call.arguments.to_string(),
|
||||
extra_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seeded_native_tool_round_recovers_structure_and_round_trips() {
|
||||
use crate::openhuman::inference::provider::ToolCall as OhToolCall;
|
||||
// The native dispatcher seeds an assistant tool round as a
|
||||
// {content, tool_calls} envelope followed by {tool_call_id, content} rows.
|
||||
let oh_call = OhToolCall {
|
||||
id: "call-1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"msg":"hi"}"#.into(),
|
||||
extra_content: None,
|
||||
};
|
||||
let assistant_cm = ChatMessage::assistant(
|
||||
serde_json::json!({ "content": "calling echo", "tool_calls": [oh_call] }).to_string(),
|
||||
);
|
||||
let tool_cm = ChatMessage::tool(
|
||||
serde_json::json!({ "tool_call_id": "call-1", "content": "echoed:hi" }).to_string(),
|
||||
);
|
||||
|
||||
// Inbound: the envelopes are recovered into structured harness messages.
|
||||
let a = chat_message_to_message(&assistant_cm);
|
||||
let Message::Assistant(am) = &a else {
|
||||
panic!("expected Assistant, got {a:?}");
|
||||
};
|
||||
assert_eq!(am.tool_calls.len(), 1);
|
||||
assert_eq!(am.tool_calls[0].id, "call-1");
|
||||
assert_eq!(am.tool_calls[0].name, "echo");
|
||||
assert_eq!(
|
||||
am.tool_calls[0].arguments,
|
||||
serde_json::json!({ "msg": "hi" })
|
||||
);
|
||||
assert_eq!(a.text(), "calling echo");
|
||||
|
||||
let t = chat_message_to_message(&tool_cm);
|
||||
let Message::Tool(tm) = &t else {
|
||||
panic!("expected Tool, got {t:?}");
|
||||
};
|
||||
assert_eq!(tm.tool_call_id, "call-1");
|
||||
assert_eq!(t.text(), "echoed:hi");
|
||||
|
||||
// Outbound: re-serialized to a well-formed native tool round (assistant
|
||||
// carries structured tool_calls, the tool row carries the matching id).
|
||||
let a_native = message_to_native_chat_message(&a);
|
||||
assert_eq!(a_native.role, "assistant");
|
||||
let av: serde_json::Value = serde_json::from_str(&a_native.content).unwrap();
|
||||
assert_eq!(av["tool_calls"][0]["id"], "call-1");
|
||||
assert_eq!(av["content"], "calling echo");
|
||||
|
||||
let t_native = message_to_native_chat_message(&t);
|
||||
assert_eq!(t_native.role, "tool");
|
||||
let tv: serde_json::Value = serde_json::from_str(&t_native.content).unwrap();
|
||||
assert_eq!(tv["tool_call_id"], "call-1");
|
||||
assert_eq!(tv["content"], "echoed:hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_assistant_prose_is_not_misread_as_a_tool_round() {
|
||||
let a = chat_message_to_message(&ChatMessage::assistant("just a normal reply"));
|
||||
let Message::Assistant(am) = &a else {
|
||||
panic!("expected Assistant, got {a:?}");
|
||||
};
|
||||
assert!(am.tool_calls.is_empty());
|
||||
assert_eq!(a.text(), "just a normal reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_content_uses_typed_thinking_block_and_round_trips_metadata() {
|
||||
let mut chat = ChatMessage::assistant("visible answer");
|
||||
chat.extra_metadata = Some(serde_json::json!({ REASONING_EXT_KEY: "private thoughts" }));
|
||||
|
||||
let msg = chat_message_to_message(&chat);
|
||||
let Message::Assistant(assistant) = &msg else {
|
||||
panic!("expected Assistant, got {msg:?}");
|
||||
};
|
||||
assert_eq!(msg.text(), "visible answer");
|
||||
assert!(assistant.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Thinking { text, signature: None } if text == "private thoughts"
|
||||
)
|
||||
}));
|
||||
assert!(!assistant
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::ProviderExtension(_))));
|
||||
|
||||
let back = message_to_chat_message(&msg);
|
||||
assert_eq!(back.content, "visible answer");
|
||||
assert_eq!(
|
||||
back.extra_metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(REASONING_EXT_KEY))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("private thoughts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_provider_extension_reasoning_still_round_trips() {
|
||||
let msg = Message::Assistant(AssistantMessage {
|
||||
id: None,
|
||||
content: vec![
|
||||
ContentBlock::Text("visible answer".into()),
|
||||
ContentBlock::ProviderExtension(
|
||||
serde_json::json!({ REASONING_EXT_KEY: "legacy thoughts" }),
|
||||
),
|
||||
],
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
});
|
||||
|
||||
let back = message_to_chat_message(&msg);
|
||||
assert_eq!(back.content, "visible answer");
|
||||
assert_eq!(
|
||||
back.extra_metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(REASONING_EXT_KEY))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("legacy thoughts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_round_trip_through_the_bridge() {
|
||||
let history = vec![
|
||||
ChatMessage::system("you are helpful"),
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
];
|
||||
let messages = history_to_messages(&history);
|
||||
assert!(matches!(messages[0], Message::System(_)));
|
||||
assert!(matches!(messages[1], Message::User(_)));
|
||||
assert!(matches!(messages[2], Message::Assistant(_)));
|
||||
|
||||
let back = messages_to_history(&messages);
|
||||
assert_eq!(back.len(), 3);
|
||||
assert_eq!(back[0].role, "system");
|
||||
assert_eq!(back[1].content, "hello");
|
||||
assert_eq!(back[2].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_message_preserves_correlation_id() {
|
||||
let messages = vec![Message::Tool(ToolMessage {
|
||||
tool_call_id: "call-7".into(),
|
||||
content: vec![ContentBlock::Text("done".into())],
|
||||
})];
|
||||
let back = messages_to_history(&messages);
|
||||
assert_eq!(back[0].role, "tool");
|
||||
assert_eq!(back[0].content, "done");
|
||||
assert_eq!(back[0].id.as_deref(), Some("call-7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_preserves_tool_call_structure() {
|
||||
let messages = vec![
|
||||
Message::User(UserMessage {
|
||||
content: vec![ContentBlock::Text("do it".into())],
|
||||
}),
|
||||
Message::Assistant(AssistantMessage {
|
||||
id: None,
|
||||
content: vec![ContentBlock::Text("calling".into())],
|
||||
tool_calls: vec![TaToolCall {
|
||||
id: "c1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: serde_json::json!({"msg": "hi"}),
|
||||
invalid: None,
|
||||
}],
|
||||
usage: None,
|
||||
}),
|
||||
Message::Tool(ToolMessage {
|
||||
tool_call_id: "c1".into(),
|
||||
content: vec![ContentBlock::Text("echoed:hi".into())],
|
||||
}),
|
||||
Message::Assistant(AssistantMessage {
|
||||
id: None,
|
||||
content: vec![ContentBlock::Text("all done".into())],
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
}),
|
||||
];
|
||||
|
||||
// Only the suffix after the last user turn is persisted.
|
||||
let suffix = messages_since_last_user(&messages);
|
||||
let convo = messages_to_conversation(suffix);
|
||||
assert_eq!(convo.len(), 3);
|
||||
match &convo[0] {
|
||||
ConversationMessage::AssistantToolCalls { tool_calls, .. } => {
|
||||
assert_eq!(tool_calls[0].name, "echo");
|
||||
assert_eq!(tool_calls[0].id, "c1");
|
||||
}
|
||||
other => panic!("expected AssistantToolCalls, got {other:?}"),
|
||||
}
|
||||
match &convo[1] {
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
assert_eq!(results[0].tool_call_id, "c1");
|
||||
assert_eq!(results[0].content, "echoed:hi");
|
||||
}
|
||||
other => panic!("expected ToolResults, got {other:?}"),
|
||||
}
|
||||
match &convo[2] {
|
||||
ConversationMessage::Chat(c) => {
|
||||
assert_eq!(c.role, "assistant");
|
||||
assert_eq!(c.content, "all done");
|
||||
}
|
||||
other => panic!("expected Chat, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_convert() {
|
||||
let ta = TaToolCall {
|
||||
id: "c1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: serde_json::json!({"msg": "hi"}),
|
||||
invalid: None,
|
||||
};
|
||||
let oh = ta_call_to_oh_call(&ta);
|
||||
assert_eq!(oh.id, "c1");
|
||||
assert_eq!(oh.name, "echo");
|
||||
assert_eq!(oh.arguments, r#"{"msg":"hi"}"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Durable OpenHuman transcript records.
|
||||
//!
|
||||
//! Runtime model calls use [`tinyagents::harness::message::Message`]. These
|
||||
//! compact records preserve the stable JSONL/thread storage contract used by
|
||||
//! existing installations and carry OpenHuman-only message metadata.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::openhuman::inference::provider::ToolCall;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
#[serde(default, skip_serializing)]
|
||||
pub id: Option<String>,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub extra_metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
role: "system".into(),
|
||||
content: content.into(),
|
||||
extra_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
role: "user".into(),
|
||||
content: content.into(),
|
||||
extra_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: content.into(),
|
||||
extra_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
role: "tool".into(),
|
||||
content: content.into(),
|
||||
extra_metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResultMessage {
|
||||
pub tool_call_id: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum ConversationMessage {
|
||||
Chat(ChatMessage),
|
||||
AssistantToolCalls {
|
||||
text: Option<String>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
extra_metadata: Option<serde_json::Value>,
|
||||
},
|
||||
ToolResults(Vec<ToolResultMessage>),
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pub mod harness;
|
||||
pub mod hooks;
|
||||
pub mod host_runtime;
|
||||
pub mod library;
|
||||
pub(crate) mod message_convert;
|
||||
pub mod messages;
|
||||
pub mod multimodal;
|
||||
pub mod pformat;
|
||||
/// Cross-platform shell selection shared by [`host_runtime::NativeRuntime`]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::config::{
|
||||
build_runtime_proxy_client_with_timeouts, MultimodalConfig, MultimodalFileConfig,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -232,11 +232,8 @@ pub(super) async fn run_autonomous(
|
||||
);
|
||||
let result = match session_thread_id.as_deref() {
|
||||
Some(thread_id) => {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
thread_id.to_string(),
|
||||
run,
|
||||
)
|
||||
.await
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(thread_id.to_string(), run)
|
||||
.await
|
||||
}
|
||||
None => run.await,
|
||||
}
|
||||
|
||||
@@ -28,17 +28,16 @@ use crate::openhuman::agent::dispatcher::{
|
||||
NativeToolDispatcher, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
|
||||
};
|
||||
use crate::openhuman::agent::harness::session::Agent;
|
||||
use crate::openhuman::agent::messages::{ChatMessage, ConversationMessage, ToolResultMessage};
|
||||
use crate::openhuman::config::{AgentConfig, MemoryConfig};
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall,
|
||||
ToolResultMessage,
|
||||
};
|
||||
use crate::openhuman::inference::provider::{ChatResponse, ToolCall};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::memory_store;
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test Helpers — Mock Provider, Mock Tool, Mock Memory
|
||||
@@ -48,56 +47,50 @@ use std::sync::{Arc, Mutex};
|
||||
/// When the queue is exhausted it returns a simple "done" text response.
|
||||
struct ScriptedProvider {
|
||||
responses: Mutex<Vec<ChatResponse>>,
|
||||
/// Records every request for assertion.
|
||||
requests: Mutex<Vec<Vec<ChatMessage>>>,
|
||||
}
|
||||
|
||||
impl ScriptedProvider {
|
||||
fn new(responses: Vec<ChatResponse>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(responses),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_count(&self) -> usize {
|
||||
self.requests.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ScriptedProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("fallback".into())
|
||||
impl ChatModel<()> for ScriptedProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(|| ModelProfile {
|
||||
provider: Some("agent-test".to_string()),
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
..ModelProfile::default()
|
||||
});
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
async fn invoke(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.messages.to_vec());
|
||||
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let mut guard = self.responses.lock().unwrap();
|
||||
if guard.is_empty() {
|
||||
return Ok(ChatResponse {
|
||||
let response = if guard.is_empty() {
|
||||
ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
} else {
|
||||
guard.remove(0)
|
||||
};
|
||||
Ok(
|
||||
crate::openhuman::tinyagents::model::native_model_response_for_request(
|
||||
&response, &request,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,24 +98,15 @@ impl Provider for ScriptedProvider {
|
||||
struct FailingProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for FailingProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for FailingProvider {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
anyhow::bail!("provider error")
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<ChatResponse> {
|
||||
anyhow::bail!("provider error")
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Err(tinyagents::TinyAgentsError::Model(
|
||||
"provider error".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +250,13 @@ fn make_sqlite_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
|
||||
/// Build an agent with an isolated temp workspace.
|
||||
/// Returns `(Agent, TempDir)` — hold `_tmp` in the test to keep the dir alive.
|
||||
fn build_agent_with(
|
||||
provider: Box<dyn Provider>,
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
dispatcher: Box<dyn ToolDispatcher>,
|
||||
) -> (Agent, tempfile::TempDir) {
|
||||
let (mem, tmp) = make_memory();
|
||||
let agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(dispatcher)
|
||||
@@ -283,14 +267,14 @@ fn build_agent_with(
|
||||
}
|
||||
|
||||
fn build_agent_with_memory(
|
||||
provider: Box<dyn Provider>,
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
mem: Arc<dyn Memory>,
|
||||
auto_save: bool,
|
||||
) -> (Agent, tempfile::TempDir) {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -302,13 +286,13 @@ fn build_agent_with_memory(
|
||||
}
|
||||
|
||||
fn build_agent_with_config(
|
||||
provider: Box<dyn Provider>,
|
||||
provider: Arc<dyn ChatModel<()>>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
config: AgentConfig,
|
||||
) -> (Agent, tempfile::TempDir) {
|
||||
let (mem, tmp) = make_memory();
|
||||
let agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.chat_model(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
@@ -357,7 +341,7 @@ fn xml_tool_response(name: &str, args: &str) -> ChatResponse {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_returns_text_when_no_tools_called() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response("Hello world")]));
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response("Hello world")]));
|
||||
let (mut agent, _tmp) = build_agent_with(
|
||||
provider,
|
||||
vec![Box::new(EchoTool)],
|
||||
@@ -377,7 +361,7 @@ async fn turn_returns_text_when_no_tools_called() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_executes_single_tool_then_returns() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
@@ -408,7 +392,7 @@ async fn turn_executes_single_tool_then_returns() {
|
||||
async fn turn_handles_multi_step_tool_chain() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "counter".into(),
|
||||
@@ -471,7 +455,7 @@ async fn turn_emits_checkpoint_at_max_iterations() {
|
||||
}]));
|
||||
}
|
||||
|
||||
let provider = Box::new(ScriptedProvider::new(responses));
|
||||
let provider = Arc::new(ScriptedProvider::new(responses));
|
||||
|
||||
let config = AgentConfig {
|
||||
max_tool_iterations: max_iters,
|
||||
@@ -507,7 +491,7 @@ async fn turn_emits_checkpoint_at_max_iterations() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_handles_unknown_tool_gracefully() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "nonexistent_tool".into(),
|
||||
@@ -551,7 +535,7 @@ async fn turn_handles_unknown_tool_gracefully() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_recovers_from_tool_failure() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "fail".into(),
|
||||
@@ -576,7 +560,7 @@ async fn turn_recovers_from_tool_failure() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_recovers_from_tool_error() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "panicker".into(),
|
||||
@@ -606,7 +590,7 @@ async fn turn_recovers_from_tool_error() {
|
||||
#[tokio::test]
|
||||
async fn turn_propagates_provider_error() {
|
||||
let (mut agent, _tmp) = build_agent_with(
|
||||
Box::new(FailingProvider),
|
||||
Arc::new(FailingProvider),
|
||||
vec![],
|
||||
Box::new(NativeToolDispatcher),
|
||||
);
|
||||
@@ -627,7 +611,7 @@ async fn history_trims_after_max_messages() {
|
||||
responses.push(text_response("ok"));
|
||||
}
|
||||
|
||||
let provider = Box::new(ScriptedProvider::new(responses));
|
||||
let provider = Arc::new(ScriptedProvider::new(responses));
|
||||
let config = AgentConfig {
|
||||
max_history_messages: max_history,
|
||||
..AgentConfig::default()
|
||||
@@ -660,7 +644,7 @@ async fn history_trims_after_max_messages() {
|
||||
#[tokio::test]
|
||||
async fn auto_save_stores_messages_in_memory() {
|
||||
let (mem, _tmp) = make_sqlite_memory();
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response(
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response(
|
||||
"I remember everything",
|
||||
)]));
|
||||
|
||||
@@ -695,7 +679,7 @@ async fn auto_save_stores_messages_in_memory() {
|
||||
#[tokio::test]
|
||||
async fn auto_save_disabled_does_not_store() {
|
||||
let (mem, _tmp) = make_sqlite_memory();
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response("hello")]));
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response("hello")]));
|
||||
|
||||
let (mut agent, _tmp2) = build_agent_with_memory(
|
||||
provider,
|
||||
@@ -716,7 +700,7 @@ async fn auto_save_disabled_does_not_store() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn xml_dispatcher_parses_and_loops() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
xml_tool_response("echo", r#"{"message": "xml-test"}"#),
|
||||
text_response("XML tool completed"),
|
||||
]));
|
||||
@@ -736,7 +720,7 @@ async fn xml_dispatcher_parses_and_loops() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_dispatcher_sends_tool_specs() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response("ok")]));
|
||||
let (mut agent, _tmp) = build_agent_with(
|
||||
provider,
|
||||
vec![Box::new(EchoTool)],
|
||||
@@ -766,7 +750,7 @@ async fn turn_errors_on_empty_text_response() {
|
||||
// answer. The old behaviour returned `Ok("")`, which rendered as a blank
|
||||
// reply and silently wedged the thread; now it surfaces as a visible
|
||||
// error the user can retry on (bug-report-2026-05-26 A1).
|
||||
let provider = Box::new(ScriptedProvider::new(vec![ChatResponse {
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
@@ -787,7 +771,7 @@ async fn turn_errors_on_empty_text_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_errors_on_none_text_response() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![ChatResponse {
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![ChatResponse {
|
||||
text: None,
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
@@ -812,7 +796,7 @@ async fn turn_errors_on_none_text_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_preserves_text_alongside_tool_calls() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
ChatResponse {
|
||||
text: Some("Let me check...".into()),
|
||||
tool_calls: vec![ToolCall {
|
||||
@@ -861,7 +845,7 @@ async fn turn_preserves_text_alongside_tool_calls() {
|
||||
async fn turn_handles_multiple_tools_in_one_response() {
|
||||
let (counting_tool, count) = CountingTool::new();
|
||||
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![
|
||||
ToolCall {
|
||||
id: "tc1".into(),
|
||||
@@ -905,7 +889,7 @@ async fn turn_handles_multiple_tools_in_one_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_native_loop_executes_text_fallback_tool_calls_and_persists_history() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
ChatResponse {
|
||||
text: Some(
|
||||
"I'll inspect now.\n<invoke>{\"name\":\"echo\",\"arguments\":{\"message\":\"from-fallback\"}}</invoke>"
|
||||
@@ -964,7 +948,7 @@ async fn e2e_native_loop_executes_text_fallback_tool_calls_and_persists_history(
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_prompt_injected_on_first_turn() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response("ok")]));
|
||||
let (mut agent, _tmp) = build_agent_with(
|
||||
provider,
|
||||
vec![Box::new(EchoTool)],
|
||||
@@ -985,7 +969,7 @@ async fn system_prompt_injected_on_first_turn() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_prompt_not_duplicated_on_second_turn() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
text_response("first"),
|
||||
text_response("second"),
|
||||
]));
|
||||
@@ -1012,7 +996,7 @@ async fn system_prompt_not_duplicated_on_second_turn() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_contains_all_expected_entries_after_tool_loop() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
@@ -1078,7 +1062,7 @@ async fn builder_fails_without_provider() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_turn_maintains_growing_history() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
text_response("response 1"),
|
||||
text_response("response 2"),
|
||||
text_response("response 3"),
|
||||
@@ -1468,7 +1452,7 @@ fn native_dispatcher_prompt_instructions_are_protocol_only_not_tool_catalog() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_history_resets_conversation() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![
|
||||
text_response("first"),
|
||||
text_response("second"),
|
||||
]));
|
||||
@@ -1495,7 +1479,7 @@ async fn clear_history_resets_conversation() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_single_delegates_to_turn() {
|
||||
let provider = Box::new(ScriptedProvider::new(vec![text_response("via run_single")]));
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![text_response("via run_single")]));
|
||||
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
|
||||
|
||||
let response = agent.run_single("test").await.unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::openhuman::config::DelegateAgentConfig;
|
||||
use crate::openhuman::inference::provider::{
|
||||
OpenHumanBackendModel, OpenHumanBackendProvider, ProviderRuntimeOptions, INFERENCE_BACKEND_ID,
|
||||
OpenHumanBackendModel, ProviderRuntimeOptions, INFERENCE_BACKEND_ID,
|
||||
};
|
||||
use crate::openhuman::security::policy::ToolOperation;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
@@ -180,7 +180,8 @@ impl Tool for DelegateTool {
|
||||
}
|
||||
|
||||
let model = OpenHumanBackendModel::new(
|
||||
OpenHumanBackendProvider::new(None, &self.provider_runtime_options),
|
||||
None,
|
||||
&self.provider_runtime_options,
|
||||
agent_config.model.clone(),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! rendering so transcripts read cleanly.
|
||||
|
||||
use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard, TaskCardStatus};
|
||||
use crate::openhuman::inference::provider::thread_context;
|
||||
use crate::openhuman::tinyagents::thread_context;
|
||||
use crate::openhuman::todos::ops::{self, BoardLocation, CardPatch};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -38,12 +38,12 @@ use crate::core::event_bus::{request_native_global, NativeRequestError};
|
||||
use crate::openhuman::agent::bus::{AgentTurnRequest, AgentTurnResponse, AGENT_RUN_TURN_METHOD};
|
||||
use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
|
||||
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::config::MultimodalConfig;
|
||||
use crate::openhuman::inference::provider::reliable::{
|
||||
use crate::openhuman::inference::provider::error_classify::{
|
||||
is_rate_limited, is_upstream_unhealthy, parse_retry_after_ms,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::scheduler_gate::LlmPermit;
|
||||
|
||||
use super::decision::{parse_triage_decision, ParseError, TriageDecision};
|
||||
|
||||
@@ -2,8 +2,6 @@ use super::*;
|
||||
use crate::openhuman::agent::bus::{mock_agent_run_turn, AgentTurnResponse};
|
||||
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent_registry::agents::BUILTINS;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc as StdArc;
|
||||
@@ -235,27 +233,15 @@ fn classify_string_does_not_misclassify_unrelated_security_phrases() {
|
||||
// first; falling through to local arm uses a different
|
||||
// `provider_name` we inspect to disambiguate.
|
||||
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
anyhow::bail!("NoopProvider should never be called — bus mock short-circuits")
|
||||
}
|
||||
fn unused_model_source() -> crate::openhuman::tinyagents::TurnModelSource {
|
||||
let model: StdArc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
StdArc::new(tinyagents::harness::testkit::ScriptedModel::new(Vec::new()));
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(model)
|
||||
}
|
||||
|
||||
fn cloud_arm() -> ResolvedProvider {
|
||||
ResolvedProvider {
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(StdArc::new(
|
||||
NoopProvider,
|
||||
)
|
||||
as StdArc<dyn Provider>),
|
||||
turn_model_source: unused_model_source(),
|
||||
provider_name: "stub-cloud".to_string(),
|
||||
model: "stub-cloud-model".to_string(),
|
||||
used_local: false,
|
||||
@@ -264,10 +250,7 @@ fn cloud_arm() -> ResolvedProvider {
|
||||
|
||||
fn local_arm() -> ResolvedProvider {
|
||||
ResolvedProvider {
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(StdArc::new(
|
||||
NoopProvider,
|
||||
)
|
||||
as StdArc<dyn Provider>),
|
||||
turn_model_source: unused_model_source(),
|
||||
provider_name: "stub-local".to_string(),
|
||||
model: "stub-local-model".to_string(),
|
||||
used_local: true,
|
||||
|
||||
@@ -2099,25 +2099,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
struct CountingProvider {
|
||||
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::openhuman::inference::provider::Provider for CountingProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok("{\"label\":\"Policy Test\",\"headline\":\"Done\",\"key_points\":[],\"action_items\":[]}"
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
@@ -2197,12 +2178,12 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _env = EnvGuard::set_workspace(tmp.path());
|
||||
|
||||
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let model = std::sync::Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(
|
||||
vec!["{\"label\":\"Policy Test\",\"headline\":\"Done\",\"key_points\":[],\"action_items\":[]}"],
|
||||
));
|
||||
let _provider =
|
||||
crate::openhuman::inference::provider::factory::test_provider_override::install(
|
||||
std::sync::Arc::new(CountingProvider {
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
crate::openhuman::inference::provider::factory::test_provider_override::install_model(
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let turns = vec![BackendMeetTurn {
|
||||
@@ -2222,7 +2203,7 @@ mod tests {
|
||||
|
||||
assert!(!thread_id.is_empty());
|
||||
assert_eq!(
|
||||
calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
model.requests().len(),
|
||||
0,
|
||||
"UseProvidedOnly must not call the summarization provider"
|
||||
);
|
||||
|
||||
@@ -434,31 +434,10 @@ mod tests {
|
||||
assert!(err.contains("no usable turns"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
/// Scripted provider that returns a fixed reply, so the full
|
||||
/// generate → parse → map path can be exercised without any network.
|
||||
struct ScriptedProvider {
|
||||
reply: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::openhuman::inference::provider::Provider for ScriptedProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_meeting_summary_parses_and_maps_provider_reply() {
|
||||
// Inject a scripted provider via the factory test override so
|
||||
// `create_chat_provider` hands back our mock instead of resolving a
|
||||
// real provider — the call stays network-free. The guard clears the
|
||||
// override on drop.
|
||||
// Inject a scripted crate model via the factory test override so the
|
||||
// call stays network-free. The guard clears the override on drop.
|
||||
let reply = "Here you go:\n```json\n{\"label\":\"Q3 Roadmap\",\
|
||||
\"headline\":\"Agreed to ship Friday.\",\
|
||||
\"key_points\":[\"Ship Friday\",\"QA owns sign-off\"],\
|
||||
@@ -467,10 +446,10 @@ mod tests {
|
||||
{\"description\":\"Book retro\",\"kind\":\"advisory\",\
|
||||
\"tool_name\":null,\"assignee\":null}]}\n```";
|
||||
let _guard =
|
||||
crate::openhuman::inference::provider::factory::test_provider_override::install(
|
||||
std::sync::Arc::new(ScriptedProvider {
|
||||
reply: reply.to_string(),
|
||||
}),
|
||||
crate::openhuman::inference::provider::factory::test_provider_override::install_model(
|
||||
std::sync::Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(vec![
|
||||
reply,
|
||||
])),
|
||||
);
|
||||
|
||||
let turns = vec![
|
||||
|
||||
@@ -364,7 +364,7 @@ impl MemoryLoader for DefaultMemoryLoader {
|
||||
// Suppressed when the profile opts out of agent-conversation recall.
|
||||
if self.include_agent_conversations {
|
||||
let current_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
let cross_hits: Vec<(String, String)> = if let Some(workspace_dir) = &self.workspace_dir
|
||||
{
|
||||
let store = ConversationStore::new(workspace_dir.clone());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Live-runtime unit tests (#3374 PR4).
|
||||
//!
|
||||
//! The worker-driving core ([`super::drive_member`]) is exercised directly with
|
||||
//! a mock `Provider` installed via [`with_parent_context`] (mirroring
|
||||
//! a mock `ChatModel` installed via [`with_parent_context`] (mirroring
|
||||
//! `workflow_runs::engine_tests`), so a teammate runs deterministically without
|
||||
//! touching the network. [`super::start_member_run`]'s pre-spawn outcome routing
|
||||
//! (blocked / already-claimed / no-claimable / unknown) is tested through the
|
||||
@@ -19,14 +19,13 @@ use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
|
||||
use crate::openhuman::config::{AgentConfig, Config};
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::session_db::run_ledger::{
|
||||
self, AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTaskStatus,
|
||||
AgentTeamTaskUpsert, AgentTeamUpsert,
|
||||
};
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
|
||||
// ── Mocks (mirror workflow_runs::engine_tests) ──────────────────────────────
|
||||
|
||||
@@ -81,58 +80,39 @@ impl Memory for NoopMemory {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(text: impl Into<String>) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
fn text_response(text: impl Into<String>) -> ModelResponse {
|
||||
ModelResponse::assistant(text)
|
||||
}
|
||||
|
||||
/// Mock provider that answers every child with a fixed completion, or fails.
|
||||
/// Mock model that answers every child with a fixed completion, or fails.
|
||||
#[derive(Clone)]
|
||||
struct CannedProvider {
|
||||
struct CannedModel {
|
||||
output: String,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CannedProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for CannedModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_s: Option<&str>,
|
||||
_m: &str,
|
||||
_model: &str,
|
||||
_t: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
if self.fail {
|
||||
return Err(anyhow::anyhow!("mock provider forced failure"));
|
||||
return Err(tinyagents::TinyAgentsError::Model(
|
||||
"mock model forced failure".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(text_response(self.output.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_parent(provider: Arc<dyn Provider>) -> ParentExecutionContext {
|
||||
fn mock_parent(model: Arc<dyn ChatModel<()>>) -> ParentExecutionContext {
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "agent_team_runtime".to_string(),
|
||||
allowed_subagent_ids: HashSet::new(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
|
||||
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -259,7 +239,7 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let provider = Arc::new(CannedProvider {
|
||||
let provider = Arc::new(CannedModel {
|
||||
output: "did the thing".into(),
|
||||
fail: false,
|
||||
});
|
||||
@@ -326,7 +306,7 @@ async fn run_member_loop_drives_member_under_ambient_parent() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let provider = Arc::new(CannedProvider {
|
||||
let provider = Arc::new(CannedModel {
|
||||
output: "did the thing".into(),
|
||||
fail: false,
|
||||
});
|
||||
@@ -382,7 +362,7 @@ async fn drive_member_releases_task_when_worker_fails() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let provider = Arc::new(CannedProvider {
|
||||
let provider = Arc::new(CannedModel {
|
||||
output: String::new(),
|
||||
fail: true,
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@ use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
|
||||
use crate::openhuman::config::AgentConfig;
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
use async_trait::async_trait;
|
||||
@@ -13,6 +11,7 @@ use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -74,12 +73,19 @@ impl Memory for NoopMemory {
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
|
||||
fn parent_context(model: Arc<dyn ChatModel<()>>) -> ParentExecutionContext {
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "orchestrator".to_string(),
|
||||
allowed_subagent_ids: ["researcher".to_string()].into_iter().collect(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model_with_profile(
|
||||
model,
|
||||
ModelProfile {
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
..ModelProfile::default()
|
||||
},
|
||||
),
|
||||
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
|
||||
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -102,13 +108,8 @@ fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(text: impl Into<String>) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
fn text_response(text: impl Into<String>) -> ModelResponse {
|
||||
ModelResponse::assistant(text)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -117,45 +118,27 @@ struct ConversationState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CodingQuestionProvider {
|
||||
struct CodingQuestionModel {
|
||||
state: Arc<ConversationState>,
|
||||
}
|
||||
|
||||
impl CodingQuestionProvider {
|
||||
impl CodingQuestionModel {
|
||||
fn prompts(&self) -> Vec<String> {
|
||||
self.state.prompts.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CodingQuestionProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for CodingQuestionModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let flattened = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.map(|message| message.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.state.prompts.lock().push(flattened.clone());
|
||||
@@ -172,7 +155,7 @@ impl Provider for CodingQuestionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of parallel sub-agents the parallel-coding test spawns. The provider's
|
||||
/// Number of parallel sub-agents the parallel-coding test spawns. The model's
|
||||
/// synchronization barrier is sized to this so the peak-concurrency assertion is
|
||||
/// deterministic regardless of scheduler/load.
|
||||
const PARALLEL_CHILDREN: usize = 3;
|
||||
@@ -185,7 +168,7 @@ struct ParallelState {
|
||||
/// Rendezvous point: every child parks here (yielding its worker thread)
|
||||
/// until all `PARALLEL_CHILDREN` are concurrently inside `chat`, so
|
||||
/// `max_active` deterministically reaches the peak instead of depending on
|
||||
/// whether the brief provider calls happen to overlap in wall-clock time.
|
||||
/// whether the brief model calls happen to overlap in wall-clock time.
|
||||
gate: tokio::sync::Barrier,
|
||||
}
|
||||
|
||||
@@ -202,11 +185,11 @@ impl Default for ParallelState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ParallelCodingProvider {
|
||||
struct ParallelCodingModel {
|
||||
state: Arc<ParallelState>,
|
||||
}
|
||||
|
||||
impl ParallelCodingProvider {
|
||||
impl ParallelCodingModel {
|
||||
fn calls(&self) -> usize {
|
||||
self.state.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
@@ -236,30 +219,12 @@ impl ParallelCodingProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ParallelCodingProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for ParallelCodingModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.state.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let current = self.state.active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.record_peak(current);
|
||||
@@ -272,7 +237,7 @@ impl Provider for ParallelCodingProvider {
|
||||
let flattened = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.map(|message| message.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.state.prompts.lock().push(flattened.clone());
|
||||
@@ -313,7 +278,7 @@ async fn unit_message_agent_rejects_empty_parent_reply() {
|
||||
#[tokio::test]
|
||||
async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let provider = CodingQuestionProvider::default();
|
||||
let provider = CodingQuestionModel::default();
|
||||
let parent = parent_context(Arc::new(provider.clone()));
|
||||
let session = AgentOrchestrationSession::new("orchestrator-session");
|
||||
|
||||
@@ -332,7 +297,7 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
|
||||
|
||||
// These waits spawn a *real* builtin (`code_executor`) sub-agent on the
|
||||
// detached executor, which builds the full agent (prompt assembly, tool
|
||||
// resolution, registry) before the mock provider returns — ~2.7s per child.
|
||||
// resolution, registry) before the mock model returns — ~2.7s per child.
|
||||
// The wait budget must clear that with CI headroom; a tight 2s expires first
|
||||
// and reports the child as `Running`.
|
||||
let first_wait = session
|
||||
@@ -403,14 +368,14 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
|
||||
|
||||
// Multi-thread runtime: this test asserts the three detached sub-agents run
|
||||
// *concurrently* (`max_active >= 2`). Each child does a CPU-bound builtin-agent
|
||||
// build before its (mock) provider call; on a single-threaded runtime those
|
||||
// builds serialize, so the brief provider calls never overlap and the peak-
|
||||
// build before its (mock) model call; on a single-threaded runtime those
|
||||
// builds serialize, so the brief model calls never overlap and the peak-
|
||||
// concurrency assertion flakes under load. Real worker threads let the builds —
|
||||
// and therefore the provider calls — actually overlap.
|
||||
// and therefore the model calls — actually overlap.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn e2e_orchestrator_waits_for_multiple_parallel_coding_subagents() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let provider = ParallelCodingProvider::default();
|
||||
let provider = ParallelCodingModel::default();
|
||||
let parent = parent_context(Arc::new(provider.clone()));
|
||||
let session = AgentOrchestrationSession::new("parallel-orchestrator-session");
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! the running loop and no way to collect the result inline. This registry
|
||||
//! closes both gaps.
|
||||
//!
|
||||
//! Each running async sub-agent registers, keyed by its `task_id`, with:
|
||||
//! Each running async sub-agent registers in TinyAgents'
|
||||
//! [`DetachedTaskRegistry`], keyed by its `task_id`, with:
|
||||
//! - an `Arc<RunQueue>` — the same steering channel the steering forwarder in
|
||||
//! `run_turn_via_tinyagents_shared` drains mid-turn, so `steer_subagent` can
|
||||
//! inject a message when no crate-native steering handle is registered;
|
||||
@@ -17,11 +18,10 @@
|
||||
//! - an `AbortHandle` — used by `subagent_cancel`/`close_subagent` paths to stop
|
||||
//! detached work.
|
||||
//!
|
||||
//! Ownership is enforced: only the spawning parent (matched by `parent_session`)
|
||||
//! may steer or wait on a given sub-agent. Terminal entries are pruned on `wait`,
|
||||
//! and swept on `register` only once the table passes a soft cap, so it can't
|
||||
//! grow unbounded if a parent never waits (the Codex "spawn-slot leak" failure
|
||||
//! mode — openai/codex#18335).
|
||||
//! TinyAgents owns the process-local watch/cancel/abort/steering mechanics.
|
||||
//! OpenHuman retains product metadata, durable task-store projection, and the
|
||||
//! legacy `RunQueue` steering fallback. Ownership is enforced by parent session;
|
||||
//! terminal entries are pruned on `wait` and swept at the registry soft cap.
|
||||
//!
|
||||
//! ## Typed lifecycle ledger (issue #4249)
|
||||
//!
|
||||
@@ -32,8 +32,7 @@
|
||||
//! (`Pending` → `Running`) and spawns a watcher that mirrors the child's
|
||||
//! terminal status into the store (`Completed`/`Failed`/`Awaiting`); the cancel
|
||||
//! paths record `Cancelled`. This gives a typed, queryable lifecycle
|
||||
//! (`task_records`) without disturbing the watch/abort/steer machinery the store
|
||||
//! does not cover.
|
||||
//! (`task_records`) alongside the crate-owned runtime registry.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -45,7 +44,8 @@ use tokio::task::AbortHandle;
|
||||
|
||||
use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue};
|
||||
use crate::openhuman::tinyagents::orchestration::{
|
||||
shared_steering_registry, InMemoryTaskStore, JsonlTaskStore, OrchestrationTaskFilter,
|
||||
shared_steering_registry, DetachedTaskRegistry, DetachedTaskRegistryError,
|
||||
DetachedTaskWaitOutcome, InMemoryTaskStore, JsonlTaskStore, OrchestrationTaskFilter,
|
||||
OrchestrationTaskKind, OrchestrationTaskRecord, OrchestrationTaskResult, OrchestrationTaskSpec,
|
||||
OrchestrationTaskStatus, SteeringCommand, SteeringCommandKind, TaskStore,
|
||||
};
|
||||
@@ -565,9 +565,9 @@ impl SubagentStatus {
|
||||
}
|
||||
}
|
||||
|
||||
struct RunningSubagentEntry {
|
||||
#[derive(Clone)]
|
||||
struct RunningSubagentMetadata {
|
||||
agent_id: String,
|
||||
parent_session: String,
|
||||
subagent_session_id: Option<String>,
|
||||
workspace_dir: PathBuf,
|
||||
/// Parent chat thread that spawned this sub-agent, captured at registration.
|
||||
@@ -575,16 +575,6 @@ struct RunningSubagentEntry {
|
||||
/// sub-agent when its parent thread is deleted (see [`cancel_for_thread`]).
|
||||
parent_thread_id: Option<String>,
|
||||
run_queue: Arc<RunQueue>,
|
||||
abort: AbortHandle,
|
||||
/// Cooperative-cancellation handle held **alongside** the hard-kill
|
||||
/// [`AbortHandle`] (issue #4249 / 07.2 step 2). The cancel/kill paths flip
|
||||
/// this token *before* aborting so a run that has opted into cooperative
|
||||
/// cancellation (a crate `CancellationToken` threaded into its `RunContext`)
|
||||
/// can unwind cleanly at its next safe checkpoint; the abort remains the
|
||||
/// executor-detail hard stop for runs that have not. Latching + cheap to
|
||||
/// clone, so cancelling it is always safe/idempotent.
|
||||
cancel: CancellationToken,
|
||||
status: watch::Receiver<SubagentStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -603,10 +593,17 @@ const REGISTRY_SOFT_CAP: usize = 256;
|
||||
/// existing detached task and wait-tool paths.
|
||||
const DETACHED_LEDGER_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<String, RunningSubagentEntry>>> = OnceLock::new();
|
||||
static REGISTRY: OnceLock<DetachedTaskRegistry<RunningSubagentMetadata, SubagentStatus>> =
|
||||
OnceLock::new();
|
||||
|
||||
fn registry() -> &'static Mutex<HashMap<String, RunningSubagentEntry>> {
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
fn registry() -> &'static DetachedTaskRegistry<RunningSubagentMetadata, SubagentStatus> {
|
||||
REGISTRY.get_or_init(|| {
|
||||
DetachedTaskRegistry::new(
|
||||
shared_steering_registry().clone(),
|
||||
REGISTRY_SOFT_CAP,
|
||||
SubagentStatus::is_terminal,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create the status channel a spawner threads into [`register`].
|
||||
@@ -654,40 +651,32 @@ pub(crate) fn register(
|
||||
);
|
||||
spawn_status_watcher(task_id.clone(), workspace_dir.clone(), status.clone());
|
||||
|
||||
let entry = RunningSubagentEntry {
|
||||
let metadata = RunningSubagentMetadata {
|
||||
agent_id,
|
||||
parent_session,
|
||||
subagent_session_id,
|
||||
workspace_dir,
|
||||
parent_thread_id,
|
||||
run_queue,
|
||||
abort,
|
||||
// Fresh cooperative-cancel token registered alongside the abort handle.
|
||||
// Threading it into the child run's `RunContext` (so cooperative cancel
|
||||
// reaches the executor loop) is part of the gated executor shrink; today
|
||||
// it establishes the cancel channel + terminal store write on the cancel
|
||||
// paths without disturbing abort-handle hard-kill.
|
||||
cancel: CancellationToken::new(),
|
||||
status,
|
||||
};
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
if map.len() >= REGISTRY_SOFT_CAP {
|
||||
// Only under genuine pressure: sweep collected/terminal entries so the
|
||||
// table can't grow without bound when a parent never waits (the Codex
|
||||
// spawn-slot leak). Live (Running) entries are always retained.
|
||||
map.retain(|task_id, e| {
|
||||
let keep = !e.status.borrow().is_terminal();
|
||||
if !keep {
|
||||
deregister_steering(task_id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
map.insert(task_id.clone(), entry);
|
||||
registry()
|
||||
.register(
|
||||
TaskId::new(task_id.clone()),
|
||||
parent_session,
|
||||
metadata,
|
||||
status,
|
||||
// Cooperative cancellation is flipped before the registry invokes
|
||||
// the hard abort. The child executor can adopt this token without
|
||||
// changing the registry/control API.
|
||||
CancellationToken::new(),
|
||||
abort,
|
||||
)
|
||||
.expect("duplicate detached sub-agent task id");
|
||||
log::debug!(
|
||||
"[running_subagents] registered task_id={} live_entries={}",
|
||||
task_id,
|
||||
map.len()
|
||||
registry()
|
||||
.len()
|
||||
.expect("detached task registry lock poisoned")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -741,21 +730,21 @@ pub(crate) struct SubagentSnapshot {
|
||||
/// never mutates the table. Ordered by `agent_id` then `task_id` so the rendered
|
||||
/// roster is stable across turns (the underlying map is unordered).
|
||||
pub(crate) fn snapshot_for_parent(parent_session: &str) -> Vec<SubagentSnapshot> {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let mut out: Vec<SubagentSnapshot> = map
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.parent_session == parent_session)
|
||||
.map(|(task_id, entry)| {
|
||||
let status = match &*entry.status.borrow() {
|
||||
let mut out: Vec<SubagentSnapshot> = registry()
|
||||
.snapshots(Some(parent_session))
|
||||
.expect("detached task registry lock poisoned")
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let status = match &entry.status {
|
||||
SubagentStatus::Running => "running",
|
||||
SubagentStatus::Completed { .. } => "completed",
|
||||
SubagentStatus::AwaitingUser { .. } => "awaiting_user",
|
||||
SubagentStatus::Failed { .. } => "failed",
|
||||
};
|
||||
SubagentSnapshot {
|
||||
agent_id: entry.agent_id.clone(),
|
||||
subagent_session_id: entry.subagent_session_id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
agent_id: entry.metadata.agent_id,
|
||||
subagent_session_id: entry.metadata.subagent_session_id,
|
||||
task_id: entry.task_id.as_str().to_string(),
|
||||
status,
|
||||
}
|
||||
})
|
||||
@@ -871,21 +860,25 @@ pub(crate) fn task_id_for_session(
|
||||
subagent_session_id: &str,
|
||||
parent_session: &str,
|
||||
) -> Result<String, WaitError> {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let mut saw_unowned = false;
|
||||
let mut owned_terminal: Option<String> = None;
|
||||
for (task_id, entry) in map
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.subagent_session_id.as_deref() == Some(subagent_session_id))
|
||||
for snapshot in registry()
|
||||
.snapshots(None)
|
||||
.expect("detached task registry lock poisoned")
|
||||
.into_iter()
|
||||
.filter(|snapshot| {
|
||||
snapshot.metadata.subagent_session_id.as_deref() == Some(subagent_session_id)
|
||||
})
|
||||
{
|
||||
if entry.parent_session != parent_session {
|
||||
if snapshot.owner_id != parent_session {
|
||||
saw_unowned = true;
|
||||
continue;
|
||||
}
|
||||
if !entry.status.borrow().is_terminal() {
|
||||
return Ok(task_id.clone());
|
||||
let task_id = snapshot.task_id.as_str().to_string();
|
||||
if !snapshot.status.is_terminal() {
|
||||
return Ok(task_id);
|
||||
}
|
||||
owned_terminal.get_or_insert_with(|| task_id.clone());
|
||||
owned_terminal.get_or_insert(task_id);
|
||||
}
|
||||
if let Some(task_id) = owned_terminal {
|
||||
return Ok(task_id);
|
||||
@@ -938,15 +931,13 @@ pub(crate) fn resume_ref_for_task(
|
||||
task_id: &str,
|
||||
parent_session: &str,
|
||||
) -> Result<SubagentResumeRef, WaitError> {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.get(task_id).ok_or(WaitError::Unknown)?;
|
||||
if entry.parent_session != parent_session {
|
||||
return Err(WaitError::NotOwned);
|
||||
}
|
||||
let snapshot = registry()
|
||||
.snapshot(&TaskId::new(task_id), parent_session)
|
||||
.map_err(wait_error_from_registry)?;
|
||||
Ok(SubagentResumeRef {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: entry.agent_id.clone(),
|
||||
subagent_session_id: entry.subagent_session_id.clone(),
|
||||
agent_id: snapshot.metadata.agent_id,
|
||||
subagent_session_id: snapshot.metadata.subagent_session_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -997,14 +988,14 @@ fn steering_command_for_mode(mode: QueueMode, text: String) -> Option<SteeringCo
|
||||
}
|
||||
}
|
||||
|
||||
fn send_registered_steering(task_id: &str, text: String, mode: QueueMode) -> bool {
|
||||
fn send_registered_steering(
|
||||
handle: &tinyagents::harness::steering::SteeringHandle,
|
||||
text: String,
|
||||
mode: QueueMode,
|
||||
) -> bool {
|
||||
let Some(command) = steering_command_for_mode(mode, text) else {
|
||||
return false;
|
||||
};
|
||||
let task_id = TaskId::new(task_id);
|
||||
let Some(handle) = shared_steering_registry().get(&task_id) else {
|
||||
return false;
|
||||
};
|
||||
handle.send(command);
|
||||
true
|
||||
}
|
||||
@@ -1088,20 +1079,9 @@ pub(crate) fn steer_directive(
|
||||
parent_session: &str,
|
||||
directive: SteeringDirective,
|
||||
) -> Result<(), SteerDirectiveError> {
|
||||
{
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.get(task_id).ok_or(SteerDirectiveError::Unknown)?;
|
||||
if entry.parent_session != parent_session {
|
||||
return Err(SteerDirectiveError::NotOwned);
|
||||
}
|
||||
if entry.status.borrow().is_terminal() {
|
||||
return Err(SteerDirectiveError::AlreadyDone);
|
||||
}
|
||||
}
|
||||
|
||||
let handle = shared_steering_registry()
|
||||
.get(&TaskId::new(task_id))
|
||||
.ok_or(SteerDirectiveError::NoRegisteredHandle)?;
|
||||
let handle = registry()
|
||||
.steering_handle(&TaskId::new(task_id), parent_session)
|
||||
.map_err(steer_directive_error_from_registry)?;
|
||||
let kind = directive.kind();
|
||||
if !handle.policy().is_allowed(kind) {
|
||||
log::warn!(
|
||||
@@ -1120,16 +1100,6 @@ pub(crate) fn steer_directive(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deregister_steering(task_id: &str) {
|
||||
let task_id = TaskId::new(task_id);
|
||||
if shared_steering_registry().deregister(&task_id).is_some() {
|
||||
log::debug!(
|
||||
"[running_subagents] deregistered steering handle task_id={}",
|
||||
task_id.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a message into a running sub-agent. Prefer the crate-native
|
||||
/// TinyAgents steering registry when the child run has registered its live
|
||||
/// handle, and fall back to the OpenHuman `RunQueue` compatibility path.
|
||||
@@ -1139,19 +1109,19 @@ pub async fn steer(
|
||||
text: String,
|
||||
mode: QueueMode,
|
||||
) -> Result<(), SteerError> {
|
||||
let run_queue = {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.get(task_id).ok_or(SteerError::Unknown)?;
|
||||
if entry.parent_session != parent_session {
|
||||
return Err(SteerError::NotOwned);
|
||||
}
|
||||
if entry.status.borrow().is_terminal() {
|
||||
return Err(SteerError::AlreadyDone);
|
||||
}
|
||||
entry.run_queue.clone()
|
||||
};
|
||||
let task_id_key = TaskId::new(task_id);
|
||||
let snapshot = registry()
|
||||
.snapshot(&task_id_key, parent_session)
|
||||
.map_err(steer_error_from_registry)?;
|
||||
if snapshot.status.is_terminal() {
|
||||
return Err(SteerError::AlreadyDone);
|
||||
}
|
||||
|
||||
if send_registered_steering(task_id, text.clone(), mode) {
|
||||
let steered_via_registry = registry()
|
||||
.steering_handle(&task_id_key, parent_session)
|
||||
.map(|handle| send_registered_steering(&handle, text.clone(), mode))
|
||||
.unwrap_or(false);
|
||||
if steered_via_registry {
|
||||
log::info!(
|
||||
"[running_subagents] steered task_id={} mode={} via=tinyagents_registry",
|
||||
task_id,
|
||||
@@ -1160,7 +1130,9 @@ pub async fn steer(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_queue
|
||||
snapshot
|
||||
.metadata
|
||||
.run_queue
|
||||
.push(QueuedMessage {
|
||||
text,
|
||||
mode,
|
||||
@@ -1192,16 +1164,19 @@ pub(crate) async fn steer_control(
|
||||
text: String,
|
||||
mode: QueueMode,
|
||||
) -> Result<(), SteerError> {
|
||||
let run_queue = {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.get(task_id).ok_or(SteerError::Unknown)?;
|
||||
if entry.status.borrow().is_terminal() {
|
||||
return Err(SteerError::AlreadyDone);
|
||||
}
|
||||
entry.run_queue.clone()
|
||||
};
|
||||
let task_id_key = TaskId::new(task_id);
|
||||
let snapshot = registry()
|
||||
.snapshot_trusted(&task_id_key)
|
||||
.map_err(steer_error_from_registry)?;
|
||||
if snapshot.status.is_terminal() {
|
||||
return Err(SteerError::AlreadyDone);
|
||||
}
|
||||
|
||||
if send_registered_steering(task_id, text.clone(), mode) {
|
||||
let steered_via_registry = registry()
|
||||
.steering_handle_trusted(&task_id_key)
|
||||
.map(|handle| send_registered_steering(&handle, text.clone(), mode))
|
||||
.unwrap_or(false);
|
||||
if steered_via_registry {
|
||||
log::info!(
|
||||
"[running_subagents] control_steered task_id={} mode={} via=tinyagents_registry",
|
||||
task_id,
|
||||
@@ -1210,7 +1185,9 @@ pub(crate) async fn steer_control(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_queue
|
||||
snapshot
|
||||
.metadata
|
||||
.run_queue
|
||||
.push(QueuedMessage {
|
||||
text,
|
||||
mode,
|
||||
@@ -1238,6 +1215,30 @@ pub(crate) enum WaitError {
|
||||
NotOwned,
|
||||
}
|
||||
|
||||
fn wait_error_from_registry(error: DetachedTaskRegistryError) -> WaitError {
|
||||
match error {
|
||||
DetachedTaskRegistryError::NotOwned => WaitError::NotOwned,
|
||||
_ => WaitError::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn steer_error_from_registry(error: DetachedTaskRegistryError) -> SteerError {
|
||||
match error {
|
||||
DetachedTaskRegistryError::NotOwned => SteerError::NotOwned,
|
||||
DetachedTaskRegistryError::AlreadyDone => SteerError::AlreadyDone,
|
||||
_ => SteerError::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn steer_directive_error_from_registry(error: DetachedTaskRegistryError) -> SteerDirectiveError {
|
||||
match error {
|
||||
DetachedTaskRegistryError::NotOwned => SteerDirectiveError::NotOwned,
|
||||
DetachedTaskRegistryError::AlreadyDone => SteerDirectiveError::AlreadyDone,
|
||||
DetachedTaskRegistryError::NoSteeringHandle => SteerDirectiveError::NoRegisteredHandle,
|
||||
_ => SteerDirectiveError::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of waiting on a sub-agent.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum WaitOutcome {
|
||||
@@ -1254,43 +1255,18 @@ pub(crate) async fn wait(
|
||||
parent_session: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<WaitOutcome, WaitError> {
|
||||
let mut rx = {
|
||||
let map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.get(task_id).ok_or(WaitError::Unknown)?;
|
||||
if entry.parent_session != parent_session {
|
||||
return Err(WaitError::NotOwned);
|
||||
match registry()
|
||||
.wait(&TaskId::new(task_id), parent_session, timeout)
|
||||
.await
|
||||
{
|
||||
Ok(DetachedTaskWaitOutcome::Terminal(status)) => Ok(WaitOutcome::Terminal(status)),
|
||||
Ok(DetachedTaskWaitOutcome::TimedOut(status)) => Ok(WaitOutcome::TimedOut(status)),
|
||||
Err(DetachedTaskRegistryError::StatusChannelClosed) => {
|
||||
Ok(WaitOutcome::Terminal(SubagentStatus::Failed {
|
||||
error: "sub-agent task ended without reporting a result".to_string(),
|
||||
}))
|
||||
}
|
||||
entry.status.clone()
|
||||
};
|
||||
|
||||
// Fast path: already terminal.
|
||||
let current = rx.borrow_and_update().clone();
|
||||
if current.is_terminal() {
|
||||
prune(task_id);
|
||||
return Ok(WaitOutcome::Terminal(current));
|
||||
}
|
||||
|
||||
let waited = async {
|
||||
loop {
|
||||
if rx.changed().await.is_err() {
|
||||
// Sender dropped without a terminal status (task aborted/panicked).
|
||||
return SubagentStatus::Failed {
|
||||
error: "sub-agent task ended without reporting a result".to_string(),
|
||||
};
|
||||
}
|
||||
let status = rx.borrow().clone();
|
||||
if status.is_terminal() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match tokio::time::timeout(timeout, waited).await {
|
||||
Ok(status) => {
|
||||
prune(task_id);
|
||||
Ok(WaitOutcome::Terminal(status))
|
||||
}
|
||||
Err(_) => Ok(WaitOutcome::TimedOut(rx.borrow().clone())),
|
||||
Err(error) => Err(wait_error_from_registry(error)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1336,27 +1312,24 @@ pub(crate) struct CancelledSubagent {
|
||||
/// `task_id` alone with no ownership check — it backs the user-facing "Cancel"
|
||||
/// affordance, and the desktop user owns every sub-agent in their own core.
|
||||
pub(crate) fn cancel_by_task(task_id: &str) -> Option<CancelledSubagent> {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let entry = map.remove(task_id)?;
|
||||
deregister_steering(task_id);
|
||||
// Cooperative cancel first (safe-boundary unwind for opted-in runs), then the
|
||||
// hard abort as the executor-detail stop, then the terminal store write.
|
||||
entry.cancel.cancel();
|
||||
entry.abort.abort();
|
||||
record_cancelled(&entry.workspace_dir, task_id);
|
||||
let cancelled = registry().cancel_trusted(&TaskId::new(task_id)).ok()?;
|
||||
let metadata = cancelled.metadata;
|
||||
record_cancelled(&metadata.workspace_dir, task_id);
|
||||
log::debug!(
|
||||
"[running_subagents] cancel_by_task task_id={} agent_id={} parent_thread_id={:?} live_entries={}",
|
||||
task_id,
|
||||
entry.agent_id,
|
||||
entry.parent_thread_id,
|
||||
map.len()
|
||||
metadata.agent_id,
|
||||
metadata.parent_thread_id,
|
||||
registry()
|
||||
.len()
|
||||
.expect("detached task registry lock poisoned")
|
||||
);
|
||||
Some(CancelledSubagent {
|
||||
agent_id: entry.agent_id,
|
||||
parent_session: entry.parent_session,
|
||||
subagent_session_id: entry.subagent_session_id,
|
||||
workspace_dir: entry.workspace_dir,
|
||||
parent_thread_id: entry.parent_thread_id,
|
||||
agent_id: metadata.agent_id,
|
||||
parent_session: cancelled.owner_id,
|
||||
subagent_session_id: metadata.subagent_session_id,
|
||||
workspace_dir: metadata.workspace_dir,
|
||||
parent_thread_id: metadata.parent_thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1384,28 +1357,20 @@ pub(crate) fn cancel_by_session_in_workspace(
|
||||
/// keep running (and later try to deliver) against a thread that no longer
|
||||
/// exists. Returns the number of sub-agents cancelled.
|
||||
pub(crate) fn cancel_for_thread(thread_id: &str) -> usize {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let to_cancel: Vec<String> = map
|
||||
.iter()
|
||||
.filter(|(_, e)| e.parent_thread_id.as_deref() == Some(thread_id))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in &to_cancel {
|
||||
if let Some(entry) = map.remove(id) {
|
||||
deregister_steering(id);
|
||||
// Cooperative cancel before the hard abort (issue #4249 / 07.2 step 2),
|
||||
// mirroring `cancel_by_task`, then the terminal store write.
|
||||
entry.cancel.cancel();
|
||||
entry.abort.abort();
|
||||
record_cancelled(&entry.workspace_dir, id);
|
||||
}
|
||||
let cancelled = registry()
|
||||
.cancel_where(|metadata| metadata.parent_thread_id.as_deref() == Some(thread_id))
|
||||
.expect("detached task registry lock poisoned");
|
||||
for entry in &cancelled {
|
||||
record_cancelled(&entry.metadata.workspace_dir, entry.task_id.as_str());
|
||||
}
|
||||
let count = to_cancel.len();
|
||||
let count = cancelled.len();
|
||||
log::debug!(
|
||||
"[running_subagents] cancel_for_thread thread_id={} cancelled={} live_entries={}",
|
||||
thread_id,
|
||||
count,
|
||||
map.len()
|
||||
registry()
|
||||
.len()
|
||||
.expect("detached task registry lock poisoned")
|
||||
);
|
||||
count
|
||||
}
|
||||
@@ -1417,18 +1382,15 @@ pub(crate) fn cancel_for_thread(thread_id: &str) -> usize {
|
||||
/// the cooperative-abort race. Headless sub-agents (no parent thread) are still
|
||||
/// aborted but contribute no id.
|
||||
pub(crate) fn cancel_all() -> Vec<String> {
|
||||
let mut map = registry().lock().expect("running_subagents mutex poisoned");
|
||||
let count = map.len();
|
||||
let cancelled = registry()
|
||||
.cancel_all()
|
||||
.expect("detached task registry lock poisoned");
|
||||
let count = cancelled.len();
|
||||
let mut thread_ids: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for (task_id, entry) in map.drain() {
|
||||
deregister_steering(&task_id);
|
||||
// Cooperative cancel before the hard abort (issue #4249 / 07.2 step 2),
|
||||
// mirroring `cancel_by_task`, then the terminal store write.
|
||||
entry.cancel.cancel();
|
||||
entry.abort.abort();
|
||||
record_cancelled(&entry.workspace_dir, &task_id);
|
||||
if let Some(thread_id) = entry.parent_thread_id {
|
||||
for entry in cancelled {
|
||||
record_cancelled(&entry.metadata.workspace_dir, entry.task_id.as_str());
|
||||
if let Some(thread_id) = entry.metadata.parent_thread_id {
|
||||
if seen.insert(thread_id.clone()) {
|
||||
thread_ids.push(thread_id);
|
||||
}
|
||||
@@ -1443,11 +1405,7 @@ pub(crate) fn cancel_all() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn prune(task_id: &str) {
|
||||
deregister_steering(task_id);
|
||||
registry()
|
||||
.lock()
|
||||
.expect("running_subagents mutex poisoned")
|
||||
.remove(task_id);
|
||||
let _ = registry().cancel_trusted(&TaskId::new(task_id));
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
use super::types::{
|
||||
DurableSubagentSession, DurableSubagentStatus, ReuseDecision, SubagentSessionSelector,
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::openhuman::agent::harness::subagent_runner::{
|
||||
run_subagent, SubagentRunOptions, SubagentRunStatus,
|
||||
};
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::inference::provider::thread_context::current_thread_id;
|
||||
use crate::openhuman::tinyagents::thread_context::current_thread_id;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -70,8 +70,7 @@ impl Tool for CloseSubagentTool {
|
||||
}
|
||||
};
|
||||
let store = SubagentSessionStore::new(parent.workspace_dir.clone());
|
||||
let parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
let parent_thread_id = crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
let owned = match subagent_sessions::list_for_parent(
|
||||
&store,
|
||||
&parent.session_id,
|
||||
@@ -131,7 +130,6 @@ mod tests {
|
||||
};
|
||||
use crate::openhuman::config::AgentConfig;
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use crate::openhuman::memory::{
|
||||
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
||||
};
|
||||
@@ -153,16 +151,13 @@ mod tests {
|
||||
let session = seed_session(&store, "thread-b");
|
||||
|
||||
let res = with_parent_context(parent_context(workspace.path()), async {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
"thread-a",
|
||||
async {
|
||||
CloseSubagentTool::new()
|
||||
.execute(json!({
|
||||
"subagent_session_id": session.subagent_session_id,
|
||||
}))
|
||||
.await
|
||||
},
|
||||
)
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id("thread-a", async {
|
||||
CloseSubagentTool::new()
|
||||
.execute(json!({
|
||||
"subagent_session_id": session.subagent_session_id,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
@@ -184,16 +179,13 @@ mod tests {
|
||||
let session = seed_session(&store, "thread-a");
|
||||
|
||||
let res = with_parent_context(parent_context(workspace.path()), async {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
"thread-a",
|
||||
async {
|
||||
CloseSubagentTool::new()
|
||||
.execute(json!({
|
||||
"subagent_session_id": session.subagent_session_id,
|
||||
}))
|
||||
.await
|
||||
},
|
||||
)
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id("thread-a", async {
|
||||
CloseSubagentTool::new()
|
||||
.execute(json!({
|
||||
"subagent_session_id": session.subagent_session_id,
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
@@ -240,13 +232,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn parent_context(workspace_dir: &Path) -> ParentExecutionContext {
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::new(Vec::new()));
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "orchestrator".into(),
|
||||
allowed_subagent_ids: HashSet::new(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(
|
||||
NoopProvider,
|
||||
)),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
all_tools: Arc::new(Vec::new()),
|
||||
all_tool_specs: Arc::new(Vec::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -269,21 +261,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopMemory;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::openhuman::agent::harness::fork_context::current_parent;
|
||||
use crate::openhuman::agent::harness::subagent_runner::{
|
||||
run_subagent, SubagentCheckpointData, SubagentRunOptions, SubagentRunStatus,
|
||||
};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -122,7 +122,7 @@ pub(crate) async fn dispatch_subagent(
|
||||
if mode == DispatchMode::PreferAsync {
|
||||
let has_parent_turn = parent_ctx.is_some();
|
||||
let has_delivery_thread =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id().is_some();
|
||||
crate::openhuman::tinyagents::thread_context::current_thread_id().is_some();
|
||||
if has_parent_turn && has_delivery_thread {
|
||||
let mut async_args = serde_json::json!({
|
||||
"agent_id": definition.id.clone(),
|
||||
|
||||
@@ -59,8 +59,7 @@ impl Tool for ListSubagentsTool {
|
||||
));
|
||||
}
|
||||
};
|
||||
let parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
let parent_thread_id = crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
let store = SubagentSessionStore::new(parent.workspace_dir.clone());
|
||||
match subagent_sessions::list_for_parent(
|
||||
&store,
|
||||
|
||||
@@ -10,13 +10,13 @@ use crate::openhuman::agent::harness::run_queue::RunQueue;
|
||||
use crate::openhuman::agent::harness::subagent_runner::{
|
||||
run_subagent, SubagentRunOptions, SubagentRunStatus,
|
||||
};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::agent_orchestration::running_subagents::{self, SubagentStatus};
|
||||
use crate::openhuman::agent_orchestration::subagent_sessions::{
|
||||
self, DurableSubagentStatus, SubagentSessionSelector, SubagentSessionStore,
|
||||
SubagentSessionUpsert,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use crate::openhuman::memory_conversations::{self as conversations, ConversationMessage};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
@@ -223,8 +223,7 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
|
||||
let parent_session = parent.session_id.clone();
|
||||
let progress_sink = parent.on_progress.clone();
|
||||
let parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
let parent_thread_id = crate::openhuman::tinyagents::thread_context::current_thread_id();
|
||||
|
||||
// Async delivery is thread-addressed: the finished result is inserted
|
||||
// back into the parent chat thread as a follow-up turn
|
||||
@@ -258,7 +257,6 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
sub-agents.",
|
||||
));
|
||||
}
|
||||
|
||||
let store = SubagentSessionStore::new(parent.workspace_dir.clone());
|
||||
let workspace_descriptor = tool_context.and_then(|ctx| ctx.workspace.clone());
|
||||
let effective_action_root = workspace_descriptor
|
||||
@@ -551,7 +549,7 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
};
|
||||
|
||||
let result = with_parent_context(background_parent, async move {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
background_thread_affinity_id,
|
||||
async move {
|
||||
run_subagent(&background_definition, &background_prompt, options).await
|
||||
@@ -1080,7 +1078,6 @@ mod tests {
|
||||
};
|
||||
use crate::openhuman::config::AgentConfig;
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use crate::openhuman::memory::{
|
||||
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
||||
};
|
||||
@@ -1377,17 +1374,14 @@ mod tests {
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
|
||||
let result = with_parent_context(parent_context(workspace.path()), async {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
"t-parent",
|
||||
async {
|
||||
SpawnAsyncSubagentTool::new()
|
||||
.execute(json!({
|
||||
"agent_id": "researcher",
|
||||
"prompt": "investigate x",
|
||||
}))
|
||||
.await
|
||||
},
|
||||
)
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id("t-parent", async {
|
||||
SpawnAsyncSubagentTool::new()
|
||||
.execute(json!({
|
||||
"agent_id": "researcher",
|
||||
"prompt": "investigate x",
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
@@ -1405,8 +1399,8 @@ mod tests {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "orchestrator".into(),
|
||||
allowed_subagent_ids: HashSet::from(["researcher".to_string()]),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(
|
||||
NoopProvider,
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::new(
|
||||
tinyagents::harness::testkit::ScriptedModel::replies(vec!["done"]),
|
||||
)),
|
||||
all_tools: Arc::new(Vec::new()),
|
||||
all_tool_specs: Arc::new(Vec::new()),
|
||||
@@ -1430,21 +1424,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopMemory;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -5,16 +5,13 @@ use crate::openhuman::agent::harness::definition::{
|
||||
AgentDefinition, AgentTier, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope,
|
||||
};
|
||||
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
use crate::openhuman::agent::Agent;
|
||||
use crate::openhuman::agent_orchestration::spawn_parallel_graph::{
|
||||
prepare_spawn_parallel_tasks_from_defs, ParallelTaskRejectionKind, SpawnParallelTaskPreflight,
|
||||
};
|
||||
use crate::openhuman::config::AgentConfig;
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall,
|
||||
};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::tools::traits::ToolTimeout;
|
||||
use crate::openhuman::tools::{PermissionLevel, Tool, ToolResult};
|
||||
@@ -26,6 +23,9 @@ use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use tinyagents::harness::message::{AssistantMessage, Message};
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
use tinyagents::harness::tool::ToolCall;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
const PARENT_PROMPT_CANARY: &str = "parallel-fanout-e2e-canary";
|
||||
@@ -186,35 +186,6 @@ async fn rejects_two_tasks_outside_agent_turn() {
|
||||
assert!(result.output().contains("outside of an agent turn"));
|
||||
}
|
||||
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
text: Some("ok".into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopMemory;
|
||||
|
||||
#[async_trait]
|
||||
@@ -273,14 +244,15 @@ impl Memory for NoopMemory {
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_context_with_provider(
|
||||
max_parallel_tools: usize,
|
||||
provider: Arc<dyn Provider>,
|
||||
) -> ParentExecutionContext {
|
||||
fn parent_context(max_parallel_tools: usize) -> ParentExecutionContext {
|
||||
let agent_config = AgentConfig {
|
||||
max_parallel_tools,
|
||||
..Default::default()
|
||||
};
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(vec![
|
||||
"ok",
|
||||
]));
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "orchestrator".into(),
|
||||
@@ -291,7 +263,7 @@ fn parent_context_with_provider(
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
all_tools: Arc::new(Vec::new()),
|
||||
all_tool_specs: Arc::new(Vec::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -314,10 +286,6 @@ fn parent_context_with_provider(
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_context(max_parallel_tools: usize) -> ParentExecutionContext {
|
||||
parent_context_with_provider(max_parallel_tools, Arc::new(NoopProvider))
|
||||
}
|
||||
|
||||
fn parent_context_with_tools(
|
||||
max_parallel_tools: usize,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
@@ -390,7 +358,7 @@ async fn rejects_more_tasks_than_parent_parallel_limit() {
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result.output().contains("max_parallel_tools"),
|
||||
"{}",
|
||||
"unexpected result: {}",
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
@@ -638,7 +606,7 @@ impl ParallelHarnessProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond_for_subagent(&self, flattened: &str) -> anyhow::Result<ChatResponse> {
|
||||
async fn respond_for_subagent(&self, flattened: &str) -> tinyagents::Result<ModelResponse> {
|
||||
let current = self
|
||||
.state
|
||||
.active_subagent_calls
|
||||
@@ -647,7 +615,7 @@ impl ParallelHarnessProvider {
|
||||
self.record_active_peak(current);
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
|
||||
let response = (|| -> anyhow::Result<ChatResponse> {
|
||||
let response = (|| -> tinyagents::Result<ModelResponse> {
|
||||
if flattened.contains(RESEARCH_PROMPT_CANARY) {
|
||||
if flattened.contains("research-step-3-ok") {
|
||||
Ok(text_response(RESEARCH_DONE_CANARY))
|
||||
@@ -687,7 +655,9 @@ impl ParallelHarnessProvider {
|
||||
))
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("unexpected subagent payload: {flattened}");
|
||||
Err(tinyagents::TinyAgentsError::Model(format!(
|
||||
"unexpected subagent payload: {flattened}"
|
||||
)))
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -699,35 +669,36 @@ impl ParallelHarnessProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ParallelHarnessProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
impl ChatModel<()> for ParallelHarnessProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::LazyLock<ModelProfile> =
|
||||
std::sync::LazyLock::new(|| ModelProfile {
|
||||
provider: Some("parallel-harness-test".to_string()),
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
..ModelProfile::default()
|
||||
});
|
||||
Some(&PROFILE)
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.state.total_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let flattened = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| format!("{}:{}", m.role, m.content))
|
||||
.map(|message| {
|
||||
let role = match message {
|
||||
Message::System(_) => "system",
|
||||
Message::User(_) => "user",
|
||||
Message::Assistant(_) => "assistant",
|
||||
Message::Tool(_) => "tool",
|
||||
};
|
||||
format!("{role}:{}", message.text())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.state.seen_payloads.lock().push(flattened.clone());
|
||||
@@ -762,26 +733,23 @@ impl Provider for ParallelHarnessProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(text: impl Into<String>) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
fn text_response(text: impl Into<String>) -> ModelResponse {
|
||||
ModelResponse::assistant(text)
|
||||
}
|
||||
|
||||
fn tool_response(name: &str, arguments: serde_json::Value) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: format!("call-{name}"),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
fn tool_response(name: &str, arguments: serde_json::Value) -> ModelResponse {
|
||||
ModelResponse {
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content: Vec::new(),
|
||||
tool_calls: vec![ToolCall::new(format!("call-{name}"), name, arguments)],
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +778,7 @@ async fn agent_turn_runs_long_parallel_subagent_flow_with_many_nested_tool_calls
|
||||
];
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(Box::new(provider.clone()))
|
||||
.chat_model(Arc::new(provider.clone()))
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
|
||||
@@ -441,7 +441,7 @@ impl Tool for SpawnSubagentTool {
|
||||
// `has_delivery_thread` fallback the `delegate_*` tools already do in
|
||||
// `dispatch.rs::dispatch_subagent`.
|
||||
let has_delivery_thread =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id().is_some();
|
||||
crate::openhuman::tinyagents::thread_context::current_thread_id().is_some();
|
||||
if !blocking && !has_delivery_thread {
|
||||
log::info!(
|
||||
"[spawn_subagent] async delegation requested for '{}' but no delivery thread \
|
||||
@@ -487,7 +487,7 @@ impl Tool for SpawnSubagentTool {
|
||||
// still proceeds live-only (`worker_thread_id: None`).
|
||||
let worker_thread_id = current_parent().and_then(|p| {
|
||||
let parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id()?;
|
||||
crate::openhuman::tinyagents::thread_context::current_thread_id()?;
|
||||
let title: String = prompt.chars().take(60).collect();
|
||||
super::worker_thread::create_worker_thread(
|
||||
p.workspace_dir.clone(),
|
||||
|
||||
@@ -153,9 +153,8 @@ impl Tool for SpawnWorkerThreadTool {
|
||||
|
||||
// ── Depth Guard ────────────────────────────────────────────────
|
||||
// Check if the current thread is already a worker thread.
|
||||
let current_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let current_thread_id = crate::openhuman::tinyagents::thread_context::current_thread_id()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
tracing::info!(
|
||||
agent_id = %agent_id,
|
||||
@@ -312,36 +311,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct MockProvider;
|
||||
#[async_trait]
|
||||
impl crate::openhuman::inference::provider::Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("".into())
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
_: crate::openhuman::inference::provider::ChatRequest<'_>,
|
||||
_: &str,
|
||||
_: f64,
|
||||
) -> anyhow::Result<crate::openhuman::inference::provider::ChatResponse> {
|
||||
Ok(crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
})
|
||||
}
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct MockMemory;
|
||||
#[async_trait]
|
||||
impl crate::openhuman::memory::Memory for MockMemory {
|
||||
@@ -398,6 +367,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_parent_ctx(workspace_dir: PathBuf) -> ParentExecutionContext {
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(vec![
|
||||
"done",
|
||||
]));
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "orchestrator".into(),
|
||||
@@ -408,9 +381,7 @@ mod tests {
|
||||
model_name: "test".into(),
|
||||
temperature: 0.4,
|
||||
workspace_dir,
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(Arc::new(
|
||||
MockProvider,
|
||||
)),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
memory: Arc::new(MockMemory),
|
||||
channel: "test".into(),
|
||||
all_tools: Arc::new(vec![]),
|
||||
@@ -444,7 +415,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
thread_id.to_string(),
|
||||
async {
|
||||
let parent = test_parent_ctx(temp.path().to_path_buf());
|
||||
@@ -487,7 +458,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
thread_id.to_string(),
|
||||
async {
|
||||
let parent = test_parent_ctx(temp.path().to_path_buf());
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::{
|
||||
};
|
||||
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::harness::{with_parent_context, ParentExecutionContext};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::context::prompt::{ConnectedIntegration, ToolCallFormat};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::memory_conversations as conversations;
|
||||
use crate::openhuman::tools::Tool;
|
||||
@@ -13,6 +13,8 @@ use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
|
||||
const SPAWN_SUBAGENT_CANARY: &str = "tool-e2e-spawn-subagent-canary";
|
||||
const ARCHETYPE_DELEGATION_CANARY: &str = "tool-e2e-archetype-delegation-canary";
|
||||
@@ -23,7 +25,7 @@ const WORKER_THREAD_CANARY: &str = "tool-e2e-worker-thread-canary";
|
||||
async fn spawn_subagent_tool_runs_child_agent_e2e() {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
SPAWN_SUBAGENT_CANARY,
|
||||
"spawn-subagent-child-answer",
|
||||
)]));
|
||||
@@ -55,7 +57,7 @@ async fn spawn_subagent_tool_runs_child_agent_e2e() {
|
||||
async fn archetype_delegation_tool_runs_child_agent_e2e() {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
ARCHETYPE_DELEGATION_CANARY,
|
||||
"archetype-delegation-child-answer",
|
||||
)]));
|
||||
@@ -93,7 +95,7 @@ async fn archetype_delegation_defaults_to_async_with_durable_session_e2e() {
|
||||
// for background delivery as a new chat turn.
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
ARCHETYPE_DELEGATION_CANARY,
|
||||
"async-delegation-child-answer",
|
||||
)]));
|
||||
@@ -106,16 +108,13 @@ async fn archetype_delegation_defaults_to_async_with_durable_session_e2e() {
|
||||
let mut ctx = parent_context(workspace.path(), provider.clone(), vec![]);
|
||||
ctx.session_id = "tools-e2e-async-session".into();
|
||||
let result = with_parent_context(ctx, async {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
"thread-async-parent",
|
||||
async {
|
||||
tool.execute(json!({
|
||||
"prompt": format!("Research {ARCHETYPE_DELEGATION_CANARY} in the background"),
|
||||
"model": "test-model"
|
||||
}))
|
||||
.await
|
||||
},
|
||||
)
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id("thread-async-parent", async {
|
||||
tool.execute(json!({
|
||||
"prompt": format!("Research {ARCHETYPE_DELEGATION_CANARY} in the background"),
|
||||
"model": "test-model"
|
||||
}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
@@ -189,7 +188,7 @@ async fn continue_subagent_resumes_idle_durable_session_e2e() {
|
||||
let registry = AgentDefinitionRegistry::global().expect("registry");
|
||||
let definition = registry.get("researcher").expect("researcher definition");
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
"continue-durable-canary",
|
||||
"resumed-child-answer",
|
||||
)]));
|
||||
@@ -243,7 +242,7 @@ async fn continue_subagent_resumes_idle_durable_session_e2e() {
|
||||
ctx.session_id = "tools-e2e-continue-session".into();
|
||||
let session_id = session.subagent_session_id.clone();
|
||||
let result = with_parent_context(ctx, async {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
"thread-continue-parent",
|
||||
async {
|
||||
ContinueSubagentTool::new()
|
||||
@@ -297,7 +296,7 @@ async fn continue_subagent_without_checkpoint_or_durable_session_names_the_roste
|
||||
use super::ContinueSubagentTool;
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![]));
|
||||
let provider = Arc::new(ScriptedModel::new(vec![]));
|
||||
|
||||
let mut ctx = parent_context(workspace.path(), provider, vec![]);
|
||||
ctx.session_id = "tools-e2e-continue-missing".into();
|
||||
@@ -329,7 +328,7 @@ async fn continue_subagent_without_checkpoint_or_durable_session_names_the_roste
|
||||
async fn skill_delegation_tool_runs_integrations_agent_e2e() {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
SKILL_DELEGATION_CANARY,
|
||||
"skill-delegation-child-answer",
|
||||
)]));
|
||||
@@ -375,7 +374,7 @@ async fn skill_delegation_tool_runs_integrations_agent_e2e() {
|
||||
async fn spawn_worker_thread_tool_persists_worker_thread_e2e() {
|
||||
let _ = AgentDefinitionRegistry::init_global_builtins();
|
||||
let workspace = tempfile::TempDir::new().expect("workspace");
|
||||
let provider = Arc::new(ScriptedProvider::new(vec![(
|
||||
let provider = Arc::new(ScriptedModel::new(vec![(
|
||||
WORKER_THREAD_CANARY,
|
||||
"worker-thread-child-answer",
|
||||
)]));
|
||||
@@ -420,7 +419,7 @@ async fn spawn_worker_thread_tool_persists_worker_thread_e2e() {
|
||||
|
||||
fn parent_context(
|
||||
workspace_dir: &Path,
|
||||
provider: Arc<dyn Provider>,
|
||||
model: Arc<dyn ChatModel<()>>,
|
||||
connected_integrations: Vec<ConnectedIntegration>,
|
||||
) -> ParentExecutionContext {
|
||||
ParentExecutionContext {
|
||||
@@ -429,7 +428,7 @@ fn parent_context(
|
||||
allowed_subagent_ids: ["researcher".to_string(), "integrations_agent".to_string()]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model(model),
|
||||
all_tools: Arc::new(Vec::new()),
|
||||
all_tool_specs: Arc::new(Vec::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -452,12 +451,12 @@ fn parent_context(
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedProvider {
|
||||
struct ScriptedModel {
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
seen: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScriptedProvider {
|
||||
impl ScriptedModel {
|
||||
fn new(responses: Vec<(&'static str, &'static str)>) -> Self {
|
||||
Self {
|
||||
responses,
|
||||
@@ -474,48 +473,39 @@ impl ScriptedProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ScriptedProvider {
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
impl ChatModel<()> for ScriptedModel {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
static PROFILE: std::sync::OnceLock<ModelProfile> = std::sync::OnceLock::new();
|
||||
Some(PROFILE.get_or_init(|| {
|
||||
let mut profile = ModelProfile::default();
|
||||
profile.tool_calling = true;
|
||||
profile.parallel_tool_calls = true;
|
||||
profile
|
||||
}))
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
self.seen.lock().push(message.to_string());
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let flattened = flatten_messages(request.messages);
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let flattened = flatten_messages(&request.messages);
|
||||
self.seen.lock().push(flattened.clone());
|
||||
for (needle, answer) in &self.responses {
|
||||
if flattened.contains(needle) {
|
||||
return Ok(ChatResponse {
|
||||
text: Some((*answer).to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
return Ok(ModelResponse::assistant(*answer));
|
||||
}
|
||||
}
|
||||
anyhow::bail!("unexpected provider request: {flattened}");
|
||||
Err(tinyagents::TinyAgentsError::Model(format!(
|
||||
"unexpected model request: {flattened}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_messages(messages: &[ChatMessage]) -> String {
|
||||
fn flatten_messages(messages: &[Message]) -> String {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| format!("{}:{}", message.role, message.content))
|
||||
.map(Message::text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Engine unit tests (#3375 PR2).
|
||||
//!
|
||||
//! These exercise the phase scheduler ([`super::super::graph::drive_phases`]) directly with a
|
||||
//! mock `Provider` so child agents resolve deterministically and never touch the
|
||||
//! mock `ChatModel` so child agents resolve deterministically and never touch the
|
||||
//! network. The full [`super::start_workflow_run`] entry point (which builds a
|
||||
//! real `Agent` from config) is covered by the JSON-RPC e2e test over the live
|
||||
//! core stack with the mock backend.
|
||||
@@ -28,13 +28,12 @@ use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
|
||||
use crate::openhuman::config::{AgentConfig, Config};
|
||||
use crate::openhuman::context::prompt::ToolCallFormat;
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::session_db::run_ledger::{
|
||||
get_workflow_run, upsert_workflow_run, WorkflowRunUpsert,
|
||||
};
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
|
||||
use super::super::types::{WorkflowDefinition, WorkflowPhase, WorkflowSafetyTier};
|
||||
|
||||
@@ -91,20 +90,15 @@ impl Memory for NoopMemory {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_response(text: impl Into<String>) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(text.into()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
fn text_response(text: impl Into<String>) -> ModelResponse {
|
||||
ModelResponse::assistant(text)
|
||||
}
|
||||
|
||||
/// Mock provider that records peak concurrency and answers each child with a
|
||||
/// Mock model that records peak concurrency and answers each child with a
|
||||
/// short deterministic completion. Sleeps briefly so overlapping spawns are
|
||||
/// observable for the concurrency-cap assertions.
|
||||
#[derive(Clone, Default)]
|
||||
struct PeakProvider {
|
||||
struct PeakModel {
|
||||
calls: Arc<AtomicUsize>,
|
||||
active: Arc<AtomicUsize>,
|
||||
max_active: Arc<AtomicUsize>,
|
||||
@@ -112,7 +106,7 @@ struct PeakProvider {
|
||||
fail_on: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl PeakProvider {
|
||||
impl PeakModel {
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
@@ -139,28 +133,12 @@ impl PeakProvider {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for PeakProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for PeakModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let current = self.active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.record_peak(current);
|
||||
@@ -168,7 +146,7 @@ impl Provider for PeakProvider {
|
||||
let flattened = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.map(|message| message.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.prompts.lock().push(flattened.clone());
|
||||
@@ -176,19 +154,28 @@ impl Provider for PeakProvider {
|
||||
|
||||
if let Some(needle) = self.fail_on.lock().as_ref() {
|
||||
if flattened.contains(needle.as_str()) {
|
||||
return Err(anyhow::anyhow!("mock provider forced failure"));
|
||||
return Err(tinyagents::TinyAgentsError::Model(
|
||||
"mock model forced failure".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(text_response("PHASE_OUTPUT_OK"))
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_parent(provider: Arc<dyn Provider>) -> ParentExecutionContext {
|
||||
fn mock_parent(model: Arc<dyn ChatModel<()>>) -> ParentExecutionContext {
|
||||
ParentExecutionContext {
|
||||
workspace_descriptor: None,
|
||||
agent_definition_id: "workflow_engine".to_string(),
|
||||
allowed_subagent_ids: HashSet::new(),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::new(provider),
|
||||
turn_model_source: crate::openhuman::tinyagents::TurnModelSource::from_model_with_profile(
|
||||
model,
|
||||
ModelProfile {
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
..ModelProfile::default()
|
||||
},
|
||||
),
|
||||
all_tools: Arc::new(Vec::<Box<dyn Tool>>::new()),
|
||||
all_tool_specs: Arc::new(Vec::<ToolSpec>::new()),
|
||||
visible_tool_names: std::collections::HashSet::new(),
|
||||
@@ -289,7 +276,7 @@ fn phase_states(config: &Config, id: &str) -> Value {
|
||||
async fn unit_phases_execute_in_dependency_order() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
let def = linear_def(2, 8, 2);
|
||||
let id = seed_run(
|
||||
&config,
|
||||
@@ -334,13 +321,13 @@ async fn unit_phases_execute_in_dependency_order() {
|
||||
/// Covers `run_engine_loop` — the `with_root_parent` wrapper around
|
||||
/// `drive_phases`. With a mock parent installed, `with_root_parent` reuses it
|
||||
/// (rather than building a real root), so the engine loop drives the run to
|
||||
/// completion under the mock provider. Mirrors the `drive_phases` happy path,
|
||||
/// completion under the mock model. Mirrors the `drive_phases` happy path,
|
||||
/// but through the wrapper the live engine spawns on its background task.
|
||||
#[tokio::test]
|
||||
async fn run_engine_loop_completes_run_under_ambient_parent() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
let def = linear_def(2, 8, 2);
|
||||
let id = seed_run(
|
||||
&config,
|
||||
@@ -364,7 +351,7 @@ async fn run_engine_loop_completes_run_under_ambient_parent() {
|
||||
async fn unit_concurrency_cap_is_respected() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
// 4 parallel workers in phase b, but concurrency capped at 2.
|
||||
let def = linear_def(2, 16, 4);
|
||||
let id = seed_run(
|
||||
@@ -392,7 +379,7 @@ async fn unit_concurrency_cap_is_respected() {
|
||||
async fn unit_max_children_hard_cap_fails_run() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
// a(1) + b(4) = 5 needed, but max_children = 3 → run fails in phase b.
|
||||
let def = linear_def(2, 3, 4);
|
||||
let id = seed_run(
|
||||
@@ -432,7 +419,7 @@ async fn unit_max_children_hard_cap_fails_run() {
|
||||
async fn unit_failed_child_marks_run_failed_with_partial_state() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
// Force the phase-b child to fail (its prompt mentions "PARALLEL").
|
||||
provider.fail_when_prompt_contains("phase b PARALLEL");
|
||||
let def = linear_def(2, 8, 1);
|
||||
@@ -480,7 +467,7 @@ async fn unit_failed_child_marks_run_failed_with_partial_state() {
|
||||
async fn unit_stop_mid_run_marks_interrupted() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
let def = linear_def(2, 8, 1);
|
||||
let id = seed_run(
|
||||
&config,
|
||||
@@ -505,7 +492,7 @@ async fn unit_stop_mid_run_marks_interrupted() {
|
||||
async fn unit_resume_skips_completed_phases() {
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
let (_dir, config) = test_config();
|
||||
let provider = PeakProvider::default();
|
||||
let provider = PeakModel::default();
|
||||
let def = linear_def(2, 8, 1);
|
||||
let id = seed_run(
|
||||
&config,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Shared channel runtime state and memory helpers.
|
||||
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::tinyagents::TurnModelSource;
|
||||
use crate::openhuman::tools::Tool;
|
||||
use crate::openhuman::util::truncate_with_ellipsis;
|
||||
use std::collections::HashMap;
|
||||
@@ -23,13 +24,15 @@ pub(crate) use tinychannels::context::MIN_CHANNEL_MESSAGE_TIMEOUT_SECS;
|
||||
/// Per-sender conversation history for channel messages.
|
||||
pub(crate) type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
|
||||
|
||||
pub(crate) type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>;
|
||||
pub(crate) type TurnModelSourceCacheMap = Arc<Mutex<HashMap<String, TurnModelSource>>>;
|
||||
pub(crate) type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ChannelRuntimeContext {
|
||||
pub(crate) channels_by_name: Arc<HashMap<String, Arc<dyn super::Channel>>>,
|
||||
pub(crate) provider: Option<Arc<dyn Provider>>,
|
||||
/// Injected model source used only by tests and bespoke channel hosts.
|
||||
/// Production contexts carry `config` and construct crate-native sources.
|
||||
pub(crate) turn_model_source: Option<TurnModelSource>,
|
||||
pub(crate) default_provider: Arc<String>,
|
||||
pub(crate) memory: Arc<dyn Memory>,
|
||||
pub(crate) tools_registry: Arc<Vec<Box<dyn Tool>>>,
|
||||
@@ -40,7 +43,7 @@ pub(crate) struct ChannelRuntimeContext {
|
||||
pub(crate) max_tool_iterations: usize,
|
||||
pub(crate) min_relevance_score: f64,
|
||||
pub(crate) conversation_histories: ConversationHistoryMap,
|
||||
pub(crate) provider_cache: ProviderCacheMap,
|
||||
pub(crate) turn_model_source_cache: TurnModelSourceCacheMap,
|
||||
pub(crate) route_overrides: RouteSelectionMap,
|
||||
pub(crate) api_url: Option<String>,
|
||||
pub(crate) inference_url: Option<String>,
|
||||
@@ -52,7 +55,7 @@ pub(crate) struct ChannelRuntimeContext {
|
||||
pub(crate) multimodal: crate::openhuman::config::MultimodalConfig,
|
||||
pub(crate) multimodal_files: crate::openhuman::config::MultimodalFileConfig,
|
||||
/// Full config for building crate-native turn models (Phase 3 P3-B). `Some` in
|
||||
/// production; `None` in tests keeps the channel turn on the `Provider` path.
|
||||
/// production; `None` lets tests inject a model source directly.
|
||||
pub(crate) config: Option<Arc<crate::openhuman::config::Config>>,
|
||||
}
|
||||
|
||||
@@ -164,26 +167,10 @@ pub(crate) async fn build_memory_context(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::channels::traits;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry};
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct DummyProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for DummyProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyTool;
|
||||
|
||||
#[async_trait]
|
||||
@@ -282,9 +269,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn runtime_context() -> ChannelRuntimeContext {
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(vec![
|
||||
"ok",
|
||||
]));
|
||||
ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(HashMap::new()),
|
||||
provider: Some(Arc::new(DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
)),
|
||||
default_provider: Arc::new("default".into()),
|
||||
memory: Arc::new(MockMemory {
|
||||
entries: Vec::new(),
|
||||
@@ -297,7 +290,7 @@ mod tests {
|
||||
max_tool_iterations: 1,
|
||||
min_relevance_score: 0.4,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -347,11 +340,11 @@ mod tests {
|
||||
let ctx = runtime_context();
|
||||
let sender = "discord_alice_reply_thread:thread-1";
|
||||
let mut history = Vec::new();
|
||||
history.push(crate::openhuman::inference::provider::ChatMessage::user(
|
||||
history.push(crate::openhuman::agent::messages::ChatMessage::user(
|
||||
"short",
|
||||
));
|
||||
history.extend((0..20).map(|idx| {
|
||||
crate::openhuman::inference::provider::ChatMessage::assistant("x".repeat(700 + idx))
|
||||
crate::openhuman::agent::messages::ChatMessage::assistant("x".repeat(700 + idx))
|
||||
}));
|
||||
ctx.conversation_histories
|
||||
.lock()
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::context::{
|
||||
};
|
||||
use super::traits;
|
||||
use super::{Channel, ChannelSendExt, SendMessage};
|
||||
use crate::openhuman::inference::provider::{self, Provider};
|
||||
use crate::openhuman::inference::provider;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
@@ -166,20 +166,18 @@ fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<S
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_or_create_provider(
|
||||
pub(crate) async fn get_or_create_turn_model_source(
|
||||
ctx: &ChannelRuntimeContext,
|
||||
provider_name: &str,
|
||||
) -> anyhow::Result<Arc<dyn Provider>> {
|
||||
) -> anyhow::Result<crate::openhuman::tinyagents::TurnModelSource> {
|
||||
if provider_name == ctx.default_provider.as_str() {
|
||||
return ctx
|
||||
.provider
|
||||
.as_ref()
|
||||
.map(Arc::clone)
|
||||
.ok_or_else(|| anyhow::anyhow!("no injected channel provider for '{provider_name}'"));
|
||||
return ctx.turn_model_source.as_ref().cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("no injected channel model source for '{provider_name}'")
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(existing) = ctx
|
||||
.provider_cache
|
||||
.turn_model_source_cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(provider_name)
|
||||
@@ -189,7 +187,7 @@ pub(crate) async fn get_or_create_provider(
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"no injected channel provider for '{provider_name}'; production routes use crate-native model sources"
|
||||
"no injected channel model source for '{provider_name}'; production routes use crate-native model sources"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::*;
|
||||
use crate::core::event_bus::{DomainEvent, EventHandler};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::channels::context::{
|
||||
ChannelRuntimeContext, ProviderCacheMap, RouteSelectionMap,
|
||||
ChannelRuntimeContext, RouteSelectionMap, TurnModelSourceCacheMap,
|
||||
};
|
||||
use crate::openhuman::channels::telegram::{TelegramRemoteCommand, TelegramRemoteSubscriber};
|
||||
use crate::openhuman::channels::traits::ChannelMessage;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry};
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
@@ -13,21 +13,6 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct DummyProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for DummyProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyMemory;
|
||||
|
||||
#[async_trait]
|
||||
@@ -131,9 +116,15 @@ impl Channel for RecordingChannel {
|
||||
}
|
||||
|
||||
fn runtime_context(workspace_dir: PathBuf) -> ChannelRuntimeContext {
|
||||
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
|
||||
Arc::new(tinyagents::harness::testkit::ScriptedModel::replies(vec![
|
||||
"ok",
|
||||
]));
|
||||
ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(HashMap::new()),
|
||||
provider: Some(Arc::new(DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
)),
|
||||
default_provider: Arc::new("openai".into()),
|
||||
memory: Arc::new(DummyMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(DummyTool) as Box<dyn Tool>]),
|
||||
@@ -144,7 +135,7 @@ fn runtime_context(workspace_dir: PathBuf) -> ChannelRuntimeContext {
|
||||
max_tool_iterations: 1,
|
||||
min_relevance_score: 0.4,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: ProviderCacheMap::default(),
|
||||
turn_model_source_cache: TurnModelSourceCacheMap::default(),
|
||||
route_overrides: RouteSelectionMap::default(),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::core::event_bus::{
|
||||
publish_global, request_native_global, DomainEvent, NativeRequestError,
|
||||
};
|
||||
use crate::openhuman::agent::bus::{AgentTurnRequest, AgentTurnResponse, AGENT_RUN_TURN_METHOD};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::channels::context::{
|
||||
build_memory_context, compact_sender_history, conversation_history_key,
|
||||
@@ -21,11 +22,11 @@ use crate::openhuman::channels::context::{
|
||||
};
|
||||
use crate::openhuman::channels::providers::telegram::TELEGRAM_APPROVAL_CLIENT_ID;
|
||||
use crate::openhuman::channels::routes::{
|
||||
get_or_create_provider, get_route_selection, handle_runtime_command_if_needed,
|
||||
get_or_create_turn_model_source, get_route_selection, handle_runtime_command_if_needed,
|
||||
};
|
||||
use crate::openhuman::channels::traits;
|
||||
use crate::openhuman::channels::{ChannelSendExt, SendMessage};
|
||||
use crate::openhuman::inference::provider::{self, ChatMessage};
|
||||
use crate::openhuman::inference::provider;
|
||||
use crate::openhuman::util::truncate_with_ellipsis;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -229,9 +230,9 @@ pub(crate) async fn process_channel_runtime_message(
|
||||
|
||||
let history_key = conversation_history_key(&msg);
|
||||
let route = get_route_selection(ctx.as_ref(), &history_key);
|
||||
let active_provider = if ctx.config.is_none() {
|
||||
match get_or_create_provider(ctx.as_ref(), &route.provider).await {
|
||||
Ok(provider) => Some(provider),
|
||||
let active_turn_model_source = if ctx.config.is_none() {
|
||||
match get_or_create_turn_model_source(ctx.as_ref(), &route.provider).await {
|
||||
Ok(source) => Some(source),
|
||||
Err(err) => {
|
||||
crate::core::observability::report_error(
|
||||
&err,
|
||||
@@ -466,7 +467,7 @@ pub(crate) async fn process_channel_runtime_message(
|
||||
// Crate-native channel turn models (Phase 3 P3-B): when the runtime carries
|
||||
// the full config, build crate `ChatModel`s from `("chat", route.provider,
|
||||
// config)` — `route.provider` is the effective provider string. Tests (no
|
||||
// `config`) stay on the injected `Provider` path.
|
||||
// `config`) stay on an injected model source.
|
||||
turn_model_source: match &ctx.config {
|
||||
Some(cfg) => {
|
||||
crate::openhuman::tinyagents::TurnModelSource::new_crate_native_from_string(
|
||||
@@ -475,11 +476,8 @@ pub(crate) async fn process_channel_runtime_message(
|
||||
cfg.clone(),
|
||||
)
|
||||
}
|
||||
None => crate::openhuman::tinyagents::TurnModelSource::new(Arc::clone(
|
||||
active_provider
|
||||
.as_ref()
|
||||
.expect("test channel context must inject a provider"),
|
||||
)),
|
||||
None => active_turn_model_source
|
||||
.expect("test channel context must inject a turn model source"),
|
||||
},
|
||||
history: std::mem::take(&mut history),
|
||||
tools_registry: Arc::clone(&ctx.tools_registry),
|
||||
|
||||
@@ -47,12 +47,10 @@ use tokio::sync::mpsc;
|
||||
/// `chat_provider` routing and unconditionally build a cloud chain, so
|
||||
/// Telegram (and other channels) never honored a user's local-Ollama /
|
||||
/// BYOK selection. `resolve_chat_workload` inspects the resolved chat
|
||||
/// workload string and chooses between preserving the legacy
|
||||
/// `create_intelligent_routing_provider` chain (Cloud) and dispatching
|
||||
/// to the unified workload factory (Workload).
|
||||
/// workload string and chooses between the managed-cloud selection (Cloud)
|
||||
/// and dispatching to the unified workload factory (Workload).
|
||||
pub(super) enum ChatWorkloadResolution {
|
||||
/// Preserve the existing cloud chain (`ReliableProvider` +
|
||||
/// `IntelligentRoutingProvider`) and `config.default_model`.
|
||||
/// Preserve the managed-cloud selection and `config.default_model`.
|
||||
Cloud,
|
||||
/// Build the channel provider via `create_chat_provider("chat", config)`.
|
||||
Workload {
|
||||
@@ -833,7 +831,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name,
|
||||
provider: None,
|
||||
turn_model_source: None,
|
||||
default_provider: Arc::new(provider_name),
|
||||
memory: Arc::clone(&mem),
|
||||
tools_registry: Arc::clone(&tools_registry),
|
||||
@@ -844,7 +842,7 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
max_tool_iterations: config.agent.max_tool_iterations,
|
||||
min_relevance_score: config.memory.min_relevance_score,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: config.api_url.clone(),
|
||||
inference_url: config.inference_url.clone(),
|
||||
|
||||
@@ -9,12 +9,13 @@ use super::dispatch::{
|
||||
pub use super::startup::test_support::resolve_yuanbao_app_secret_for_test;
|
||||
use crate::core::event_bus::{init_global, register_native_global, DomainEvent, DEFAULT_CAPACITY};
|
||||
use crate::openhuman::agent::bus::{AgentTurnRequest, AgentTurnResponse, AGENT_RUN_TURN_METHOD};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::channels::context::{ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS};
|
||||
use crate::openhuman::channels::traits::{ChannelMessage, SendMessage};
|
||||
use crate::openhuman::channels::Channel;
|
||||
use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig, ReliabilityConfig};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider, ProviderRuntimeOptions};
|
||||
use crate::openhuman::inference::provider::ProviderRuntimeOptions;
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts};
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use anyhow::Result;
|
||||
@@ -23,6 +24,7 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DispatchHarnessOptions {
|
||||
@@ -177,18 +179,21 @@ impl Channel for HarnessChannel {
|
||||
}
|
||||
}
|
||||
|
||||
struct HarnessProvider;
|
||||
struct HarnessModel;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HarnessProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for HarnessModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok(format!("provider echo: {message}"))
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let message = request
|
||||
.messages
|
||||
.last()
|
||||
.map(|message| message.text())
|
||||
.unwrap_or_default();
|
||||
Ok(ModelResponse::assistant(format!("model echo: {message}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,9 +403,12 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(options.channel_name.clone(), channel);
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(HarnessProvider);
|
||||
let model: Arc<dyn ChatModel<()>> = Arc::new(HarnessModel);
|
||||
let mut provider_cache = HashMap::new();
|
||||
provider_cache.insert("harness-provider".to_string(), Arc::clone(&provider));
|
||||
provider_cache.insert(
|
||||
"harness-provider".to_string(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::clone(&model)),
|
||||
);
|
||||
let conversation_histories = Arc::new(Mutex::new(HashMap::new()));
|
||||
let history_key = if options.channel_name == "telegram" {
|
||||
format!("{}_alice_reply", options.channel_name)
|
||||
@@ -420,7 +428,9 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa
|
||||
|
||||
let ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(provider),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
)),
|
||||
default_provider: Arc::new("harness-provider".to_string()),
|
||||
memory: Arc::new(HarnessMemory {
|
||||
entries: options
|
||||
@@ -437,7 +447,7 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa
|
||||
max_tool_iterations: 3,
|
||||
min_relevance_score: 0.2,
|
||||
conversation_histories: Arc::clone(&conversation_histories),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache)),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(provider_cache)),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
use crate::openhuman::channels::{traits, Channel, SendMessage};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider};
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry};
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tinyagents::harness::message::{AssistantMessage, Message};
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
use tinyagents::harness::tool::ToolCall;
|
||||
|
||||
fn message_role(message: &Message) -> &'static str {
|
||||
match message {
|
||||
Message::System(_) => "system",
|
||||
Message::User(_) => "user",
|
||||
Message::Assistant(_) => "assistant",
|
||||
Message::Tool(_) => "tool",
|
||||
}
|
||||
}
|
||||
|
||||
fn native_tool_profile() -> &'static ModelProfile {
|
||||
static PROFILE: std::sync::OnceLock<ModelProfile> = std::sync::OnceLock::new();
|
||||
PROFILE.get_or_init(|| {
|
||||
let mut profile = ModelProfile::default();
|
||||
profile.tool_calling = true;
|
||||
profile.parallel_tool_calls = true;
|
||||
profile
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_call_response(step: Option<usize>) -> ModelResponse {
|
||||
let mut arguments = serde_json::json!({"symbol": "BTC"});
|
||||
if let Some(step) = step {
|
||||
arguments["step"] = serde_json::json!(step);
|
||||
}
|
||||
ModelResponse {
|
||||
message: AssistantMessage {
|
||||
id: None,
|
||||
content: Vec::new(),
|
||||
tool_calls: vec![ToolCall::new("mock-price-call", "mock_price", arguments)],
|
||||
usage: None,
|
||||
},
|
||||
usage: None,
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
raw: None,
|
||||
resolved_model: None,
|
||||
continue_turn: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the shared bus handler lock and the "install the real agent
|
||||
// handler for this test" helper both live in
|
||||
@@ -43,18 +84,16 @@ pub(super) fn make_workspace() -> TempDir {
|
||||
tmp
|
||||
}
|
||||
|
||||
pub(super) struct DummyProvider;
|
||||
pub(super) struct DummyModel;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for DummyProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for DummyModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant("ok"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,227 +171,153 @@ impl Channel for RecordingChannel {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct SlowProvider {
|
||||
pub(super) struct SlowModel {
|
||||
pub(super) delay: Duration,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for SlowProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for SlowModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(format!("echo: {message}"))
|
||||
let message = request
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|message| matches!(message, Message::User(_)))
|
||||
.map(Message::text)
|
||||
.unwrap_or_default();
|
||||
Ok(ModelResponse::assistant(format!("echo: {message}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ToolCallingProvider;
|
||||
|
||||
pub(super) fn tool_call_payload() -> String {
|
||||
r#"<tool_call>
|
||||
{"name":"mock_price","arguments":{"symbol":"BTC"}}
|
||||
</tool_call>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Like [`tool_call_payload`] but embeds the iteration index as an extra `step`
|
||||
/// argument so successive calls have DISTINCT `(tool, args)` signatures.
|
||||
/// `mock_price` only reads `symbol`, so the extra field is ignored at execution
|
||||
/// but keeps the harness repeat-CALL guard — which hashes args, not narration —
|
||||
/// from treating a legitimate multi-step loop as a no-progress repeat.
|
||||
pub(super) fn tool_call_payload_for_iteration(step: usize) -> String {
|
||||
format!(
|
||||
r#"<tool_call>
|
||||
{{"name":"mock_price","arguments":{{"symbol":"BTC","step":{step}}}}}
|
||||
</tool_call>"#
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn tool_call_payload_with_alias_tag() -> String {
|
||||
r#"<toolcall>
|
||||
{"name":"mock_price","arguments":{"symbol":"BTC"}}
|
||||
</toolcall>"#
|
||||
.to_string()
|
||||
}
|
||||
pub(super) struct ToolCallingModel;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for ToolCallingProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(tool_call_payload())
|
||||
impl ChatModel<()> for ToolCallingModel {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
async fn invoke(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let has_tool_results = messages
|
||||
.iter()
|
||||
.any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let has_tool_results = request.messages.iter().any(|message| {
|
||||
matches!(message, Message::Tool(_)) || message.text().contains("[Tool results]")
|
||||
});
|
||||
if has_tool_results {
|
||||
Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
|
||||
Ok(ModelResponse::assistant(
|
||||
"BTC is currently around $65,000 based on latest tool output.",
|
||||
))
|
||||
} else {
|
||||
Ok(tool_call_payload())
|
||||
Ok(tool_call_response(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ToolCallingAliasProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for ToolCallingAliasProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(tool_call_payload_with_alias_tag())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let has_tool_results = messages
|
||||
.iter()
|
||||
.any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
|
||||
if has_tool_results {
|
||||
Ok("BTC alias-tag flow resolved to final text output.".to_string())
|
||||
} else {
|
||||
Ok(tool_call_payload_with_alias_tag())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct IterativeToolProvider {
|
||||
pub(super) struct IterativeToolModel {
|
||||
pub(super) required_tool_iterations: usize,
|
||||
}
|
||||
|
||||
impl IterativeToolProvider {
|
||||
pub(super) fn completed_tool_iterations(messages: &[ChatMessage]) -> usize {
|
||||
impl IterativeToolModel {
|
||||
pub(super) fn completed_tool_iterations(messages: &[Message]) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
|
||||
.filter(|message| {
|
||||
matches!(message, Message::Tool(_)) || message.text().contains("[Tool results]")
|
||||
})
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for IterativeToolProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(tool_call_payload())
|
||||
impl ChatModel<()> for IterativeToolModel {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(native_tool_profile())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
async fn invoke(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let completed_iterations = Self::completed_tool_iterations(messages);
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let completed_iterations = Self::completed_tool_iterations(&request.messages);
|
||||
if completed_iterations >= self.required_tool_iterations {
|
||||
Ok(format!(
|
||||
Ok(ModelResponse::assistant(format!(
|
||||
"Completed after {completed_iterations} tool iterations."
|
||||
))
|
||||
)))
|
||||
} else {
|
||||
// Prefix a per-iteration progress note so each turn's assistant
|
||||
// output is distinct. A healthy multi-step agent varies its
|
||||
// narration as it advances; only byte-identical repeats (the
|
||||
// degeneration signature) should trip the harness repeat guard.
|
||||
Ok(format!(
|
||||
"Progress update {completed_iterations}.\n{}",
|
||||
tool_call_payload_for_iteration(completed_iterations)
|
||||
))
|
||||
Ok(tool_call_response(Some(completed_iterations)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct HistoryCaptureProvider {
|
||||
pub(super) struct HistoryCaptureModel {
|
||||
pub(super) calls: Mutex<Vec<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for HistoryCaptureProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for HistoryCaptureModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("fallback".to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let snapshot = messages
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let snapshot = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| (m.role.clone(), m.content.clone()))
|
||||
.map(|message| (message_role(message).to_string(), message.text()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
|
||||
calls.push(snapshot);
|
||||
Ok(format!("response-{}", calls.len()))
|
||||
Ok(ModelResponse::assistant(format!(
|
||||
"response-{}",
|
||||
calls.len()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MockPriceTool;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModelCaptureProvider {
|
||||
pub(super) struct ModelCaptureModel {
|
||||
pub(super) call_count: AtomicUsize,
|
||||
pub(super) models: Mutex<Vec<String>>,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl ModelCaptureModel {
|
||||
pub(super) fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for ModelCaptureProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for ModelCaptureModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("fallback".to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
self.models
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(model.to_string());
|
||||
Ok("ok".to_string())
|
||||
.push(self.label.clone());
|
||||
Ok(ModelResponse::assistant("ok"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use super::super::context::{
|
||||
CHANNEL_MESSAGE_TIMEOUT_SECS, MIN_CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
};
|
||||
use super::super::traits;
|
||||
use super::common::DummyProvider;
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use super::common::DummyModel;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -63,7 +63,9 @@ fn compact_sender_history_keeps_recent_truncated_messages() {
|
||||
|
||||
let ctx = ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(HashMap::new()),
|
||||
provider: Some(Arc::new(DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::new(
|
||||
DummyModel,
|
||||
))),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(super::common::NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -74,7 +76,7 @@ fn compact_sender_history_keeps_recent_truncated_messages() {
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(histories)),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -29,12 +29,12 @@ use super::super::context::{
|
||||
use super::super::runtime::process_channel_message;
|
||||
use super::super::traits;
|
||||
use super::super::{Channel, SendMessage};
|
||||
use super::common::{HistoryCaptureProvider, NoopMemory};
|
||||
use super::common::{HistoryCaptureModel, NoopMemory};
|
||||
use crate::openhuman::agent::bus::{mock_agent_run_turn, AgentTurnResponse};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,44 +81,35 @@ impl Channel for DiscordRecordingChannel {
|
||||
// can prove dispatch honors that capability for Discord.
|
||||
}
|
||||
|
||||
/// Provider that immediately returns a fixed response string — the channels
|
||||
/// Model that immediately returns a fixed response string — the channels
|
||||
/// module never needs to know or care that it's not a real LLM.
|
||||
struct FixedResponseProvider {
|
||||
struct FixedResponseModel {
|
||||
response: &'static str,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for FixedResponseProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for FixedResponseModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(self.response.to_string())
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant(self.response))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_discord_ctx(
|
||||
channel: Arc<dyn Channel>,
|
||||
provider: Arc<dyn Provider>,
|
||||
model: Arc<dyn ChatModel<()>>,
|
||||
) -> Arc<ChannelRuntimeContext> {
|
||||
let mut channels = HashMap::new();
|
||||
channels.insert(channel.name().to_string(), channel);
|
||||
|
||||
Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels),
|
||||
provider: Some(provider),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -129,7 +120,7 @@ fn make_discord_ctx(
|
||||
max_tool_iterations: 1,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -156,7 +147,7 @@ async fn discord_inbound_dispatches_through_full_pipeline() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(DiscordRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel {
|
||||
response: "hi from discord",
|
||||
});
|
||||
let ctx = make_discord_ctx(channel, provider);
|
||||
@@ -208,7 +199,7 @@ async fn discord_threaded_message_does_not_emit_reaction_ack() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(DiscordRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider { response: "roger" });
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel { response: "roger" });
|
||||
let ctx = make_discord_ctx(channel, provider);
|
||||
|
||||
process_channel_message(
|
||||
@@ -257,8 +248,8 @@ async fn discord_thread_ts_splits_conversation_history_end_to_end() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(DiscordRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider_impl = Arc::new(HistoryCaptureProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let provider_impl = Arc::new(HistoryCaptureModel::default());
|
||||
let provider: Arc<dyn ChatModel<()>> = provider_impl.clone();
|
||||
let ctx = make_discord_ctx(channel, provider);
|
||||
|
||||
let first = traits::ChannelMessage {
|
||||
@@ -360,7 +351,7 @@ async fn discord_dispatch_routes_through_agent_run_turn_bus_handler() {
|
||||
let recorder = Arc::new(DiscordRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
// Minimal provider — never invoked because the stub short-circuits.
|
||||
let ctx = make_discord_ctx(channel, Arc::new(super::common::DummyProvider));
|
||||
let ctx = make_discord_ctx(channel, Arc::new(super::common::DummyModel));
|
||||
|
||||
process_channel_message(
|
||||
ctx,
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::super::context::{
|
||||
};
|
||||
use super::super::runtime::process_channel_message;
|
||||
use super::super::{traits, Channel};
|
||||
use super::common::{HistoryCaptureProvider, NoopMemory, RecordingChannel};
|
||||
use super::common::{HistoryCaptureModel, NoopMemory, RecordingChannel};
|
||||
use crate::openhuman::embeddings::NoopEmbedding;
|
||||
use crate::openhuman::inference::provider;
|
||||
use crate::openhuman::memory::{Memory, MemoryCategory};
|
||||
@@ -134,11 +134,13 @@ async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let provider_impl = Arc::new(HistoryCaptureProvider::default());
|
||||
let provider_impl = Arc::new(HistoryCaptureModel::default());
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(provider_impl.clone()),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
provider_impl.clone(),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -149,7 +151,7 @@ async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -217,13 +219,15 @@ async fn process_channel_message_uses_autosaved_memory_after_history_is_cleared(
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let provider_impl = Arc::new(HistoryCaptureProvider::default());
|
||||
let provider_impl = Arc::new(HistoryCaptureModel::default());
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = Arc::new(UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap());
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(provider_impl.clone()),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
provider_impl.clone(),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory,
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -234,7 +238,7 @@ async fn process_channel_message_uses_autosaved_memory_after_history_is_cleared(
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::super::runtime::{
|
||||
process_channel_message, run_message_dispatch_loop, RuntimeChannelMessage,
|
||||
};
|
||||
use super::super::{traits, Channel};
|
||||
use super::common::{use_real_agent_handler, NoopMemory, RecordingChannel, SlowProvider};
|
||||
use super::common::{use_real_agent_handler, NoopMemory, RecordingChannel, SlowModel};
|
||||
use crate::core::event_bus::{init_global, DomainEvent, DEFAULT_CAPACITY};
|
||||
use crate::openhuman::agent::bus::{mock_agent_run_turn, AgentTurnRequest, AgentTurnResponse};
|
||||
use crate::openhuman::inference::provider;
|
||||
@@ -116,9 +116,11 @@ async fn message_dispatch_processes_messages_in_parallel() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(SlowProvider {
|
||||
delay: Duration::from_millis(5),
|
||||
})),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(SlowModel {
|
||||
delay: Duration::from_millis(5),
|
||||
}),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -129,7 +131,7 @@ async fn message_dispatch_processes_messages_in_parallel() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -189,9 +191,11 @@ async fn process_channel_message_cancels_scoped_typing_task() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(SlowProvider {
|
||||
delay: Duration::from_millis(20),
|
||||
})),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(SlowModel {
|
||||
delay: Duration::from_millis(20),
|
||||
}),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -202,7 +206,7 @@ async fn process_channel_message_cancels_scoped_typing_task() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -277,9 +281,11 @@ async fn dispatch_routes_through_agent_run_turn_bus_handler() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
// Still need a Provider for the Arc field, but the stubbed bus
|
||||
// Still need a model for the context field, but the stubbed bus
|
||||
// handler never invokes it — so a minimal no-op is fine.
|
||||
provider: Some(Arc::new(super::common::DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(super::common::DummyModel),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -290,7 +296,7 @@ async fn dispatch_routes_through_agent_run_turn_bus_handler() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -362,7 +368,9 @@ async fn channel_processed_event_records_resolved_agent_route() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(super::common::DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(super::common::DummyModel),
|
||||
)),
|
||||
default_provider: Arc::new("requested-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -373,7 +381,7 @@ async fn channel_processed_event_records_resolved_agent_route() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -474,7 +482,9 @@ async fn process_channel_message_hardens_multimodal_files_against_smuggled_marke
|
||||
};
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(super::common::DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(super::common::DummyModel),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -485,7 +495,7 @@ async fn process_channel_message_hardens_multimodal_files_against_smuggled_marke
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -557,7 +567,9 @@ async fn process_channel_message_hardens_against_relative_path_markers() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(super::common::DummyProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(super::common::DummyModel),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -568,7 +580,7 @@ async fn process_channel_message_hardens_against_relative_path_markers() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -5,72 +5,17 @@ use super::super::context::{
|
||||
use super::super::runtime::process_channel_message;
|
||||
use super::super::{traits, Channel};
|
||||
use super::common::{
|
||||
IterativeToolProvider, MockPriceTool, ModelCaptureProvider, NoopMemory, RecordingChannel,
|
||||
TelegramRecordingChannel, ToolCallingAliasProvider, ToolCallingProvider,
|
||||
IterativeToolModel, MockPriceTool, ModelCaptureModel, NoopMemory, RecordingChannel,
|
||||
TelegramRecordingChannel, ToolCallingModel,
|
||||
};
|
||||
use crate::openhuman::inference::provider::{self, Provider};
|
||||
use crate::openhuman::inference::provider;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let channel_impl = Arc::new(RecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = channel_impl.clone();
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(ToolCallingProvider)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
system_prompt: Arc::new("test-system-prompt".to_string()),
|
||||
model: Arc::new("test-model".to_string()),
|
||||
temperature: 0.0,
|
||||
auto_save_memory: false,
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: provider::ProviderRuntimeOptions::default(),
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
multimodal: crate::openhuman::config::MultimodalConfig::default(),
|
||||
multimodal_files: crate::openhuman::config::MultimodalFileConfig::default(),
|
||||
config: None,
|
||||
});
|
||||
|
||||
process_channel_message(
|
||||
runtime_ctx,
|
||||
traits::ChannelMessage {
|
||||
id: "msg-1".to_string(),
|
||||
sender: "alice".to_string(),
|
||||
reply_target: "chat-42".to_string(),
|
||||
content: "What is the BTC price now?".to_string(),
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-42:"));
|
||||
assert!(sent_messages[0].contains("BTC is currently around"));
|
||||
assert!(!sent_messages[0].contains("\"tool_calls\""));
|
||||
assert!(!sent_messages[0].contains("mock_price"));
|
||||
}
|
||||
use tinyagents::harness::model::ChatModel;
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_channel_message_executes_tool_calls_with_alias_tags() {
|
||||
async fn process_channel_message_executes_native_tool_calls() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let channel_impl = Arc::new(RecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = channel_impl.clone();
|
||||
@@ -80,7 +25,9 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(ToolCallingAliasProvider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(ToolCallingModel),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
@@ -91,7 +38,7 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -121,8 +68,7 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-84:"));
|
||||
assert!(sent_messages[0].contains("alias-tag flow resolved"));
|
||||
assert!(!sent_messages[0].contains("<toolcall>"));
|
||||
assert!(sent_messages[0].contains("BTC is currently around"));
|
||||
assert!(!sent_messages[0].contains("mock_price"));
|
||||
}
|
||||
|
||||
@@ -135,18 +81,26 @@ async fn process_channel_message_handles_models_command_without_llm_call() {
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let default_provider_impl = Arc::new(ModelCaptureProvider::default());
|
||||
let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
|
||||
let fallback_provider_impl = Arc::new(ModelCaptureProvider::default());
|
||||
let fallback_provider: Arc<dyn Provider> = fallback_provider_impl.clone();
|
||||
let default_provider_impl = Arc::new(ModelCaptureModel::new("default-model"));
|
||||
let default_provider: Arc<dyn ChatModel<()>> = default_provider_impl.clone();
|
||||
let fallback_provider_impl = Arc::new(ModelCaptureModel::new("fallback-model"));
|
||||
let fallback_provider: Arc<dyn ChatModel<()>> = fallback_provider_impl.clone();
|
||||
|
||||
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
|
||||
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
|
||||
provider_cache_seed.insert("openrouter".to_string(), fallback_provider);
|
||||
let mut provider_cache_seed = HashMap::new();
|
||||
provider_cache_seed.insert(
|
||||
"test-provider".to_string(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::clone(&default_provider)),
|
||||
);
|
||||
provider_cache_seed.insert(
|
||||
"openrouter".to_string(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(fallback_provider),
|
||||
);
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::clone(&default_provider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::clone(&default_provider),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -157,7 +111,7 @@ async fn process_channel_message_handles_models_command_without_llm_call() {
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -209,14 +163,20 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let default_provider_impl = Arc::new(ModelCaptureProvider::default());
|
||||
let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
|
||||
let routed_provider_impl = Arc::new(ModelCaptureProvider::default());
|
||||
let routed_provider: Arc<dyn Provider> = routed_provider_impl.clone();
|
||||
let default_provider_impl = Arc::new(ModelCaptureModel::new("default-model"));
|
||||
let default_provider: Arc<dyn ChatModel<()>> = default_provider_impl.clone();
|
||||
let routed_provider_impl = Arc::new(ModelCaptureModel::new("route-model"));
|
||||
let routed_provider: Arc<dyn ChatModel<()>> = routed_provider_impl.clone();
|
||||
|
||||
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
|
||||
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
|
||||
provider_cache_seed.insert("openrouter".to_string(), routed_provider);
|
||||
let mut provider_cache_seed = HashMap::new();
|
||||
provider_cache_seed.insert(
|
||||
"test-provider".to_string(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(Arc::clone(&default_provider)),
|
||||
);
|
||||
provider_cache_seed.insert(
|
||||
"openrouter".to_string(),
|
||||
crate::openhuman::tinyagents::TurnModelSource::from_model(routed_provider),
|
||||
);
|
||||
|
||||
let routed_msg = traits::ChannelMessage {
|
||||
id: "msg-routed-1".to_string(),
|
||||
@@ -239,7 +199,9 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::clone(&default_provider)),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::clone(&default_provider),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -250,7 +212,7 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(route_overrides)),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -288,9 +250,11 @@ async fn process_channel_message_respects_configured_max_tool_iterations_above_d
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(IterativeToolProvider {
|
||||
required_tool_iterations: 11,
|
||||
})),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(IterativeToolModel {
|
||||
required_tool_iterations: 11,
|
||||
}),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
@@ -301,7 +265,7 @@ async fn process_channel_message_respects_configured_max_tool_iterations_above_d
|
||||
max_tool_iterations: 12,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -346,9 +310,11 @@ async fn process_channel_message_reports_configured_max_tool_iterations_limit()
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Some(Arc::new(IterativeToolProvider {
|
||||
required_tool_iterations: 20,
|
||||
})),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
Arc::new(IterativeToolModel {
|
||||
required_tool_iterations: 20,
|
||||
}),
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
@@ -359,7 +325,7 @@ async fn process_channel_message_reports_configured_max_tool_iterations_limit()
|
||||
max_tool_iterations: 3,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
|
||||
@@ -11,13 +11,13 @@ use super::super::context::{
|
||||
use super::super::runtime::process_channel_message;
|
||||
use super::super::traits;
|
||||
use super::super::{Channel, SendMessage};
|
||||
use super::common::{NoopMemory, SlowProvider};
|
||||
use super::common::{NoopMemory, SlowModel};
|
||||
use crate::openhuman::agent::bus::{mock_agent_run_turn, AgentTurnResponse};
|
||||
use crate::openhuman::inference::provider::{ChatMessage, Provider};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse};
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,43 +58,34 @@ impl Channel for FullRecordingChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider that immediately returns a fixed response string.
|
||||
struct FixedResponseProvider {
|
||||
/// Model that immediately returns a fixed response string.
|
||||
struct FixedResponseModel {
|
||||
response: &'static str,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for FixedResponseProvider {
|
||||
async fn chat_with_system(
|
||||
impl ChatModel<()> for FixedResponseModel {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(self.response.to_string())
|
||||
_state: &(),
|
||||
_request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
Ok(ModelResponse::assistant(self.response))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_context(
|
||||
channel: Arc<dyn Channel>,
|
||||
provider: Arc<dyn Provider>,
|
||||
model: Arc<dyn ChatModel<()>>,
|
||||
) -> Arc<ChannelRuntimeContext> {
|
||||
let mut channels = HashMap::new();
|
||||
channels.insert(channel.name().to_string(), channel);
|
||||
|
||||
Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels),
|
||||
provider: Some(provider),
|
||||
turn_model_source: Some(crate::openhuman::tinyagents::TurnModelSource::from_model(
|
||||
model,
|
||||
)),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
@@ -105,7 +96,7 @@ fn make_test_context(
|
||||
max_tool_iterations: 1,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
turn_model_source_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_url: None,
|
||||
inference_url: None,
|
||||
@@ -129,7 +120,7 @@ async fn inbound_thread_ts_is_forwarded_to_channel_send() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(FullRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider { response: "pong" });
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel { response: "pong" });
|
||||
let ctx = make_test_context(channel, provider);
|
||||
|
||||
process_channel_message(
|
||||
@@ -166,7 +157,7 @@ async fn no_thread_ts_on_inbound_message_results_in_none_on_send() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(FullRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider { response: "ok" });
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel { response: "ok" });
|
||||
let ctx = make_test_context(channel, provider);
|
||||
|
||||
process_channel_message(
|
||||
@@ -201,7 +192,7 @@ async fn reaction_marker_in_llm_response_is_passed_to_channel_send() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(FullRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel {
|
||||
response: "[REACTION:👍]",
|
||||
});
|
||||
let ctx = make_test_context(channel, provider);
|
||||
@@ -252,7 +243,7 @@ async fn typing_indicator_starts_and_stops_once_per_message() {
|
||||
// Must be non-zero: the first typing interval fires at t=0 but the
|
||||
// cancellation only arrives after the provider returns. A tiny delay
|
||||
// ensures the tick wins the race reliably.
|
||||
let provider: Arc<dyn Provider> = Arc::new(SlowProvider {
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(SlowModel {
|
||||
delay: Duration::from_millis(20),
|
||||
});
|
||||
let ctx = make_test_context(channel, provider);
|
||||
@@ -376,7 +367,7 @@ async fn telegram_threaded_inbound_emits_ack_reaction_then_reply() {
|
||||
let _bus_guard = super::common::use_real_agent_handler().await;
|
||||
let recorder = Arc::new(TelegramReactingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
let provider: Arc<dyn Provider> = Arc::new(FixedResponseProvider { response: "pong" });
|
||||
let provider: Arc<dyn ChatModel<()>> = Arc::new(FixedResponseModel { response: "pong" });
|
||||
let ctx = make_test_context(channel, provider);
|
||||
|
||||
process_channel_message(
|
||||
@@ -481,7 +472,7 @@ async fn telegram_dispatch_routes_through_agent_run_turn_bus_handler() {
|
||||
let recorder = Arc::new(TelegramReactingChannel::default());
|
||||
let channel: Arc<dyn Channel> = recorder.clone();
|
||||
// Minimal provider — never invoked because the stub short-circuits.
|
||||
let ctx = make_test_context(channel, Arc::new(super::common::DummyProvider));
|
||||
let ctx = make_test_context(channel, Arc::new(super::common::DummyModel));
|
||||
|
||||
process_channel_message(
|
||||
ctx,
|
||||
|
||||
@@ -34,8 +34,8 @@ pub(crate) use crate::openhuman::config::Config;
|
||||
#[cfg(test)]
|
||||
pub(crate) use loader::{
|
||||
active_workspace_marker_path, config_openhuman_dir, default_openhuman_dir, env_flag_enabled,
|
||||
fallback_workspace_dir, reset_local_data_for_paths, reset_local_data_remove_error,
|
||||
BROWSER_ALLOW_ALL_ENV, BROWSER_ALLOW_ALL_RPC_ENABLE_ENV,
|
||||
fallback_workspace_dir, reset_local_data_for_paths, BROWSER_ALLOW_ALL_ENV,
|
||||
BROWSER_ALLOW_ALL_RPC_ENABLE_ENV,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use model::resolve_backend_api_url;
|
||||
|
||||
@@ -340,7 +340,7 @@ pub struct Config {
|
||||
// openhuman, behaves identically to "openhuman"
|
||||
// "openhuman" → OpenHuman backend (api_url + api_key session JWT)
|
||||
// "openai:<model>" → look up cloud_providers entry of type=openai;
|
||||
// build OpenAiCompatibleProvider with Bearer auth
|
||||
// build crate OpenAiModel with Bearer auth
|
||||
// "anthropic:<model>" → type=anthropic; Bearer auth on the compat endpoint
|
||||
// "openrouter:<model>" → type=openrouter; Bearer auth
|
||||
// "orcarouter:<model>" → type=orcarouter; Bearer auth (e.g. "orcarouter:orcarouter/auto")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Global context management for agent sessions: the home for system-prompt assembly, per-session context bookkeeping (utilisation stats, budget configuration, session-memory triggers), and prompt-cache diagnostics. Agents hold one `ContextManager` per session. This is a **pure logic / state-tracking domain** — no RPC controllers, no agent tools, no event-bus subscribers, no persisted store.
|
||||
|
||||
> **Status (#4249): live history reduction/summarization moved to the tinyagents graph.** The in-turn compaction that used to live here — `ContextManager::reduce_before_call`, the `Summarizer` trait, `ProviderSummarizer`, `SegmentRecapSummarizer`, `context/microcompact.rs`, `context/pipeline.rs`, and `context/guard.rs` — has been **removed**. Folding an over-budget transcript into a summary now runs as `ContextCompressionMiddleware` (+ `MessageTrimMiddleware` backstop) inside `run_turn_via_tinyagents_shared`, backed by `tinyagents::summarize::ProviderModelSummarizer`. Tool-result body clearing now runs in TinyAgents `MicrocompactMiddleware`; `context/stats.rs` keeps the data model behind `ContextManager::stats()` (the utilisation footer) and session-memory bookkeeping.
|
||||
> **Status (#4249): live history reduction/summarization moved to the tinyagents graph.** The in-turn compaction that used to live here — `ContextManager::reduce_before_call`, the `Summarizer` trait, `ProviderSummarizer`, `SegmentRecapSummarizer`, `context/microcompact.rs`, `context/pipeline.rs`, and `context/guard.rs` — has been **removed**. Folding an over-budget transcript into a summary now runs as `ContextCompressionMiddleware` (+ `MessageTrimMiddleware` backstop) inside `run_turn_via_tinyagents_shared`, backed by `tinyagents::summarize::ModelSummarizer`. Tool-result body clearing now runs in TinyAgents `MicrocompactMiddleware`; `context/stats.rs` keeps the data model behind `ContextManager::stats()` (the utilisation footer) and session-memory bookkeeping.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
@@ -24,7 +24,7 @@ Global context management for agent sessions: the home for system-prompt assembl
|
||||
| `src/openhuman/context/guard.rs` | **Removed (#4249).** The live 0.90 compression threshold is mirrored by `tinyagents::summarize::SUMMARIZE_THRESHOLD_FRACTION`. |
|
||||
| `src/openhuman/context/microcompact.rs` | **Removed (#4249).** Live tool-result body clearing is owned by TinyAgents `MicrocompactMiddleware`; only shared constants remain in `context/mod.rs`. |
|
||||
| `src/openhuman/context/tool_result_budget.rs` | **Removed (#4249).** UTF-8-safe per-result truncation moved next to action-workspace artifact preview/fallback handling in `agent/harness/tool_result_artifacts`. |
|
||||
| `src/openhuman/context/summarizer.rs` | **Removed (#4249).** Live summarization moved to `tinyagents::summarize` (`ProviderModelSummarizer`); the summarizer system prompt was relocated there. |
|
||||
| `src/openhuman/context/summarizer.rs` | **Removed (#4249).** Live summarization moved to `tinyagents::summarize` (`ModelSummarizer`); the summarizer system prompt was relocated there. |
|
||||
| `src/openhuman/context/segment_recap_summarizer.rs` | **Removed (#4249).** The archivist-recap-backed compaction wrapper is gone; the archivist still produces durable segment recaps on its own post-turn path. |
|
||||
| `src/openhuman/context/session_memory.rs` | `SessionMemoryState` / `SessionMemoryConfig` — threshold-gated `should_extract` decision (token growth + tool calls + turns must all cross) and extraction bookkeeping. Holds `ARCHIVIST_EXTRACTION_PROMPT`. State-tracking only; does not spawn the archivist. |
|
||||
| `src/openhuman/context/prompt.rs` | Compat shim — `pub use crate::openhuman::agent::prompts::*`. Prompt rendering moved to `agent::prompts`; this keeps `context::prompt::...` as a stable import path. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tests for `ContextManager`.
|
||||
//!
|
||||
//! History reduction/summarization moved to the tinyagents graph in #4249
|
||||
//! (`ContextCompressionMiddleware` + `tinyagents::summarize::ProviderModelSummarizer`),
|
||||
//! (`ContextCompressionMiddleware` + `tinyagents::summarize::ModelSummarizer`),
|
||||
//! so the old `reduce_before_call` / `Summarizer` / `ReductionOutcome` suite is
|
||||
//! gone. `ContextManager` is now a pure state-tracking handle: utilisation
|
||||
//! stats, tool-result budget config, microcompact knobs, and session-memory
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
use crate::openhuman::config::CostConfig;
|
||||
use crate::openhuman::inference::provider::traits::UsageInfo;
|
||||
use crate::openhuman::inference::provider::types::UsageInfo;
|
||||
|
||||
use super::tracker::CostTracker;
|
||||
use super::types::{CostSource, TokenUsage};
|
||||
|
||||
@@ -931,7 +931,7 @@ pub async fn store_provider_credentials(
|
||||
/// bare slug (`openrouter`) the inference classifier records under.
|
||||
fn clear_provider_auth_error(provider: &str) {
|
||||
let slug = provider.strip_prefix("provider:").unwrap_or(provider);
|
||||
crate::openhuman::inference::provider::auth_error_registry::clear(slug);
|
||||
crate::openhuman::inference::auth_error_registry::clear(slug);
|
||||
}
|
||||
|
||||
pub async fn remove_provider_credentials(
|
||||
|
||||
@@ -5299,7 +5299,7 @@ pub async fn flows_discover(
|
||||
);
|
||||
let timed = match &stream {
|
||||
Some(target) => {
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
target.thread_id.clone(),
|
||||
run,
|
||||
)
|
||||
@@ -5622,7 +5622,7 @@ pub async fn flows_build(
|
||||
);
|
||||
let run =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run);
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(
|
||||
target.thread_id.clone(),
|
||||
run,
|
||||
)
|
||||
@@ -5945,7 +5945,7 @@ const TRAIL_OFF_BLOCKER_TOOLS: &[&str] = &[
|
||||
/// blocker is found (the model may have simply stopped with nothing to point
|
||||
/// to).
|
||||
fn build_trail_off_fallback(
|
||||
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
||||
history: &[crate::openhuman::agent::messages::ConversationMessage],
|
||||
) -> String {
|
||||
match last_builder_tool_blocker(history) {
|
||||
Some(blocker) => format!(
|
||||
@@ -5985,9 +5985,9 @@ fn combine_trail_off_fallback(fallback: &str, original: &str) -> String {
|
||||
/// misattributes an unrelated read-only tool's plain-text output as a
|
||||
/// blocker.
|
||||
fn last_builder_tool_blocker(
|
||||
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
||||
history: &[crate::openhuman::agent::messages::ConversationMessage],
|
||||
) -> Option<String> {
|
||||
use crate::openhuman::inference::provider::ConversationMessage;
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
|
||||
let mut call_names: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -6058,9 +6058,9 @@ fn describe_tool_result_blocker(content: &str) -> Option<String> {
|
||||
/// their tool result, so we match on that (the same gate the frontend uses) and
|
||||
/// return the LAST one — the most recent proposal in the turn.
|
||||
fn extract_workflow_proposal(
|
||||
history: &[crate::openhuman::inference::provider::ConversationMessage],
|
||||
history: &[crate::openhuman::agent::messages::ConversationMessage],
|
||||
) -> Option<Value> {
|
||||
use crate::openhuman::inference::provider::ConversationMessage;
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
let mut latest = None;
|
||||
for message in history {
|
||||
if let ConversationMessage::ToolResults(results) = message {
|
||||
|
||||
@@ -5961,7 +5961,7 @@ async fn compute_required_connections_skips_native_and_http_nodes() {
|
||||
|
||||
#[test]
|
||||
fn extract_workflow_proposal_survives_large_graph() {
|
||||
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
|
||||
use crate::openhuman::agent::messages::{ConversationMessage, ToolResultMessage};
|
||||
|
||||
// 6 nodes, several columns each — comfortably over tinyjuice's MIN_ROWS (3)
|
||||
// and ~512-byte tabulation thresholds, so an unprotected payload would get
|
||||
@@ -6013,7 +6013,7 @@ fn extract_workflow_proposal_survives_large_graph() {
|
||||
|
||||
#[test]
|
||||
fn extract_workflow_proposal_returns_the_latest_of_multiple_results() {
|
||||
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
|
||||
use crate::openhuman::agent::messages::{ConversationMessage, ToolResultMessage};
|
||||
|
||||
let first = json!({ "type": "workflow_proposal", "flow_id": "first" });
|
||||
let second = json!({ "type": "workflow_proposal", "flow_id": "second" });
|
||||
@@ -6034,7 +6034,7 @@ fn extract_workflow_proposal_returns_the_latest_of_multiple_results() {
|
||||
|
||||
#[test]
|
||||
fn extract_workflow_proposal_ignores_non_proposal_tool_results() {
|
||||
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
|
||||
use crate::openhuman::agent::messages::{ConversationMessage, ToolResultMessage};
|
||||
|
||||
let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: "call-1".to_string(),
|
||||
@@ -6052,8 +6052,9 @@ fn extract_workflow_proposal_ignores_non_proposal_tool_results() {
|
||||
fn builder_tool_call(
|
||||
id: &str,
|
||||
name: &str,
|
||||
) -> crate::openhuman::inference::provider::ConversationMessage {
|
||||
use crate::openhuman::inference::provider::{ConversationMessage, ToolCall};
|
||||
) -> crate::openhuman::agent::messages::ConversationMessage {
|
||||
use crate::openhuman::agent::messages::ConversationMessage;
|
||||
use crate::openhuman::inference::provider::ToolCall;
|
||||
ConversationMessage::AssistantToolCalls {
|
||||
text: None,
|
||||
tool_calls: vec![ToolCall {
|
||||
@@ -6070,8 +6071,8 @@ fn builder_tool_call(
|
||||
fn builder_tool_result(
|
||||
call_id: &str,
|
||||
content: &str,
|
||||
) -> crate::openhuman::inference::provider::ConversationMessage {
|
||||
use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage};
|
||||
) -> crate::openhuman::agent::messages::ConversationMessage {
|
||||
use crate::openhuman::agent::messages::{ConversationMessage, ToolResultMessage};
|
||||
ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: call_id.to_string(),
|
||||
content: content.to_string(),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# inference
|
||||
|
||||
Unified inference domain: the canonical home for everything LLM/STT/TTS/embedding-related. It owns the local-runtime manager (Ollama / LM Studio / Whisper / Piper), the unified cloud + local provider abstraction (trait, factory, router, reliability/retry wrapper), voice transcription and TTS inference, OpenAI/Codex subscription OAuth, and an OpenAI-compatible `/v1/chat/completions` HTTP endpoint. It consolidates the previously separate `local_ai/`, `providers/`, and inference parts of `voice/` under one domain root. The RPC surface is `inference.*`; older `local_ai_*` method names are compatibility aliases in `src/core/legacy_aliases.rs`.
|
||||
Unified inference domain: the canonical home for everything LLM/STT/TTS/embedding-related. It owns the local-runtime manager (Ollama / LM Studio / Whisper / Piper), native TinyAgents `ChatModel` construction for managed/cloud/local/CLI routes, host credential and access policy, voice transcription and TTS inference, OpenAI/Codex subscription OAuth, and an OpenAI-compatible `/v1/chat/completions` HTTP endpoint. It consolidates the previously separate `local_ai/`, `providers/`, and inference parts of `voice/` under one domain root. The RPC surface is `inference.*`; older `local_ai_*` method names are compatibility aliases in `src/core/legacy_aliases.rs`.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Resolve workload names (`chat`, `reasoning`, `agentic`, `coding`, `memory`, `embeddings`, `heartbeat`, `learning`, `subconscious`, etc.) and provider strings (`openhuman`, `cloud`, `ollama:<model>`, `lmstudio:<model>`, `claude_agent_sdk:<model>`, `<slug>:<model>[@<temp>]`) to a concrete `Box<dyn Provider>` + model id.
|
||||
- Resolve workload names (`chat`, `reasoning`, `agentic`, `coding`, `memory`, `embeddings`, `heartbeat`, `learning`, `subconscious`, etc.) and provider strings (`openhuman`, `cloud`, `ollama:<model>`, `lmstudio:<model>`, `claude_agent_sdk:<model>`, `<slug>:<model>[@<temp>]`) to a concrete `Arc<dyn tinyagents::ChatModel<()>>` + model id.
|
||||
- Manage the local AI runtime: detect/spawn/adopt `ollama serve` and LM Studio, install/run Whisper (STT) and Piper (TTS), track download progress, and enforce a minimum-context-window floor.
|
||||
- Provide chat, vision (multimodal), summarization, embeddings, sentiment, and "should react" inference operations.
|
||||
- Wrap providers with retry/backoff and config-rejection/billing-error classification (`reliable`, `config_rejection`, `billing_error`).
|
||||
- Multi-model routing via a hint table (`RouterProvider`) keyed off abstract tier model names (`reasoning-v1`, `agentic-v1`, `coding-v1`, etc.).
|
||||
- Preserve config-rejection, billing, authentication, and retry classification while TinyAgents owns model-call retry execution.
|
||||
- Resolve abstract tier names (`reasoning-v1`, `agentic-v1`, `coding-v1`, etc.) through the TinyAgents `ModelRouter` in `openhuman/tinyagents/routes.rs` and the provider factory here.
|
||||
- Run ChatGPT/Codex OAuth (PKCE) for the `openai` cloud slug and persist tokens in the encrypted auth-profile store.
|
||||
- Expose an OpenAI-compatible `/v1/*` HTTP endpoint guarded by a stable user-managed external bearer.
|
||||
- Detect device hardware profile and recommend/apply local model presets/tiers.
|
||||
@@ -38,16 +38,15 @@ Unified inference domain: the canonical home for everything LLM/STT/TTS/embeddin
|
||||
| `local/install*.rs`, `local/voice_install_common.rs` | Whisper/Piper install + shared download logic. |
|
||||
| `local/model_requirements.rs` | `MIN_CONTEXT_TOKENS`, `evaluate_context`, `ContextEligibility`. |
|
||||
| `local/service/` | `LocalAiService` impl split: `bootstrap`, `ollama_admin`, `public_infer`, `speech`, `vision_embed`, `whisper_engine`, `assets`, `spawn_marker`. |
|
||||
| `provider/` | Unified provider abstraction (was `providers/`). |
|
||||
| `provider/traits.rs` | `Provider` trait + `ChatMessage`/`ChatRequest`/`ChatResponse`/`ToolCall`/`UsageInfo`/`ProviderDelta` etc. |
|
||||
| `provider/factory.rs` | `create_chat_provider`, `provider_for_role`, provider-string grammar, local/cloud construction; `BYOK_INCOMPLETE_SENTINEL`. |
|
||||
| `provider/router.rs` | `RouterProvider` hint-based multi-model routing. |
|
||||
| `provider/reliable.rs` | Retry/backoff wrapper. |
|
||||
| `provider/compatible*.rs` | OpenAI-compatible provider (request dump/parse/stream/types). |
|
||||
| `provider/openhuman_backend.rs` | Managed OpenHuman backend provider (session JWT). |
|
||||
| `provider/` | Native TinyAgents model construction plus host provider configuration, auth, error taxonomy, DTOs, and RPC helpers (was `providers/`). |
|
||||
| `provider/types.rs` | Host request/response, streaming delta, tool-call, and usage DTOs retained at product/RPC boundaries. |
|
||||
| `provider/factory.rs` | `create_chat_model*`, `provider_for_role`, provider-string grammar, access gates, and local/cloud/CLI model construction; `BYOK_INCOMPLETE_SENTINEL`. |
|
||||
| `provider/crate_openai.rs` | TinyAgents OpenAI-compatible model builders for managed, BYOK, and local endpoints. |
|
||||
| `provider/openhuman_backend_model.rs` | Managed OpenHuman backend `ChatModel` with session JWT, billing metadata, and thread context. |
|
||||
| `provider/openai_codex.rs` | Codex OAuth/Responses transport as a host `ChatModel`. |
|
||||
| `provider/claude_agent_sdk/` | Claude Agent SDK subprocess provider (`protocol.rs`, `subprocess.rs`). |
|
||||
| `provider/config_rejection.rs`, `provider/billing_error.rs` | Error classifiers (unknown-model / config rejection / budget exhausted). |
|
||||
| `provider/temperature.rs`, `provider/thread_context.rs` | Per-workload temperature override; thread context plumbing. |
|
||||
| `temperature.rs`, `tinyagents/thread_context.rs` | Per-workload temperature policy and ambient thread-context plumbing. |
|
||||
| `provider/ops.rs` | `list_configured_models`, SessionExpired publishing on auth failure. |
|
||||
| `provider/schemas.rs` | Provider-layer schemas. |
|
||||
| `voice/` | Inference implementations imported by `crate::openhuman::voice`. |
|
||||
@@ -68,7 +67,7 @@ From `mod.rs` re-exports:
|
||||
- `local::all_local_inference_controller_schemas` / `local::all_local_inference_registered_controllers` (legacy export names; registered schemas are in the `inference` namespace)
|
||||
- `rpc` (alias for `ops`) and `all_inference_controller_schemas` / `all_inference_registered_controllers`
|
||||
|
||||
Provider-layer (via `provider::`): `Provider`, `ChatMessage`, `ChatRequest`, `ChatResponse`, `create_chat_provider`, `provider_for_role`, `BYOK_INCOMPLETE_SENTINEL`, plus error classifiers. Local runtime: `local::{global, try_global}` → `Arc<LocalAiService>`.
|
||||
Provider-layer (via `provider::`): `ChatRequest`, `ChatResponse`, `ProviderDelta`, `ToolCall`, `UsageInfo`, `create_chat_model*`, `provider_for_role`, `BYOK_INCOMPLETE_SENTINEL`, `OpenHumanBackendModel`, plus error classifiers. Local runtime: `local::{global, try_global}` → `Arc<LocalAiService>`.
|
||||
|
||||
## RPC / controllers
|
||||
|
||||
@@ -96,8 +95,8 @@ Also exposes a non-RPC HTTP router (`http::router()`) nested at `/v1` by `src/co
|
||||
|
||||
- `crate::openhuman::config` — `Config`, `config::rpc` (load/save, `ModelSettingsPatch`, `LocalAiSettingsPatch`), cloud-provider schema (`AuthStyle`, slug reservation, id generation), abstract tier model constants. Heaviest dependency.
|
||||
- `crate::openhuman::credentials` — `AuthService`, `AuthProfilesStore`/`AuthProfile`/`TokenSet`, state dir — for OAuth token storage and provider auth resolution.
|
||||
- `crate::openhuman::tools` — `ToolSpec`/`ToolCall` types used in provider chat requests (agent tool plumbing).
|
||||
- `crate::openhuman::agent` — agent harness types referenced by provider/thread-context paths.
|
||||
- `crate::openhuman::tools` — tool schemas and product tool metadata projected into TinyAgents requests.
|
||||
- `crate::openhuman::tinyagents` — native model, route, message, usage, and thread-context seams used by the agent harness.
|
||||
- `crate::openhuman::voice` — voice RPC/audio layer that imports these inference STT/TTS implementations (also a consumer).
|
||||
- `crate::openhuman::prompt_injection` — prompt-injection handling on the inference path.
|
||||
- `crate::openhuman::util` — small shared helpers.
|
||||
@@ -111,7 +110,7 @@ Also exposes a non-RPC HTTP router (`http::router()`) nested at `/v1` by `src/co
|
||||
|
||||
## Used by
|
||||
|
||||
Widely depended on (74 internal `use` sites + many external). Top consumers (by file count) are the agent layer (`agent/harness`, `agent/harness/session`, `agent/tools`, `agent/triage`, `agent/harness/subagent_runner`), `context`, `voice`, `routing`, `memory_tree/tree_runtime`, `learning` (+ `learning/transcript_ingest`), `channels`, `embeddings`, `subconscious`, `screen_intelligence`, `threads`, and `migrations`/`config/schema`.
|
||||
Widely depended on by the agent layer (`agent/harness`, `agent/harness/session`, `agent/tools`, `agent/triage`, `agent/harness/subagent_runner`), `context`, `voice`, `memory_tree/tree_runtime`, `learning` (+ `learning/transcript_ingest`), `channels`, `embeddings`, `subconscious`, `screen_intelligence`, `threads`, and `migrations`/`config/schema`.
|
||||
|
||||
## Notes / gotchas
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
//! `openhuman.inference_provider_auth_errors` RPC ([`snapshot`]).
|
||||
//!
|
||||
//! Entries are recorded at the demote site
|
||||
//! ([`super::ops::http_error::log_byo_provider_auth_failure`]) and cleared
|
||||
//! ([`provider::ops::http_error::log_byo_provider_auth_failure`](super::provider::ops::http_error::log_byo_provider_auth_failure)) and cleared
|
||||
//! when the user updates or removes that provider's key
|
||||
//! (`credentials::ops`). The [`record`] latch is what makes the notification
|
||||
//! fire **once per failure episode** rather than once per retry: the
|
||||
@@ -33,18 +33,17 @@ use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::core::types::AppState;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::inference::provider::traits::ChatMessage;
|
||||
|
||||
use super::types::{
|
||||
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionChunkChoice, ChatCompletionDelta,
|
||||
ChatCompletionMessage, ChatCompletionRequest, ChatCompletionResponse, ChatCompletionUsage,
|
||||
ModelObject, ModelsResponse,
|
||||
};
|
||||
use crate::core::types::AppState;
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
const LOG_PREFIX: &str = "[inference::http]";
|
||||
|
||||
@@ -113,15 +112,15 @@ async fn chat_completions_handler(
|
||||
}
|
||||
};
|
||||
|
||||
// Map request messages to provider ChatMessage type.
|
||||
let messages: Vec<ChatMessage> = req
|
||||
// Map the OpenAI-compatible wire messages directly into crate messages.
|
||||
let messages: Vec<Message> = req
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| ChatMessage {
|
||||
id: None,
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
extra_metadata: None,
|
||||
.map(|message| match message.role.as_str() {
|
||||
"system" => Message::system(&message.content),
|
||||
"assistant" => Message::assistant(&message.content),
|
||||
"tool" => Message::tool("external-tool-call", &message.content),
|
||||
_ => Message::user(&message.content),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -131,7 +130,7 @@ async fn chat_completions_handler(
|
||||
// check on the outbound body, so this is belt-and-suspenders for logging.
|
||||
let temperature = {
|
||||
let raw = req.temperature.unwrap_or(config.default_temperature);
|
||||
let suppressed = crate::openhuman::inference::provider::temperature::temperature_for_model(
|
||||
let suppressed = crate::openhuman::inference::temperature::temperature_for_model(
|
||||
&model_id, raw, &config,
|
||||
);
|
||||
if suppressed.is_none() && req.temperature.is_some() {
|
||||
@@ -146,14 +145,9 @@ async fn chat_completions_handler(
|
||||
let completion_id = format!("chatcmpl-{}", uuid::Uuid::new_v4());
|
||||
let created = chrono::Utc::now().timestamp();
|
||||
let model_name = req.model.clone();
|
||||
let model_request = ModelRequest::new(
|
||||
messages
|
||||
.iter()
|
||||
.map(crate::openhuman::tinyagents::chat_message_to_message)
|
||||
.collect(),
|
||||
)
|
||||
.with_model(model_id.clone())
|
||||
.with_temperature(temperature);
|
||||
let model_request = ModelRequest::new(messages)
|
||||
.with_model(model_id.clone())
|
||||
.with_temperature(temperature);
|
||||
|
||||
if req.stream {
|
||||
let model_stream = match chat_model.stream(&(), model_request).await {
|
||||
|
||||
@@ -112,7 +112,7 @@ pub async fn agent_chat(
|
||||
let response = match thread_id.as_deref() {
|
||||
Some(id) if !id.trim().is_empty() => {
|
||||
log::debug!("[inference] agent_chat routing with thread_id={id}");
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(id, run).await
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(id, run).await
|
||||
}
|
||||
_ => {
|
||||
log::debug!("[inference] agent_chat routing without thread_id");
|
||||
@@ -170,7 +170,7 @@ pub async fn agent_chat_simple(
|
||||
let response = match thread_id.as_deref() {
|
||||
Some(id) if !id.trim().is_empty() => {
|
||||
log::debug!("[inference] agent_chat_simple routing with thread_id={id}");
|
||||
crate::openhuman::inference::provider::thread_context::with_thread_id(id, run).await
|
||||
crate::openhuman::tinyagents::thread_context::with_thread_id(id, run).await
|
||||
}
|
||||
_ => {
|
||||
log::debug!("[inference] agent_chat_simple routing without thread_id");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! This module is the canonical home for all inference concerns:
|
||||
//! - `local/` — Ollama / LM Studio / Whisper / Piper runtime management
|
||||
//! (was `src/openhuman/local_ai/`)
|
||||
//! - `provider/` — cloud + local provider trait, routing, reliability
|
||||
//! - `provider/` — native chat models, cloud/local routing, auth and errors
|
||||
//! (was `src/openhuman/providers/`)
|
||||
//! - `voice/` — transcription (STT) and TTS inference implementations
|
||||
//! (moved from `src/openhuman/voice/`)
|
||||
@@ -20,6 +20,7 @@
|
||||
/// `cargo tree -i whisper-rs` / `cargo tree -i cpal`).
|
||||
pub const INFERENCE_COMPILED_IN: bool = cfg!(feature = "inference");
|
||||
|
||||
pub mod auth_error_registry;
|
||||
pub mod device;
|
||||
pub mod http;
|
||||
pub mod local;
|
||||
@@ -33,6 +34,7 @@ pub mod presets;
|
||||
pub mod provider;
|
||||
mod schemas;
|
||||
pub mod sentiment;
|
||||
pub mod temperature;
|
||||
pub mod types;
|
||||
pub mod voice;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ fn is_unknown_provider_user_config(err: &str) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct InferenceTestProviderModelResult {
|
||||
pub struct InferenceTestChatModelResult {
|
||||
pub reply: String,
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ pub async fn inference_test_provider_model(
|
||||
workload: &str,
|
||||
provider: &str,
|
||||
prompt: &str,
|
||||
) -> Result<RpcOutcome<InferenceTestProviderModelResult>, String> {
|
||||
) -> Result<RpcOutcome<InferenceTestChatModelResult>, String> {
|
||||
debug!(
|
||||
workload,
|
||||
provider,
|
||||
@@ -181,7 +181,7 @@ pub async fn inference_test_provider_model(
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|response| {
|
||||
RpcOutcome::single_log(
|
||||
InferenceTestProviderModelResult {
|
||||
InferenceTestChatModelResult {
|
||||
reply: response.text(),
|
||||
},
|
||||
"provider model test completed",
|
||||
@@ -362,7 +362,7 @@ pub async fn inference_device_profile() -> Result<RpcOutcome<Value>, String> {
|
||||
/// editor, not only in the notification center. Cleared when the user updates
|
||||
/// or removes the offending key.
|
||||
pub async fn inference_provider_auth_errors() -> Result<RpcOutcome<Value>, String> {
|
||||
let errors = providers::auth_error_registry::snapshot();
|
||||
let errors = crate::openhuman::inference::auth_error_registry::snapshot();
|
||||
debug!(count = errors.len(), "{LOG_PREFIX} provider_auth_errors:ok");
|
||||
Ok(RpcOutcome::single_log(
|
||||
json!({ "errors": errors }),
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use tinyagents::error::TinyAgentsError;
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse};
|
||||
use tinyagents::harness::tool::{coalesce_prompt_tool_results, with_prompt_tool_instructions};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::openhuman::config::schema::claude_agent_sdk::ClaudeAgentSdkConfig;
|
||||
use crate::openhuman::inference::provider::traits::Provider;
|
||||
|
||||
use super::protocol::SdkMessage;
|
||||
use crate::openhuman::config::schema::claude_agent_sdk::ClaudeAgentSdkConfig;
|
||||
|
||||
pub struct ClaudeAgentSdkProvider {
|
||||
pub(super) config: ClaudeAgentSdkConfig,
|
||||
profile: ModelProfile,
|
||||
}
|
||||
|
||||
struct ClaudeInvocation {
|
||||
@@ -56,18 +59,26 @@ fn spawn_error(binary: &str, source: std::io::Error) -> anyhow::Error {
|
||||
|
||||
impl ClaudeAgentSdkProvider {
|
||||
pub fn new(config: ClaudeAgentSdkConfig) -> Self {
|
||||
Self { config }
|
||||
let model = config.default_model.clone();
|
||||
Self::for_model(config, model)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ClaudeAgentSdkProvider {
|
||||
async fn chat_with_system(
|
||||
pub fn for_model(config: ClaudeAgentSdkConfig, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
profile: ModelProfile {
|
||||
provider: Some("claude-agent-sdk".to_string()),
|
||||
model: Some(model.into()),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn invoke_cli(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let model = if model.is_empty() {
|
||||
&self.config.default_model
|
||||
@@ -238,6 +249,45 @@ impl Provider for ClaudeAgentSdkProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatModel<()> for ClaudeAgentSdkProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(&self.profile)
|
||||
}
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let messages = coalesce_prompt_tool_results(&request.messages);
|
||||
let messages = with_prompt_tool_instructions(&messages, &request.tools);
|
||||
let system = messages.iter().find_map(|message| match message {
|
||||
Message::System(_) => Some(message.text()),
|
||||
_ => None,
|
||||
});
|
||||
let last_user = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|message| match message {
|
||||
Message::User(_) => Some(message.text()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let model = request
|
||||
.model
|
||||
.as_deref()
|
||||
.or(self.profile.model.as_deref())
|
||||
.unwrap_or(&self.config.default_model);
|
||||
let output = self
|
||||
.invoke_cli(system.as_deref(), &last_user, model)
|
||||
.await
|
||||
.map_err(|error| TinyAgentsError::Model(error.to_string()))?;
|
||||
|
||||
Ok(crate::openhuman::tinyagents::model::prompt_guided_text_response(output, &request))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -334,9 +384,17 @@ printf '%s\n' '{"type":"result","result":"captured","is_error":false}'
|
||||
let system_prompt = "system instruction\n".repeat(2_500);
|
||||
|
||||
let output = provider
|
||||
.chat_with_system(Some(&system_prompt), "hello", "claude-sonnet-4-6", 0.0)
|
||||
.invoke(
|
||||
&(),
|
||||
ModelRequest::new(vec![
|
||||
Message::system(&system_prompt),
|
||||
Message::user("hello"),
|
||||
])
|
||||
.with_model("claude-sonnet-4-6"),
|
||||
)
|
||||
.await
|
||||
.expect("fake claude response");
|
||||
.expect("fake claude response")
|
||||
.text();
|
||||
|
||||
assert_eq!(output, "captured");
|
||||
assert_eq!(
|
||||
@@ -353,7 +411,7 @@ printf '%s\n' '{"type":"result","result":"captured","is_error":false}'
|
||||
let provider = ClaudeAgentSdkProvider::new(config);
|
||||
|
||||
let error = provider
|
||||
.chat_with_system(None, "hello", "claude-sonnet-4-6", 0.0)
|
||||
.invoke_cli(None, "hello", "claude-sonnet-4-6")
|
||||
.await
|
||||
.expect_err("missing binary must fail");
|
||||
|
||||
@@ -365,4 +423,85 @@ printf '%s\n' '{"type":"result","result":"captured","is_error":false}'
|
||||
"io::Error source must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn chat_model_uses_prompt_guided_protocol_and_model_override() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tinyagents::harness::tool::ToolSchema;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let script = dir.path().join("claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/bin/sh
|
||||
cat > "$0.stdin"
|
||||
printf '%s\n' "$@" > "$0.args"
|
||||
printf '%s\n' '{"type":"result","result":"Calling.<tool_call>{\"name\":\"lookup\",\"arguments\":{\"query\":\"needle\"}}</tool_call>","is_error":false}'
|
||||
"#,
|
||||
)
|
||||
.expect("write fake claude");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("make fake claude executable");
|
||||
|
||||
let mut config = ClaudeAgentSdkConfig::default();
|
||||
config.binary = script.display().to_string();
|
||||
let provider = ClaudeAgentSdkProvider::for_model(config, "profile-model");
|
||||
let request = ModelRequest {
|
||||
messages: vec![
|
||||
Message::system("Base system"),
|
||||
Message::user("original question"),
|
||||
Message::assistant("calling"),
|
||||
Message::tool("call-1", "first result"),
|
||||
Message::tool("call-2", "second result"),
|
||||
],
|
||||
tools: vec![ToolSchema::new(
|
||||
"lookup",
|
||||
"looks up data",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } }
|
||||
}),
|
||||
)],
|
||||
model: Some("request-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = provider
|
||||
.invoke(&(), request)
|
||||
.await
|
||||
.expect("fake claude response");
|
||||
|
||||
assert_eq!(response.text(), "Calling.");
|
||||
assert_eq!(response.message.tool_calls.len(), 1);
|
||||
assert_eq!(response.message.tool_calls[0].name, "lookup");
|
||||
assert_eq!(
|
||||
response.message.tool_calls[0].arguments,
|
||||
serde_json::json!({"query": "needle"})
|
||||
);
|
||||
let stdin =
|
||||
std::fs::read_to_string(format!("{}.stdin", script.display())).expect("captured stdin");
|
||||
assert!(stdin.contains("Base system"));
|
||||
assert!(stdin.contains("## Tool Use Protocol"));
|
||||
assert!(
|
||||
stdin.contains("[Tool results]\n<tool_result>\nfirst result\n</tool_result>"),
|
||||
"unexpected CLI stdin: {stdin:?}"
|
||||
);
|
||||
assert!(stdin.ends_with("<tool_result>\nsecond result\n</tool_result>"));
|
||||
let args =
|
||||
std::fs::read_to_string(format!("{}.args", script.display())).expect("captured args");
|
||||
assert!(args.contains("request-model"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.profile()
|
||||
.and_then(|profile| profile.model.as_deref()),
|
||||
Some("profile-model")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.profile()
|
||||
.and_then(|profile| profile.provider.as_deref()),
|
||||
Some("claude-agent-sdk")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ use super::event_mapper::EventMapper;
|
||||
use super::input_builder::build_stdin;
|
||||
use super::session_store::{generate_uuid_v4, is_uuid_v4, SessionStore};
|
||||
use super::stream_parser::StreamJsonParser;
|
||||
use crate::openhuman::inference::provider::traits::{ChatMessage, ChatResponse, ProviderDelta};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
use crate::openhuman::inference::provider::types::{ChatResponse, ProviderDelta};
|
||||
|
||||
/// Tools withheld in the DEFAULT (`acceptEdits`) posture: Claude Code can
|
||||
/// read/edit files in the project, but not run shell, hit the network, or
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::stream_parser::ClaudeCodeEvent;
|
||||
use crate::openhuman::inference::provider::traits::{
|
||||
use crate::openhuman::inference::provider::types::{
|
||||
ChatResponse, ProviderDelta, ToolCall, UsageInfo,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::inference::provider::traits::ChatMessage;
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
/// Build the bytes to write to claude's stdin. Returns an empty `Vec`
|
||||
/// when there is nothing to send (caller should abort).
|
||||
|
||||
@@ -23,9 +23,14 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tinyagents::error::TinyAgentsError;
|
||||
use tinyagents::harness::model::{
|
||||
ChatModel, ModelProfile, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities};
|
||||
use super::types::{ChatRequest, ChatResponse};
|
||||
use crate::openhuman::agent::messages::ChatMessage;
|
||||
|
||||
/// Provider string prefix used in the factory grammar: `claude-code:<model>`.
|
||||
pub const PROVIDER_PREFIX: &str = "claude-code:";
|
||||
@@ -62,6 +67,7 @@ pub const MAX_CONCURRENT_TURNS: usize = 4;
|
||||
|
||||
/// CC-CLI-backed `Provider`. Owns a `Semaphore` that caps concurrent
|
||||
/// child processes and an `Arc<SessionStore>` for per-thread UUIDs.
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeCodeProvider {
|
||||
pub model: String,
|
||||
bin_path: PathBuf,
|
||||
@@ -72,6 +78,7 @@ pub struct ClaudeCodeProvider {
|
||||
anthropic_api_key: Option<String>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
session_store: Arc<session_store::SessionStore>,
|
||||
profile: ModelProfile,
|
||||
}
|
||||
|
||||
impl ClaudeCodeProvider {
|
||||
@@ -83,9 +90,19 @@ impl ClaudeCodeProvider {
|
||||
project_dir: PathBuf,
|
||||
anthropic_api_key: Option<String>,
|
||||
) -> Self {
|
||||
let model = model.into();
|
||||
let session_store = Arc::new(session_store::SessionStore::open(&workspace_dir));
|
||||
Self {
|
||||
model: model.into(),
|
||||
profile: ModelProfile {
|
||||
provider: Some("claude-code".to_string()),
|
||||
model: Some(model.clone()),
|
||||
tool_calling: true,
|
||||
parallel_tool_calls: true,
|
||||
streaming: true,
|
||||
streaming_tool_chunks: true,
|
||||
..Default::default()
|
||||
},
|
||||
model,
|
||||
bin_path,
|
||||
workspace_dir,
|
||||
project_dir,
|
||||
@@ -183,6 +200,102 @@ impl ClaudeCodeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_model_error(error: anyhow::Error) -> TinyAgentsError {
|
||||
let message = format!("claude-code model call failed: {error}");
|
||||
if crate::openhuman::inference::provider::error_classify::is_non_retryable(&error) {
|
||||
TinyAgentsError::Validation(message)
|
||||
} else {
|
||||
TinyAgentsError::Model(message)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatModel<()> for ClaudeCodeProvider {
|
||||
fn profile(&self) -> Option<&ModelProfile> {
|
||||
Some(&self.profile)
|
||||
}
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
_state: &(),
|
||||
request: ModelRequest,
|
||||
) -> tinyagents::Result<ModelResponse> {
|
||||
let messages = crate::openhuman::tinyagents::model::native_chat_messages(&request);
|
||||
let response = self
|
||||
.run_chat(
|
||||
ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
max_tokens: request.max_tokens,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_model_error)?;
|
||||
Ok(crate::openhuman::tinyagents::model::native_model_response(
|
||||
&response,
|
||||
))
|
||||
}
|
||||
|
||||
async fn stream(&self, _state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
|
||||
let provider = self.clone();
|
||||
let label = self.model.clone();
|
||||
let (item_tx, item_rx) = tokio::sync::mpsc::unbounded_channel::<ModelStreamItem>();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = item_tx.send(ModelStreamItem::Started);
|
||||
let messages = crate::openhuman::tinyagents::model::native_chat_messages(&request);
|
||||
let (delta_tx, mut delta_rx) =
|
||||
tokio::sync::mpsc::channel::<super::types::ProviderDelta>(64);
|
||||
let chat = async {
|
||||
provider
|
||||
.run_chat(
|
||||
ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: Some(&delta_tx),
|
||||
max_tokens: request.max_tokens,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
};
|
||||
tokio::pin!(chat);
|
||||
|
||||
let response = loop {
|
||||
tokio::select! {
|
||||
delta = delta_rx.recv() => {
|
||||
if let Some(delta) = delta {
|
||||
crate::openhuman::tinyagents::model::forward_provider_delta(
|
||||
&item_tx,
|
||||
delta,
|
||||
);
|
||||
}
|
||||
}
|
||||
response = &mut chat => break response,
|
||||
}
|
||||
};
|
||||
while let Ok(delta) = delta_rx.try_recv() {
|
||||
crate::openhuman::tinyagents::model::forward_provider_delta(&item_tx, delta);
|
||||
}
|
||||
|
||||
let terminal = match response {
|
||||
Ok(response) => ModelStreamItem::Completed(
|
||||
crate::openhuman::tinyagents::model::native_model_response(&response),
|
||||
),
|
||||
Err(error) => ModelStreamItem::Failed(map_model_error(error).to_string()),
|
||||
};
|
||||
let _ = item_tx.send(terminal);
|
||||
});
|
||||
let guard = crate::openhuman::tinyagents::abort_guard::AbortOnDrop::new(handle, label);
|
||||
let stream =
|
||||
futures_util::stream::unfold((item_rx, guard), |(mut receiver, guard)| async move {
|
||||
receiver.recv().await.map(|item| (item, (receiver, guard)))
|
||||
});
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable session key derived from the conversation's first user message.
|
||||
/// Best-effort — Phase 4 will plumb the real OpenHuman thread id through
|
||||
/// `ChatRequest`.
|
||||
@@ -204,71 +317,31 @@ fn thread_key_from_messages(messages: &[ChatMessage]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ClaudeCodeProvider {
|
||||
fn telemetry_provider_id(&self) -> String {
|
||||
"claude-code".to_string()
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let mut messages = Vec::new();
|
||||
if let Some(sp) = system_prompt {
|
||||
messages.push(ChatMessage::system(sp));
|
||||
}
|
||||
messages.push(ChatMessage::user(message));
|
||||
let request = ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let resp = self.run_chat(request, Some(model)).await?;
|
||||
Ok(resp.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let request = ChatRequest {
|
||||
messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let resp = self.run_chat(request, Some(model)).await?;
|
||||
Ok(resp.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.run_chat(request, Some(model)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chat_model_profile_advertises_native_streaming_tools() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let project = tempfile::tempdir().expect("project");
|
||||
let provider = ClaudeCodeProvider::new(
|
||||
"claude-sonnet-4-6",
|
||||
PathBuf::from("claude"),
|
||||
workspace.path().to_path_buf(),
|
||||
project.path().to_path_buf(),
|
||||
None,
|
||||
);
|
||||
|
||||
let profile = provider.profile().expect("profile");
|
||||
assert_eq!(profile.provider.as_deref(), Some("claude-code"));
|
||||
assert_eq!(profile.model.as_deref(), Some("claude-sonnet-4-6"));
|
||||
assert!(profile.tool_calling);
|
||||
assert!(profile.parallel_tool_calls);
|
||||
assert!(profile.streaming);
|
||||
assert!(profile.streaming_tool_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_key_is_stable_for_same_conversation() {
|
||||
let a = vec![ChatMessage::user("hello world")];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user