mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
123 lines
5.1 KiB
TypeScript
123 lines
5.1 KiB
TypeScript
import createDebug from 'debug';
|
|
|
|
/**
|
|
* toolCallEnvelope — defends any chat transcript against rendering the raw
|
|
* provider wire-format envelope instead of clean assistant text (B25).
|
|
*
|
|
* Applied by the shared `ChatThreadView` transcript renderer, so it guards
|
|
* BOTH the home chat and the workflow copilot (which now reuses that
|
|
* renderer). Originally lived under `lib/flows` for the copilot alone.
|
|
*
|
|
* The Rust core's `NativeToolDispatcher::to_provider_messages`
|
|
* (`src/openhuman/agent/dispatcher.rs`) and the tinyagents bridge's
|
|
* `message_to_native_chat_message` (`src/openhuman/tinyagents/convert.rs`)
|
|
* serialize an assistant turn that both talks AND calls a tool into a
|
|
* `{ "content": "...", "tool_calls": [...] }` JSON envelope — the provider
|
|
* wire format used to round-trip tool-calling history on the NEXT request.
|
|
* That envelope is meant to stay internal to the agent session; this
|
|
* sanitizer is a shape-based (not string-match) belt-and-suspenders guard so
|
|
* that if it ever leaks into a chat message's `content` (from any Rust-side
|
|
* vector, present or future), the transcript still renders only the human
|
|
* text — never the raw JSON — mirroring the `unwrapPayloadEnvelope`
|
|
* philosophy from PR #4822 (`app/src/lib/flows/runItems.ts`).
|
|
*/
|
|
const log = createDebug('app:flows:copilot-sanitizer');
|
|
|
|
/** Top-level keys the native tool-call envelope may carry. */
|
|
const ENVELOPE_KEYS = new Set(['content', 'tool_calls', 'reasoning_content']);
|
|
|
|
/** A single entry of the envelope's `tool_calls` array. */
|
|
interface EnvelopeToolCall {
|
|
name?: unknown;
|
|
}
|
|
|
|
/** Result of unwrapping a chat message's raw `content` string. */
|
|
export interface UnwrappedToolCallMessage {
|
|
/** The human-readable text to render (never the raw JSON). */
|
|
text: string;
|
|
/** Tool names found on the envelope's `tool_calls` array, in order. */
|
|
toolNames: string[];
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* If `raw` is a serialized `{ "content": "...", "tool_calls": [...] }` JSON
|
|
* envelope (the provider wire format for a native tool-calling turn), extract
|
|
* and return only the human-readable `content` text plus the tool names found
|
|
* (for chip rendering). Otherwise returns `raw` unchanged.
|
|
*
|
|
* Shape-based, not string-match: only collapses when the parsed value is a
|
|
* plain object whose keys are ALL drawn from the known envelope key set AND a
|
|
* string `content` key is present — so ordinary JSON-looking assistant prose
|
|
* (e.g. a code block containing `{"content": "hi"}`) is never misread as an
|
|
* envelope unless it structurally matches. Never silently drops data: any
|
|
* non-envelope shape (including malformed JSON, or JSON with unexpected
|
|
* top-level keys) passes `raw` straight through unchanged.
|
|
*/
|
|
export function unwrapToolCallEnvelope(raw: string): UnwrappedToolCallMessage {
|
|
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
return { text: raw ?? '', toolNames: [] };
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
log('unwrapToolCallEnvelope: not JSON — pass through (chars=%d)', raw.length);
|
|
return { text: raw, toolNames: [] };
|
|
}
|
|
|
|
if (!isPlainObject(parsed)) {
|
|
log('unwrapToolCallEnvelope: JSON but not an object — pass through');
|
|
return { text: raw, toolNames: [] };
|
|
}
|
|
|
|
const keys = Object.keys(parsed);
|
|
if (keys.length === 0 || !keys.every(key => ENVELOPE_KEYS.has(key))) {
|
|
log('unwrapToolCallEnvelope: non-envelope object (keys=%o) — pass through', keys);
|
|
return { text: raw, toolNames: [] };
|
|
}
|
|
|
|
// Require `tool_calls` to be an array before treating this as a tool-call
|
|
// envelope at all — a content-only object like `{ "content": "hi" }` is
|
|
// ordinary JSON-looking prose, not a native envelope, and must pass
|
|
// through unchanged rather than being unwrapped down to `hi`.
|
|
if (!Array.isArray(parsed.tool_calls)) {
|
|
log(
|
|
'unwrapToolCallEnvelope: envelope-shaped keys=%o but `tool_calls` is not an array — pass through',
|
|
keys
|
|
);
|
|
return { text: raw, toolNames: [] };
|
|
}
|
|
const toolCalls = parsed.tool_calls as EnvelopeToolCall[];
|
|
|
|
// The native dispatcher can serialize a tool-only turn with `content: null`
|
|
// (or omit `content` entirely) while still including `tool_calls`. Treat
|
|
// null/missing `content` as `''` so the bubble still exposes the tool
|
|
// activity chip instead of falling back to the raw JSON.
|
|
const { content } = parsed;
|
|
if (content !== null && content !== undefined && typeof content !== 'string') {
|
|
log(
|
|
'unwrapToolCallEnvelope: envelope-shaped keys=%o but `content` is non-string (and not null/missing) — pass through',
|
|
keys
|
|
);
|
|
return { text: raw, toolNames: [] };
|
|
}
|
|
const text = typeof content === 'string' ? content : '';
|
|
|
|
const toolNames = toolCalls
|
|
.map(call => call?.name)
|
|
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
|
|
|
log(
|
|
'unwrapToolCallEnvelope: envelope keys=%o — extracted text (chars=%d) + toolNames=%o',
|
|
keys,
|
|
text.length,
|
|
toolNames
|
|
);
|
|
return { text, toolNames };
|
|
}
|