diff --git a/docs/_static/install-builder.mjs b/docs/_static/install-builder.mjs
index 8540a745..1c968547 100644
--- a/docs/_static/install-builder.mjs
+++ b/docs/_static/install-builder.mjs
@@ -48,6 +48,8 @@ export function buildCommand(state) {
}
for (const name of ['ui', 'execution', 'observability', ...Object.keys(modules).filter(name => !observabilityServices.includes(name))]) set(`modules.${name}.enabled`, ['ui', 'execution'].includes(name) || enabled.has(name));
set('modules.bcm.redfish.enabled', enabled.has('bcm') && !!state.redfish);
+ set('llm.requestCompatibility.researchToolCalls.enabled', enabled.has('research') && !!state.researchToolCalls);
+ if (enabled.has('research') && state.researchToolCalls) set('llm.requestCompatibility.enabled', true);
for (const service of ['prometheus', 'alertmanager']) {
set(`observability.${service}Url`, enabled.has(service) ? value(`${service}Url`) : '', true);
}
@@ -115,12 +117,17 @@ if (root) {
const redfishOptions = document.createElement('div'); redfishOptions.className = 'builder-suboptions';
const redfishLabel = document.createElement('label'); const redfishInput = document.createElement('input'); redfishInput.type = 'checkbox'; redfishInput.name = 'redfish';
redfishInput.addEventListener('change', () => {state.redfish = redfishInput.checked; update(false);}); redfishLabel.append(redfishInput, document.createTextNode('Read-only Redfish')); redfishOptions.append(redfishLabel);
+ const researchOptions = document.createElement('div'); researchOptions.className = 'builder-suboptions';
+ const researchLabel = document.createElement('label'); const researchInput = document.createElement('input'); researchInput.type = 'checkbox'; researchInput.name = 'researchToolCalls';
+ researchInput.addEventListener('change', () => {state.researchToolCalls = researchInput.checked; update(false);}); researchLabel.append(researchInput, document.createTextNode('Search tool compatibility workaround')); researchOptions.append(researchLabel);
for (const [name, label] of Object.entries(modules)) {
const wrapper = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.name = name;
input.addEventListener('change', () => {const requested = new Set(state.modules); input.checked ? requested.add(name) : requested.delete(name); state.modules = [...requested]; update(true);});
wrapper.append(input, document.createTextNode(label)); inputs.set(name, input);
if (name === 'bcm') {
const block = document.createElement('div'); block.className = 'builder-module'; block.append(wrapper, redfishOptions); buttons.append(block);
+ } else if (name === 'research') {
+ const block = document.createElement('div'); block.className = 'builder-module'; block.append(wrapper, researchOptions); buttons.append(block);
} else buttons.append(wrapper);
}
moduleGroup.append(buttons); form.append(moduleGroup);
@@ -142,6 +149,7 @@ if (root) {
function update(renderSettings) {
const enabled = enabledModules(state);
redfishOptions.hidden = !enabled.has('bcm'); redfishInput.checked = !!state.redfish;
+ researchOptions.hidden = !enabled.has('research'); researchInput.checked = !!state.researchToolCalls;
for (const [name, input] of inputs) {input.checked = enabled.has(name); input.disabled = enabled.has(name) && !state.modules.includes(name);}
if (renderSettings) {
settingsBody.replaceChildren();
@@ -177,6 +185,7 @@ if (root) {
if (enabled.has('bcm')) notes.push(`Create ${state.bcmSecret || fields.bcmSecret[1]} with key id_ecdsa; its SSH identity must bootstrap the read-only CMSH user.`);
if (enabled.has('bcm') && state.redfish) notes.push('Redfish reads node BMCs through the BCM head using credentials stored in BCM. Resets and configuration changes are unavailable. BMC TLS certificate verification is off by default; see the Redfish installation guide to configure it.');
if (enabled.has('research')) notes.push('Create iraop-secrets with NVIDIA_API_KEY, NVIDIA_CHAT_API_KEY and IRAOP_API_KEY; prepare the corpus directory.');
+ if (enabled.has('research') && state.researchToolCalls) notes.push('The search workaround is for inference backends returning named search arguments as ordinary text. Verify the affected protocol first; it does not establish answer or citation quality. Full request tracing stays disabled by default.');
if (enabled.has('webSearch')) notes.push('Web search uses DuckDuckGo without an API key; the OpenClaw pod needs HTTPS egress to duckduckgo.com. Clear it for air-gapped installations.');
if (state.inference !== 'external') notes.push('Verify GPU, memory and model cache requirements in the on-prem inference section.');
prerequisites.textContent = notes.join(' '); code.textContent = buildCommand(state); status.textContent = '';
diff --git a/docs/installation.md b/docs/installation.md
index 459f0600..4d7dacc5 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -621,6 +621,61 @@ helm upgrade mosaic "$MOSAIC_CHART" \
unset NVIDIA_API_KEY IRAOP_API_KEY
```
+`iraop_query` requires a nonempty answer and at least one document reference with
+a filename and positive page number. A response without that evidence returns
+`status: "incomplete"` and an error event in the Research panel. Its unverified
+answer is withheld from model-visible content; the full original MCP response
+remains in the tool result's `details` in the OpenClaw session transcript. The
+chat history API may omit `details`. OpenClaw assigns its transport-level
+`isError` flag independently, so `isError: false` does not mean research passed.
+
+Missing sources can reflect an agent or model/tool execution problem even when
+the PDF is indexed and direct search works. Check collection metadata, the
+original PDF, actual search execution and citations separately. A source
+reference alone does not establish that every claim or inline citation is
+correct, or that a proposed cause matches the current cluster.
+
+#### Optional research tool compatibility
+
+Some inference configurations return a named search tool's JSON arguments as
+ordinary text, without an OpenAI `tool_calls` envelope. The research agent then
+never executes the search. An opt-in workaround is available in the existing
+LLM compatibility proxy:
+
+```bash
+helm upgrade mosaic "$MOSAIC_CHART" \
+ --devel -n mosaic --reuse-values \
+ --set modules.research.enabled=true \
+ --set llm.requestCompatibility.enabled=true \
+ --set llm.requestCompatibility.researchToolCalls.enabled=true \
+ --wait --timeout 12m
+```
+
+This setting is disabled by default. For an initial research request exposing
+`task` and the supported query-only `opensearch_search` schema, it explicitly
+selects that read-only search and disables thinking for this first generation.
+It wraps only a complete, validated JSON argument object into the named tool
+envelope; the original research agent must execute the actual search. Proper
+upstream tool calls pass through. Explicit selection of another tool, synthesis
+after a tool result, and unrelated OpenClaw requests retain their existing
+behavior. Malformed or unfinished arguments return an error, never a fabricated
+search result. Collection scope remains enforced by the research service.
+
+Use this workaround only after reproducing the protocol problem. For example,
+the installed vLLM `Qwen3CoderToolParser` may declare named/required tool calls
+unsupported; the checkpoint's documented parser configuration should also be
+checked. This proxy setting does not change or restart the model server, and
+does not certify document relevance, citation accuracy or general answer quality.
+
+For a controlled validation, additionally set
+`llm.requestCompatibility.researchToolCalls.auditEnabled=true`. Full research
+request bodies, raw upstream response bytes and returned response bytes are
+saved as Base64 in `/audit/records` inside the proxy Pod. Authentication/cookie
+headers are excluded. These traces can contain private prompts and documents;
+export them to the authorized evidence location before replacing the Pod.
+The volume is temporary and limited to 128 MiB; audit write failures fail the
+request. Disable this tracing setting after the required evidence is exported.
+
### Hardware Agent
The Hardware Agent reuses the Base Command Manager head address and `bcm-host-ssh-key` configured above. The chart exposes the required Base Command Manager lookup inside the cluster and generates its internal credentials. It uses NVDebug evidence and generic analysis.
diff --git a/docs/installation_builder.md b/docs/installation_builder.md
index 602765df..a72a1029 100644
--- a/docs/installation_builder.md
+++ b/docs/installation_builder.md
@@ -6,6 +6,8 @@ CPU, RAM, GPUs and disk update automatically with your selections. Values come f
Select Base Command Manager to expose the optional **Read-only Redfish** checkbox inside its module block. See the [Redfish installation guide](installation.md#read-only-redfish) for BMC credential resolution and TLS verification settings.
+Select Research to expose the optional **Search tool compatibility workaround** checkbox. Leave it off unless the inference backend returns named search arguments as ordinary text and the agent fails to execute the search. See [research evidence validation](installation.md#research) and [the compatibility guide](installation.md#optional-research-tool-compatibility) for the supported schema and verification steps. The builder does not enable full request-body tracing; temporary tracing requires separate controlled configuration and export.
+
```{raw} html
```
diff --git a/helm/mosaic-stack/files/agent-documentation.tar.gz b/helm/mosaic-stack/files/agent-documentation.tar.gz
index 4abee1d0..31c94264 100644
Binary files a/helm/mosaic-stack/files/agent-documentation.tar.gz and b/helm/mosaic-stack/files/agent-documentation.tar.gz differ
diff --git a/helm/mosaic-stack/files/llm-compat-server.mjs b/helm/mosaic-stack/files/llm-compat-server.mjs
new file mode 100644
index 00000000..7624d1eb
--- /dev/null
+++ b/helm/mosaic-stack/files/llm-compat-server.mjs
@@ -0,0 +1,228 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import http from 'node:http';
+import https from 'node:https';
+import { randomUUID } from 'node:crypto';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { realpathSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib';
+
+const SEARCH = 'opensearch_search';
+const textDecoder = new TextDecoder('utf-8', { fatal: true });
+
+function readBody(stream, limit = Infinity) {
+ return new Promise((resolveBody, reject) => {
+ const chunks = [];
+ let bytes = 0;
+ stream.on('data', chunk => {
+ bytes += chunk.length;
+ if (bytes > limit) {
+ reject(new Error('Research response exceeds the compatibility buffer limit.'));
+ stream.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ stream.on('end', () => resolveBody(Buffer.concat(chunks)));
+ stream.on('error', reject);
+ stream.on('aborted', () => reject(new Error('Upstream response ended before completion.')));
+ });
+}
+
+function searchSchema(tools) {
+ const tool = tools?.find?.(item => item.type === 'function' && item.function?.name === SEARCH);
+ const parameters = tool?.function?.parameters;
+ return parameters?.type === 'object'
+ && parameters.properties?.query?.type === 'string'
+ && Object.keys(parameters.properties).length === 1
+ && parameters.required?.length === 1 && parameters.required[0] === 'query';
+}
+
+function prepare(method, path, raw, researchToolCalls) {
+ if (method !== 'POST' || !path.endsWith('/chat/completions')) return { body: raw };
+ let json;
+ try { json = JSON.parse(raw.toString('utf8')); } catch { return { body: raw }; }
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { body: raw };
+ json.reasoning_effort = 'none';
+ const research = searchSchema(json.tools);
+ const named = json.tool_choice?.type === 'function' && json.tool_choice.function?.name === SEARCH;
+ const automatic = json.tool_choice == null || json.tool_choice === 'auto';
+ const initial = json.messages?.at?.(-1)?.role === 'user'
+ && json.tools?.some?.(item => item.function?.name === 'task');
+ const normalize = researchToolCalls && research && (json.n == null || json.n === 1)
+ && (named || (automatic && initial));
+ const stream = json.stream === true;
+ if (normalize) {
+ json.tool_choice = { type: 'function', function: { name: SEARCH } };
+ json.chat_template_kwargs = { ...json.chat_template_kwargs, enable_thinking: false };
+ // Some affected providers discard named arguments in their nonstream path.
+ json.stream = true;
+ if (!stream) json.stream_options = { ...json.stream_options, include_usage: true };
+ }
+ return { body: Buffer.from(JSON.stringify(json)), research, normalize, stream };
+}
+
+function decodeBody(raw, headers) {
+ const encoding = String(headers['content-encoding'] || 'identity').toLowerCase();
+ if (encoding === 'gzip') raw = gunzipSync(raw);
+ else if (encoding === 'deflate') raw = inflateSync(raw);
+ else if (encoding === 'br') raw = brotliDecompressSync(raw);
+ else if (encoding !== 'identity') throw new Error('Unsupported upstream response encoding.');
+ return textDecoder.decode(raw);
+}
+
+function validArguments(text) {
+ let value;
+ try { value = JSON.parse(text); } catch { throw new Error('Named research tool arguments are not complete JSON.'); }
+ if (!value || typeof value !== 'object' || Array.isArray(value)
+ || Object.keys(value).length !== 1 || typeof value.query !== 'string' || !value.query.trim()) {
+ throw new Error('Named research tool arguments do not match the query-only schema.');
+ }
+}
+
+function normalizeResponse(raw, headers, stream) {
+ if (!String(headers['content-type']).includes('text/event-stream')) {
+ throw new Error('Expected a streaming named research response.');
+ }
+ const events = [];
+ let done = false;
+ for (const frame of decodeBody(raw, headers).replace(/\r\n/g, '\n').split('\n\n')) {
+ const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
+ if (!data) continue;
+ if (data === '[DONE]') { done = true; continue; }
+ const event = JSON.parse(data);
+ if (event.error) throw new Error('Upstream reported an error in the research stream.');
+ if (event.choices?.length > 1) throw new Error('Expected one research completion choice.');
+ events.push(event);
+ }
+ const metadata = events.find(event => event.id && event.model);
+ if (!done || !metadata) throw new Error('Research stream did not complete.');
+ let content = '', reasoning = '', finish;
+ const calls = new Map();
+ for (const event of events) {
+ const choice = event.choices?.[0];
+ if (!choice) continue;
+ if (choice.index !== 0) throw new Error('Unexpected research completion choice.');
+ if (choice.finish_reason) finish = choice.finish_reason;
+ const delta = choice.delta || {};
+ content += delta.content || '';
+ reasoning += delta.reasoning || delta.reasoning_content || '';
+ for (const part of delta.tool_calls || []) {
+ const value = calls.get(part.index) || { id: '', type: 'function', function: { name: '', arguments: '' } };
+ if (part.id) value.id = part.id;
+ if (part.function?.name) value.function.name += part.function.name;
+ if (part.function?.arguments) value.function.arguments += part.function.arguments;
+ calls.set(part.index, value);
+ }
+ }
+ if (!['stop', 'tool_calls'].includes(finish)) throw new Error('Research tool generation did not finish normally.');
+ let call;
+ const performed = calls.size === 0;
+ if (calls.size) {
+ if (calls.size !== 1 || calls.get(0)?.function.name !== SEARCH || !calls.get(0).id) {
+ throw new Error('Upstream returned an unexpected research tool call.');
+ }
+ call = calls.get(0);
+ validArguments(call.function.arguments);
+ if (stream) return { body: raw, headers, performed: false, toolCallId: call.id };
+ } else {
+ validArguments(content);
+ call = { id: `call_research_${randomUUID().replaceAll('-', '')}`, type: 'function', function: { name: SEARCH, arguments: content } };
+ content = '';
+ }
+ const usage = events.findLast(event => event.usage)?.usage;
+ const meta = { id: metadata.id, created: metadata.created, model: metadata.model };
+ if (!stream) {
+ const message = { role: 'assistant', content: content || null, tool_calls: [call] };
+ if (reasoning) message.reasoning = reasoning;
+ const value = { ...meta, object: 'chat.completion', choices: [{ index: 0, message, finish_reason: 'tool_calls' }], usage: usage || null };
+ return { body: Buffer.from(JSON.stringify(value)), headers: { 'content-type': 'application/json' }, performed, toolCallId: call.id };
+ }
+ const first = { role: 'assistant', content: '' };
+ if (reasoning) first.reasoning = reasoning;
+ const chunks = [first, { tool_calls: [{ index: 0, ...call }] }, {}].map((delta, index) => ({
+ ...meta, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: index === 2 ? 'tool_calls' : null }],
+ }));
+ if (usage) chunks.push({ ...meta, object: 'chat.completion.chunk', choices: [], usage });
+ const body = Buffer.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n');
+ return { body, headers: { 'content-type': 'text/event-stream' }, performed, toolCallId: call.id };
+}
+
+function sendBody(res, status, headers, body) {
+ const output = { ...headers, 'content-length': String(body.length) };
+ delete output['transfer-encoding'];
+ res.writeHead(status, output).end(body);
+}
+
+export function createCompatibilityServer({ upstreamBaseUrl, researchToolCalls = false, auditDirectory = '', timeoutMs = 180_000, maxBufferedBytes = 16 * 1024 * 1024, log = () => {} }) {
+ const upstream = new URL(upstreamBaseUrl);
+ if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password) throw new Error('Invalid upstream URL.');
+ async function audit(id, record) {
+ if (!auditDirectory) return;
+ await mkdir(auditDirectory, { recursive: true, mode: 0o700 });
+ await writeFile(join(auditDirectory, `${id}.json`), JSON.stringify(record, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
+ }
+ return http.createServer(async (req, res) => {
+ if (req.url === '/health') return res.writeHead(200).end('ok');
+ const id = randomUUID();
+ let record, audited = false;
+ try {
+ const target = new URL(req.url, upstream);
+ if (target.origin !== upstream.origin) throw new Error('Request must use the configured upstream.');
+ const original = await readBody(req);
+ const prepared = prepare(req.method, target.pathname, original, researchToolCalls);
+ const shouldAudit = researchToolCalls && prepared.research && Boolean(auditDirectory);
+ if (shouldAudit) record = { id, at: new Date().toISOString(), originalRequestBase64: original.toString('base64'), forwardedRequestBase64: prepared.body.toString('base64'), headersExcluded: 'Authentication and cookie headers are never recorded.' };
+ const headers = { ...req.headers, host: target.host, 'content-length': String(prepared.body.length) };
+ delete headers['transfer-encoding'];
+ if (prepared.normalize) headers['accept-encoding'] = 'identity';
+ const client = target.protocol === 'https:' ? https : http;
+ await new Promise((resolveRequest, reject) => {
+ const out = client.request(target, { method: req.method, headers }, async response => {
+ try {
+ if (!prepared.normalize && !shouldAudit) {
+ res.writeHead(response.statusCode || 502, response.headers);
+ response.on('error', reject);
+ response.on('end', resolveRequest);
+ response.pipe(res);
+ return;
+ }
+ const raw = await readBody(response, maxBufferedBytes);
+ if (record) Object.assign(record, { upstreamStatus: response.statusCode, upstreamResponseBase64: raw.toString('base64'), upstreamContentType: response.headers['content-type'], upstreamContentEncoding: response.headers['content-encoding'], upstreamResponseComplete: true });
+ let output = { body: raw, headers: response.headers, performed: false };
+ if (prepared.normalize && response.statusCode === 200) output = normalizeResponse(raw, response.headers, prepared.stream);
+ if (record) {
+ Object.assign(record, { normalizationPerformed: output.performed, toolCallId: output.toolCallId, returnedResponseBase64: output.body.toString('base64') });
+ await audit(id, record); audited = true;
+ }
+ if (output.performed) log({ event: 'research-tool-normalized', requestId: id, toolCallId: output.toolCallId, upstreamBytes: raw.length, returnedBytes: output.body.length });
+ sendBody(res, response.statusCode || 502, output.headers, output.body);
+ resolveRequest();
+ } catch (error) { reject(error); }
+ });
+ if (prepared.normalize || shouldAudit) out.setTimeout(timeoutMs, () => out.destroy(new Error('Research upstream request timed out.')));
+ out.on('error', reject);
+ out.end(prepared.body);
+ });
+ } catch (error) {
+ if (record && !audited) {
+ try { await audit(id, { ...record, error: String(error.message), returnedSuccess: false }); }
+ catch { /* The failed response below does not claim successful auditing. */ }
+ }
+ if (res.headersSent) return res.destroy();
+ sendBody(res, 502, { 'content-type': 'application/json' }, Buffer.from(JSON.stringify({ error: { message: String(error.message), type: 'research_tool_compatibility_error', request_id: id } })));
+ }
+ });
+}
+
+if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
+ createCompatibilityServer({
+ upstreamBaseUrl: process.env.UPSTREAM_BASE_URL,
+ researchToolCalls: process.env.RESEARCH_TOOL_CALLS_ENABLED === 'true',
+ auditDirectory: process.env.RESEARCH_TOOL_CALLS_AUDIT_DIR || '',
+ log: value => console.log(JSON.stringify(value)),
+ }).listen(Number(process.env.PORT || 8000), '0.0.0.0');
+}
diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts
index cc800b35..14fb47de 100644
--- a/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts
+++ b/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts
@@ -297,7 +297,7 @@ function toOpenClawResult(mcpResult: unknown) {
.join("\n");
throw new Error(typeof text === "string" && text ? text : "iraop tool call reported isError");
}
- return { content: r.content, details: mcpResult };
+ return { content: [...r.content], details: mcpResult };
}
const text = typeof mcpResult === "string" ? mcpResult : JSON.stringify(mcpResult, null, 2);
return { content: [{ type: "text", text }], details: mcpResult };
@@ -316,6 +316,37 @@ function firstTextContent(result: { content?: unknown[] }) {
.join("\n");
}
+function queryEvidenceProblem(mcpResult: unknown): string | null {
+ const candidates: unknown[] = [];
+ if (mcpResult && typeof mcpResult === "object") {
+ const raw = mcpResult as Record;
+ candidates.push(raw.structuredContent, raw);
+ if (Array.isArray(raw.content)) {
+ for (const item of raw.content) {
+ if (!item || typeof item !== "object" || !("text" in item) || typeof item.text !== "string") continue;
+ try { candidates.push(JSON.parse(item.text)); } catch { /* Try the next content block. */ }
+ }
+ }
+ } else if (typeof mcpResult === "string") {
+ try { candidates.push(JSON.parse(mcpResult)); } catch { /* Report an unreadable research response below. */ }
+ }
+ const response = candidates.find((candidate) =>
+ candidate && typeof candidate === "object" && !Array.isArray(candidate)
+ && "answer" in candidate,
+ ) as Record | undefined;
+ if (!response || typeof response.answer !== "string" || !response.answer.trim()) {
+ return "IRAOP did not return a readable, nonempty research answer with document sources.";
+ }
+ const hasSource = Array.isArray(response.sources) && response.sources.some((source) =>
+ source && typeof source === "object"
+ && typeof source.source_file === "string" && source.source_file.trim()
+ && Number.isInteger(source.page_number) && source.page_number > 0,
+ );
+ return hasSource ? null : "IRAOP returned no usable document sources. The research is incomplete. "
+ + "Do not present its answer as retrieved documentation or use it to justify a repair. "
+ + "Missing sources do not establish that a collection is empty.";
+}
+
function summarizeQueryResult(result: ReturnType, elapsedMs: number) {
const text = firstTextContent(result).trim();
const elapsed = Math.max(1, Math.round(elapsedMs / 1000));
@@ -398,6 +429,21 @@ export default definePluginEntry({
postSubagentEvent(subagent, "delta", message);
});
const openClawResult = toOpenClawResult(result);
+ const evidenceProblem = queryEvidenceProblem(result);
+ if (evidenceProblem) {
+ postSubagentEvent(subagent, "error", `IRA research incomplete: ${evidenceProblem}`);
+ return {
+ // The tool transport completed. Research completion is explicit in
+ // content; returning normally also preserves the raw MCP details.
+ // OpenClaw assigns its own isError flag to the resulting message.
+ content: [{ type: "text", text: JSON.stringify({
+ status: "incomplete",
+ error: evidenceProblem,
+ evidenceScope: "No document-grounded answer is available from this call. The full original response is retained in tool details for inspection.",
+ }) }],
+ details: result,
+ };
+ }
postSubagentEvent(subagent, "complete", summarizeQueryResult(openClawResult, Date.now() - startedAt));
openClawResult.content.unshift({ type: "text", text: "Evidence scope: documentation research. This service has not inspected the current workload. Proposed local causes remain hypotheses until checked against live evidence on the participating resources." });
return openClawResult;
diff --git a/helm/mosaic-stack/templates/llm-compat.yaml b/helm/mosaic-stack/templates/llm-compat.yaml
index e763a0d9..c265d7e6 100644
--- a/helm/mosaic-stack/templates/llm-compat.yaml
+++ b/helm/mosaic-stack/templates/llm-compat.yaml
@@ -2,6 +2,9 @@
# SPDX-License-Identifier: Apache-2.0
{{- $c := .Values.llm.requestCompatibility -}}
+{{- $r := $c.researchToolCalls | default dict -}}
+{{- $research := and .Values.modules.research.enabled ($r.enabled | default false) -}}
+{{- $audit := and $research ($r.auditEnabled | default false) -}}
{{- if and .Values.modules.execution.enabled $c.enabled }}
apiVersion: v1
kind: ConfigMap
@@ -11,48 +14,8 @@ metadata:
app: {{ $c.name }}
{{- include "mosaic-stack.labels" . | nindent 4 }}
data:
- server.js: |
- const http = require('http');
- const https = require('https');
- const upstream = new URL(process.env.UPSTREAM_BASE_URL);
- const port = Number(process.env.PORT || {{ $c.port }});
-
- function readBody(req) {
- return new Promise((resolve, reject) => {
- const chunks = [];
- req.on('data', chunk => chunks.push(chunk));
- req.on('end', () => resolve(Buffer.concat(chunks)));
- req.on('error', reject);
- });
- }
-
- function patchBody(req, body) {
- if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) return body;
- try {
- const json = JSON.parse(body.toString('utf8'));
- json.reasoning_effort = 'none';
- return Buffer.from(JSON.stringify(json));
- } catch {
- return body;
- }
- }
-
- http.createServer(async (req, res) => {
- if (req.url === '/health') return res.writeHead(200).end('ok');
- const body = patchBody(req, await readBody(req));
- const target = new URL(req.url, upstream);
- const headers = { ...req.headers, host: target.host, 'content-length': String(body.length) };
- const client = target.protocol === 'https:' ? https : http;
- const out = client.request(target, { method: req.method, headers }, upstreamRes => {
- res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
- upstreamRes.pipe(res);
- });
- out.on('error', error => {
- res.writeHead(502, { 'content-type': 'application/json' });
- res.end(JSON.stringify({ error: String(error.message || error) }));
- });
- out.end(body);
- }).listen(port, '0.0.0.0');
+ server.mjs: |
+ {{- .Files.Get "files/llm-compat-server.mjs" | nindent 4 }}
---
apiVersion: apps/v1
kind: Deployment
@@ -71,8 +34,12 @@ spec:
labels:
app: {{ $c.name }}
annotations:
- checksum/config: {{ printf "%s-%s-%s" .Chart.Version $c.name (include "mosaic-stack.llmUpstreamBaseUrl" .) | sha256sum }}
+ checksum/config: {{ printf "%s-%s-%s-%s-%v-%v" .Chart.Version $c.name (include "mosaic-stack.llmUpstreamBaseUrl" .) (.Files.Get "files/llm-compat-server.mjs" | sha256sum) $research $audit | sha256sum }}
spec:
+ {{- if $audit }}
+ securityContext:
+ fsGroup: 1000
+ {{- end }}
{{- include "mosaic-stack.imagePullSecrets" . | nindent 6 }}
{{- include "mosaic-stack.nodeSelector" . | nindent 6 }}
{{- include "mosaic-stack.tolerations" . | nindent 6 }}
@@ -80,12 +47,18 @@ spec:
- name: proxy
image: {{ include "mosaic-stack.image" .Values.mosaicUi.image | quote }}
imagePullPolicy: {{ .Values.mosaicUi.image.pullPolicy }}
- command: ["node", "/config/server.js"]
+ command: ["node", "/config/server.mjs"]
env:
- name: PORT
value: {{ $c.port | quote }}
- name: UPSTREAM_BASE_URL
value: {{ include "mosaic-stack.llmUpstreamBaseUrl" . | quote }}
+ - name: RESEARCH_TOOL_CALLS_ENABLED
+ value: {{ $research | quote }}
+ {{- if $audit }}
+ - name: RESEARCH_TOOL_CALLS_AUDIT_DIR
+ value: /audit/records
+ {{- end }}
{{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }}
- name: NODE_EXTRA_CA_CERTS
value: /etc/llm-ca/ca.crt
@@ -102,6 +75,10 @@ spec:
- name: config
mountPath: /config
readOnly: true
+ {{- if $audit }}
+ - name: research-audit
+ mountPath: /audit
+ {{- end }}
{{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }}
- name: llm-ca
mountPath: /etc/llm-ca
@@ -111,6 +88,11 @@ spec:
- name: config
configMap:
name: {{ $c.name }}
+ {{- if $audit }}
+ - name: research-audit
+ emptyDir:
+ sizeLimit: 128Mi
+ {{- end }}
{{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }}
- name: llm-ca
secret:
diff --git a/helm/mosaic-stack/tests/chart-render.test.mjs b/helm/mosaic-stack/tests/chart-render.test.mjs
index 6052459c..83bc79d4 100644
--- a/helm/mosaic-stack/tests/chart-render.test.mjs
+++ b/helm/mosaic-stack/tests/chart-render.test.mjs
@@ -3,7 +3,7 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
-import { mkdtempSync, readFileSync, readdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
+import { cpSync, mkdtempSync, readFileSync, readdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -437,7 +437,44 @@ test("uses one no-reasoning request contract for external endpoints", () => {
"--show-only", "templates/llm-compat.yaml",
);
assert.match(output, /json\.reasoning_effort = 'none'/);
- assert.doesNotMatch(output, /DISABLE_THINKING|VLLM_UPSTREAM|chat_template_kwargs|scrubVisibleThinking|pipeOpenAiStream/);
+ assert.doesNotMatch(output, /DISABLE_THINKING|VLLM_UPSTREAM|scrubVisibleThinking|pipeOpenAiStream/);
+ assert.match(output, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "false"/);
+});
+
+test("research compatibility is opt-in and requires the research module", () => {
+ const args = ["--show-only", "templates/llm-compat.yaml"];
+ const disabled = render(...args, "--set", "llm.requestCompatibility.researchToolCalls.enabled=true");
+ assert.match(disabled, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "false"/);
+ assert.doesNotMatch(disabled, /name: research-audit/);
+ const enabled = render(...args, "--set", "modules.research.enabled=true,llm.requestCompatibility.researchToolCalls.enabled=true,llm.requestCompatibility.researchToolCalls.auditEnabled=true");
+ assert.match(enabled, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "true"/);
+ assert.match(enabled, /name: RESEARCH_TOOL_CALLS_AUDIT_DIR\s+value: \/audit\/records/);
+ assert.match(enabled, /fsGroup: 1000/);
+ assert.match(enabled, /sizeLimit: 128Mi/);
+});
+
+test("the rendered compatibility server is the tested source file", () => {
+ const output = render("--show-only", "templates/llm-compat.yaml");
+ const source = readFileSync(join(chart, "files/llm-compat-server.mjs"), "utf8");
+ assert.equal(configMapBlock(output, "server.mjs").trimEnd(), source.trimEnd());
+});
+
+test("a compatibility source change rolls the proxy without a chart version change", () => {
+ const directory = mkdtempSync(join(tmpdir(), "mosaic-compat-checksum-"));
+ try {
+ const copy = join(directory, "chart");
+ cpSync(chart, copy, { recursive: true });
+ const checksum = () => {
+ const output = execFileSync("helm", ["template", "mosaic", copy, "--show-only", "templates/llm-compat.yaml"], { encoding: "utf8" });
+ return output.match(/checksum\/config: ([a-f0-9]{64})/)[1];
+ };
+ const before = checksum();
+ const path = join(copy, "files/llm-compat-server.mjs");
+ writeFileSync(path, readFileSync(path, "utf8") + "\n// Independent test change.\n");
+ assert.notEqual(checksum(), before);
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
});
test("configures chart-managed vLLM for non-thinking generation", () => {
diff --git a/helm/mosaic-stack/tests/iraop-response.test.mjs b/helm/mosaic-stack/tests/iraop-response.test.mjs
new file mode 100644
index 00000000..44b6d8fe
--- /dev/null
+++ b/helm/mosaic-stack/tests/iraop-response.test.mjs
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createServer } from "node:http";
+import { stripTypeScriptTypes } from "node:module";
+import test from "node:test";
+
+const source = readFileSync(new URL("../files/openclaw-seed/extensions/iraop/index.ts", import.meta.url), "utf8")
+ .replace('import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";', "const definePluginEntry = (entry) => entry;");
+const javascript = stripTypeScriptTypes(source, { mode: "strip", sourceUrl: "iraop-response-test.ts" });
+const { default: plugin } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`);
+
+const queryPayload = (answer, sources = []) => ({
+ answer, depth: "quick", collections_searched: ["validation"], sources,
+});
+const mcp = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload) }], isError: false });
+const sourceReference = { source_file: "control.pdf", page_number: 2, collection: "validation", doc_id: "control.pdf", url: "/api/v1/documents/control.pdf" };
+
+async function fixture(result, run) {
+ const sessions = new Map();
+ const events = [];
+ let nextSession = 0;
+ const server = createServer(async (req, res) => {
+ const url = new URL(req.url, "http://localhost");
+ if (req.method === "GET" && url.pathname === "/mcp/sse") {
+ const id = String(++nextSession);
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
+ sessions.set(id, res);
+ res.write(`event: endpoint\ndata: /mcp/messages?session=${id}\n\n`);
+ req.on("close", () => sessions.delete(id));
+ return;
+ }
+ let text = "";
+ for await (const chunk of req) text += chunk;
+ const body = JSON.parse(text || "{}");
+ if (url.pathname === "/events") {
+ events.push(body);
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"success":true}');
+ return;
+ }
+ if (url.pathname === "/mcp/messages") {
+ const stream = sessions.get(url.searchParams.get("session"));
+ if (body.id !== undefined) {
+ const response = body.method === "initialize"
+ ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "iraop-test", version: "1" } }
+ : result;
+ stream.write(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result: response })}\n\n`);
+ }
+ res.writeHead(202).end();
+ return;
+ }
+ res.writeHead(404).end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tools = new Map();
+ plugin.register({
+ pluginConfig: { baseUrl: `${base}/mcp/sse`, timeoutMs: 3000, subagentEventsUrl: `${base}/events` },
+ registerTool: (tool) => tools.set(tool.name, tool),
+ });
+ try {
+ await run(tools, events);
+ for (let i = 0; i < 100 && !events.some((event) => ["complete", "error"].includes(event.phase)); i++) {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ } finally {
+ for (const stream of sessions.values()) stream.end();
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(resolve));
+ }
+ return events;
+}
+
+async function query(result) {
+ let value;
+ const events = await fixture(result, async (tools) => {
+ value = await tools.get("iraop_query").execute("validation-call", { question: "Read the indexed control document.", collections: ["validation"] });
+ });
+ return { value, events };
+}
+
+test("planning text without sources is incomplete and emits an error event", async () => {
+ const original = mcp(queryPayload("I will search for the document."));
+ const { value, events } = await query(original);
+ assert.equal(JSON.parse(value.content[0].text).status, "incomplete");
+ assert.deepEqual(value.details, original);
+ assert.ok(events.some((event) => event.phase === "error" && event.content.includes("incomplete")));
+ assert.ok(!events.some((event) => event.phase === "complete"));
+});
+
+test("fabricated source-free answer stays complete in details and is withheld from model content", async () => {
+ const answer = "Verified code: MVE-2026-1000002. " + "未经检索的原始输出。".repeat(600);
+ const original = mcp(queryPayload(answer));
+ const { value } = await query(original);
+ assert.equal(JSON.parse(value.content[0].text).status, "incomplete");
+ assert.ok(!value.content.map((item) => item.text).join("\n").includes("MVE-2026-1000002"));
+ assert.equal(JSON.parse(value.details.content[0].text).answer, answer);
+ assert.deepEqual(value.details, original);
+});
+
+for (const [label, sources] of [
+ ["missing", undefined], ["null", null], ["wrong type", "control.pdf"],
+ ["empty reference", [{}]], ["invalid page", [{ ...sourceReference, page_number: 0 }]],
+]) {
+ test(`research with ${label} sources is incomplete`, async () => {
+ const payload = queryPayload("A factual claim.", sources);
+ if (label === "missing") delete payload.sources;
+ const { value } = await query(mcp(payload));
+ assert.equal(JSON.parse(value.content[0].text).status, "incomplete");
+ assert.match(value.content[0].text, /incomplete/);
+ });
+}
+
+test("an empty answer is incomplete even when a source is present", async () => {
+ const { value } = await query(mcp(queryPayload(" ", [sourceReference])));
+ assert.equal(JSON.parse(value.content[0].text).status, "incomplete");
+});
+
+test("valid sourced answer remains complete and evidence notes do not mutate raw details", async () => {
+ const original = mcp(queryPayload("A documented statement. " + "完整原文。".repeat(700), [sourceReference]));
+ const { value, events } = await query(original);
+ assert.ok(!value.isError);
+ assert.deepEqual(value.details, original);
+ assert.equal(value.content.length, original.content.length + 1);
+ assert.notEqual(value.content, value.details.content);
+ assert.equal(value.content[1].text, original.content[0].text);
+ assert.ok(events.some((event) => event.phase === "complete"));
+ assert.ok(!events.some((event) => event.phase === "error"));
+});
+
+test("structured MCP query payload can carry the source references", async () => {
+ const payload = queryPayload("Documented answer.", [sourceReference]);
+ const original = { content: [{ type: "text", text: payload.answer }], structuredContent: payload };
+ const { value } = await query(original);
+ assert.ok(!value.isError);
+ assert.deepEqual(value.details, original);
+});
+
+test("unreadable research payload is incomplete with the raw response retained", async () => {
+ const original = { content: [{ type: "text", text: "Unstructured unsupported response." }] };
+ const { value } = await query(original);
+ assert.equal(JSON.parse(value.content[0].text).status, "incomplete");
+ assert.deepEqual(value.details, original);
+});
+
+test("MCP error remains a rejected tool call and error event", async () => {
+ const original = { content: [{ type: "text", text: "Backend query failed." }], isError: true };
+ const events = await fixture(original, async (tools) => {
+ await assert.rejects(tools.get("iraop_query").execute("error-call", { question: "Read the document." }), /Backend query failed/);
+ });
+ assert.ok(events.some((event) => event.phase === "error"));
+ assert.ok(!events.some((event) => event.phase === "complete"));
+});
+
+test("collection listing does not require answer or sources fields", async () => {
+ const original = { content: [{ type: "text", text: JSON.stringify([{ name: "validation", doc_count: 4 }]) }] };
+ await fixture(original, async (tools) => {
+ const value = await tools.get("iraop_list_collections").execute();
+ assert.deepEqual(value.details, original);
+ assert.deepEqual(value.content, original.content);
+ assert.ok(!value.isError);
+ });
+});
diff --git a/helm/mosaic-stack/tests/llm-compat.test.mjs b/helm/mosaic-stack/tests/llm-compat.test.mjs
new file mode 100644
index 00000000..e6c80611
--- /dev/null
+++ b/helm/mosaic-stack/tests/llm-compat.test.mjs
@@ -0,0 +1,238 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from 'node:assert/strict';
+import http from 'node:http';
+import { mkdtemp, readFile, readdir, rm, symlink } from 'node:fs/promises';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { gzipSync, deflateSync, brotliCompressSync } from 'node:zlib';
+import { fileURLToPath } from 'node:url';
+import { setTimeout as delay } from 'node:timers/promises';
+import test from 'node:test';
+import { createCompatibilityServer } from '../files/llm-compat-server.mjs';
+
+const search = { type: 'function', function: { name: 'opensearch_search', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } };
+const task = { type: 'function', function: { name: 'task', parameters: { type: 'object' } } };
+const input = () => ({ model: 'test-model', messages: [{ role: 'user', content: 'Search the indexed PDF.' }], tools: [search, task], stream: true, temperature: 1, max_completion_tokens: 4096 });
+function frames(content, { calls, finish = 'stop', done = true, usage, choices } = {}) {
+ const meta = { id: 'chatcmpl-real-upstream', model: 'test-model', created: 1793400000, object: 'chat.completion.chunk' };
+ const delta = calls ? { tool_calls: calls } : { content };
+ const result = [
+ { ...meta, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] },
+ { ...meta, choices: choices || [{ index: 0, delta, finish_reason: null }] },
+ { ...meta, choices: [{ index: 0, delta: {}, finish_reason: finish }] },
+ ];
+ if (usage) result.push({ ...meta, choices: [], usage });
+ return Buffer.from(result.map(value => `data: ${JSON.stringify(value)}\n\n`).join('') + (done ? 'data: [DONE]\n\n' : ''));
+}
+function parseFrames(body) {
+ return body.toString().split('\n').filter(line => line.startsWith('data: ') && line !== 'data: [DONE]').map(line => JSON.parse(line.slice(6)));
+}
+function returnedCall(body) {
+ return parseFrames(body).flatMap(event => event.choices || []).flatMap(choice => choice.delta?.tool_calls || [])[0];
+}
+async function request(base, body, path = '/v1/chat/completions') {
+ return new Promise((resolveRequest, reject) => {
+ const req = http.request(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer never-record-this-auth-value' } }, res => {
+ const chunks = [];
+ res.on('data', chunk => chunks.push(chunk));
+ res.on('end', () => resolveRequest({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
+ res.on('error', reject);
+ });
+ req.on('error', reject);
+ req.end(body === undefined ? undefined : JSON.stringify(body));
+ });
+}
+async function fixture(options, run) {
+ const upstreamRequests = [], events = [];
+ const auditDirectory = await mkdtemp(join(tmpdir(), 'mosaic-research-compat-'));
+ const upstream = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ upstreamRequests.push({ path: req.url, headers: req.headers, body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : null });
+ if (options.handler) return options.handler(req, res);
+ const response = options.response || frames('{"query":"MOSAIC-KB page 2"}');
+ res.writeHead(options.status || 200, { 'Content-Type': 'text/event-stream', ...options.headers });
+ if (options.fragment) {
+ for (const byte of response) res.write(Buffer.from([byte]));
+ res.end();
+ } else res.end(response);
+ });
+ await new Promise(resolveListen => upstream.listen(0, '127.0.0.1', resolveListen));
+ const proxy = createCompatibilityServer({ upstreamBaseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, researchToolCalls: options.enabled !== false, auditDirectory: options.audit ? auditDirectory : '', log: value => events.push(value), ...options.server });
+ await new Promise(resolveListen => proxy.listen(0, '127.0.0.1', resolveListen));
+ try {
+ await run(`http://127.0.0.1:${proxy.address().port}`, upstreamRequests, auditDirectory, events);
+ } finally {
+ upstream.closeAllConnections(); proxy.closeAllConnections();
+ await Promise.all([new Promise(resolveClose => upstream.close(resolveClose)), new Promise(resolveClose => proxy.close(resolveClose))]);
+ await rm(auditDirectory, { recursive: true });
+ }
+}
+
+test('disabled research compatibility preserves the existing no-reasoning contract and raw reply', async () => {
+ const raw = frames('{"query":"plain unexecuted text"}');
+ await fixture({ enabled: false, response: raw, audit: true }, async (base, requests, directory) => {
+ const body = input();
+ const result = await request(base, body);
+ assert.deepEqual(result.body, raw);
+ assert.deepEqual(requests[0].body, { ...body, reasoning_effort: 'none' });
+ assert.deepEqual(await readdir(directory), []);
+ });
+});
+
+test('normalizes only complete named query arguments and retains the full original audit', async () => {
+ const argumentsText = JSON.stringify({ query: '完整检索内容。'.repeat(1000) }, null, 2);
+ const raw = frames(argumentsText);
+ await fixture({ response: raw, audit: true, fragment: true }, async (base, requests, directory, events) => {
+ const body = input();body.chat_template_kwargs = { custom_setting: 'retained' };
+ const result = await request(base, body);
+ assert.equal(result.status, 200);
+ const call = returnedCall(result.body);
+ assert.equal(call.function.name, 'opensearch_search');
+ assert.equal(call.function.arguments, argumentsText);
+ assert.deepEqual(requests[0].body.chat_template_kwargs, { custom_setting: 'retained', enable_thinking: false });
+ assert.deepEqual(requests[0].body.tool_choice, { type: 'function', function: { name: 'opensearch_search' } });
+ assert.deepEqual(requests[0].body.messages, body.messages);
+ const files = await readdir(directory);assert.equal(files.length, 1);
+ const audit = JSON.parse(await readFile(join(directory, files[0]), 'utf8'));
+ assert.deepEqual(Buffer.from(audit.upstreamResponseBase64, 'base64'), raw);
+ assert.deepEqual(Buffer.from(audit.returnedResponseBase64, 'base64'), result.body);
+ assert.deepEqual(JSON.parse(Buffer.from(audit.originalRequestBase64, 'base64')), body);
+ assert.equal(audit.normalizationPerformed, true);
+ assert.equal(audit.upstreamResponseComplete, true);
+ assert.ok(!JSON.stringify(audit).includes('never-record-this-auth-value'));
+ assert.equal(events[0].toolCallId, call.id);
+ });
+});
+
+test('already structured named calls pass through byte for byte', async () => {
+ const raw = frames('', { calls: [{ index: 0, id: 'call_actual_provider', type: 'function', function: { name: 'opensearch_search', arguments: '{"query":"actual query"}' } }], finish: 'tool_calls' });
+ await fixture({ response: raw }, async (base, _requests, _directory, events) => {
+ const result = await request(base, input());assert.deepEqual(result.body, raw);assert.deepEqual(events, []);
+ });
+});
+
+for (const [name, modify] of [
+ ['explicit none', body => { body.tool_choice = 'none'; }],
+ ['another named tool', body => { body.tool_choice = { type: 'function', function: { name: 'task' } }; }],
+ ['post-search synthesis', body => { body.messages.push({ role: 'tool', tool_call_id: 'actual', content: 'Full retrieved document content.' }); }],
+ ['subagent without task', body => { body.tools = [search]; }],
+ ['unrelated OpenClaw tools', body => { body.tools = [task]; }],
+ ['multiple requested choices', body => { body.n = 2; }],
+ ['a different search schema', body => { body.tools = [{ ...search, function: { ...search.function, parameters: { ...search.function.parameters, required: ['query', 'collection'] } } }, task]; }],
+]) {
+ test(`${name} is forwarded without a forced search or response rewrite`, async () => {
+ const raw = frames('A complete ordinary response.');
+ await fixture({ response: raw }, async (base, requests) => {
+ const body = input();modify(body);
+ const result = await request(base, body);assert.deepEqual(result.body, raw);
+ assert.deepEqual(requests[0].body, { ...body, reasoning_effort: 'none' });
+ });
+ });
+}
+
+test('nonstream callers receive real tool arguments and complete provider usage', async () => {
+ const usage = { prompt_tokens: 17, completion_tokens: 8, total_tokens: 25, completion_tokens_details: { reasoning_tokens: 0 } };
+ await fixture({ response: frames('{"query":"indexed doc"}', { usage }) }, async (base, requests) => {
+ const body = input();body.stream = false;
+ const result = await request(base, body);const value = JSON.parse(result.body);
+ assert.equal(value.object, 'chat.completion');assert.deepEqual(value.usage, usage);
+ assert.equal(value.choices[0].message.tool_calls[0].function.arguments, '{"query":"indexed doc"}');
+ assert.equal(requests[0].body.stream, true);assert.equal(requests[0].body.stream_options.include_usage, true);
+ });
+});
+
+for (const [encoding, compress] of [['gzip', gzipSync], ['deflate', deflateSync], ['br', brotliCompressSync]]) {
+ test(`handles complete ${encoding} upstream frames`, async () => {
+ await fixture({ response: compress(frames('{"query":"GPU 文档"}')), headers: { 'Content-Encoding': encoding } }, async base => {
+ const result = await request(base, input());assert.equal(result.status, 200);
+ assert.deepEqual(JSON.parse(returnedCall(result.body).function.arguments), { query: 'GPU 文档' });
+ assert.equal(result.headers['content-encoding'], undefined);
+ });
+ });
+}
+
+for (const content of ['I will search. {"query":"a"}', '{"query":', '{"query":""}', '{"query":123}', '{}', '[]', '{"query":"a","unexpected":"b"}']) {
+ test(`rejects unsupported arguments without inventing a call: ${content}`, async () => {
+ const raw = frames(content);
+ await fixture({ response: raw, audit: true }, async (base, _requests, directory) => {
+ const result = await request(base, input());assert.equal(result.status, 502);
+ assert.ok(!result.body.toString().includes('call_research_'));
+ const files = await readdir(directory);assert.equal(files.length, 1);
+ const audit = JSON.parse(await readFile(join(directory, files[0]), 'utf8'));
+ assert.deepEqual(Buffer.from(audit.upstreamResponseBase64, 'base64'), raw);
+ assert.equal(audit.returnedSuccess, false);
+ });
+ });
+}
+
+for (const [name, options] of [['missing terminator', { done: false }], ['token exhaustion', { finish: 'length' }], ['filtered output', { finish: 'content_filter' }]]) {
+ test(`${name} cannot become a successful tool call`, async () => {
+ await fixture({ response: frames('{"query":"valid but incomplete generation"}', options) }, async base => {
+ assert.equal((await request(base, input())).status, 502);
+ });
+ });
+}
+
+test('upstream failures preserve their status and complete body', async () => {
+ const body = Buffer.from('{"error":{"message":"upstream denied"}}');
+ await fixture({ status: 401, response: body, headers: { 'Content-Type': 'application/json' } }, async base => {
+ const result = await request(base, input());assert.equal(result.status, 401);assert.deepEqual(result.body, body);
+ });
+});
+
+test('unexpected tools cannot replace the explicitly selected read-only search', async () => {
+ const raw = frames('', { calls: [{ index: 0, id: 'wrong', type: 'function', function: { name: 'task', arguments: '{}' } }], finish: 'tool_calls' });
+ await fixture({ response: raw }, async base => { assert.equal((await request(base, input())).status, 502); });
+});
+
+test('oversized buffered responses fail instead of truncating arguments', async () => {
+ await fixture({ response: frames(JSON.stringify({ query: 'a'.repeat(4096) })), server: { maxBufferedBytes: 1024 } }, async base => {
+ assert.equal((await request(base, input())).status, 502);
+ });
+});
+
+test('an upstream timeout returns an error rather than a fabricated tool result', async () => {
+ await fixture({ server: { timeoutMs: 25 }, handler: (_req, res) => { setTimeout(() => { if (!res.destroyed) res.end('late'); }, 100); } }, async base => {
+ assert.equal((await request(base, input())).status, 502);
+ });
+});
+
+test('health does not call the model or create an audit', async () => {
+ await fixture({ audit: true }, async (base, requests, directory) => {
+ const result = await request(base, undefined, '/health');assert.equal(result.status, 200);assert.equal(result.body.toString(), 'ok');
+ assert.deepEqual(requests, []);assert.deepEqual(await readdir(directory), []);
+ });
+});
+
+test('the CLI starts through a ConfigMap-style symbolic link', { timeout: 10000 }, async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'mosaic-research-entrypoint-'));
+ const link = join(directory, 'server.mjs');
+ await symlink(fileURLToPath(new URL('../files/llm-compat-server.mjs', import.meta.url)), link);
+ const reserve = http.createServer();
+ await new Promise(resolveListen => reserve.listen(0, '127.0.0.1', resolveListen));
+ const port = reserve.address().port;
+ await new Promise(resolveClose => reserve.close(resolveClose));
+ const child = spawn(process.execPath, [link], { env: { ...process.env, PORT: String(port), UPSTREAM_BASE_URL: 'http://127.0.0.1:1/v1', RESEARCH_TOOL_CALLS_ENABLED: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
+ const closed = once(child, 'close');
+ let stderr = '';
+ child.stderr.on('data', chunk => { stderr += chunk; });
+ try {
+ let result;
+ for (let attempt = 0; attempt < 100; attempt++) {
+ assert.equal(child.exitCode, null, stderr || 'CLI exited before listening');
+ try { result = await request(`http://127.0.0.1:${port}`, undefined, '/health'); break; }
+ catch { await delay(25); }
+ }
+ assert.equal(result?.status, 200, stderr || 'CLI did not listen');
+ assert.equal(result.body.toString(), 'ok');
+ } finally {
+ if (child.exitCode === null) child.kill('SIGTERM');
+ await closed;
+ await rm(directory, { recursive: true });
+ }
+});
diff --git a/helm/mosaic-stack/values.yaml b/helm/mosaic-stack/values.yaml
index d1d0c328..a2fe067b 100644
--- a/helm/mosaic-stack/values.yaml
+++ b/helm/mosaic-stack/values.yaml
@@ -126,6 +126,11 @@ llm:
enabled: true
name: llm-openai-compat
port: 8000
+ researchToolCalls:
+ # Opt in only for affected research backends returning named arguments as text.
+ enabled: false
+ # Temporary full body traces contain private prompts; export before Pod replacement.
+ auditEnabled: false
external:
baseUrl: ""
model: ""
diff --git a/tests/docs/install-builder.test.mjs b/tests/docs/install-builder.test.mjs
index 3ba30798..5dd25f41 100644
--- a/tests/docs/install-builder.test.mjs
+++ b/tests/docs/install-builder.test.mjs
@@ -24,6 +24,7 @@ const example = {inference:'external', baseUrl:'https://inference.example.com/v1
for (const state of [
{modules:[]}, {modules:['terminal']}, {modules:['slurm']}, {modules:['slurm'], slurmBackend:'vanilla'},
{modules:['bcm'],redfish:true},
+ {modules:['research'],researchToolCalls:true},
{modules:['kubernetes','prometheus','alertmanager','loki','grafana','bcm','slurm','diagnostics','research','terminal','edit','clusters']},
{inference:'super',modules:['terminal']}, {inference:'ultra',modules:['grafana']},
]) test(`generated selection renders in Helm: ${JSON.stringify(state)}`, () => {
@@ -50,6 +51,16 @@ test('Redfish is opt-in and requires BCM, including when BCM is selected by a de
assert.ok(args.includes(`modules.bcm.redfish.enabled=${expected}`));
}
});
+test('search compatibility is opt-in, requires Research, and does not enable request tracing', () => {
+ for (const [selected, researchToolCalls, expected] of [
+ [['research'], true, true], [['research'], false, false], [[], true, false],
+ ]) {
+ const args = argumentsFor({...example, modules:selected, researchToolCalls});
+ assert.ok(args.includes(`llm.requestCompatibility.researchToolCalls.enabled=${expected}`));
+ assert.equal(args.includes('llm.requestCompatibility.enabled=true'), expected);
+ assert.ok(!args.includes('llm.requestCompatibility.researchToolCalls.auditEnabled=true'));
+ }
+});
test('shell metacharacters remain literal arguments and Helm list separators are escaped', () => {
for(const model of ["quoted' name,revision", 'model $(printf unsafe); $HOME']) {
const args=argumentsFor({...example,modules:[],model});