Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 31 additions & 9 deletions scripts/mock-api/routes/llm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import {
import { buildDynamicCompletion } from "./llm/dynamic.mjs";
import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs";

// The scripted `llmForcedResponses` FIFO models the *interactive* agent turn,
// which always advertises tools (the orchestrator's delegate_* tools). Ancillary
// completions that share the endpoint but carry no tools — thread-title/summary
// generation via `chat_with_system` (tools: None), fired fire-and-forget and
// racing the visible turn — must NOT drain the queue, or the scripted responses
// desync and the turn falls through to the dynamic fallback
// (tinyhumansai/openhuman#4517).
function isPrimaryTurn(parsedBody) {
return Array.isArray(parsedBody?.tools) && parsedBody.tools.length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't skip prompt-guided primary turns

When the mocked provider is used with prompt-guided/local providers, the main agent request intentionally has no tools array (tinyagents/model.rs only passes tools when supports_native_tools() is true and specs are non-empty; Ollama/LM Studio explicitly opt out of native tools). Those requests are still primary user turns that tests can script with llmForcedResponses, but this predicate now treats them as ancillary and leaves the FIFO untouched, so the response falls through to the dynamic/default text instead of the configured script. Please distinguish the title/summary calls by a more specific signal than tools.length so prompt-guided primary turns can still consume forced responses.

Useful? React with 👍 / 👎.

}

function requestRuleMatches(rule, ctx) {
if (!rule || typeof rule !== "object") return false;
const { url, parsedBody, req } = ctx;
Expand Down Expand Up @@ -242,14 +253,22 @@ function handleStreamingCompletion({

if (!Array.isArray(script)) {
// 2. Forced queue: pop the next entry and convert it into a script.
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
const next = forced.shift();
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
script = defaultStreamScript({
content: next.content,
toolCalls: next.toolCalls,
});
// Only the primary *interactive* turn drains the queue. The agent
// (orchestrator) always sends a `tools` array; ancillary completions
// that race the turn — notably fire-and-forget thread-title generation
// (`chat_with_system`, tools: None) dispatched on send — carry no tools
// and must NOT consume a scripted response, or the queue desyncs and the
// turn falls through to the dynamic fallback (tinyhumansai/openhuman#4517).
if (isPrimaryTurn(parsedBody)) {
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
const next = forced.shift();
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
script = defaultStreamScript({
content: next.content,
toolCalls: next.toolCalls,
});
}
}
}

Expand Down Expand Up @@ -620,8 +639,11 @@ export function handleLlmCompletions(ctx) {
}

// 1. Forced queue — replay exact ChatResponse objects in order.
// Only the primary interactive turn drains the queue; ancillary no-tools
// completions (e.g. fire-and-forget thread-title generation racing the
// turn) must not consume a scripted response (tinyhumansai/openhuman#4517).
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
if (isPrimaryTurn(parsedBody) && Array.isArray(forced) && forced.length > 0) {
const next = forced.shift();
// Persist the shrunk queue back so subsequent requests advance.
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
Expand Down
19 changes: 18 additions & 1 deletion scripts/mock-api/routes/llm/dynamic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,26 @@ function buildReasoningSummary(prompt, toolText, thread) {
].join(" ");
}

// Orchestration/delegate tools (analyze_image, delegate_*) require a `prompt`
// argument (and carry `citation_requirement`). The dynamic fallback can only
// synthesize simple worker-tool args (q/path/cmd/url), so a fabricated call to a
// delegate tool fails schema validation with a confusing "arguments.prompt is
// required" bubble (tinyhumansai/openhuman#4517). Treat only tools that do NOT
// require `prompt` as fabricatable; otherwise fall through to benign text.
function isSimpleWorkerTool(toolDef) {
const params = toolDef?.function?.parameters || toolDef?.parameters || {};
const required = Array.isArray(params.required) ? params.required : [];
return !required.includes("prompt");
}

function inferToolCalls(prompt, toolDefs = []) {
const lower = String(prompt || "").toLowerCase();
const declared = Array.isArray(toolDefs) ? toolDefs : [];
const declared = (Array.isArray(toolDefs) ? toolDefs : []).filter(
isSimpleWorkerTool,
);
// Only delegate/orchestration tools are available — the fallback can't build a
// valid call, so return benign text rather than a schema-error tool call.
if (declared.length === 0) return [];
const picked = (name, args) => ({
id: createMockId("tool"),
name,
Expand Down
Loading