Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
type EventSink<T> = {
push(event: T): void;
};
export function createDeferredEventBuffer<T>(sink: EventSink<T>, onBufferedEvent?: () => void) {
let events: T[] = [];
return {
push(event: T): void {
events.push(event);
onBufferedEvent?.();
},
flush(): void {
for (const event of events) {
sink.push(event);
}
events = [];
},
discard(): void {
events = [];
},
};
}

View File

@@ -0,0 +1,2 @@
/** Shared provider diagnostics. */
export * from "@openclaw/llm-core/diagnostics";

View File

@@ -0,0 +1,2 @@
/** Assistant message event stream implementation. */
export * from "@openclaw/llm-core/event-stream";

View File

@@ -0,0 +1,13 @@
/** Fast deterministic hash to shorten long strings */
export function shortHash(str: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
}

View File

@@ -0,0 +1,8 @@
/** Converts a Headers object to a plain record for provider request handling. */
export function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of headers.entries()) {
result[key] = value;
}
return result;
}

View File

@@ -0,0 +1,153 @@
// JSON parse helpers recover structured values from partial model output.
import { parse as partialParse } from "partial-json";
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]);
const JSON_CONTROL_ESCAPES = new Set(["b", "f", "n", "r", "t"]);
function isControlCharacter(char: string): boolean {
const codePoint = char.codePointAt(0);
return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f;
}
function escapeControlCharacter(char: string): string {
switch (char) {
case "\b":
return "\\b";
case "\f":
return "\\f";
case "\n":
return "\\n";
case "\r":
return "\\r";
case "\t":
return "\\t";
default:
return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`;
}
}
/**
* Repairs malformed JSON string literals by:
* - escaping raw control characters inside strings
* - doubling backslashes before invalid escape characters
*/
export function repairJson(json: string): string {
let repaired = "";
let inString = false;
let stringValuePrefix = "";
for (let index = 0; index < json.length; index++) {
const char = json[index];
if (!inString) {
repaired += char;
if (char === '"') {
inString = true;
stringValuePrefix = "";
}
continue;
}
if (char === '"') {
repaired += char;
inString = false;
stringValuePrefix = "";
continue;
}
if (char === "\\") {
const nextChar = json[index + 1];
if (nextChar === undefined) {
repaired += "\\\\";
continue;
}
if (nextChar === "u") {
const unicodeDigits = json.slice(index + 2, index + 6);
if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) {
repaired += `\\u${unicodeDigits}`;
stringValuePrefix += `\\u${unicodeDigits}`;
index += 5;
continue;
}
// A \u not followed by four hex digits is an invalid escape: double the
// backslash like the other invalid escapes below. Falling through would
// hit the valid-escape branch (VALID_JSON_ESCAPES contains "u") and
// re-emit the broken \u, leaving the JSON unparseable.
repaired += "\\\\";
stringValuePrefix += "\\";
continue;
}
if (JSON_CONTROL_ESCAPES.has(nextChar) && looksLikeWindowsPathPrefix(stringValuePrefix)) {
repaired += "\\\\";
stringValuePrefix += "\\";
continue;
}
if (VALID_JSON_ESCAPES.has(nextChar)) {
repaired += `\\${nextChar}`;
stringValuePrefix += nextChar === "\\" ? "\\" : `\\${nextChar}`;
index += 1;
continue;
}
repaired += "\\\\";
stringValuePrefix += "\\";
continue;
}
repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char;
stringValuePrefix += char;
}
return repaired;
}
export function parseJsonWithRepair(json: string): unknown {
const repairedJson = repairJson(json);
if (repairedJson !== json) {
return JSON.parse(repairedJson) as unknown;
}
return JSON.parse(json) as unknown;
}
function looksLikeWindowsPathPrefix(prefix: string): boolean {
const tail = prefix.slice(-160);
return /(?:^|[^A-Za-z0-9])[A-Za-z]:(?:[\\/][^"\\/:*?<>|\r\n]*)*$/.test(tail);
}
function asStreamingJsonRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Attempts to parse potentially incomplete JSON during streaming.
* Always returns a valid object, even if the JSON is incomplete.
*
* @param partialJson The partial JSON string from streaming
* @returns Parsed object or empty object if parsing fails
*/
export function parseStreamingJson(partialJson: string | undefined): Record<string, unknown> {
if (!partialJson || partialJson.trim() === "") {
return {};
}
try {
return asStreamingJsonRecord(parseJsonWithRepair(partialJson));
} catch {
try {
const result = partialParse(partialJson);
return asStreamingJsonRecord(result);
} catch {
try {
const result = partialParse(repairJson(partialJson));
return asStreamingJsonRecord(result);
} catch {
return {};
}
}
}
}

View File

@@ -0,0 +1,23 @@
const requestActivityListeners = new WeakMap<AbortSignal, Set<() => void>>();
export function notifyLlmRequestActivity(signal: AbortSignal | undefined): void {
if (!signal) {
return;
}
for (const listener of requestActivityListeners.get(signal) ?? []) {
listener();
}
}
export function onLlmRequestActivity(signal: AbortSignal, listener: () => void): () => void {
const listeners = requestActivityListeners.get(signal) ?? new Set<() => void>();
listeners.add(listener);
requestActivityListeners.set(signal, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
requestActivityListeners.delete(signal);
}
};
}

View File

@@ -0,0 +1,32 @@
// OpenAI ChatGPT JWT helpers inspect auth claims for ChatGPT OAuth sessions.
const OPENAI_CODEX_AUTH_CLAIM = "https://api.openai.com/auth";
export type OpenAICodexJwtPayload = {
[OPENAI_CODEX_AUTH_CLAIM]?: {
chatgpt_account_id?: unknown;
};
[key: string]: unknown;
};
export function decodeOpenAICodexJwtPayload(token: string): OpenAICodexJwtPayload | null {
const parts = token.split(".");
if (parts.length !== 3) {
return null;
}
try {
const decoded = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
const parsed = JSON.parse(decoded);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as OpenAICodexJwtPayload)
: null;
} catch {
return null;
}
}
export function resolveOpenAICodexAccountId(token: string): string | null {
const accountId =
decodeOpenAICodexJwtPayload(token)?.[OPENAI_CODEX_AUTH_CLAIM]?.chatgpt_account_id;
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { AssistantMessage } from "../types.js";
import { isConfiguredContextSizeOverflowError, isContextOverflow } from "./overflow.js";
function errorMessage(message: string): AssistantMessage {
return {
role: "assistant",
content: [],
api: "test-api",
provider: "test-provider",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: message,
timestamp: 1,
};
}
function successfulMessage(
contextUsage?: AssistantMessage["usage"]["contextUsage"],
): AssistantMessage {
return {
...errorMessage(""),
usage: {
input: 12,
output: 15_104,
cacheRead: 1_100_000,
cacheWrite: 93_130,
...(contextUsage ? { contextUsage } : {}),
totalTokens: 1_208_246,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
errorMessage: undefined,
};
}
describe("configured context size overflow", () => {
it.each([
"400 Prompt has 256468 tokens, but the configured context size is 256000 tokens",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
])("detects %s", (text) => {
expect(isConfiguredContextSizeOverflowError(text)).toBe(true);
expect(isContextOverflow(errorMessage(text), 256_000)).toBe(true);
});
});
describe("usage-based overflow", () => {
it("prefers an available context snapshot over aggregate billing usage", () => {
expect(
isContextOverflow(
successfulMessage({
state: "available",
promptTokens: 148_874,
totalTokens: 163_978,
}),
1_000_000,
),
).toBe(false);
});
it("does not infer overflow from aggregate billing when context is unavailable", () => {
expect(isContextOverflow(successfulMessage({ state: "unavailable" }), 1_000_000)).toBe(false);
});
});

View File

@@ -0,0 +1,171 @@
// Overflow helpers classify provider overflow errors and retryable responses.
import type { AssistantMessage } from "../types.js";
const CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE =
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i;
/** Detects DS4-style raw token-count context overflow errors. */
export function isConfiguredContextSizeOverflowError(errorMessage: string): boolean {
return CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE.test(errorMessage);
}
/**
* Regex patterns to detect context overflow errors from different providers.
*
* These patterns match error messages returned when the input exceeds
* the model's context window.
*
* Provider-specific patterns (with example error messages):
*
* - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum"
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
* - OpenAI: "Your input exceeds the context window of this model"
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
* - Groq: "Please reduce the length of the messages or completion"
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
* - MiniMax: "invalid params, context window exceeds limit"
* - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)"
* - Cerebras: "400/413 status code (no body)"
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
* - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length"
* with output=0 (no room left to generate). Detected via stopReason "length" + zero output +
* input filling the context window.
* - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens"
*/
const OVERFLOW_PATTERNS = [
/prompt is too long/i, // Anthropic token overflow
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
/input is too long for requested model/i, // Amazon Bedrock
/exceeds the context window/i, // OpenAI (Completions & Responses API)
/exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM)
/input token count.*exceeds the maximum/i, // Google (Gemini)
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter (all backends)
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio
/context window exceeds limit/i, // MiniMax
/exceeded model token limit/i, // Kimi For Coding
/too large for model with \d+ maximum context length/i, // Mistral
CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE, // DS4 server
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
/context[_ ]length[_ ]exceeded/i, // Generic fallback
/too many tokens/i, // Generic fallback
/token limit exceeded/i, // Generic fallback
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
];
/**
* Patterns that indicate non-overflow errors (e.g. rate limiting, server errors).
* Error messages matching unknown of these are excluded from overflow detection
* even if they also match an OVERFLOW_PATTERN.
*
* Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens,
* please wait before trying again." which would match the /too many tokens/i overflow
* pattern without this exclusion.
*/
const NON_OVERFLOW_PATTERNS = [
/^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError)
/rate limit/i, // Generic rate limiting
/too many requests/i, // Generic HTTP 429 style
];
function resolveContextInputTokens(message: AssistantMessage): number | undefined {
if (message.usage.contextUsage?.state === "available") {
return message.usage.contextUsage.promptTokens;
}
if (message.usage.contextUsage?.state === "unavailable") {
return undefined;
}
return message.usage.input + message.usage.cacheRead;
}
/**
* Check if an assistant message represents a context overflow error.
*
* This handles two cases:
* 1. Error-based overflow: Most providers return stopReason "error" with a
* specific error message pattern.
* 2. Silent overflow: Some providers accept overflow requests and return
* successfully. For these, we check if usage.input exceeds the context window.
*
* ## Reliability by Provider
*
* **Reliable detection (returns error with detectable message):**
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
* - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens"
* - Google Gemini: "input token count exceeds the maximum"
* - xAI (Grok): "maximum prompt length is X but request contains Y"
* - Groq: "reduce the length of the messages"
* - Cerebras: 400/413 status code (no body)
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
* - OpenRouter (all backends): "maximum context length is X tokens"
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "exceeds the available context size"
* - LM Studio: "greater than the context length"
* - Kimi For Coding: "exceeded model token limit: X (requested: Y)"
*
* **Unreliable detection:**
* - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow),
* sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow.
* - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with
* output=0. Pass contextWindow param to detect via the "filled context + zero output" signal.
* - Ollama: May truncate input silently for some setups, but may also return explicit
* overflow errors that match the patterns above. Silent truncation still cannot be
* detected here because we do not know the expected token count.
*
* ## Custom Providers
*
* If you've added custom models via settings.json, this function may not detect
* overflow errors from those providers. To add support:
*
* 1. Send a request that exceeds the model's context window
* 2. Check the errorMessage in the response
* 3. Create a regex pattern that matches the error
* 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or
* check the errorMessage yourself before calling this function
*
* @param message - The assistant message to check
* @param contextWindow - Optional context window size for detecting silent overflow (z.ai)
* @returns true if the message indicates a context overflow
*/
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
// Case 1: Check error message patterns
if (message.stopReason === "error" && message.errorMessage) {
// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
return true;
}
}
// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context
if (contextWindow && message.stopReason === "stop") {
const inputTokens = resolveContextInputTokens(message);
if (inputTokens !== undefined && inputTokens > contextWindow) {
return true;
}
}
// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input
// to fit the context window, leaving no room for output. Returns stopReason "length"
// with output=0 and input+cacheRead filling the context window.
if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {
const inputTokens = resolveContextInputTokens(message);
if (inputTokens !== undefined && inputTokens >= contextWindow * 0.99) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,29 @@
/**
* Prompt-cache normalization helpers. They keep generated prompt sections
* deterministic across platform newlines, trailing whitespace, and input
* ordering.
*/
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
/** Normalize structured prompt text before hashing or snapshot comparison. */
export function normalizeStructuredPromptSection(text: string): string {
return text
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+$/gm, "")
.trim();
}
/** Normalize, de-dupe, and sort capability ids for stable prompt payloads. */
export function normalizePromptCapabilityIds(capabilities: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const capability of capabilities) {
const value = normalizeLowercaseStringOrEmpty(normalizeStructuredPromptSection(capability));
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -0,0 +1,297 @@
// Reasoning tag partitioner tests cover splitting reasoning and visible text segments.
import { describe, expect, it } from "vitest";
import { createReasoningTagTextPartitioner } from "./reasoning-tag-text-partitioner.js";
describe("createReasoningTagTextPartitioner", () => {
it("routes split inline reasoning tags away from visible text", () => {
const partitioner = createReasoningTagTextPartitioner();
const deltas = [
...partitioner.push("before <thi"),
...partitioner.push("nk>hidden"),
...partitioner.push("</think> after"),
...partitioner.flush(),
];
expect(deltas).toEqual([
{ kind: "text", text: "before " },
{ kind: "thinking", text: "hidden" },
{ kind: "text", text: " after" },
]);
});
it("keeps unterminated reasoning as thinking on flush", () => {
const partitioner = createReasoningTagTextPartitioner();
expect([...partitioner.push("visible <reasoning>hidden tail"), ...partitioner.flush()]).toEqual(
[
{ kind: "text", text: "visible " },
{ kind: "thinking", text: "hidden tail" },
],
);
});
it("emits ordinary angle-bracket text immediately", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.push("<div>visible")).toEqual([{ kind: "text", text: "<div>visible" }]);
expect(partitioner.flush()).toEqual([]);
});
it("reports pending partial tags and active reasoning", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.hasPending()).toBe(false);
expect(partitioner.push("before <thi")).toEqual([{ kind: "text", text: "before " }]);
expect(partitioner.hasPending()).toBe(true);
expect(partitioner.push("nk>hidden")).toEqual([{ kind: "thinking", text: "hidden" }]);
expect(partitioner.hasPending()).toBe(true);
expect(partitioner.isInsideReasoning()).toBe(true);
expect(partitioner.push("</think> after")).toEqual([{ kind: "text", text: " after" }]);
expect(partitioner.hasPending()).toBe(false);
expect(partitioner.isInsideReasoning()).toBe(false);
});
it("holds possible reasoning opens in visible mode until they are resolved", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("before <thi")).toEqual([{ kind: "text", text: "before " }]);
expect(partitioner.push("nk>hidden")).toEqual([{ kind: "thinking", text: "hidden" }]);
});
it("strips complete reasoning tags in visible mode", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use <think>literal</think> here")).toEqual([
{ kind: "text", text: "Use " },
{ kind: "thinking", text: "literal" },
{ kind: "text", text: " here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("keeps split reasoning tags with attributes out of visible text", () => {
const partitioner = createReasoningTagTextPartitioner();
const deltas = [
...partitioner.pushVisible("Before <think "),
...partitioner.pushVisible("id='x'>secret</think> after"),
...partitioner.flush(),
];
expect(deltas).toEqual([
{ kind: "text", text: "Before " },
{ kind: "thinking", text: "secret" },
{ kind: "text", text: " after" },
]);
});
it("keeps split antml reasoning tags out of visible text", () => {
const partitioner = createReasoningTagTextPartitioner();
const deltas = [
...partitioner.pushVisible("Before <antml:reas"),
...partitioner.pushVisible("oning>secret</antml:reasoning> after"),
...partitioner.flush(),
];
expect(deltas).toEqual([
{ kind: "text", text: "Before " },
{ kind: "thinking", text: "secret" },
{ kind: "text", text: " after" },
]);
});
it("keeps split mm reasoning tags out of visible text", () => {
const partitioner = createReasoningTagTextPartitioner();
const deltas = [
...partitioner.push("Before <mm:thi"),
...partitioner.push("nk>secret</mm:think> after"),
...partitioner.flush(),
];
expect(deltas).toEqual([
{ kind: "text", text: "Before " },
{ kind: "thinking", text: "secret" },
{ kind: "text", text: " after" },
]);
});
it("keeps nested reasoning hidden until the outer tag closes", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(
partitioner.pushVisible("<think>outer <think>inner</think> still outer</think>visible"),
).toEqual([
{ kind: "thinking", text: "outer inner still outer" },
{ kind: "text", text: "visible" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("drops malformed reasoning before orphan close tags in strict mode", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.push("private chain of thought </think> Visible answer")).toEqual([
{ kind: "text", text: " Visible answer" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("keeps unmatched close-tag prose visible in visible mode", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use </think> to close the tag")).toEqual([
{ kind: "text", text: "Use </think> to close the tag" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("buffers split orphan close tags until the visible suffix arrives", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.push("private chain of thought </think>")).toEqual([]);
expect(partitioner.push(" Visible answer")).toEqual([
{ kind: "text", text: " Visible answer" },
]);
});
it("buffers split orphan close tag prefixes with their hidden prefix", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.push("private chain of thought </thi")).toEqual([]);
expect(partitioner.push("nk> Visible answer")).toEqual([
{ kind: "text", text: " Visible answer" },
]);
});
it("keeps close tags inside hidden code fences private", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("<think>\n```ts\nliteral ")).toEqual([]);
expect(partitioner.pushVisible("</think> still private")).toEqual([]);
expect(partitioner.flush()).toEqual([
{ kind: "thinking", text: "\n```ts\nliteral </think> still private" },
]);
});
it("recovers fully wrapped unclosed visible-mode text on flush", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("<think>Visible answer from a malformed local model")).toEqual(
[],
);
expect(partitioner.flush()).toEqual([
{ kind: "text", text: "Visible answer from a malformed local model" },
]);
});
it("keeps unclosed trailing tags as visible prose in visible mode", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use <think> only in this mode")).toEqual([
{ kind: "text", text: "Use " },
]);
expect(partitioner.flush()).toEqual([{ kind: "text", text: "<think> only in this mode" }]);
});
it("does not treat code-span reasoning tag examples as hidden reasoning", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.push("Use `<think>literal</think>` here")).toEqual([
{ kind: "text", text: "Use `<think>literal</think>` here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("preserves split code-span reasoning tag examples when active routing begins", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use `<thi")).toEqual([{ kind: "text", text: "Use `<thi" }]);
expect(partitioner.push("nk>literal</think>` here")).toEqual([
{ kind: "text", text: "nk>literal</think>` here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("preserves code-span reasoning tag examples when the closing backtick arrives later", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use `<think>literal</think>")).toEqual([
{ kind: "text", text: "Use " },
]);
expect(partitioner.pushVisible("` here")).toEqual([
{ kind: "text", text: "`<think>literal</think>` here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("preserves code-span reasoning tag examples when the stream splits after the opener", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use `")).toEqual([{ kind: "text", text: "Use `" }]);
expect(partitioner.pushVisible("<think>literal</think>` here")).toEqual([
{ kind: "text", text: "<think>literal</think>` here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("preserves multi-backtick code-span reasoning tag examples across chunks", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use ``<think>")).toEqual([{ kind: "text", text: "Use " }]);
expect(partitioner.pushVisible("literal</think>`` here")).toEqual([
{ kind: "text", text: "``<think>literal</think>`` here" },
]);
expect(partitioner.flush()).toEqual([]);
});
it("reclassifies reasoning tags inside unclosed inline code on final flush", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Start `unclosed <think>secret</think> end")).toEqual([
{ kind: "text", text: "Start " },
]);
expect(partitioner.flush()).toEqual([
{ kind: "text", text: "`unclosed " },
{ kind: "thinking", text: "secret" },
{ kind: "text", text: " end" },
]);
});
it("keeps buffered unclosed reasoning hidden after strict mode is marked", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("<think>secret")).toEqual([]);
partitioner.markStrict();
expect(partitioner.flush()).toEqual([{ kind: "thinking", text: "secret" }]);
});
it("preserves fenced reasoning tag examples when the stream splits after the fence", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Example:\n```")).toEqual([
{ kind: "text", text: "Example:\n```" },
]);
expect(partitioner.pushVisible("\n<think>literal</think>\n```\nDone.")).toEqual([
{ kind: "text", text: "\n<think>literal</think>\n```\nDone." },
]);
expect(partitioner.flush()).toEqual([]);
});
it("preserves fenced reasoning tag examples when the fence marker is split", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("``")).toEqual([]);
expect(
partitioner.pushVisible(
"`xml\n<thinking>literal</thinking>\n```\n<think>secret</think>answer",
),
).toEqual([
{
kind: "text",
text: "```xml\n<thinking>literal</thinking>\n```\n",
},
{ kind: "thinking", text: "secret" },
{ kind: "text", text: "answer" },
]);
expect(partitioner.flush()).toEqual([]);
});
});

