Skip to content
Merged
11 changes: 10 additions & 1 deletion src/openhuman/agent/harness/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,19 @@ pub(crate) async fn run_channel_turn_via_graph(
context_window,
"[channel:graph] routing channel turn through tinyagents harness"
);
let outcome = run_turn_via_tinyagents_shared(
// Build the turn's crate `ChatModel` set from the resolved provider; the seam
// entry is crate-native (issue #4249, Phase 5).
let provider_id = provider.telemetry_provider_id();
let turn_models = crate::openhuman::tinyagents::build_turn_models(
provider,
model,
temperature,
context_window,
);
let outcome = run_turn_via_tinyagents_shared(
turn_models,
provider_id,
model,
prepared,
vec![extra_arc, tools_registry],
allowed,
Expand Down
11 changes: 10 additions & 1 deletion src/openhuman/agent/harness/session/turn/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,19 @@ pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result<Tinyagen
} else {
Some(graph.visible_tool_names)
};
run_turn_via_tinyagents_shared(
// Build the turn's crate `ChatModel` set from the session provider; the seam
// entry is crate-native (issue #4249, Phase 5).
let provider_id = graph.provider.telemetry_provider_id();
let turn_models = crate::openhuman::tinyagents::build_turn_models(
graph.provider,
&graph.model,
graph.temperature,
graph.context_window,
);
run_turn_via_tinyagents_shared(
turn_models,
provider_id,
&graph.model,
graph.messages,
vec![graph.tools],
visible_tool_names,
Expand Down
11 changes: 10 additions & 1 deletion src/openhuman/agent/harness/subagent_runner/ops/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,19 @@ pub(super) async fn run_subagent_via_graph(
// Capture native-tool support before `provider` is moved: the durable-history
// append below serializes this turn's typed suffix with the matching dispatcher.
let native_tools = provider.supports_native_tools();
let run_result = Box::pin(run_turn_via_tinyagents_shared(
// Build the child turn's crate `ChatModel` set from the resolved provider; the
// seam entry is crate-native (issue #4249, Phase 5).
let provider_id = provider.telemetry_provider_id();
let turn_models = crate::openhuman::tinyagents::build_turn_models(
provider,
model,
temperature,
context_window,
);
let run_result = Box::pin(run_turn_via_tinyagents_shared(
turn_models,
provider_id,
model,
dispatch_history,
// Dynamic (per-spawn) tools first so a dynamic tool that intentionally
// shadows a parent-registry tool of the same name is the one that
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/agent_registry/agents/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,7 @@ mod tests {
"get_flow_run",
"list_flow_connections",
"search_tool_catalog",
"get_tool_contract",
"list_agent_profiles",
"dry_run_workflow",
"run_flow",
Expand Down
157 changes: 101 additions & 56 deletions src/openhuman/tinyagents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ use crate::openhuman::agent::harness::tool_result_artifacts::{
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;
Expand Down Expand Up @@ -451,9 +450,9 @@ fn is_subagent_spawn_or_delegate_tool(name: &str) -> bool {

#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_turn_via_tinyagents_shared(
provider: Arc<dyn Provider>,
turn_models: TurnModels,
provider_id: String,
model: &str,
temperature: f64,
history: Vec<ChatMessage>,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: Option<HashSet<String>>,
Expand Down Expand Up @@ -483,10 +482,10 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// 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);
// Snapshot the provider's telemetry id before `provider` moves into the
// harness assembly — the event bridge stamps it on every per-call
// generation event (`{provider_id}.{model}` in Langfuse).
let provider_id = provider.telemetry_provider_id();
// The turn's crate `ChatModel` set (`turn_models`) and the provider telemetry
// id are built by the caller via `build_turn_models` — the seam entry is
// crate-native and no longer names `Provider` (issue #4249, Phase 5). The
// telemetry id (`{provider_id}.{model}` in Langfuse) rides in as a param.
let AssembledTurnHarness {
harness,
cursor,
Expand All @@ -505,9 +504,8 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
compression_mw,
prompt_cache_guard,
} = assemble_turn_harness(
provider,
turn_models,
model,
temperature,
tool_sets,
allowed,
max_iterations,
Expand Down Expand Up @@ -1113,6 +1111,69 @@ fn tinyagents_depth_error(
}
}

/// The per-turn crate [`ChatModel`](tinyagents::harness::model::ChatModel) set,
/// built once from an openhuman [`Provider`] by [`build_turn_models`] — the
/// single place a turn's `ProviderModel`s are constructed (issue #4249, Phase 5).
///
/// [`assemble_turn_harness`] takes this bundle instead of the raw provider, so
/// the harness assembly is expressed purely in crate model types; the
/// `Provider` → `ChatModel` adaptation is confined to `build_turn_models`.
pub(crate) struct TurnModels {
/// The turn's effective/primary model (registry default + dispatch target).
primary: Arc<dyn tinyagents::harness::model::ChatModel<()>>,
/// Additive workload-tier routes (registry name → model), excluding the
/// primary; the crate registry resolves fallback/selection across them.
routes: Vec<(String, Arc<dyn tinyagents::harness::model::ChatModel<()>>)>,
/// A model for the context-window summarizer (a distinct adapter instance so
/// its provider errors don't touch the turn's `error_slot`).
summarizer: Arc<dyn tinyagents::harness::model::ChatModel<()>>,
/// Recovers the primary's original (downcastable) provider error on failure.
error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot,
}

/// Build the per-turn [`TurnModels`] from an openhuman [`Provider`] — the sole
/// `ProviderModel` construction site for a turn (issue #4249, Phase 5). The
/// primary carries the model's context window on its capability profile; the
/// workload-tier routes are projected via [`routes::build_route_models`]; the
/// summarizer is a separate adapter over the same provider/model.
pub(crate) fn build_turn_models(
provider: Arc<dyn Provider>,
model: &str,
temperature: f64,
context_window: Option<u64>,
) -> TurnModels {
let summary_provider = provider.clone();
let mut primary = ProviderModel::new(provider, model, temperature);
// Record the model's context window on its capability profile (issue #4249,
// Phase 2) so the crate can validate input capacity before dispatch. The
// per-call output cap rides `RunConfig.max_turn_output_tokens` instead.
if let Some(window) = context_window.filter(|w| *w > 0) {
primary = primary.with_context_window(window);
}
let error_slot = primary.error_slot();
let primary: Arc<dyn tinyagents::harness::model::ChatModel<()>> = Arc::new(primary);

let routes = routes::build_route_models(&summary_provider, temperature, model)
.into_iter()
.map(|route| {
let model: Arc<dyn tinyagents::harness::model::ChatModel<()>> = route.model;
(route.name, model)
})
.collect();

// A distinct adapter instance for the summarizer (own error_slot), matching
// the pre-Phase-5 separate `summary_provider` clone.
let summarizer: Arc<dyn tinyagents::harness::model::ChatModel<()>> =
Arc::new(ProviderModel::new(summary_provider, model, temperature));
Comment on lines +1153 to +1167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file first
ast-grep outline src/openhuman/tinyagents/mod.rs --view expanded

# Locate the route model builder and error-slot related code
rg -n "error_slot|build_route_models|with_error_slot|ProviderErrorSlot|TurnModels|summary_provider|summarizer" src/openhuman/tinyagents/mod.rs src/openhuman -S

# Read the relevant section around the reported lines
sed -n '1120,1195p' src/openhuman/tinyagents/mod.rs

# Find the definitions for build_route_models and any error-slot plumbing
rg -n "fn build_route_models|struct .*Route|error_slot" src/openhuman -S

Repository: tinyhumansai/openhuman

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact build_turn_models section
sed -n '1121,1178p' src/openhuman/tinyagents/mod.rs

# Read the route-model builder definition and any slot wiring
sed -n '1,260p' src/openhuman/tinyagents/routes.rs

# Read the failure-path handling that consumes TurnModels.error_slot
sed -n '820,920p' src/openhuman/tinyagents/mod.rs

# Show how the route models are used downstream
sed -n '1288,1325p' src/openhuman/tinyagents/mod.rs

Repository: tinyhumansai/openhuman

Length of output: 22093


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect ProviderModel slot plumbing
sed -n '320,430p' src/openhuman/tinyagents/model.rs

# Inspect where the turn-level error slot is ultimately consumed after fallback/route handling
sed -n '460,520p' src/openhuman/tinyagents/mod.rs
sed -n '880,910p' src/openhuman/tinyagents/mod.rs

# Check whether route fallbacks are wired to the turn models built here
rg -n "route_fallback_policy|build_route_models\\(|replace_model\\(|register_model\\(" src/openhuman/tinyagents -S

Repository: tinyhumansai/openhuman

Length of output: 10390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the harness assembly around fallback installation and model registration
sed -n '1240,1345p' src/openhuman/tinyagents/mod.rs

# Read the full route fallback builder for exact behavior
sed -n '230,280p' src/openhuman/tinyagents/routes.rs

# Check whether any route/model code shares or replaces error slots
rg -n "error_slot|with_error_slot|route_fallback_policy|FallbackPolicy|register_model\\(" src/openhuman/tinyagents -S

Repository: tinyhumansai/openhuman

Length of output: 12164


Share the turn error slot with fallback route adapters.

TurnModels.error_slot is taken from the primary adapter, but routes::build_route_models creates separate ProviderModels with their own private slots. If a fallback route fails, the run loop only consults the turn-level slot, so the route’s real provider error can be lost and the primary error re-surfaced instead. Thread one shared slot through the route adapters; keep the summarizer isolated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyagents/mod.rs` around lines 1153 - 1167, The fallback route
adapters in routes::build_route_models are using their own private error slots,
so TurnModels.error_slot does not reflect the actual route failure. Update the
route construction in mod.rs to thread the primary turn-level slot into each
ProviderModel used for fallback routes, while keeping the summarizer’s separate
ProviderModel isolated with its own slot. Use the existing primary.error_slot()
/ TurnModels wiring and the routes::build_route_models / ProviderModel symbols
to locate the change.


TurnModels {
primary,
routes,
summarizer,
error_slot,
}
}
Comment on lines +1114 to +1175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move the new turn-model construction out of mod.rs.

TurnModels and build_turn_models add new business logic directly to src/openhuman/tinyagents/mod.rs. Please move this into a dedicated module, e.g. src/openhuman/tinyagents/models/, and re-export from mod.rs. As per coding guidelines, "src/openhuman/**/mod.rs: mod.rs files must be export-only: mod/pub mod, pub use, and controller schema wiring only; no business logic."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyagents/mod.rs` around lines 1114 - 1175, `mod.rs` currently
contains the new turn-model business logic via `TurnModels` and
`build_turn_models`, which violates the export-only guideline. Move those
definitions and related logic into a dedicated module under
`src/openhuman/tinyagents/models/`, keep `mod.rs` limited to module wiring and
re-exports, and update any callers to import the re-exported symbols from
`mod.rs`.

Source: Coding guidelines


/// 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.
Expand Down Expand Up @@ -1177,9 +1238,8 @@ struct AssembledTurnHarness {
/// exposes the harness registries without driving a run.
#[allow(clippy::too_many_arguments)]
fn assemble_turn_harness(
provider: Arc<dyn Provider>,
turn_models: TurnModels,
model: &str,
temperature: f64,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: Option<HashSet<String>>,
max_iterations: usize,
Expand Down Expand Up @@ -1228,61 +1288,47 @@ fn assemble_turn_harness(
// 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();
// Shared FIFO carry of per-call provider `UsageInfo`: the model adapter
// pushes each successful response's usage (charged USD + context window +
// cache-creation/reasoning tokens the crate `Usage` drops), the event bridge
// pops it when recording that call's usage (#4467, item 1).
// Shared FIFO carry of per-call provider `UsageInfo`: `UsageCarryMiddleware`
// pushes each response's usage (charged USD + context window +
// cache-creation/reasoning tokens, read off the response via G1), the event
// bridge pops it when recording that call's usage (#4467, item 1). The carry
// is produced by a wrap-model middleware now, not the adapter, so route models
// carry no usage side-channel (Phase 5).
let provider_usage_carry: ProviderUsageCarry = 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)
.with_usage_carry(provider_usage_carry.clone());
// The per-call output cap now rides `RunConfig.max_turn_output_tokens`
// (Phase 5 groundwork), set by the caller: the loop stamps it onto every
// `ModelRequest` and the adapter honors `request.max_tokens`, so the cap no
// longer needs to be baked into the primary model or each route model.
// 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());
// The turn's models are pre-built by `build_turn_models` (the single
// `ProviderModel` construction site) and handed in as crate `ChatModel`s —
// the assembly no longer touches the raw provider (issue #4249, Phase 5).
let TurnModels {
primary,
routes,
summarizer: summarizer_model,
error_slot,
} = turn_models;
capability_registry.replace_model(model, primary.clone());
harness
.register_model(model, provider_model)
.register_model(model, primary)
.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, &provider_usage_carry)
{
let routes::RouteModel {
name,
model: route_model,
} = route;
// fallback/selection (02.2) chooses among the routes. `build_turn_models`
// already skipped the turn's own model, so we don't shadow the default.
for (name, route_model) in routes {
capability_registry.replace_model(name.as_str(), route_model.clone());
harness.register_model(name, route_model);
}

// Cost usage capture (issue #4249, Phase 5): feed the event bridge's usage
// carry from a wrap-model middleware that reads the full `UsageInfo` off each
// response, instead of every `ProviderModel` pushing it. Installed
// unconditionally — usage flows on every turn — and shares the same carry the
// bridge drains on `UsageRecorded`.
harness.push_model_middleware(Arc::new(routes::UsageCarryMiddleware::new(
provider_usage_carry.clone(),
)));

// 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
Expand Down Expand Up @@ -1705,9 +1751,8 @@ fn assemble_turn_harness(
// identical re-issued input slice must not re-run the summarizer LLM.
let summarizer = summarize::FaultTolerantCachingSummarizer::new(
Box::new(summarize::ProviderModelSummarizer::new(
summary_provider,
summarizer_model,
model,
temperature,
)),
&policy,
);
Expand Down
Loading
Loading