Skip to content
7 changes: 5 additions & 2 deletions src/openhuman/agent_orchestration/subagent_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,11 @@ fn handle_subagent_steer(params: Map<String, Value>) -> ControllerFuture {
SteerError::Unknown => "unknown",
SteerError::AlreadyDone => "already_done",
// `steer_control` is trusted UI/RPC control and currently
// does not perform parent ownership checks. Keep the arm
// for future trust-model changes that may return NotOwned.
// does not perform parent ownership checks, so this arm is
// unreachable on THIS path. The variant is not dead overall:
// the agent-tool `running_subagents::steer` path enforces
// ownership and returns `NotOwned`. Kept here for the Phase 4
// SteeringRegistry ownership consolidation.
SteerError::NotOwned => "not_owned",
};
log::debug!(
Expand Down
5 changes: 5 additions & 0 deletions src/openhuman/inference/presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ pub fn preset_for_tier(tier: ModelTier) -> Option<ModelPreset> {

/// Recommend a tier based on device capabilities.
pub fn recommend_tier(device: &DeviceProfile) -> ModelTier {
// NOTE: the MVP intentionally caps every device at `MVP_MAX_TIER`
// regardless of installed RAM (the `recommend_tier_scales_with_ram` test
// pins this non-scaling contract). `ram_gb` is read only for the
// diagnostic log below; RAM->tier scaling is deferred until the higher
// tiers are productised.
let ram_gb = device.total_ram_gb();
let tier = MVP_MAX_TIER;
tracing::debug!(ram_gb, ?tier, "[local_ai] recommended model tier");
Expand Down
8 changes: 4 additions & 4 deletions src/openhuman/inference/provider/config_rejection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,10 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool {
// User-state — the remediation is "add/pick a model in Settings →
// LLM", which the user-facing copy surfaces; Sentry has no
// remediation. Anchored on the role/slug-interpolation-free
// substring, which is also `factory::NO_MODEL_CONFIGURED_ANCHOR`; a
// round-trip test in `factory_tests.rs` couples the two so a
// wording drift fails CI instead of silently re-flooding Sentry.
"resolved to an empty model id",
// substring, which IS `factory::NO_MODEL_CONFIGURED_ANCHOR`
// referenced directly so the two can never drift (the round-trip
// test in `factory_tests.rs` remains as a belt-and-braces guard).
super::factory::NO_MODEL_CONFIGURED_ANCHOR,
// TAURI-RUST-2G (~2684 events) / TAURI-RUST-2F (~950 events) —
// thinking-mode model (DeepSeek-R1 / Moonshot K2-thinking on
// `provider=cloud` custom_openai) rejects a follow-up turn that
Expand Down
6 changes: 4 additions & 2 deletions src/openhuman/inference/provider/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,10 @@ pub trait Provider: Send + Sync {
_options: StreamOptions,
) -> stream::BoxStream<'static, StreamResult<StreamChunk>> {
// For default implementation, we need to convert to owned strings
// This is a limitation of the default implementation
let provider_name = "unknown".to_string();
// This is a limitation of the default implementation. Name the real
// provider via its telemetry slug so the diagnostic is actionable
// instead of the useless literal "unknown".
let provider_name = self.telemetry_provider_id();

// Create a single empty chunk to indicate not supported
let chunk = StreamChunk::error(format!("{} does not support streaming", provider_name));
Expand Down
51 changes: 51 additions & 0 deletions src/openhuman/inference/provider/traits_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,54 @@ async fn provider_chat_prompt_guided_rejects_non_prompt_payload() {

assert!(message.contains("non-prompt-guided"));
}

// Provider that reports a real telemetry name but does not override streaming,
// so it falls back to the default `stream_chat_with_history` implementation.
struct NamedNoStreamProvider;

#[async_trait]
impl Provider for NamedNoStreamProvider {
fn telemetry_provider_id(&self) -> String {
"acme".to_string()
}

async fn chat_with_system(
&self,
_system: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("ok".to_string())
}
}

#[tokio::test]
async fn default_stream_chat_with_history_names_provider_when_unsupported() {
let provider = NamedNoStreamProvider;
let mut stream = provider.stream_chat_with_history(
&[ChatMessage::user("Hi")],
"model",
0.7,
StreamOptions::default(),
);

let chunk = stream
.next()
.await
.expect("one chunk")
.expect("chunk is Ok");

assert!(chunk.is_final);
assert!(
chunk.delta.contains("acme"),
"diagnostic should name the provider, got: {}",
chunk.delta
);
assert!(chunk.delta.contains("does not support streaming"));
assert!(
!chunk.delta.contains("unknown"),
"diagnostic must not use the misleading literal 'unknown', got: {}",
chunk.delta
);
}
14 changes: 7 additions & 7 deletions src/openhuman/tools/orchestrator_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,13 +589,6 @@ mod tests {
assert_eq!(slugs, vec!["google_calendar", "slack_bot"]);
}

/// Two upstream toolkits whose names sanitise to the same slug
/// must not silently both land in the collapsed enum — the second
/// arrival is dropped (with a warn log) so the orchestrator's
/// routing handle stays unambiguous. Without this guard,
/// `Slack.Bot` and `Slack-Bot` would both render as `slack_bot`
/// in the enum and the orchestrator could no longer distinguish
/// them.
/// An integration with an empty description must not render as a
/// bare ` - slug` line in the collapsed tool description — the
/// orchestrator LLM would have no signal about what the toolkit
Expand Down Expand Up @@ -631,6 +624,13 @@ mod tests {
assert!(desc.contains("Email."));
}

/// Two upstream toolkits whose names sanitise to the same slug
/// must not silently both land in the collapsed enum — the second
/// arrival is dropped (with a warn log) so the orchestrator's
/// routing handle stays unambiguous. Without this guard,
/// `Slack.Bot` and `Slack-Bot` would both render as `slack_bot`
/// in the enum and the orchestrator could no longer distinguish
/// them.
#[test]
fn duplicate_sanitised_slug_drops_later_collisions() {
let mut orch = def("orchestrator", "t", None);
Expand Down
6 changes: 4 additions & 2 deletions src/openhuman/tools/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ pub use crate::openhuman::workflows::types::{ToolContent, ToolResult};
pub enum ToolScope {
/// Available in agent loop, CLI, and RPC.
All,
/// Only available in the autonomous agent loop.
#[allow(dead_code)]
/// Intended to mark tools available only in the autonomous agent loop.
/// NOTE: not yet gated — no execution path filters on `AgentOnly`, so it
/// currently behaves like `All`. The `AgentOnly` vs `All` reconciliation is
/// deferred to the Phase 2 tool-model work (docs/tinyagents-port-plan.md §5).
AgentOnly,
/// Only available via explicit CLI/RPC invocation (not autonomous agent).
CliRpcOnly,
Expand Down
Loading