View File

@@ -0,0 +1,364 @@
// Reasoning tag partitioner helpers split text around reasoning tag regions.
import {
buildCodeSpanIndex,
createInlineCodeState,
type InlineCodeState,
} from "@openclaw/markdown-core/code-spans";
import type { FenceScanState } from "@openclaw/markdown-core/fences";
export type ReasoningTagTextDelta =
| { kind: "text"; text: string }
| { kind: "thinking"; text: string };
const REASONING_TAG_RE =
/<\s*(\/?)\s*(?:(?:antml:|mm:)?(?:think(?:ing)?|thought|reasoning)|antthinking)\b[^<>]*>/gi;
const REASONING_TAG_NAMES = [
"think",
"thinking",
"thought",
"reasoning",
"antthinking",
"antml:think",
"antml:thinking",
"antml:thought",
"antml:reasoning",
"mm:think",
"mm:thinking",
"mm:thought",
"mm:reasoning",
] as const;
export interface ReasoningTagTextPartitioner {
markStrict(): void;
push(chunk: string): ReasoningTagTextDelta[];
pushVisible(chunk: string): ReasoningTagTextDelta[];
flush(): ReasoningTagTextDelta[];
hasPending(): boolean;
isInsideReasoning(): boolean;
}
export function createReasoningTagTextPartitioner(): ReasoningTagTextPartitioner {
let buffer = "";
let reasoningDepth = 0;
let strictMode = false;
let emittedVisibleText = false;
let inlineCodeState: InlineCodeState = createInlineCodeState();
let fenceState: FenceScanState | undefined;
let hiddenInlineCodeState: InlineCodeState = createInlineCodeState();
let hiddenFenceState: FenceScanState | undefined;
let recoverableOpenTagText: string | undefined;
const consume = (final: boolean, recoverFullUnclosed: boolean): ReasoningTagTextDelta[] => {
const output: ReasoningTagTextDelta[] = [];
const emit = (kind: ReasoningTagTextDelta["kind"], text: string) => {
if (!text) {
return;
}
if (kind === "text" && text.trim().length > 0) {
emittedVisibleText = true;
}
if (kind === "text") {
const nextCode = buildCodeSpanIndex(text, inlineCodeState, fenceState);
inlineCodeState = nextCode.inlineState;
fenceState = nextCode.fenceState;
} else {
const nextCode = buildCodeSpanIndex(text, hiddenInlineCodeState, hiddenFenceState);
hiddenInlineCodeState = nextCode.inlineState;
hiddenFenceState = nextCode.fenceState;
}
const previous = output[output.length - 1];
if (previous?.kind === kind) {
previous.text += text;
return;
}
output.push({ kind, text });
};
while (buffer) {
const activeInlineCodeState = reasoningDepth === 0 ? inlineCodeState : hiddenInlineCodeState;
const activeFenceState = reasoningDepth === 0 ? fenceState : hiddenFenceState;
const codeSpans = buildCodeSpanIndex(buffer, activeInlineCodeState, activeFenceState);
const hasUnclosedCode =
reasoningDepth === 0 && Boolean(codeSpans.inlineState.open || codeSpans.fenceState.open);
const hasRawReasoning = hasRawReasoningTag(buffer);
const tag = findNextReasoningTag(buffer, (index) =>
final && hasUnclosedCode && hasRawReasoning ? false : codeSpans.isInside(index),
);
if (!tag) {
if (final) {
const recoverAsText =
reasoningDepth > 0 && recoverFullUnclosed && !hasRawReasoningCloseTag(buffer);
const recoveredText =
recoverAsText && recoverableOpenTagText ? recoverableOpenTagText + buffer : buffer;
emit(reasoningDepth > 0 && !recoverAsText ? "thinking" : "text", recoveredText);
buffer = "";
reasoningDepth = 0;
recoverableOpenTagText = undefined;
return output;
}
if (
reasoningDepth > 0 &&
recoverFullUnclosed &&
(!emittedVisibleText || recoverableOpenTagText)
) {
return output;
}
if (hasUnclosedCode && hasRawReasoning) {
const openCodeIndex =
inlineCodeState.open || fenceState?.open ? 0 : findOpenCodeContextStart(buffer);
if (openCodeIndex !== -1) {
emit("text", buffer.slice(0, openCodeIndex));
buffer = buffer.slice(openCodeIndex);
return output;
}
}
const trailingFenceStart = findTrailingFenceFragmentStart(
buffer,
activeInlineCodeState,
activeFenceState,
);
if (trailingFenceStart !== -1) {
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, trailingFenceStart));
buffer = buffer.slice(trailingFenceStart);
return output;
}
const keepFrom = reasoningTagPrefixSuffixIndex(buffer, (index) =>
codeSpans.isInside(index),
);
if (keepFrom === -1) {
emit(reasoningDepth > 0 ? "thinking" : "text", buffer);
buffer = "";
return output;
}
if (
reasoningDepth === 0 &&
keepFrom > 0 &&
buffer.slice(0, keepFrom).trim().length > 0 &&
isReasoningCloseTagPrefix(buffer.slice(keepFrom))
) {
return output;
}
if (keepFrom > 0) {
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, keepFrom));
buffer = buffer.slice(keepFrom);
}
return output;
}
const beforeTag = buffer.slice(0, tag.index);
const afterTag = buffer.slice(tag.index + tag.text.length);
if (tag.isClose && reasoningDepth === 0) {
if (recoverFullUnclosed && beforeTag.trim().length > 0 && afterTag.trim().length > 0) {
emit("text", beforeTag + tag.text);
buffer = afterTag;
continue;
}
if (beforeTag.trim().length > 0 && afterTag.trim().length === 0 && !final) {
return output;
}
if (beforeTag.trim().length === 0 || afterTag.trim().length === 0) {
emit("text", beforeTag);
}
buffer = afterTag;
continue;
}
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, tag.index));
buffer = afterTag;
if (tag.isClose) {
reasoningDepth = Math.max(0, reasoningDepth - 1);
if (reasoningDepth === 0) {
recoverableOpenTagText = undefined;
hiddenInlineCodeState = createInlineCodeState();
hiddenFenceState = undefined;
}
} else {
if (reasoningDepth === 0) {
recoverableOpenTagText = recoverFullUnclosed && emittedVisibleText ? tag.text : undefined;
hiddenInlineCodeState = createInlineCodeState();
hiddenFenceState = undefined;
}
reasoningDepth += 1;
}
}
return output;
};
return {
markStrict() {
strictMode = true;
},
push(chunk: string) {
strictMode = true;
buffer += chunk;
return consume(false, false);
},
pushVisible(chunk: string) {
buffer += chunk;
return consume(false, true);
},
flush() {
return consume(true, !strictMode);
},
hasPending() {
return buffer.length > 0 || reasoningDepth > 0;
},
isInsideReasoning() {
return reasoningDepth > 0;
},
};
}
function hasRawReasoningTag(text: string): boolean {
REASONING_TAG_RE.lastIndex = 0;
return REASONING_TAG_RE.test(text);
}
function hasRawReasoningCloseTag(text: string): boolean {
REASONING_TAG_RE.lastIndex = 0;
for (;;) {
const match = REASONING_TAG_RE.exec(text);
if (!match) {
return false;
}
if (match[1] === "/") {
return true;
}
}
}
function findNextReasoningTag(
text: string,
isIndexInsideCode: (index: number) => boolean,
): { index: number; text: string; isClose: boolean } | null {
REASONING_TAG_RE.lastIndex = 0;
for (;;) {
const match = REASONING_TAG_RE.exec(text);
if (!match) {
return null;
}
if (!isIndexInsideCode(match.index)) {
return {
index: match.index,
text: match[0],
isClose: match[1] === "/",
};
}
}
}
function reasoningTagPrefixSuffixIndex(
text: string,
isIndexInsideCode: (index: number) => boolean,
): number {
for (let index = text.lastIndexOf("<"); index >= 0; ) {
if (!isIndexInsideCode(index) && isReasoningTagPrefix(text.slice(index))) {
return index;
}
if (index === 0) {
break;
}
index = text.lastIndexOf("<", index - 1);
}
return -1;
}
function isReasoningTagPrefix(text: string): boolean {
const name = normalizeReasoningTagPrefixName(text);
return REASONING_TAG_NAMES.some((tagName) => {
if (tagName.startsWith(name)) {
return true;
}
if (!name.startsWith(tagName)) {
return false;
}
const rest = name.slice(tagName.length);
return rest.length === 0 || /^[\s/>]/.test(rest);
});
}
function isReasoningCloseTagPrefix(text: string): boolean {
const normalized = text
.replace(/^<\s*/, "<")
.replace(/^<\s*\//, "</")
.replace(/^<\/\s*/, "</")
.toLowerCase();
return normalized.startsWith("</") && isReasoningTagPrefix(text);
}
function normalizeReasoningTagPrefixName(text: string): string {
const normalized = text
.replace(/^<\s*/, "<")
.replace(/^<\s*\//, "</")
.replace(/^<\/\s*/, "</")
.toLowerCase();
const rawName = normalized.startsWith("</") ? normalized.slice(2) : normalized.slice(1);
return rawName.trimStart();
}
function findOpenCodeContextStart(text: string): number {
const fence = findOpenFenceStart(text);
const inline = findOpenInlineCodeStart(text);
if (fence === -1) {
return inline;
}
if (inline === -1) {
return fence;
}
return Math.min(fence, inline);
}
function findOpenInlineCodeStart(text: string): number {
let openStart = -1;
let openTicks = 0;
let index = 0;
while (index < text.length) {
if (text[index] !== "`") {
index += 1;
continue;
}
const runStart = index;
let runLength = 0;
while (index < text.length && text[index] === "`") {
runLength += 1;
index += 1;
}
if (openStart === -1) {
openStart = runStart;
openTicks = runLength;
} else if (runLength === openTicks) {
openStart = -1;
openTicks = 0;
}
}
return openStart;
}
function findOpenFenceStart(text: string): number {
const fenceRe = /(^|\n)(```|~~~)[^\n]*(?:\n|$)/g;
let open: { marker: string; index: number } | null = null;
for (const match of text.matchAll(fenceRe)) {
const index = (match.index ?? 0) + match[1].length;
const marker = match[2] ?? "";
if (open !== null && open.marker === marker) {
open = null;
} else if (!open) {
open = { marker, index };
}
}
return open?.index ?? -1;
}
function findTrailingFenceFragmentStart(
text: string,
inlineState: InlineCodeState,
fenceState: FenceScanState | undefined,
): number {
if (inlineState.open || fenceState?.open) {
return -1;
}
const lineStart = Math.max(text.lastIndexOf("\n") + 1, 0);
const line = text.slice(lineStart);
const match = line.match(/^( {0,3})(`{1,2}|~{1,2})$/);
return match ? lineStart : -1;
}

View File

@@ -0,0 +1,28 @@
/**
* Removes unpaired Unicode surrogate characters from a string.
*
* Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF,
* or vice versa) cause JSON serialization errors in many API providers.
*
* Valid emoji and other characters outside the Basic Multilingual Plane use properly paired
* surrogates and will NOT be affected by this function.
*
* @param text - The text to sanitize
* @returns The sanitized text with unpaired surrogates removed
*
* @example
* // Valid emoji (properly paired surrogates) are preserved
* sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World"
*
* // Unpaired high surrogate is removed
* const unpaired = String.fromCharCode(0xD83D); // high surrogate without low
* sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here"
*/
export function sanitizeSurrogates(text: string): string {
// Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate)
// Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate)
return text.replace(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
"",
);
}

View File

@@ -0,0 +1,171 @@
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import {
createFirstStreamEventAbortController,
withFirstStreamEventTimeout,
} from "./stream-first-event-timeout.js";
function createNeverYieldingStream(onReturn?: () => void): AsyncIterable<unknown> {
return {
[Symbol.asyncIterator]() {
return {
async next() {
return new Promise<IteratorResult<unknown>>(() => {});
},
async return() {
onReturn?.();
return { done: true, value: undefined };
},
};
},
};
}
describe("withFirstStreamEventTimeout", () => {
it("fails when the first event never arrives", async () => {
vi.useFakeTimers();
try {
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
provider: "local",
api: "openai-completions",
model: "test-model",
timeoutMs: 5,
stage: "completions",
});
const iterator = stream[Symbol.asyncIterator]();
const next = expect(iterator.next()).rejects.toThrow(
/completions HTTP stream opened but did not deliver a first SSE event within 5ms after streaming headers \(first-event timeout\)/,
);
await vi.advanceTimersByTimeAsync(5);
await next;
} finally {
vi.useRealTimers();
}
});
it("calls iterator return on first-event timeout", async () => {
vi.useFakeTimers();
try {
const onReturn = vi.fn();
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(onReturn), {
timeoutMs: 5,
});
const iterator = stream[Symbol.asyncIterator]();
const next = iterator.next().catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(5);
await next;
expect(onReturn).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("calls iterator return when the consumer closes after the first event", async () => {
const onReturn = vi.fn();
const source: AsyncIterable<unknown> = {
[Symbol.asyncIterator]() {
return {
async next() {
return { done: false, value: "first" };
},
async return() {
onReturn();
return { done: true, value: undefined };
},
};
},
};
const stream = withFirstStreamEventTimeout(source, { timeoutMs: 5 });
const iterator = stream[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toEqual({ done: false, value: "first" });
await iterator.return?.();
expect(onReturn).toHaveBeenCalledTimes(1);
});
it("aborts the underlying request on first-event timeout", async () => {
vi.useFakeTimers();
try {
const abort = vi.fn();
const onTimeout = vi.fn();
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
timeoutMs: 5,
abort,
onTimeout,
});
const iterator = stream[Symbol.asyncIterator]();
const next = iterator.next().catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(5);
const error = await next;
expect(error).toBeInstanceOf(Error);
expect(onTimeout).toHaveBeenCalledWith(error);
expect(abort).toHaveBeenCalledWith(error);
} finally {
vi.useRealTimers();
}
});
it("clamps oversized first-event timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
timeoutMs: Number.MAX_SAFE_INTEGER,
});
const iterator = stream[Symbol.asyncIterator]();
const next = expect(iterator.next()).rejects.toThrow(
new RegExp(`within ${MAX_TIMER_TIMEOUT_MS}ms`),
);
await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS);
await next;
} finally {
vi.useRealTimers();
}
});
it("propagates parent aborts through derived first-event signals", () => {
const parent = new AbortController();
const firstEventAbort = createFirstStreamEventAbortController(parent.signal);
parent.abort("run-timeout");
expect(firstEventAbort.signal.aborted).toBe(true);
expect(firstEventAbort.signal.reason).toBe("run-timeout");
firstEventAbort.dispose();
});
it("passes through events after the first event without adding inter-event timing", async () => {
async function* delayedSecondEvent() {
yield "first";
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
yield "second";
}
vi.useFakeTimers();
try {
const stream = withFirstStreamEventTimeout(delayedSecondEvent(), { timeoutMs: 5 });
const iterator = stream[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toEqual({ done: false, value: "first" });
const second = iterator.next();
await vi.advanceTimersByTimeAsync(50);
await expect(second).resolves.toEqual({ done: false, value: "second" });
} finally {
vi.useRealTimers();
}
});
it("returns the original stream when disabled", () => {
const stream = createNeverYieldingStream();
expect(withFirstStreamEventTimeout(stream, { timeoutMs: 0 })).toBe(stream);
expect(withFirstStreamEventTimeout(stream, { timeoutMs: Number.NaN })).toBe(stream);
});
});

