Files
openhuman/src/openhuman/tinyagents/mod.rs
T

1447 lines
68 KiB
Rust

//! `tinyagents` integration — drive an openhuman agent turn on the published
//! [`tinyagents`](https://crates.io/crates/tinyagents) orchestration framework
//! (issue #4249).
//!
//! openhuman's agent execution runs on the `tinyagents` crate
//! (LangGraph/LangChain-style durable graphs + an agent-loop harness with model/
//! tool registries, middleware, retry/fallback, and limits). This module is the
//! **adapter seam**: it bridges openhuman's `Provider`, `Tool`, and `ChatMessage`
//! types onto the crate's `ChatModel`, `Tool`, and `Message` traits, then drives
//! a turn through [`AgentHarness::invoke`]. The chat / channel / sub-agent
//! routes call [`run_turn_via_tinyagents_shared`] (default ON in production).
//!
//! The chat route is at functional parity with the legacy `run_turn_engine`:
//! the [`OpenhumanEventBridge`] mirrors the harness event stream onto
//! `AgentProgress` (live tool timeline, incremental text deltas, cost footer),
//! [`ProviderModel::stream`] forwards true token streaming, multimodal markers
//! are expanded, and history is trimmed to the context window. Mid-flight
//! steering, sub-agent child-progress deltas (incl. thinking), and the
//! `ask_user_clarification` early-exit pause are all re-wired onto the
//! tinyagents harness.
mod convert;
pub(crate) mod delegation;
mod embeddings;
pub(crate) mod journal;
pub(crate) mod middleware;
mod model;
pub(crate) mod observability;
pub(crate) mod orchestration;
pub(crate) mod payload_summarizer;
mod policy_denial;
pub(crate) mod retriever;
mod routes;
mod run_cancellation_context;
pub(crate) mod stop_hooks;
pub(crate) mod subagent_graph;
mod summarize;
mod tools;
mod topology;
use std::sync::Arc;
use anyhow::Result;
use tinyagents::harness::cache::InMemoryResponseCache;
use tinyagents::harness::context::{RunConfig, RunContext};
use tinyagents::harness::events::EventSink;
use tinyagents::harness::message::Message as TaMessage;
use tinyagents::harness::middleware::{
ContextCompressionMiddleware, MessageTrimMiddleware, PromptCacheGuardMiddleware,
ToolPolicyMiddleware as TaToolPolicyMiddleware,
};
use tinyagents::harness::model::CapabilitySet;
use tinyagents::harness::runtime::{AgentHarness, RunPolicy, UnknownToolPolicy};
use tinyagents::harness::steering::{SteeringCommand, SteeringHandle};
use tinyagents::harness::store::StoreRegistry;
use tinyagents::harness::summarization::TrimStrategy;
use tinyagents::harness::workspace::WorkspaceDescriptor;
use tinyagents::registry::{
CapabilityRegistry, ComponentKind, DiagnosticSeverity, RegistryDiagnostic, RegistrySnapshot,
};
use crate::openhuman::agent::harness::tool_result_artifacts::{
ToolResultArtifactIndexStore, TINYAGENTS_TOOL_RESULT_ARTIFACT_STORE,
};
use crate::openhuman::agent::harness::{run_queue::RunQueue, MAX_SPAWN_DEPTH};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider};
use model::ThinkingForwarder;
#[allow(unused_imports)] // Wired into the recall/retrieval facade in workstream 09.2.
pub(crate) use embeddings::ProviderEmbeddingModel;
pub(crate) use middleware::{HandoffConfig, SuperContextConfig, TurnContextMiddleware};
use model::ProviderModel;
pub(crate) use observability::SubagentScope;
use observability::{CapPauser, IterationCursor, OpenhumanEventBridge, ToolNameMap};
pub(crate) use run_cancellation_context::{current_run_cancellation, with_run_cancellation};
#[cfg(test)]
use tools::ToolAdapter;
use tools::{EarlyExitHook, SharedToolAdapter};
pub(crate) use topology::all_graph_topologies;
use std::collections::HashSet;
use std::sync::Arc as StdArc;
use tokio::sync::mpsc::Sender;
/// The builder-configured [`ToolPolicy`](crate::openhuman::agent::tool_policy::ToolPolicy)
/// plus the session context a policy check needs, handed to the shared turn seam
/// so it can install the [`ToolPolicyMiddleware`](middleware::ToolPolicyMiddleware).
/// `None` means "no policy enforcement on this turn" (the channel/CLI + sub-agent
/// paths, which carry their own gating).
pub(crate) struct ToolPolicyEnforcement {
pub policy: StdArc<dyn crate::openhuman::agent::tool_policy::ToolPolicy>,
/// The session's channel-permission snapshot — enforces the per-channel
/// permission ceiling (deny + per-call permission-level gate) the in-house
/// engine ran in `agent_tool_exec`.
pub session: crate::openhuman::agent_tool_policy::ToolPolicySession,
pub session_id: String,
pub channel: String,
pub agent_definition_id: String,
}
/// Drain the run queue's pending steer messages and forward them to the
/// tinyagents [`SteeringHandle`] as injected user turns (the harness applies
/// them to the working transcript at the next iteration checkpoint). This is the
/// bridge behind the `steer_subagent` / mid-flight-steering feature.
async fn forward_steers(queue: &RunQueue, handle: &SteeringHandle) {
for msg in queue.drain_steers().await {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"[User steering message]: {}",
msg.text
))));
}
}
/// Forward any queued **collect** messages (orchestrator/monitor lines enqueued
/// via `QueueMode::Collect`) into the run as injected user turns so they reach the
/// next LLM call as additional context. The in-house loop drained these each
/// iteration (`drain_collects`); the tinyagents rewrite wired only `forward_steers`
/// (issue #4249), so monitor lines never reached the model. Mirrors the legacy
/// `[Additional context from user]:` framing the model was taught to read.
async fn forward_collects(queue: &RunQueue, handle: &SteeringHandle) {
for msg in queue.drain_collects().await {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"[Additional context from user]: {}",
msg.text
))));
}
}
/// Build the harness [`RunPolicy`] for an openhuman turn.
///
/// The loop enforces limits from `self.policy.limits` (not the per-run
/// `RunConfig`), so the model-call cap **must** be set here or it falls back to
/// the tinyagents default of 25 — far more than openhuman's `max_iterations`.
/// The recursion depth cap is also set here so TinyAgents uses OpenHuman's
/// existing sub-agent spawn depth instead of the SDK default.
/// Retry is set to a single attempt: the openhuman [`Provider`] already does its
/// own internal retry/backoff (via the still-wrapped `ReliableProvider`), so a
/// second harness-level retry layer would double-retry transient errors and,
/// worse, swallow a deterministic provider error when a mock/test provider yields
/// a different result on the retry. This pin stays until `ReliableProvider` is
/// un-wrapped in the 02.2 conformance pass (Workstream 11); the crate's
/// exp-backoff [`RetryPolicy`](tinyagents::harness::retry::RetryPolicy) fields
/// stay at the default schedule so raising `max_attempts` later is a one-line flip.
///
/// Cross-route **fallback** (`RunPolicy.fallback`) is orthogonal to retry and is
/// populated per-turn by the caller ([`assemble_turn_harness`] via
/// [`routes::route_fallback_policy`]); it is safe to enable now because
/// `ReliableProvider` does *not* fail over across the registered workload-tier
/// routes (chat→burst, reasoning→agentic, …) the way the harness registry can.
fn run_policy_for(max_iterations: usize, response_cache_enabled: bool) -> RunPolicy {
let mut policy = RunPolicy::default();
policy.limits.max_model_calls = max_iterations;
policy.limits.max_tool_calls = max_iterations.saturating_mul(8).max(8);
policy.limits.max_depth = MAX_SPAWN_DEPTH;
policy.retry.max_attempts = 1;
policy.unknown_tool = UnknownToolPolicy::ReturnToolError;
// Prompt-prefix protection is always on (issue #4249, 03.2): the
// `PromptCacheGuardMiddleware` records a `CacheLayoutEvent` whenever volatile
// content busts the provider KV-cache prefix. Purely diagnostic — never
// mutates the request.
policy.cache.protect_prompt_prefix = true;
// Response caching is gated: it is enabled only for deterministic internal
// runs (which additionally attach a `ResponseCache`). Interactive chat turns
// pass `false` here AND attach no cache, so a live user turn can never be
// served a cached model response (double fail-safe).
policy.cache.response_cache_enabled = response_cache_enabled;
policy
}
/// Consecutive identical tool failures that trip the repeated-failure circuit
/// breaker (see `middleware::RepeatedToolFailureMiddleware`). Three matches the
/// legacy progress-guard's tolerance before it halted a stuck loop.
const REPEATED_TOOL_FAILURE_THRESHOLD: usize = 3;
/// Legacy default model-call cap used when a caller passes `max_iterations == 0`
/// to request "unset" (native-bus / test callers relied on the old loop treating
/// `max_tool_iterations == 0` as the default of 10). Passing `0` straight through
/// would set the harness `max_model_calls` to zero and abort before the first
/// provider call, so the runners normalize `0` to this value.
const DEFAULT_MAX_ITERATIONS: usize = 10;
/// Normalize a caller-supplied iteration cap: `0` means "unset" → the default.
fn effective_max_iterations(max_iterations: usize) -> usize {
if max_iterations == 0 {
DEFAULT_MAX_ITERATIONS
} else {
max_iterations
}
}
/// The outcome of a turn driven on the `tinyagents` harness.
#[derive(Debug, Clone)]
pub(crate) struct TinyagentsTurnOutcome {
/// Final assistant text.
pub text: String,
/// The full transcript, converted back to openhuman messages (flat — tool
/// calls rendered as text).
pub history: Vec<ChatMessage>,
/// The **typed** messages this turn appended (after the user turn):
/// `AssistantToolCalls` / `ToolResults` / final assistant `Chat`. The chat
/// session persists these to keep structured tool-call history fidelity.
pub conversation: Vec<ConversationMessage>,
/// Number of model calls the loop made.
pub model_calls: usize,
/// Number of tool calls the loop made.
pub tool_calls: usize,
/// Accumulated input tokens.
pub input_tokens: u64,
/// Accumulated output tokens.
pub output_tokens: u64,
/// Accumulated cached (cache-read) input tokens. Carried so the turn persists
/// real cached usage instead of zero (issue #4249, Phase 5).
pub cached_input_tokens: u64,
/// Estimated charged USD for the turn (from `cost::catalog::estimate_cost_usd`
/// over the observed usage). Carried so the transcript / session meters record
/// a real cost instead of `$0` on every non-cap turn.
pub charged_amount_usd: f64,
/// Set when an early-exit tool (e.g. `ask_user_clarification`) fired: the
/// loop paused so the caller can checkpoint and surface the question. When
/// present, `text` holds the question. Mirrors the legacy `early_exit_tool`.
pub early_exit_tool: Option<String>,
/// `true` when the run stopped because it reached the model-call cap with
/// work still pending (the last response requested more tools). The caller
/// should summarize a resumable checkpoint rather than treat `text` as a
/// final answer — the tinyagents analogue of the legacy cap checkpoint seam.
pub hit_cap: bool,
/// Per-tool-call execution outcomes (success + raw result content), keyed by
/// provider call id, captured at the tool boundary. The harness folds a tool
/// result into a `Message::tool` that drops its `error` flag, so this is the
/// only place the caller can recover whether each call actually failed — used
/// to build honest `ToolCallRecord`s for post-turn hooks + the cap checkpoint.
pub tool_outcomes: Vec<ToolCallOutcome>,
}
/// One tool call's execution outcome, captured at the tool boundary before the
/// harness discards the failure flag. `success` mirrors the absence of a
/// `TaToolResult::error`; `content` is the (possibly summarized/capped) result
/// text used to derive a sanitized post-turn summary.
#[derive(Debug, Clone)]
pub(crate) struct ToolCallOutcome {
pub call_id: String,
pub name: String,
pub success: bool,
pub content: String,
}
/// Shared sink the [`ToolOutcomeCaptureMiddleware`](middleware::ToolOutcomeCaptureMiddleware)
/// appends each tool call's outcome to, drained into the turn outcome.
pub(crate) type ToolOutcomeSink = std::sync::Arc<std::sync::Mutex<Vec<ToolCallOutcome>>>;
/// Shared slot the repeated-failure breaker writes a root-cause halt summary into
/// when it trips. The turn overrides its final text with this summary so the
/// no-progress halt surfaces the cause instead of an empty/last-model reply
/// (legacy `RepeatFailureGuard` parity).
pub(crate) type HaltSummarySlot = std::sync::Arc<std::sync::Mutex<Option<String>>>;
/// Drive an agent turn through the `tinyagents` agent-loop harness.
///
/// Registers `provider` as the default model and every entry in `resolved_tools`
/// as a harness tool, seeds the loop with `history`, and runs the loop bounded
/// by `max_iterations` model calls. Returns the final text plus the resulting
/// transcript translated back to openhuman [`ChatMessage`]s.
#[cfg(test)]
pub(crate) async fn run_turn_via_tinyagents(
provider: Arc<dyn Provider>,
model: &str,
temperature: f64,
history: Vec<ChatMessage>,
resolved_tools: Vec<Arc<dyn crate::openhuman::tools::Tool>>,
max_iterations: usize,
) -> Result<TinyagentsTurnOutcome> {
// `0` means "unset" → the legacy default; otherwise the harness cap would be
// zero and the run would abort before the first model call.
let max_iterations = effective_max_iterations(max_iterations);
let mut harness: AgentHarness<()> = AgentHarness::new();
// Thin test variant: no response cache (chat-safe default).
harness.with_policy(run_policy_for(max_iterations, false));
let provider_model = ProviderModel::new(provider, model, temperature);
let error_slot = provider_model.error_slot();
harness
.register_model(model, Arc::new(provider_model))
.set_default_model(model);
let tool_count = resolved_tools.len();
for tool in resolved_tools {
harness.register_tool(Arc::new(ToolAdapter::new(tool)));
}
// Bound the run: one model call per legacy "iteration", and allow generous
// tool calls (the loop also stops when the model stops requesting tools).
let config = RunConfig::new("agent_turn")
.with_max_model_calls(max_iterations)
.with_max_tool_calls(max_iterations.saturating_mul(8).max(8))
.with_max_depth(MAX_SPAWN_DEPTH)
.with_tag("openhuman")
.with_tag("scope:root")
.with_tag("unobserved");
tracing::info!(
model,
max_iterations,
tools = tool_count,
"[tinyagents] routing agent turn through tinyagents harness"
);
let input = convert::history_to_messages(&history);
// Box the (large) harness drive future — see `run_turn_via_tinyagents_shared`.
let run = match Box::pin(harness.invoke(&(), (), config, input)).await {
Ok(run) => run,
Err(e) => {
if let Some(original) = error_slot.lock().unwrap().take() {
return Err(original);
}
return Err(anyhow::anyhow!("tinyagents harness run failed: {e}"));
}
};
let text = run.text().unwrap_or_default();
let out_history = convert::messages_to_history(&run.messages);
let conversation =
convert::messages_to_conversation(convert::messages_since_last_user(&run.messages));
Ok(TinyagentsTurnOutcome {
text,
history: out_history,
conversation,
model_calls: run.model_calls,
tool_calls: run.tool_calls,
input_tokens: run.usage.usage.input_tokens,
output_tokens: run.usage.usage.output_tokens,
cached_input_tokens: run.usage.usage.cache_read_tokens,
charged_amount_usd: crate::openhuman::cost::catalog::estimate_cost_usd(
model,
run.usage.usage.input_tokens,
run.usage.usage.output_tokens,
run.usage.usage.cache_read_tokens,
),
early_exit_tool: None,
hit_cap: false,
// This thin variant carries no per-call outcome capture middleware.
tool_outcomes: Vec::new(),
})
}
/// Drive a turn through the tinyagents harness over the routes' **shared**,
/// `Arc`-owned tool registry sets (`Arc<Vec<Box<dyn Tool>>>`), advertising
/// exactly `specs` (already filtered/deduped by the caller's visibility rules).
///
/// This is the entry point the channel/sub-agent routes use to retire the
/// in-house `live` turn machine: it registers a [`SharedToolAdapter`] per
/// advertised spec so the same `Arc`-shared tools the legacy loop runs are
/// reused without cloning.
///
/// `allowed` is the callable tool-name whitelist (empty = every tool visible in
/// `tool_sets`); each callable tool is advertised via its own `spec()`.
///
/// When `on_progress` is `Some`, the run streams (`invoke_streaming_in_context`)
/// and a [`OpenhumanEventBridge`] mirrors the harness event stream onto
/// `AgentProgress` (live tool timeline, text deltas, cost/token footer) and the
/// global cost tracker — restoring the seams the legacy `run_turn_engine`
/// produced. Pass `None` for fire-and-forget turns (channel/sub-agent) that
/// only need the final text.
///
/// When `context_window` is known, a [`MessageTrimMiddleware`] keeps history
/// under budget (autocompaction parity).
///
/// `run_queue` forwards mid-flight steer messages into the run; `subagent_scope`
/// re-scopes progress to the `Subagent*` variants (child runs); `early_exit_tools`
/// name the tools that pause the loop (e.g. `ask_user_clarification`) and surface
/// the question via [`TinyagentsTurnOutcome::early_exit_tool`].
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_turn_via_tinyagents_shared(
provider: Arc<dyn Provider>,
model: &str,
temperature: f64,
history: Vec<ChatMessage>,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: HashSet<String>,
max_iterations: usize,
on_progress: Option<Sender<AgentProgress>>,
subagent_scope: Option<SubagentScope>,
context_window: Option<u64>,
run_queue: Option<Arc<RunQueue>>,
early_exit_tools: &[&str],
pause_at_cap: bool,
max_output_tokens: Option<u32>,
context_mw: TurnContextMiddleware,
tool_policy: Option<ToolPolicyEnforcement>,
workspace_descriptor: Option<WorkspaceDescriptor>,
deterministic_cacheable: bool,
) -> Result<TinyagentsTurnOutcome> {
// `0` means "unset" → the legacy default (a native-bus / test convention);
// otherwise the harness model-call cap would be zero and abort the run before
// the first provider call.
let max_iterations = effective_max_iterations(max_iterations);
let AssembledTurnHarness {
harness,
cursor,
tool_names,
error_slot,
halt_summary,
tool_outcome_sink,
handle,
early_exit_hook,
tool_count,
registry_snapshot: _,
registry_diagnostics,
tool_result_artifact_index,
compression_mw,
prompt_cache_guard,
} = assemble_turn_harness(
provider,
model,
temperature,
tool_sets,
allowed,
max_iterations,
on_progress.clone(),
subagent_scope.clone(),
context_window,
early_exit_tools,
max_output_tokens,
context_mw,
tool_policy,
routes::turn_required_capabilities(model),
deterministic_cacheable,
);
// Fail-closed registry validation gate (issue #4249, Workstream 10 — registry).
// The projected `CapabilityRegistry` produced these diagnostics during
// assembly; enforce them here, *before* the first model dispatch, so an
// ambiguous/broken tool surface (duplicate name across native/MCP/Composio/
// generated tools, dangling alias, etc.) aborts the turn instead of silently
// resolving to an unintended component while a provider call is in flight.
if !registry_diagnostics.is_empty() {
let (errors, warnings): (Vec<&RegistryDiagnostic>, Vec<&RegistryDiagnostic>) =
registry_diagnostics
.iter()
.partition(|d| matches!(d.severity, DiagnosticSeverity::Error));
for diag in &warnings {
tracing::warn!(
kind = diag.kind.as_str(),
name = %diag.name,
"[registry] non-fatal diagnostic: {}",
diag.message
);
}
if !errors.is_empty() {
let messages: Vec<String> = errors
.iter()
.map(|d| format!("[{}] {}: {}", d.kind.as_str(), d.name, d.message))
.collect();
for msg in &messages {
tracing::error!("[registry] error-severity diagnostic aborting turn: {msg}");
}
tracing::error!(
error_count = messages.len(),
warning_count = warnings.len(),
"[registry] aborting turn before model dispatch: capability registry validation failed"
);
return Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::RegistryValidationFailed {
diagnostics: messages,
},
));
}
tracing::debug!(
warning_count = warnings.len(),
"[registry] registry diagnostics present (warnings only); proceeding with turn"
);
}
let config = RunConfig::new("agent_turn")
.with_max_model_calls(max_iterations)
.with_max_tool_calls(max_iterations.saturating_mul(8).max(8))
.with_max_depth(MAX_SPAWN_DEPTH)
.with_tag("openhuman")
.with_tag(if subagent_scope.is_some() {
"scope:subagent"
} else {
"scope:root"
})
.with_tag(if on_progress.is_some() {
"observed"
} else {
"unobserved"
});
tracing::info!(
model,
max_iterations,
tools = tool_count,
observed = on_progress.is_some(),
"[tinyagents] routing turn through tinyagents harness (shared tools)"
);
let input = convert::history_to_messages(&history);
// Build the run context: an optional event sink feeds the progress/cost
// bridge (streaming) and/or the model-call-cap pauser; the shared steering
// handle carries mid-flight, early-exit, and cap pauses.
let cancellation = tinyagents::CancellationToken::new();
let mut ctx = RunContext::new(config, ()).with_cancellation(cancellation.clone());
if let Some(descriptor) = workspace_descriptor {
tracing::debug!(
root = %descriptor.root.display(),
policy_id = %descriptor.policy_id,
"[tinyagents] attaching workspace descriptor"
);
ctx = ctx.with_workspace(descriptor);
}
if let Some(index) = tool_result_artifact_index {
let mut stores = StoreRegistry::new();
stores.register(TINYAGENTS_TOOL_RESULT_ARTIFACT_STORE, index);
ctx = ctx.with_stores(stores);
}
let streaming = on_progress.is_some();
// Retain a clone of the progress sink so the turn can emit a terminal
// `TurnCompleted` after the run (the harness event stream the bridge mirrors
// has no run-completed event). Parent turns only — a sub-agent turn reports
// via its `Subagent*` events, not a top-level `TurnCompleted`.
let turn_completed_sink = subagent_scope
.is_none()
.then(|| on_progress.clone())
.flatten();
// A sink is needed to mirror progress (bridge), to observe model-call
// completions for the cap pauser, or to persist a durable event journal
// (issue #4249, 05.1). The journal must attach even for an unobserved
// (`on_progress = None`) turn so the run stays reconstructable, so the
// EventSink is now created unconditionally — cheap (an empty sink) and, if
// no consumer subscribes, inert.
let events = Some(EventSink::new());
let bridge = match (&events, on_progress) {
(Some(events), Some(tx)) => {
let bridge = OpenhumanEventBridge::with_scope(
Some(tx),
model,
max_iterations,
subagent_scope.clone(),
cursor.clone(),
tool_names.clone(),
);
events.subscribe(bridge.clone());
Some(bridge)
}
_ => None,
};
// Cap pauser: stop gracefully at the model-call budget (returning the partial
// transcript) so the caller can summarize a checkpoint instead of erroring.
if pause_at_cap {
if let (Some(events), Some(handle)) = (&events, &handle) {
events.subscribe(CapPauser::new(handle.clone(), max_iterations));
}
}
// Durable event journal + status store (issue #4249, 05.1). Attached *in
// addition to* the bridge above: the EventSink fans out to both, so the
// existing progress/global-bus path is untouched. Best-effort and non-fatal
// — a failure to open/attach the journal returns `None` and the turn runs
// unaffected. The handle stamps the terminal status once the run returns.
let turn_journal = match &events {
Some(events) => journal::attach_turn_journal(events, model).await,
None => None,
};
if let Some(events) = &events {
ctx = ctx.with_events(events.clone());
}
// Steering: attach the shared handle (when present), drain any already-queued
// steer messages into it (so a pre-run steer lands before the first model
// call), and forward mid-flight steers via a poller aborted when the run
// returns. The same handle carries the early-exit `Pause`.
let mut registered_steering_task_id = None;
let steering_forwarder = if let Some(handle) = handle {
if let Some(scope) = &subagent_scope {
let task_id = orchestration::TaskId::new(scope.task_id.clone());
orchestration::shared_steering_registry().register(task_id.clone(), handle.clone());
tracing::debug!(
task_id = scope.task_id.as_str(),
"[tinyagents] registered subagent steering handle"
);
registered_steering_task_id = Some(task_id);
}
if let Some(queue) = run_queue.clone() {
forward_steers(&queue, &handle).await;
forward_collects(&queue, &handle).await;
}
ctx = ctx.with_steering(handle.clone());
run_queue.map(|queue| {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
forward_steers(&queue, &handle).await;
forward_collects(&queue, &handle).await;
}
})
})
} else {
None
};
// Heap-allocate the harness drive future. It is large (it owns the whole run
// context, middleware stack, and loop state), and a sub-agent turn runs
// nested inside its parent's drive future — leaving it inline on the stack
// overflows when the parent + child drives compose. Boxing keeps only a
// pointer on the stack at each level.
let run_result = with_run_cancellation(cancellation.clone(), async {
if streaming {
Box::pin(harness.invoke_streaming_in_context(&(), ctx, input)).await
} else {
Box::pin(harness.invoke_in_context(&(), ctx, input)).await
}
})
.await;
if let Some(forwarder) = steering_forwarder {
forwarder.abort();
}
if let Some(task_id) = registered_steering_task_id {
orchestration::shared_steering_registry().deregister(&task_id);
tracing::debug!(
task_id = task_id.as_str(),
"[tinyagents] deregistered subagent steering handle"
);
}
let run = match run_result {
Ok(run) => run,
Err(e) => {
// Durable journal: stamp the terminal failed status (best-effort,
// non-fatal) before unwinding through the typed-error mapping below.
if let Some(journal) = &turn_journal {
journal.finish_failed(&e.to_string()).await;
}
// Prefer the original typed provider error (preserves `AgentError`
// downcasts the caller relies on) over the harness's string wrap.
if let Some(original) = error_slot.lock().unwrap().take() {
return Err(original);
}
// The model-call cap (when not pausing gracefully — the channel/CLI
// path) maps to the typed `AgentError::MaxIterationsExceeded` so
// callers downcast it (Sentry skip) and render the canonical
// "Agent exceeded maximum tool iterations" message, matching the
// legacy `ErrorCheckpoint`.
if let tinyagents::TinyAgentsError::LimitExceeded(msg) = &e {
if msg.contains("model call") {
return Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::MaxIterationsExceeded {
max: max_iterations,
},
));
}
}
if let Some(depth_err) = tinyagents_depth_error(&e) {
return Err(anyhow::Error::new(depth_err));
}
return Err(anyhow::anyhow!("tinyagents harness run failed: {e}"));
}
};
// Durable journal: the harness returned a transcript, so stamp the terminal
// completed status (best-effort, non-fatal). The event stream carries no
// run-terminal event, so this caller-driven write is authoritative.
if let Some(journal) = &turn_journal {
journal.finish_completed().await;
}
// Context-compression provenance (issue #4249, 03.1 item 6): the harness's
// `AgentEvent::Compressed` projection only carries token deltas, so drain the
// compression middleware's `records()` here — each carries the full
// `CompressionProvenance` (source ids + before/after token estimates + policy
// reason) built by `ProviderModelSummarizer`. Surfaced at info with a
// grep-friendly `[context]` prefix so every compaction is auditable, not just
// its net token saving.
if let Some(mw) = &compression_mw {
let records = mw.records();
if !records.is_empty() {
tracing::info!(
model,
compactions = records.len(),
"[context] turn performed {} context compaction(s); surfacing provenance",
records.len()
);
for (idx, record) in records.iter().enumerate() {
let provenance = &record.provenance;
tracing::info!(
model,
compaction = idx + 1,
of = records.len(),
source_count = provenance.source_ids.len(),
source_ids = ?provenance.source_ids,
from_tokens = provenance.original_token_estimate,
to_tokens = provenance.summary_token_estimate,
saved_tokens = provenance
.original_token_estimate
.saturating_sub(provenance.summary_token_estimate),
reason = %provenance.reason,
"[context] compaction provenance: folded {} source message(s) ({} -> {} tokens)",
provenance.source_ids.len(),
provenance.original_token_estimate,
provenance.summary_token_estimate,
);
}
}
}
// Prompt-cache layout diagnostics (issue #4249, 03.2): drain the crate
// `PromptCacheGuardMiddleware`'s recorded `CacheLayoutEvent`s and surface each
// as a structured `[cache]` warning. Fires only when the cacheable prompt
// prefix (system prompt + tool set) changed across model calls — i.e. volatile
// content silently busting the provider KV-cache prefix. The structured
// successor to `CacheAlignMiddleware`'s free-text warn-log (still installed in
// parallel until parity is shown).
let cache_layout_events = prompt_cache_guard.layout_events();
if !cache_layout_events.is_empty() {
tracing::debug!(
model,
events = cache_layout_events.len(),
"[cache] surfacing prompt-cache layout change events"
);
observability::surface_cache_layout_events(model, &cache_layout_events);
}
// Terminal turn event (parity with the legacy engine's `progress::emit`): the
// harness stream has no run-completed event, so emit `TurnCompleted` here with
// the model-call count as the iteration total. Parent turns only; best-effort.
if let Some(sink) = &turn_completed_sink {
let _ = sink.try_send(AgentProgress::TurnCompleted {
iterations: run.model_calls as u32,
});
}
// Response-cache effectiveness for this turn (issue #4249, 03.2). Additive —
// logged with a grep-friendly `[cache]` prefix here; wiring the counts into the
// cost-footer DTO is a follow-up coordinated with workstream 06. Only the
// observed (bridge) path accumulates these; deterministic internal runs that
// attach a `ResponseCache` are where non-zero counts appear.
if let Some(bridge) = &bridge {
let (cache_hits, cache_misses) = bridge.cache_counts();
if cache_hits > 0 || cache_misses > 0 {
tracing::debug!(
model,
cache_hits,
cache_misses,
"[cache] turn response-cache summary"
);
}
}
let bridge_totals = bridge.map(|bridge| bridge.totals_with_cost());
// Prefer the bridge's accumulated usage (per-call, authoritative — including
// cached tokens and the estimated charged USD) when the observed path ran;
// otherwise fall back to the run's aggregate totals and estimate the cost from
// them so a fire-and-forget turn still reports a real (non-$0) cost.
let (input_tokens, output_tokens, cached_input_tokens, charged_amount_usd) = bridge_totals
.unwrap_or_else(|| {
let input = run.usage.usage.input_tokens;
let output = run.usage.usage.output_tokens;
let cached = run.usage.usage.cache_read_tokens;
let charged =
crate::openhuman::cost::catalog::estimate_cost_usd(model, input, output, cached);
record_unobserved_turn_usage(model, input, output, cached, charged);
(input, output, cached, charged)
});
// An early-exit tool fired: the loop paused after its round. Surface the tool
// name and use its captured question as the turn text (the paused assistant
// turn carries the tool call, not a final answer) so the caller can
// checkpoint and prompt the user — matching the legacy `early_exit_tool`.
let early_exit = early_exit_hook.and_then(|hook| hook.take());
// Cap detection: the harness sets `final_response` only when the loop
// finishes naturally (the model stopped requesting tools). When the cap
// pauser stops the loop mid-work, `final_response` stays `None` — that's the
// cap hit. An early-exit is a clean pause and takes precedence; under
// `pause_at_cap` the only other `Pause` source is the cap pauser, so this is
// unambiguous. (`run_queue` steering injects messages, never pauses.)
// The repeated-failure breaker halts the run with a root-cause summary instead
// of a final model turn; surface it as the turn's text so the no-progress cause
// reaches the caller/user rather than an empty reply.
let breaker_halt = halt_summary.lock().ok().and_then(|mut s| s.take());
// Cap detection: the harness sets `final_response` only when the loop
// finishes naturally (the model stopped requesting tools). When the cap
// pauser stops the loop mid-work, `final_response` stays `None` — that's the
// cap hit. An early-exit is a clean pause and takes precedence; under
// `pause_at_cap` the only other `Pause` source is the cap pauser, so this is
// unambiguous. (`run_queue` steering injects messages, never pauses.) A
// breaker halt is *not* a cap hit: it already carries a root-cause summary, so
// treating it as a cap would let the caller (sub-agent runner) overwrite that
// summary with a generic checkpoint digest.
let hit_cap = pause_at_cap
&& early_exit.is_none()
&& breaker_halt.is_none()
&& run.model_calls >= max_iterations
&& run.final_response.is_none();
let (early_exit_tool, mut text) = match early_exit {
Some(exit) => (Some(exit.tool), exit.question),
None => (None, run.text().unwrap_or_default()),
};
if let Some(summary) = breaker_halt {
text = summary;
}
let tool_outcomes = tool_outcome_sink
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
Ok(TinyagentsTurnOutcome {
text,
history: convert::messages_to_history(&run.messages),
conversation: convert::messages_to_conversation(convert::messages_since_last_user(
&run.messages,
)),
model_calls: run.model_calls,
tool_calls: run.tool_calls,
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
early_exit_tool,
hit_cap,
tool_outcomes,
})
}
fn tinyagents_depth_error(
err: &tinyagents::TinyAgentsError,
) -> Option<crate::openhuman::agent::harness::subagent_runner::SubagentRunError> {
match err {
tinyagents::TinyAgentsError::SubAgentDepth(max_depth)
| tinyagents::TinyAgentsError::RecursionLimit(max_depth) => {
Some(
crate::openhuman::agent::harness::subagent_runner::SubagentRunError::SpawnDepthExceeded {
attempted_depth: max_depth.saturating_add(1),
max_depth: *max_depth,
},
)
}
_ => None,
}
}
/// Everything [`assemble_turn_harness`] wires up for one turn: the configured
/// harness plus the shared slots/handles the run loop reads after the drive
/// future returns.
struct AssembledTurnHarness {
/// The fully assembled harness: model, tools, and middleware registered in
/// the intended order.
harness: AgentHarness<()>,
/// Shared 1-based model-call cursor (event bridge advances, model adapter
/// reads for out-of-band thinking attribution).
cursor: IterationCursor,
/// Shared `call_id → tool_name` map: the model adapter's `ThinkingForwarder`
/// writes it on tool-call start; the event bridge reads it to label the
/// tool-argument fragments it now projects off the crate stream.
tool_names: ToolNameMap,
/// Recovers the original (downcastable) provider error on run failure.
error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot,
/// Root-cause summary recorded by the repeated-tool-failure breaker.
halt_summary: HaltSummarySlot,
/// Per-call tool success/content capture for honest `ToolCallRecord`s.
tool_outcome_sink: ToolOutcomeSink,
/// The shared steering handle (mid-flight steer, early-exit, cap, stop-hook
/// pauses).
handle: Option<SteeringHandle>,
/// Records the first early-exit tool round, when early-exit tools exist.
early_exit_hook: Option<EarlyExitHook>,
/// Number of callable tools registered.
tool_count: usize,
/// TinyAgents named-capability projection for this turn. The live run still
/// uses the harness registries above; this snapshot makes the projected
/// model/tool/graph inventory inspectable without changing dispatch.
registry_snapshot: RegistrySnapshot,
/// Health diagnostics from the projected registry.
registry_diagnostics: Vec<RegistryDiagnostic>,
/// TinyAgents store index for OpenHuman action-dir tool-result artifacts.
tool_result_artifact_index: Option<Arc<ToolResultArtifactIndexStore>>,
/// Concrete handle to the installed [`ContextCompressionMiddleware`], when the
/// summarization step is active. Drained after the run to surface each
/// compaction's [`CompressionProvenance`][tinyagents::harness::summarization::CompressionProvenance]
/// (source ids + before/after token estimates) via the observability path.
compression_mw: Option<Arc<ContextCompressionMiddleware>>,
/// Crate prompt-cache guard (issue #4249, 03.2). Records a `CacheLayoutEvent`
/// whenever the cacheable prompt prefix (system prompt + tool set) changes
/// across model calls. Drained after the run and surfaced via
/// [`observability::surface_cache_layout_events`] — the structured successor to
/// the `CacheAlignMiddleware` warn-log.
prompt_cache_guard: Arc<PromptCacheGuardMiddleware>,
}
/// Assemble the turn harness for [`run_turn_via_tinyagents_shared`]: register
/// the provider model, every shared tool, and the full middleware stack in the
/// intended order. Split out of the runner so the adapter inventory is directly
/// testable (issue #4249, Phase 11) — the returned [`AssembledTurnHarness`]
/// exposes the harness registries without driving a run.
#[allow(clippy::too_many_arguments)]
fn assemble_turn_harness(
provider: Arc<dyn Provider>,
model: &str,
temperature: f64,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: HashSet<String>,
max_iterations: usize,
on_progress: Option<Sender<AgentProgress>>,
subagent_scope: Option<SubagentScope>,
context_window: Option<u64>,
early_exit_tools: &[&str],
max_output_tokens: Option<u32>,
context_mw: TurnContextMiddleware,
tool_policy: Option<ToolPolicyEnforcement>,
required_capabilities: Option<CapabilitySet>,
deterministic_cacheable: bool,
) -> AssembledTurnHarness {
let mut harness: AgentHarness<()> = AgentHarness::new();
// Cross-route fallback ownership (issue #4249, Workstream 02.2): populate the
// SDK `RunPolicy.fallback` with the ordered same-family route chain for this
// turn's primary model so the harness fails over to a sibling workload tier
// (e.g. chat-v1 → burst-v1) when the primary route errors. Retry stays pinned
// to a single attempt (see `run_policy_for`) — fallback and retry are
// independent knobs, and only fallback is enabled here because `ReliableProvider`
// (still wrapped) does not fail over across the registered tier routes.
let mut policy = run_policy_for(max_iterations, deterministic_cacheable);
let route_fallback = routes::route_fallback_policy(model);
policy.fallback = route_fallback.clone();
tracing::debug!(
model,
fallback_chain = ?route_fallback.as_ref().map(|f| &f.models),
"[models] assembling turn harness with SDK retry/fallback policy"
);
harness.with_policy(policy);
// Deterministic internal runs (summarizer/triage/memory-scoring style) may
// reuse a prior identical model response; attach an in-memory response cache
// so the agent loop can short-circuit a recurring provider call and emit
// `CacheHit`/`CacheMiss` (issue #4249, 03.2). NEVER attached for interactive
// chat turns — a live user turn must never be served a cached response.
if deterministic_cacheable {
harness.with_response_cache(Arc::new(InMemoryResponseCache::new()));
tracing::debug!(
model,
"[cache] response cache attached (deterministic internal run)"
);
}
let mut capability_registry: CapabilityRegistry<()> = CapabilityRegistry::new();
let cursor: IterationCursor = Arc::default();
// Shared `call_id → tool_name` map: the forwarder records the name on
// tool-call start (the crate `ToolDelta` carries none), the bridge reads it
// to label the argument fragments now streamed via `MessageDelta.tool_call`.
let tool_names: ToolNameMap = Arc::default();
// Keep a provider handle for the context-window summarizer (the run consumes
// the other clone into the `ProviderModel`).
let summary_provider = provider.clone();
let mut provider_model = ProviderModel::new(provider, model, temperature);
// Cap the model's per-call output budget (parity with the legacy engine,
// which bounded the main agent at `AGENT_TURN_MAX_OUTPUT_TOKENS` and each
// sub-agent at its `max_turn_output_tokens`). Without this the tinyagents
// path ran the provider uncapped.
if let Some(cap) = max_output_tokens {
provider_model = provider_model.with_max_tokens(cap);
}
// Record the model's context window on its capability profile (issue #4249,
// Phase 2) so the crate can validate input capacity before dispatch.
if let Some(window) = context_window.filter(|w| *w > 0) {
provider_model = provider_model.with_context_window(window);
}
if let Some(tx) = &on_progress {
provider_model = provider_model.with_thinking(ThinkingForwarder::new(
tx.clone(),
subagent_scope.clone(),
cursor.clone(),
tool_names.clone(),
));
}
// Recover the original (downcastable) provider error if the run fails — the
// harness only carries a stringified copy.
let error_slot = provider_model.error_slot();
let provider_model = Arc::new(provider_model);
capability_registry.replace_model(model, provider_model.clone());
harness
.register_model(model, provider_model)
.set_default_model(model);
// Project the full workload-route set into the registry (issue #4249,
// Workstream 02.1). Each route is an additive registry entry carrying its
// per-route capability profile; `set_default_model` above keeps the turn's
// effective model as the dispatch target, so behavior is preserved until
// fallback/selection (02.2) chooses among the routes. `summary_provider` is
// the retained provider handle (the other clone was consumed into the
// primary `ProviderModel`); `build_route_models` clones it per route and
// skips the turn's own model so we don't shadow the default.
for route in
routes::build_route_models(&summary_provider, temperature, model, max_output_tokens)
{
let routes::RouteModel {
name,
model: route_model,
} = route;
capability_registry.replace_model(name.as_str(), route_model.clone());
harness.register_model(name, route_model);
}
// Per-call capability gate (issue #4249, Workstream 02.1): when the turn has
// derivable capability needs (today: vision for a `vision-v1` turn), stamp
// them onto every `ModelRequest` via `with_required_capabilities` so an unfit
// model is rejected pre-dispatch (and, once 02.2 lands, a capable fallback is
// selected) instead of failing at the provider.
if let Some(required) = required_capabilities {
harness.push_model_middleware(Arc::new(routes::RequiredCapabilitiesMiddleware::new(
required,
)));
}
// Fallback event parity (issue #4249, Workstream 02.2): the crate's
// registry-backed `RunPolicy.fallback` traversal (wired above) performs the
// cross-route swap silently — it emits no `AgentEvent::FallbackSelected`. Wrap
// the model-resolving core with an observer that surfaces the parity event
// whenever the resolved model differs from the primary, so a fallback is
// visible on the OpenHuman progress/observability bridge (and grep-logged under
// `[fallback]`). Installed only when a fallback chain exists; it never re-issues
// the call, so it adds no provider dispatch (no double-fallback).
if route_fallback.is_some() {
harness.push_model_middleware(Arc::new(routes::FallbackObserverMiddleware::new(model)));
}
// Capture context settings before `install` consumes `context_mw`.
let autocompact_enabled = context_mw.autocompact_enabled;
let tool_result_artifact_index = context_mw
.artifact_store
.as_ref()
.map(|_| Arc::new(ToolResultArtifactIndexStore::new()));
// Snapshot the installed stop hooks while the `CURRENT_STOP_HOOKS`
// task-local is in scope (the harness drive future runs inline on this
// task, but capturing here keeps the wiring robust). When present they fire
// via `StopHookMiddleware` and pause through the shared steering handle.
let stop_hooks = crate::openhuman::agent::stop_hooks::current_stop_hooks();
// A single steering handle drives mid-flight steering (run queue), the
// early-exit pause, the model-call-cap pause, and stop-hook pauses, so they
// all reach the same loop. Created when any of them is active.
// A steering handle is always created now: besides run-queue steering, the
// early-exit / cap / stop-hook pauses, the repeated-tool-failure breaker
// (below) also pauses through it, and it wants to fire on every path
// (including plain channel turns that set none of the other flags). An idle
// handle is a no-op — the loop just drains an empty steering channel.
// Tighten the steering allowlist by run class: a live interactive chat turn
// keeps the InjectMessage/Pause allowlist exactly, while a detached
// sub-agent run (identified by its `subagent_scope`) additionally accepts
// graceful control-flow steering (Resume/Cancel/Redirect). `subagent_scope`
// is the only run-class signal available at this steer site.
let steering_run_class = if subagent_scope.is_some() {
orchestration::SteeringRunClass::Background
} else {
orchestration::SteeringRunClass::Interactive
};
let handle = Some(orchestration::openhuman_steering_handle(steering_run_class));
// Repeated-failure circuit breaker: pause the run when a tool returns the same
// error `REPEATED_TOOL_FAILURE_THRESHOLD` times in a row, so a deterministic
// security/approval denial or terminal tool error surfaces its root cause
// instead of burning the whole iteration budget (legacy ProgressGuard parity).
let halt_summary: HaltSummarySlot = std::sync::Arc::new(std::sync::Mutex::new(None));
if let Some(handle) = &handle {
harness.push_middleware(Arc::new(middleware::RepeatedToolFailureMiddleware::new(
handle.clone(),
REPEATED_TOOL_FAILURE_THRESHOLD,
halt_summary.clone(),
)));
}
// Policy-driven stop hooks (budget cap, thread-goal budget, ad-hoc iteration
// ceiling): fire after each model call and pause the run on the first stop
// vote. Replaces the legacy tool-call-loop firing point.
if let Some(handle) = &handle {
if !stop_hooks.is_empty() {
harness.push_middleware(Arc::new(stop_hooks::StopHookMiddleware::new(
handle.clone(),
model,
max_iterations,
stop_hooks,
)));
}
}
let early_exit_set: HashSet<&str> = early_exit_tools.iter().copied().collect();
// One hook per run, shared by every early-exit adapter (records the first
// early-exit and pauses). Requires the steering handle.
let early_exit_hook = handle
.as_ref()
.filter(|_| !early_exit_set.is_empty())
.map(|h| EarlyExitHook::new(h.clone()));
// Register one adapter per unique callable tool name found across the shared
// sets (newest set wins on a name clash; `allowed` empty = all visible).
let mut seen_candidates: HashSet<String> = HashSet::new();
let candidate_names: Vec<String> = tool_sets
.iter()
.flat_map(|set| set.iter())
.map(|tool| tool.name())
.filter_map(|name| {
seen_candidates
.insert(name.to_string())
.then(|| name.to_string())
})
.collect();
let mut registered: HashSet<String> = HashSet::new();
for name in candidate_names.iter().map(String::as_str) {
if !registered.contains(name) && (allowed.is_empty() || allowed.contains(name)) {
if let Some(mut adapter) = SharedToolAdapter::for_name(tool_sets.clone(), name) {
if early_exit_set.contains(name) {
if let Some(hook) = &early_exit_hook {
adapter = adapter.with_early_exit(hook.clone());
}
}
registered.insert(name.to_string());
let adapter = Arc::new(adapter);
capability_registry.replace_tool(adapter.clone());
harness.register_tool(adapter);
}
}
}
let tool_count = registered.len();
for report in all_graph_topologies() {
let _ = capability_registry.register_descriptor(ComponentKind::Graph, report.name);
}
// Project the agents visible to this turn into the registry as name-only
// `ComponentKind::Agent` descriptors (issue #4249, Workstream 10.1). This is
// metadata only: no executable `HarnessAgent` is attached (sub-agent
// dispatch still flows through the openhuman sub-agent runner), so the
// registration is cheap and leaves the turn hot path unchanged. Agents are
// sourced from BOTH the runtime `AgentDefinitionRegistry` global (built-ins
// plus any workspace/config custom overrides, already merged by id) AND the
// `agent_registry` built-in loader, deduped by id — the runtime registry is
// registered first and wins, since it carries the richer, override-aware
// `when_to_use`. Registering here keeps the ids in
// `capability_registry.snapshot()` so the 10.3 `agent.registry_snapshot` RPC
// and the fail-closed diagnostics (10.2) observe them.
//
// DEFERRED (rich metadata): tinyagents 1.3.0 exposes no public API to attach
// a `ComponentMetadata` description/tags to a name-only descriptor — only
// `register_agent(Arc<dyn HarnessAgent>)` carries a full executable blueprint
// we do not have at this layer. Until the crate grows a
// `register_descriptor_with_meta` (or we thread real `HarnessAgent`s through
// `assemble_turn_harness`), the `when_to_use` descriptions and
// `display_name`/`source` tags cannot be persisted onto the snapshot entry;
// only the ids are projected. The runtime-registry → executable-agent
// projection (so `register_agent`/`.rag` sub-agent resolution can bind these)
// remains the deferred follow-up.
let mut registered_agents: HashSet<String> = HashSet::new();
let mut runtime_agent_count = 0usize;
if let Some(runtime) =
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global()
{
for def in runtime.list() {
if registered_agents.insert(def.id.clone()) {
let _ =
capability_registry.register_descriptor(ComponentKind::Agent, def.id.clone());
runtime_agent_count += 1;
}
}
}
// agent_registry built-ins as a supplement/fallback (deduped by id). When the
// runtime global is uninitialised this is the sole source; otherwise it only
// contributes ids the runtime registry did not already cover.
let mut builtin_supplement_count = 0usize;
match crate::openhuman::agent_registry::agents::load_builtins() {
Ok(builtins) => {
for def in builtins {
if registered_agents.insert(def.id.clone()) {
let _ = capability_registry
.register_descriptor(ComponentKind::Agent, def.id.clone());
builtin_supplement_count += 1;
}
}
}
Err(err) => {
tracing::debug!(
%err,
"[registry] agent_registry builtin load failed; \
registered runtime-registry agents only"
);
}
}
let registry_diagnostics = capability_registry.diagnostics();
let registry_snapshot = capability_registry.snapshot();
// Validation/projection pass (issue #4249, Workstream 10.1): exercise the
// model/tool projection helpers that are slated to eventually replace the
// live `harness.register_model`/`register_tool` glue. Today they are
// infallible projections — they cannot themselves surface diagnostics — so
// invoking them here is a non-fatal cross-check that every registered model
// and tool projects into a harness registry cleanly. The authoritative,
// fail-closed health signal stays `capability_registry.diagnostics()`
// (captured in `registry_diagnostics` above and enforced by 10.2); a benign
// projection difference must never abort a turn, so nothing here is folded
// into that stream. The harness is deliberately NOT switched over to these
// projections yet — that glue swap is explicitly deferred.
let projected_models = capability_registry.to_model_registry();
let projected_tools = capability_registry.to_tool_registry();
tracing::debug!(
models = projected_models.names().len(),
tools = projected_tools.names().len(),
graphs = capability_registry.names(ComponentKind::Graph).len(),
agents = registered_agents.len(),
runtime_agents = runtime_agent_count,
builtin_supplement_agents = builtin_supplement_count,
diagnostics = registry_diagnostics.len(),
"[registry] per-turn capability projection summary"
);
// SHADOW tool-exposure layer (issue #4249, 01.3 — dynamic exposure). Compose
// the OpenHuman exposure policy as a crate-native selection layer
// (ToolAllowlistMiddleware + a ContextualToolSelectionMiddleware built via
// `inheriting`) and run it in shadow: it emits the exposure decision
// event-native (`AgentEvent::ToolsFiltered`) and logs any divergence between
// the crate layer's decision and the set OpenHuman actually registered as
// callable — WITHOUT changing the callable set (byte-identical to today). The
// ownership flip + deletion of `tool_filter.rs`/`tool_prep.rs` is the gated
// follow-up once the `[tool-exposure]` divergence logs show parity. Tags encode
// the OpenHuman run context (agent id / channel / scope) for the flip; the
// name-based `inheriting` predicate does not consult them yet.
let exposure_tags: Vec<String> = {
let mut tags = vec![if subagent_scope.is_some() {
"scope:subagent".to_string()
} else {
"scope:root".to_string()
}];
if let Some(scope) = &subagent_scope {
tags.push(format!("agent:{}", scope.agent_id));
tags.push(format!("task:{}", scope.task_id));
}
if let Some(enforcement) = &tool_policy {
tags.push(format!("channel:{}", enforcement.channel));
tags.push(format!("agent_def:{}", enforcement.agent_definition_id));
}
tags
};
harness.push_middleware(Arc::new(
middleware::OpenHumanToolExposureShadowMiddleware::new(
&candidate_names,
&allowed,
exposure_tags,
),
));
// Prompt-cache prefix protection (issue #4249, 03.2). First declare the turn's
// stable prefix (system prompt + tool schemas) as `PromptSegment`s, then let
// the crate `PromptCacheGuardMiddleware` diff the cacheable prefix across model
// calls and record a `CacheLayoutEvent` when volatile content busts it.
// `before_model` hooks run in registration order, so the segment stamper must
// precede the guard; both run before the context middlewares below (they only
// touch the volatile tail / tool bodies, never the stable prefix). The guard is
// returned so the run loop can drain its events into the observability bridge —
// the structured successor to `CacheAlignMiddleware`'s warn-log (kept installed
// via `context_mw` until parity is shown).
harness.push_middleware(Arc::new(middleware::PromptCacheSegmentMiddleware));
let prompt_cache_guard = Arc::new(PromptCacheGuardMiddleware::new());
harness.push_middleware(prompt_cache_guard.clone());
// openhuman context concerns as graph middlewares (issue #4249): cache-align
// warnings, microcompact tool-body clearing, and the after-tool byte cap /
// payload summarizer. Installed before the summarization/trim block below so
// `before_model` hooks run cache-align → microcompact → compress → trim.
// Tool-result caps read the SDK registry policy snapshot, not the
// OpenHuman-side tool lookup.
let tool_policies = harness.tools().policies();
context_mw.install(&mut harness, tool_policies);
// Pre-call cost budget gate (issue #4249, Phase 5): fail before a model call
// when OpenHuman's daily/monthly cost budget is already exceeded. Self-gating
// — a no-op unless cost budgets are configured.
harness.push_middleware(Arc::new(middleware::CostBudgetMiddleware));
// Autocompaction parity: when the provider's context window is known, install
// the two-stage context-management step (issue #4249).
//
// 1. `ContextCompressionMiddleware` — the **summarization** step. Once the
// running token estimate crosses `window * SUMMARIZE_THRESHOLD_FRACTION`
// (90% of *this model's* context window), it folds the older slice of the
// transcript into a single LLM-generated system summary (keeping system
// messages + the recent window verbatim). This is keyed to whatever model
// the turn is running on, preserving the legacy context threshold.
// 2. `MessageTrimMiddleware` — a deterministic, no-extra-LLM-call hard cap.
// Pushed **after** compression (so `before_model` runs compression first),
// it front-trims to budget only as a last resort when even the summary +
// recent window still overflow.
//
// The LLM summarization step honors the `[context].enabled` /
// `autocompact_enabled` opt-outs (a disabled config must not spend summarizer
// tokens or rewrite history); the deterministic trim backstop always installs
// when a window is known, matching the legacy always-on `trim_history` cap.
// Concrete handle to the compression middleware (when installed), retained so
// the run loop can drain its `records()` after the drive future returns and
// surface each compaction's provenance (source ids + before/after token
// estimates) — the `AgentEvent::Compressed` projection only carries the token
// deltas, so provenance would otherwise be dropped (issue #4249, 03.1 item 6).
let mut compression_mw: Option<Arc<ContextCompressionMiddleware>> = None;
if let Some(window) = context_window.filter(|w| *w > 0) {
if autocompact_enabled {
let mw = Arc::new(ContextCompressionMiddleware::with_summarizer(
summarize::summarization_policy(window),
Box::new(summarize::ProviderModelSummarizer::new(
summary_provider,
model,
temperature,
)),
));
harness.push_middleware(mw.clone());
compression_mw = Some(mw);
}
let budget = window.saturating_sub(
crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS as u64,
);
harness.push_middleware(Arc::new(MessageTrimMiddleware::new(
TrimStrategy::MaxTokens(budget.max(1024)),
)));
}
// SDK-owned tool-policy projection (issue #4249 / tinyagents-full-migration
// 01.1). Keep this narrow for now: enforce sandbox requirements declared by
// adapter policies without enabling classification/approval/result-byte
// gates yet. `require_classification(true)` would currently reject an
// unregistered hallucinated tool in `before_tool` before
// `RunPolicy::unknown_tool` can return a recoverable tool error, while
// OpenHuman's existing wrappers still own HITL approval and output caps.
harness.push_middleware(Arc::new(
TaToolPolicyMiddleware::new(harness.tools().policies()).require_sandbox(true),
));
// Human-in-the-loop approval as a named tool middleware (issue #4249,
// Phase 1): an external-effect tool intercepts through the global
// `ApprovalGate`, a denial short-circuits with a model-consumable result, and
// an approved call records a terminal audit row. Replaces the inline approval
// block that used to live in `execute_openhuman_tool`.
harness.push_tool_middleware(Arc::new(middleware::ApprovalSecurityMiddleware::new(
tool_sets.clone(),
)));
// CLI/RPC-only scope gate — a tool restricted to explicit CLI/RPC invocation
// must not run from the model loop. Intrinsic to the tool, so installed on
// every path (channel/session/sub-agent).
harness.push_tool_middleware(Arc::new(middleware::CliRpcOnlyMiddleware::new(
tool_sets.clone(),
)));
// Capture each tool call's real success + content before the harness folds the
// result into a `Message::tool` that drops the failure flag, so the turn can
// build honest per-call `ToolCallRecord`s (post-turn hooks + cap checkpoint).
let tool_outcome_sink: ToolOutcomeSink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
harness.push_middleware(Arc::new(middleware::ToolOutcomeCaptureMiddleware::new(
tool_outcome_sink.clone(),
)));
// Builder-configured tool policy (`.tool_policy()`), enforced at the tool
// boundary. The in-house engine ran this in `agent_tool_exec`; the tinyagents
// path bypassed it, so a deny/require-approval silently no-opped (security
// regression). Installed only when the caller threads an enforcement context
// (the session chat path); channel/CLI + sub-agent paths pass `None`.
if let Some(enforcement) = tool_policy {
harness.push_tool_middleware(Arc::new(middleware::ToolPolicyMiddleware::new(
enforcement.policy,
enforcement.session,
tool_sets.clone(),
enforcement.session_id,
enforcement.channel,
enforcement.agent_definition_id,
)));
}
// Malformed-argument recovery (`before_tool`): coerce a call's non-object
// arguments (invalid JSON parses to Null) to `{}` so a single bad tool call is
// recoverable — the harness would otherwise reject it against an object schema
// and abort the whole turn. Engine parity.
harness.push_middleware(Arc::new(middleware::ArgRecoveryMiddleware));
AssembledTurnHarness {
harness,
cursor,
tool_names,
error_slot,
halt_summary,
tool_outcome_sink,
handle,
early_exit_hook,
tool_count,
registry_snapshot,
registry_diagnostics,
tool_result_artifact_index,
compression_mw,
prompt_cache_guard,
}
}
/// Feed an **unobserved** turn's aggregate usage into the global cost tracker.
///
/// The per-call tracker feed lives in the event bridge
/// ([`OpenhumanEventBridge::record_usage`]), which only exists on observed runs
/// (`on_progress` set). Without this aggregate record a fire-and-forget turn's
/// spend never reaches the cost dashboard / wallet surfaces (issue #4249,
/// Phase 5 rollup gap). The bridge and this fallback are mutually exclusive,
/// so spend is recorded exactly once either way.
///
/// Returns `true` when a record was attempted (any tokens observed); all-zero
/// usage is skipped so providers that echo no usage don't inflate the request
/// count. Recording is best-effort — a missing/uninitialised tracker is a
/// silent no-op by contract.
fn record_unobserved_turn_usage(
model: &str,
input_tokens: u64,
output_tokens: u64,
cached_input_tokens: u64,
charged_amount_usd: f64,
) -> bool {
if input_tokens == 0 && output_tokens == 0 {
return false;
}
tracing::debug!(
model,
input_tokens,
output_tokens,
charged_usd = charged_amount_usd,
"[tinyagents] recording unobserved-turn usage into the global cost tracker"
);
crate::openhuman::cost::record_provider_usage(
model,
&crate::openhuman::inference::provider::UsageInfo {
input_tokens,
output_tokens,
context_window: 0,
cached_input_tokens,
cache_creation_tokens: 0,
reasoning_tokens: 0,
charged_amount_usd,
},
);
true
}
#[cfg(test)]
mod tests;