Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 13 additions & 37 deletions src/openhuman/agent/harness/run_queue/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,31 @@
mod types;

use std::sync::Arc;
use tokio::sync::Mutex;
use tinyagents::harness::run_queue::{QueueLane, RunQueue as TinyAgentsRunQueue};

pub use types::{QueueMode, QueueStatus, QueuedMessage};
pub use tinyagents::harness::run_queue::QueueStatus;
pub use types::{QueueMode, QueuedMessage};

/// Thread-safe run queue with three lanes. Wrapped in `Arc` for shared
/// ownership between the web channel producer and the engine consumer.
#[derive(Debug)]
pub struct RunQueue {
inner: Mutex<RunQueueInner>,
}

#[derive(Debug, Default)]
struct RunQueueInner {
steers: Vec<QueuedMessage>,
followups: Vec<QueuedMessage>,
collects: Vec<QueuedMessage>,
inner: TinyAgentsRunQueue<QueuedMessage>,
}

impl RunQueue {
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(RunQueueInner::default()),
inner: TinyAgentsRunQueue::new(),
})
}

/// Push a message into the appropriate lane based on its mode.
pub async fn push(&self, msg: QueuedMessage) {
let mut inner = self.inner.lock().await;
match msg.mode {
QueueMode::Steer => inner.steers.push(msg),
QueueMode::Followup => inner.followups.push(msg),
QueueMode::Collect => inner.collects.push(msg),
QueueMode::Steer => self.inner.push(QueueLane::Steer, msg).await,
QueueMode::Followup => self.inner.push(QueueLane::Followup, msg).await,
QueueMode::Collect => self.inner.push(QueueLane::Collect, msg).await,
QueueMode::Interrupt => {
log::warn!(
"[run_queue] interrupt-mode message pushed to queue — should have been handled by caller"
Expand All @@ -62,44 +55,27 @@ impl RunQueue {

/// Drain all pending steer messages (FIFO order).
pub async fn drain_steers(&self) -> Vec<QueuedMessage> {
let mut inner = self.inner.lock().await;
std::mem::take(&mut inner.steers)
self.inner.drain(QueueLane::Steer).await
}

/// Drain all pending collect messages (FIFO order).
pub async fn drain_collects(&self) -> Vec<QueuedMessage> {
let mut inner = self.inner.lock().await;
std::mem::take(&mut inner.collects)
self.inner.drain(QueueLane::Collect).await
}

/// Drain all pending followup messages (FIFO order).
pub async fn drain_followups(&self) -> Vec<QueuedMessage> {
let mut inner = self.inner.lock().await;
std::mem::take(&mut inner.followups)
self.inner.drain(QueueLane::Followup).await
}

/// Snapshot the current queue depth per lane.
pub async fn status(&self) -> QueueStatus {
let inner = self.inner.lock().await;
let steers = inner.steers.len();
let followups = inner.followups.len();
let collects = inner.collects.len();
QueueStatus {
steers,
followups,
collects,
total: steers + followups + collects,
}
self.inner.status().await
}

/// Clear all lanes and return the total number of messages dropped.
pub async fn clear(&self) -> usize {
let mut inner = self.inner.lock().await;
let total = inner.steers.len() + inner.followups.len() + inner.collects.len();
inner.steers.clear();
inner.followups.clear();
inner.collects.clear();
total
self.inner.clear().await
}
}

Expand Down
9 changes: 0 additions & 9 deletions src/openhuman/agent/harness/run_queue/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,3 @@ pub struct QueuedMessage {
pub profile_id: Option<String>,
pub locale: Option<String>,
}

/// Snapshot of the queue state for introspection.
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueStatus {
pub steers: usize,
pub followups: usize,
pub collects: usize,
pub total: usize,
}
6 changes: 6 additions & 0 deletions src/openhuman/config/migration_helpers/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ mod tests {
use tempfile::TempDir;

fn test_config(tmp: &TempDir) -> Config {
// Apply-mode migrations import into unified memory, which resolves its
// embedding provider through the explicit host seams. Those are wired at
// startup in the running app but not in a bare unit test, so install the
// test seam wiring before building a config that can exercise that path
// (idempotent, safe to call from every test).
crate::openhuman::memory::host_impls::install_for_tests();
Config {
workspace_dir: tmp.path().join("workspace"),
action_dir: tmp.path().join("workspace"),
Expand Down
137 changes: 6 additions & 131 deletions src/openhuman/inference/provider/chat_template.rs
Original file line number Diff line number Diff line change
@@ -1,133 +1,8 @@
//! Classifier for **chat-template rejections** from local serving runtimes.
//! Host compatibility path for TinyAgents' provider-neutral chat-template
//! rejection classifier.
//!
//! A local runtime (LM Studio, llama.cpp, Ollama) renders every request
//! through the model's own Jinja chat template, baked into the GGUF. When
//! the template cannot render the message list it aborts with a `400`
//! *before* the model is ever called, and the body describes a template
//! failure — not a bad model id, not a bad sampling parameter:
//!
//! ```text
//! lmstudio returned: Engine protocol predict request returned 400:
//! {"error":{"code":400,"message":"Unable to generate parser for this
//! template. Automatic parser generation failed: While executing
//! CallExpression at line 79, column 24 in source: ...multi_step_tool %}
//! {{- raise_exception('No user query found in messages.') }}...
//! Error: Jinja Exception: No user query found in messages.",
//! "type":"invalid_request_error"}}
//! ```
//!
//! The canonical instance (tinyhumansai/openhuman#5291) is Qwen 3's
//! required-user-query guard firing against the prompt-guided tool loop's
//! message shape, on a model that reports no native tool calling. The
//! harness-side fix is to guarantee a resolvable user turn
//! (`tinyagents::harness::tool::ensure_resolvable_user_turn`); this
//! classifier exists so the class is *named accurately* while it happens
//! — with any template, any model.
//!
//! ## Why it needs its own arm
//!
//! Nothing in the raw body matches
//! [`super::config_rejection::is_provider_config_rejection_message`], but
//! the retry aggregate that wraps two failed attempts does (`"may not be
//! available on your provider"`), so the user was shown *"Your AI provider
//! rejected the request's model or temperature setting. Check your model
//! and routing in Settings → LLM."* That is wrong in a way that costs the
//! user real time: the model is fine, the temperature is fine, and every
//! remediation it suggests is a dead end. Classified ahead of the
//! config-rejection arm, the template failure keeps its own identity even
//! when it reaches the classifier inside an aggregate.
//!
//! Deliberately NOT added to
//! [`crate::core::observability::expected_error_kind`]: unlike a user
//! picking an unavailable model, a template that rejects our own message
//! shape is a defect on our side, and it should stay visible until the
//! harness normalization has shipped everywhere.

/// Returns true if a provider error body indicates the model's **chat
/// template** rejected the request — a template render/parse failure, as
/// opposed to a rejected model id, sampling parameter, or credential.
///
/// Case-insensitive substring match, anchored on phrases emitted by the
/// template engine itself (`raise_exception` text, the Jinja exception
/// marker) and by LM Studio's template-parser generation, so it holds
/// across runtimes that embed the same Jinja templates. Keep the list
/// tight: a false positive would relabel an unrelated provider error as a
/// template problem and send the user chasing a template they cannot see.
pub fn is_chat_template_rejection_message(body: &str) -> bool {
const PHRASES: &[&str] = &[
// Qwen 3-family required-user-query guard — the #5291 repro. Raised
// by the template when it cannot locate a user turn to answer.
"no user query found in messages",
// LM Studio wraps a template it cannot compile into a tool-call
// parser with this prefix; the inner cause is always a template
// render failure.
"unable to generate parser for this template",
"automatic parser generation failed",
// Generic escape hatch for any other template that raises: the
// engine tags every one of these with the Jinja exception marker.
// Covers the sibling guards in Llama-3 / Mistral / ChatML templates
// (alternating-role requirements, "conversation roles must
// alternate", tool-response ordering) without enumerating each.
"jinja exception",
];

let lower = body.to_ascii_lowercase();
PHRASES.iter().any(|phrase| lower.contains(phrase))
}

#[cfg(test)]
mod tests {
use super::*;

/// The verbatim body from the #5291 user log, wrapper and all.
const LMSTUDIO_5291_BODY: &str = "lmstudio returned: Engine protocol predict request \
returned 400: {\"error\":{\"code\":400,\"message\":\"Unable to generate parser for \
this template. Automatic parser generation failed: While executing CallExpression \
at line 79, column 24 in source: ...multi_step_tool %} {{- raise_exception('No \
user query found in messages.') }}...Error: Jinja Exception: No user query found \
in messages.\",\"type\":\"invalid_request_error\"}}";

#[test]
fn classifies_the_lmstudio_template_body() {
assert!(is_chat_template_rejection_message(LMSTUDIO_5291_BODY));
}

#[test]
fn classifies_inside_a_retry_aggregate() {
// Two attempts fail and the aggregate wraps both; the anchor must
// survive so the template arm still wins over the config-rejection
// arm the aggregate's own wording would otherwise trip.
let aggregate = format!(
"The model `qwen/qwen3.5-9b` may not be available on your provider. \
Configure a fallback chain via `reliability.model_fallbacks` in your \
OpenHuman config.\n\nAll providers/models failed. Attempts:\n\
provider=lmstudio model=qwen/qwen3.5-9b attempt 1/2: {LMSTUDIO_5291_BODY}\n\
provider=lmstudio model=qwen/qwen3.5-9b attempt 2/2: {LMSTUDIO_5291_BODY}"
);
assert!(is_chat_template_rejection_message(&aggregate));
}

#[test]
fn detection_is_case_insensitive() {
assert!(is_chat_template_rejection_message(
"Error: JINJA EXCEPTION: No User Query Found In Messages."
));
}
//! OpenHuman retains the user-facing error mapping in `web_chat::web_errors`;
//! reusable recognition of LM Studio, llama.cpp, and Ollama template-engine
//! failures belongs to the default inference driver.

#[test]
fn does_not_classify_unrelated_provider_errors() {
for body in [
"openai API error (400): invalid temperature: only 1 is allowed for this model",
"The model `gpt-5.5` does not exist or you do not have access to it.",
"lmstudio returned: model 'qwen3.5-9b' does not support tools",
"openrouter API error (429): rate limited",
// A prose mention of templates is not a template rejection.
"Failed to render the prompt template file on disk",
] {
assert!(
!is_chat_template_rejection_message(body),
"{body:?} must not be classified as a chat-template rejection"
);
}
}
}
pub use tinyagents::harness::providers::openai::is_chat_template_rejection_message;
Loading