View File

@@ -0,0 +1,134 @@
import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
type StreamStage = "responses" | "completions";
export type FirstStreamEventTimeoutContext = {
provider?: string;
api?: string;
model?: string;
timeoutMs: number;
stage?: StreamStage;
hint?: string;
abort?: (reason: Error) => void;
onTimeout?: (reason: Error) => void;
};
export type FirstStreamEventInternalOptions = {
firstEventTimeoutMs?: number;
abortFirstEventStream?: (reason: Error) => void;
onFirstEventTimeout?: (reason: Error) => void;
};
export type FirstStreamEventAbortController = {
signal: AbortSignal;
abort: (reason: Error) => void;
dispose: () => void;
};
export function getFirstStreamEventTimeoutMs(options: unknown): number | undefined {
return (options as FirstStreamEventInternalOptions | undefined)?.firstEventTimeoutMs;
}
export function getFirstStreamEventTimeoutHandler(
options: unknown,
): ((reason: Error) => void) | undefined {
return (options as FirstStreamEventInternalOptions | undefined)?.onFirstEventTimeout;
}
function formatOptionalField(name: string, value: string | undefined): string {
return value ? ` ${name}=${value}` : "";
}
export function createFirstStreamEventTimeoutError(context: FirstStreamEventTimeoutContext): Error {
const stage = context.stage ? `${context.stage} ` : "";
const details = [
formatOptionalField("provider", context.provider),
formatOptionalField("api", context.api),
formatOptionalField("model", context.model),
].join("");
return new Error(
`${stage}HTTP stream opened but did not deliver a first SSE event within ${context.timeoutMs}ms after streaming headers (first-event timeout).${details}` +
(context.hint ? ` ${context.hint}` : ""),
);
}
export function createFirstStreamEventAbortController(
parentSignal?: AbortSignal,
): FirstStreamEventAbortController {
const controller = new AbortController();
const abortFromParent = () => {
if (!controller.signal.aborted) {
controller.abort(parentSignal?.reason);
}
};
if (parentSignal?.aborted) {
abortFromParent();
} else {
parentSignal?.addEventListener("abort", abortFromParent, { once: true });
}
return {
signal: controller.signal,
abort(reason: Error) {
if (!controller.signal.aborted) {
controller.abort(reason);
}
},
dispose() {
parentSignal?.removeEventListener("abort", abortFromParent);
},
};
}
export function withFirstStreamEventTimeout<T>(
stream: AsyncIterable<T>,
context: FirstStreamEventTimeoutContext,
): AsyncIterable<T> {
const timeoutMs = clampTimerTimeoutMs(context.timeoutMs);
if (timeoutMs === undefined || context.timeoutMs <= 0) {
return stream;
}
const timeoutContext = { ...context, timeoutMs };
return {
async *[Symbol.asyncIterator]() {
const iterator = stream[Symbol.asyncIterator]();
let timer: ReturnType<typeof setTimeout> | undefined;
let completed = false;
const clear = () => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
try {
const first = await new Promise<IteratorResult<T>>((resolve, reject) => {
timer = setTimeout(() => {
const timeoutError = createFirstStreamEventTimeoutError(timeoutContext);
timeoutContext.onTimeout?.(timeoutError);
timeoutContext.abort?.(timeoutError);
reject(timeoutError);
}, timeoutMs);
timer.unref?.();
iterator.next().then(resolve, reject);
}).finally(clear);
if (first.done) {
completed = true;
return;
}
yield first.value;
for (;;) {
const next = await iterator.next();
if (next.done) {
completed = true;
return;
}
yield next.value;
}
} finally {
clear();
if (!completed) {
void iterator.return?.().catch(() => undefined);
}
}
},
};
}

