refactor(tinyagents): complete provider cutover and lifecycle consolidation (#5143)

This commit is contained in:
Steven Enamakel
2026-07-24 05:11:30 +03:00
committed by GitHub
parent ef38f1c27f
commit 7db104b71d
211 changed files with 6773 additions and 23458 deletions
+82 -139
View File
@@ -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")?;
+1 -3
View File
@@ -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
View File
@@ -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")?;
+8 -32
View File
@@ -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"),
+2 -3
View File
@@ -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;
+6 -6
View File
@@ -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 {
+1 -1
View File
@@ -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};
+25 -49
View File
@@ -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);
+100 -77
View File
@@ -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))
+1 -1
View File
@@ -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`
-72
View File
@@ -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.
+633
View File
@@ -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"}"#);
}
}
+78
View File
@@ -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>),
}
+2
View File
@@ -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 -1
View File
@@ -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,
}
+66 -82
View File
@@ -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();
+3 -2
View File
@@ -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(),
);
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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};
+6 -23
View File
@@ -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,
+6 -25
View File
@@ -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"
);
+6 -27
View File
@@ -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![
+1 -1
View File
@@ -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,
});
+36 -71
View File
@@ -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,
+18 -25
View File
@@ -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()
+8 -10
View File
@@ -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"
)
}
+10 -19
View File
@@ -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),
+5 -7
View File
@@ -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(),
+24 -14
View File
@@ -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,
+126 -161
View File
@@ -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"))
}
}
+6 -4
View File
@@ -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,
+11 -7
View File
@@ -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,
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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(
+7 -7
View File
@@ -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 {
+8 -7
View File
@@ -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(),
+15 -16
View File
@@ -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
@@ -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
+14 -20
View File
@@ -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 {
+2 -2
View File
@@ -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 -1
View File
@@ -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;
+4 -4
View File
@@ -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