View File

@@ -0,0 +1,87 @@
/**
* Bounded SSE / NDJSON stream reader guard.
*
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
* against a hard cap. On overflow the underlying reader is cancelled and a
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
*
* Internal helper for now. If extensions need it, promote to a plugin-SDK
* subpath in a separate, dedicated PR with full SDK metadata sync.
*/
export type SseStreamOverflow = {
size: number;
maxBytes: number;
};
export type ReadSseStreamWithLimitOptions = {
maxBytes: number;
onOverflow?: (params: SseStreamOverflow) => Error;
};
export type SseByteGuard = {
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
cancel(reason?: unknown): Promise<void>;
totalBytes(): number;
overflowed(): boolean;
cancelled(): boolean;
};
export function createSseByteGuard(
reader: ReadableStreamDefaultReader<Uint8Array>,
opts: ReadSseStreamWithLimitOptions,
): SseByteGuard {
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
}
const onOverflow =
opts.onOverflow ??
((params) =>
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
let total = 0;
let overflowedFlag = false;
let cancelledFlag = false;
return {
read: async () => {
if (overflowedFlag || cancelledFlag) {
return { done: true, value: undefined };
}
const result = await reader.read();
if (result.done) {
return result;
}
const chunkLen = result.value?.byteLength ?? 0;
const next = total + chunkLen;
if (next > opts.maxBytes) {
overflowedFlag = true;
cancelledFlag = true;
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
try {
await reader.cancel(err);
} catch {
// best-effort cancellation; caller observes the overflow error
}
throw err;
}
total = next;
return result;
},
cancel: async (reason?: unknown) => {
if (overflowedFlag) {
// overflow already set cancelledFlag; do not overwrite
return;
}
cancelledFlag = true;
try {
await reader.cancel(reason);
} catch {
// best-effort cancellation
}
},
totalBytes: () => total,
overflowed: () => overflowedFlag,
cancelled: () => cancelledFlag,
};
}

View File

@@ -0,0 +1,92 @@
// System prompt cache-boundary tests cover the internal marker that separates
// stable prompt text from dynamic per-turn additions.
import { describe, expect, it } from "vitest";
import {
ensureSystemPromptCacheBoundary,
prependSystemPromptAdditionAfterCacheBoundary,
splitSystemPromptCacheBoundary,
stripSystemPromptCacheBoundary,
SYSTEM_PROMPT_CACHE_BOUNDARY,
} from "./system-prompt-cache-boundary.js";
describe("system prompt cache boundary helpers", () => {
it("splits stable and dynamic prompt regions", () => {
expect(
splitSystemPromptCacheBoundary(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`),
).toEqual({
stablePrefix: "Stable prefix",
dynamicSuffix: "Dynamic suffix",
});
});
it("strips the internal marker from prompt text", () => {
expect(
stripSystemPromptCacheBoundary(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`),
).toBe("Stable prefix\nDynamic suffix");
});
it("inserts prompt additions after the cache boundary", () => {
expect(
prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`,
systemPromptAddition: "Per-turn lab context",
}),
).toBe(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\n\nDynamic suffix`);
});
it("normalizes structured additions and dynamic suffix whitespace", () => {
expect(
prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix \r\n\r\nMore detail \t\r\n`,
systemPromptAddition: " Per-turn lab context \r\nSecond line\t\r\n",
}),
).toBe(
`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\nSecond line\n\nDynamic suffix\n\nMore detail`,
);
});
});
describe("ensureSystemPromptCacheBoundary", () => {
it("returns a marker-bearing prompt unchanged", () => {
const prompt = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
expect(ensureSystemPromptCacheBoundary(prompt)).toBe(prompt);
});
it("appends the boundary to a marker-free prompt", () => {
expect(ensureSystemPromptCacheBoundary("Marker-free override")).toBe(
`Marker-free override${SYSTEM_PROMPT_CACHE_BOUNDARY}`,
);
});
it("does not add a boundary for an empty prompt", () => {
expect(ensureSystemPromptCacheBoundary("")).toBe("");
expect(ensureSystemPromptCacheBoundary(" \n\t ")).toBe(" \n\t ");
});
it("uses a per-turn addition directly when the base prompt is empty", () => {
expect(
prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: ensureSystemPromptCacheBoundary(""),
systemPromptAddition: "Per-turn media task hint",
}),
).toBe("Per-turn media task hint");
});
it("is idempotent for a marker-free prompt", () => {
const once = ensureSystemPromptCacheBoundary("Marker-free override");
expect(ensureSystemPromptCacheBoundary(once)).toBe(once);
});
it("lets a per-turn addition split into the uncached suffix for a marker-free prompt", () => {
// Marker-free overrides become stable prefixes; additions stay in the
// dynamic suffix so prompt-cache bytes remain deterministic.
const result = prependSystemPromptAdditionAfterCacheBoundary({
systemPrompt: ensureSystemPromptCacheBoundary("Marker-free override"),
systemPromptAddition: "Per-turn media task hint",
});
expect(splitSystemPromptCacheBoundary(result)).toEqual({
stablePrefix: "Marker-free override",
dynamicSuffix: "Per-turn media task hint",
});
});
});

View File

@@ -0,0 +1,66 @@
/**
* System prompt cache-boundary helpers.
*
* Keeps stable prompt prefixes separate from dynamic runtime additions for provider prompt caching.
*/
import { normalizeStructuredPromptSection } from "./prompt-cache-stability.js";
export const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
export function stripSystemPromptCacheBoundary(text: string): string {
return text.replaceAll(SYSTEM_PROMPT_CACHE_BOUNDARY, "\n");
}
// Append the cache boundary when a prompt has none (e.g. a hook systemPrompt override),
// so dynamic additions route into an uncached suffix instead of the cached prefix (#85203).
export function ensureSystemPromptCacheBoundary(systemPrompt: string): string {
if (systemPrompt.trim().length === 0) {
return systemPrompt;
}
return systemPrompt.includes(SYSTEM_PROMPT_CACHE_BOUNDARY)
? systemPrompt
: `${systemPrompt}${SYSTEM_PROMPT_CACHE_BOUNDARY}`;
}
export function splitSystemPromptCacheBoundary(
text: string,
): { stablePrefix: string; dynamicSuffix: string } | undefined {
const boundaryIndex = text.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
if (boundaryIndex === -1) {
return undefined;
}
return {
stablePrefix: text.slice(0, boundaryIndex).trimEnd(),
dynamicSuffix: text.slice(boundaryIndex + SYSTEM_PROMPT_CACHE_BOUNDARY.length).trimStart(),
};
}
export function prependSystemPromptAdditionAfterCacheBoundary(params: {
systemPrompt: string;
systemPromptAddition?: string;
}): string {
const systemPromptAddition =
typeof params.systemPromptAddition === "string"
? normalizeStructuredPromptSection(params.systemPromptAddition)
: "";
if (!systemPromptAddition) {
return params.systemPrompt;
}
if (params.systemPrompt.trim().length === 0) {
return systemPromptAddition;
}
const split = splitSystemPromptCacheBoundary(params.systemPrompt);
if (!split) {
return `${systemPromptAddition}\n\n${params.systemPrompt}`;
}
const dynamicSuffix = split.dynamicSuffix
? normalizeStructuredPromptSection(split.dynamicSuffix)
: "";
if (!dynamicSuffix) {
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}`;
}
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}\n\n${dynamicSuffix}`;
}