docs: clarify inline code comments

Comment-only follow-up documenting reusable gateway, auth, proxy, device, Talk, session, and agent helper contracts.\n\nVerification: git diff --check plus targeted tests recorded in PR body.
This commit is contained in:
Peter Steinberger
2026-05-31 14:37:41 +01:00
committed by GitHub
parent 75e0053cf9
commit 85beee613c
262 changed files with 2351 additions and 61 deletions

View File

@@ -1,8 +1,3 @@
/**
* Agent loop that works with AgentMessage throughout.
* Transforms to Message[] only at the LLM call boundary.
*/
// Keep the runtime class on the package specifier so built agent-core shares
// constructor identity with @openclaw/llm-core; source types keep SDK d.ts bundled.
import { EventStream as LlmEventStream } from "@openclaw/llm-core";
@@ -26,6 +21,7 @@ import type {
} from "./types.js";
import { validateToolArguments } from "./validation.js";
/** Callback used by synchronous loop runners to publish agent lifecycle events. */
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
const EMPTY_USAGE = {
@@ -119,6 +115,7 @@ export function agentLoopContinue(
return stream;
}
/** Run a prompt-started loop and emit events through a caller-owned sink. */
export async function runAgentLoop(
prompts: AgentMessage[],
context: AgentContext,
@@ -145,6 +142,7 @@ export async function runAgentLoop(
return newMessages;
}
/** Continue an existing loop context and emit only newly produced messages. */
export async function runAgentLoopContinue(
context: AgentContext,
config: AgentLoopConfig,
@@ -326,10 +324,10 @@ async function runLoop(
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
// Set as pending so inner loop processes them
// Follow-up messages arrive after a turn would otherwise end; route them through the
// same pending-message path so event ordering matches steering messages.
pendingMessages = followUpMessages;
continue;
}

View File

@@ -100,33 +100,51 @@ function createMutableAgentState(
/** Options for constructing an {@link Agent}. */
export interface AgentOptions {
/** Initial transcript, tools, model, and prompt state. */
initialState?: Partial<
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
>;
/** Convert agent-owned transcript messages into provider-facing messages. */
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** Optionally rewrite context before each provider request. */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
/** Injected stream runtime used when streamFn is not supplied. */
runtime?: AgentCoreStreamRuntimeDeps;
/** Explicit stream implementation, preferred over runtime.streamSimple. */
streamFn?: StreamFn;
/** Resolve provider API keys at request time. */
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/** Inspect the provider payload before it is sent. */
onPayload?: SimpleStreamOptions["onPayload"];
/** Inspect the provider response after it returns. */
onResponse?: SimpleStreamOptions["onResponse"];
/** Hook that may short-circuit or alter a tool call before execution. */
beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
/** Hook that may alter a tool result after execution. */
afterToolCall?: (
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
/** Hook that may update model, reasoning, or context after a turn. */
prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
/** Queue drain mode for steering messages injected before the next assistant response. */
steeringMode?: QueueMode;
/** Queue drain mode for follow-up messages injected after the agent would otherwise stop. */
followUpMode?: QueueMode;
/** Session identifier forwarded to cache-aware providers. */
sessionId?: string;
/** Optional per-thinking-level token budgets forwarded to providers. */
thinkingBudgets?: ThinkingBudgets;
/** Preferred provider transport. */
transport?: Transport;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Default strategy for executing multiple tool calls in one assistant message. */
toolExecution?: ToolExecutionMode;
}
@@ -153,6 +171,7 @@ class PendingMessageQueue {
return drained;
}
// one-at-a-time preserves later queued messages for subsequent loop turns.
const first = this.messages[0];
if (!first) {
return [];

View File

@@ -51,11 +51,15 @@ export interface CollectEntriesResult {
commonAncestorId: string | null;
}
/** Minimal tree entry shape needed to compare two session branches. */
export interface BranchPathEntry {
/** Stable entry id. */
id: string;
/** Parent entry id, or null for the session root. */
parentId: string | null;
}
/** Branch entries selected after comparing old and target paths. */
export interface CollectBranchPathEntriesResult<TEntry extends BranchPathEntry> {
/** Entries to summarize in chronological order. */
entries: TEntry[];
@@ -106,7 +110,7 @@ export function collectEntriesForBranchSummaryFromBranches<TEntry extends Branch
return { entries: oldBranch.slice(firstSummarizedIndex), commonAncestorId };
}
/** Collect entries that should be summarized before navigating to a different session tree entry. */
/** Collect concrete session entries to summarize before moving from one leaf to another. */
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null,
@@ -191,6 +195,8 @@ export function prepareBranchEntries(
const tokens = estimateTokens(message);
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// Prefer already-compressed summaries when the budget is almost filled; they
// preserve older branch context better than dropping the whole prefix.
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);

View File

@@ -35,6 +35,7 @@ function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
}
/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */
export function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined {
if (
typeof timeoutSeconds !== "number" ||
@@ -241,6 +242,7 @@ function getShellEnv(
};
}
/** Node-backed execution environment for agent harness filesystem and shell operations. */
export class NodeExecutionEnv implements ExecutionEnv {
cwd: string;
private shellPath?: string;

View File

@@ -12,6 +12,7 @@ interface FileInfoDiagnostics {
push(diagnostic: FileInfoDiagnostic): unknown;
}
/** Parse optional YAML frontmatter and return the normalized Markdown body. */
export function parseFrontmatter(
content: string,
): Result<{ frontmatter: Record<string, unknown>; body: string }, Error> {
@@ -35,6 +36,7 @@ export function parseFrontmatter(
}
}
/** Resolve symlink or unknown file info into the concrete loadable file kind. */
export async function resolveFileInfoKind(
env: ExecutionEnv,
info: FileInfo,
@@ -72,22 +74,26 @@ export async function resolveFileInfoKind(
: undefined;
}
/** Join harness environment paths without requiring Node path semantics. */
export function joinEnvPath(base: string, child: string): string {
return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`;
}
/** Return the parent path for slash-separated harness environment paths. */
export function dirnameEnvPath(path: string): string {
const normalized = path.replace(/\/+$/, "");
const slashIndex = normalized.lastIndexOf("/");
return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex);
}
/** Return the leaf name for slash-separated harness environment paths. */
export function basenameEnvPath(path: string): string {
const normalized = path.replace(/\/+$/, "");
const slashIndex = normalized.lastIndexOf("/");
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
}
/** Return a root-relative path when possible, otherwise a display-safe non-absolute path. */
export function relativeEnvPath(root: string, path: string): string {
const normalizedRoot = root.replace(/\/+$/, "");
const normalizedPath = path.replace(/\/+$/, "");

View File

@@ -15,6 +15,7 @@ export type {
CustomMessage,
} from "../types.js";
/** Harness-only transcript entries that can be normalized into LLM messages. */
export type HarnessMessage =
| AgentMessage
| BashExecutionMessage
@@ -52,6 +53,7 @@ export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch tha
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
/** Render a shell execution record as user-visible context text for the model. */
export function bashExecutionToText(msg: BashExecutionMessage): string {
let text = `Ran \`${msg.command}\`\n`;
if (msg.output) {
@@ -70,6 +72,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
return text;
}
/** Build a persisted branch summary message from the repository timestamp string. */
export function createBranchSummaryMessage(
summary: string,
fromId: string,
@@ -83,6 +86,7 @@ export function createBranchSummaryMessage(
};
}
/** Build a persisted compaction summary message from the repository timestamp string. */
export function createCompactionSummaryMessage(
summary: string,
tokensBefore: number,
@@ -96,6 +100,7 @@ export function createCompactionSummaryMessage(
};
}
/** Build a custom transcript message that can be shown and replayed into context. */
export function createCustomMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
@@ -113,6 +118,7 @@ export function createCustomMessage(
};
}
/** Convert harness transcript messages into the LLM-facing message sequence. */
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | undefined => {

View File

@@ -33,7 +33,12 @@ function parseSafeNonNegativeInteger(raw: string): number | undefined {
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */
/**
* Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments.
*
* Unsafe integer placeholders resolve to empty text instead of throwing, so malformed templates cannot abort prompt
* loading or invocation.
*/
export function substituteArgs(content: string, args: string[]): string {
let result = content;
result = result.replace(/\$(\d+)/g, (_, num: string) => {
@@ -50,6 +55,8 @@ export function substituteArgs(content: string, args: string[]): string {
if (parsedStart === undefined) {
return "";
}
// Keep shell-style `${@:0:...}` compatibility: start 0 includes `$0` in shell, but
// prompt templates have no command name, so it maps to the first provided argument.
let start = parsedStart - 1;
if (start < 0) {
start = 0;

View File

@@ -36,6 +36,7 @@ function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
/** Repository for JSONL sessions grouped by working directory. */
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private readonly fs: JsonlSessionRepoFileSystem;
private readonly sessionsRootInput: string;
@@ -130,6 +131,8 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
} catch (error) {
const cause = toError(error);
// Listing is best-effort across a sessions directory; corrupt session
// headers are skipped, while filesystem and unexpected errors still fail.
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") {
throw cause;
}

View File

@@ -125,6 +125,7 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess
};
}
/** Read only the JSONL session header and convert it to session metadata. */
export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
@@ -168,6 +169,7 @@ async function loadJsonlStorage(
return { header, entries, leafId };
}
/** Append-only JSONL-backed storage for one session tree. */
export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata> {
private readonly fs: JsonlSessionStorageFileSystem;
private readonly filePath: string;
@@ -192,6 +194,7 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
/** Create a new JSONL file with a session header and no entries. */
static async create(
fs: JsonlSessionStorageFileSystem,
filePath: string,

View File

@@ -2,6 +2,7 @@ import type { SessionMetadata, SessionTreeEntry } from "../types.js";
import { BaseSessionStorage } from "./storage-base.js";
import { uuidv7 } from "./uuid.js";
/** Volatile session storage used by tests and in-process harness callers. */
export class InMemorySessionStorage<
TMetadata extends SessionMetadata = SessionMetadata,
> extends BaseSessionStorage<TMetadata> {

View File

@@ -23,6 +23,7 @@ import type {
} from "../types.js";
import { SessionError } from "../types.js";
/** Build model context from the active session branch and its latest state markers. */
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
@@ -76,6 +77,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
const compactionIdx = pathEntries.findIndex(
(e) => e.type === "compaction" && e.id === compaction.id,
);
// Replay only the compacted entry's retained tail plus newer branch entries; older
// transcript content is represented by the synthetic compaction summary above.
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i];
@@ -98,6 +101,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
return { messages, thinkingLevel, model };
}
/** High-level session API backed by pluggable tree storage. */
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
private storage: SessionStorage<TMetadata>;
@@ -199,6 +203,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies CompactionEntry);
}
/** Append a non-LLM transcript marker for harness-specific state. */
async appendCustomEntry(customType: string, data?: unknown): Promise<string> {
return this.appendTypedEntry({
type: "custom",
@@ -210,6 +215,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies CustomEntry);
}
/** Append harness-specific content that can also be replayed into model context. */
async appendCustomMessageEntry(
customType: string,
content: string | (TextContent | ImageContent)[],
@@ -228,6 +234,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies CustomMessageEntry);
}
/** Record or clear the display label for an existing session entry. */
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
if (!(await this.storage.getEntry(targetId))) {
throw new SessionError("not_found", `Entry ${targetId} not found`);
@@ -252,6 +259,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies SessionInfoEntry);
}
/** Move the visible branch leaf and optionally attach a summary of the abandoned branch. */
async moveTo(
entryId: string | null,
summary?: { summary: string; details?: unknown; fromHook?: boolean },

View File

@@ -37,6 +37,7 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
return uuidv7();
}
/** Return the effective branch leaf after applying a session tree entry. */
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
@@ -102,6 +103,8 @@ export abstract class BaseSessionStorage<
}
protected recordEntry(entry: SessionTreeEntry): void {
// Leaf and label entries are append-only state changes; keep derived indexes
// synchronized here so memory and JSONL storage expose identical behavior.
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);

View File

@@ -1,6 +1,9 @@
import type { Skill } from "./types.js";
/** Format model-visible skill metadata for inclusion in the harness system prompt. */
export function formatSkillsForSystemPrompt(skills: Skill[]): string {
// Hidden skills can still be invoked directly by host code, but should not be
// advertised to the model for autonomous selection.
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
if (visibleSkills.length === 0) {
return "";

View File

@@ -361,29 +361,38 @@ export interface Shell {
/** Filesystem and process execution environment used by the harness. */
export interface ExecutionEnv extends FileSystem, Shell {}
/** Base fields shared by append-only session tree entries. */
export interface SessionTreeEntryBase {
/** Entry discriminator used for JSONL persistence and typed narrowing. */
type: string;
/** Stable entry id unique within a session file. */
id: string;
/** Parent entry id, or null for a root entry. */
parentId: string | null;
/** ISO timestamp string used for persistence and sorting. */
timestamp: string;
}
/** Persisted transcript message entry. */
export interface MessageEntry extends SessionTreeEntryBase {
type: "message";
message: AgentMessage;
}
/** Persisted thinking-level selection marker. */
export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
type: "thinking_level_change";
thinkingLevel: string;
}
/** Persisted model selection marker. */
export interface ModelChangeEntry extends SessionTreeEntryBase {
type: "model_change";
provider: string;
modelId: string;
}
/** Persisted summary that replaces older transcript history in context. */
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
@@ -393,6 +402,7 @@ export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
fromHook?: boolean;
}
/** Persisted summary of an abandoned branch when navigating the session tree. */
export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
type: "branch_summary";
fromId: string;
@@ -401,12 +411,14 @@ export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
fromHook?: boolean;
}
/** Persisted harness/application marker that is not replayed into model context. */
export interface CustomEntry<T = unknown> extends SessionTreeEntryBase {
type: "custom";
customType: string;
data?: T;
}
/** Persisted harness/application message that can be replayed into model context. */
export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
type: "custom_message";
customType: string;
@@ -415,22 +427,27 @@ export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
display: boolean;
}
/** Append-only label update for another session entry. */
export interface LabelEntry extends SessionTreeEntryBase {
type: "label";
targetId: string;
label: string | undefined;
}
/** Persisted session metadata marker. */
export interface SessionInfoEntry extends SessionTreeEntryBase {
type: "session_info"; // legacy name, kept for backwards compatibility
// The persisted discriminator predates the public "session name" wording.
type: "session_info";
name?: string;
}
/** Append-only marker that changes the active visible leaf. */
export interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null;
}
/** All persisted session tree entry variants. */
export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
@@ -679,28 +696,36 @@ export type AgentHarnessEvent<
TPromptTemplate extends PromptTemplate = PromptTemplate,
> = AgentEvent | AgentHarnessOwnEvent<TSkill, TPromptTemplate>;
/** Hook result for mutating the initial prompt run before the agent starts. */
export interface BeforeAgentStartResult {
/** Replacement messages for the prompt run. */
messages?: AgentMessage[];
/** Replacement system prompt for the prompt run. */
systemPrompt?: string;
}
/** Hook result for replacing the full context message list before provider conversion. */
export interface ContextResult {
messages: AgentMessage[];
}
/** Hook result for patching provider request options before payload construction. */
export interface BeforeProviderRequestResult {
streamOptions?: AgentHarnessStreamOptionsPatch;
}
/** Hook result for replacing the provider payload after construction. */
export interface BeforeProviderPayloadResult {
payload: unknown;
}
/** Hook result for blocking a tool call before execution. */
export interface ToolCallResult {
block?: boolean;
reason?: string;
}
/** Hook patch for a completed tool result before it is persisted/emitted. */
export interface ToolResultPatch {
content?: Array<TextContent | ImageContent>;
details?: unknown;
@@ -708,11 +733,13 @@ export interface ToolResultPatch {
terminate?: boolean;
}
/** Hook result for cancelling or replacing a planned compaction. */
export interface SessionBeforeCompactResult {
cancel?: boolean;
compaction?: CompactResult;
}
/** Hook result for cancelling, labeling, or supplying branch-summary behavior before tree navigation. */
export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: { summary: string; details?: unknown };
@@ -721,6 +748,7 @@ export interface SessionBeforeTreeResult {
label?: string;
}
/** Typed return values expected from AgentHarness hook handlers by event type. */
export type AgentHarnessEventResultMap = {
before_agent_start: BeforeAgentStartResult | undefined;
context: ContextResult | undefined;
@@ -742,15 +770,18 @@ export type AgentHarnessEventResultMap = {
settled: undefined;
};
/** Options for a prompt submitted through AgentHarness. */
export interface AgentHarnessPromptOptions {
images?: ImageContent[];
}
/** Queued messages removed by an abort operation. */
export interface AbortResult {
clearedSteer: AgentMessage[];
clearedFollowUp: AgentMessage[];
}
/** Compaction data supplied by hooks or returned from compaction preparation. */
export interface CompactResult {
summary: string;
firstKeptEntryId: string;
@@ -758,18 +789,21 @@ export interface CompactResult {
details?: unknown;
}
/** Result of moving the active session-tree leaf. */
export interface NavigateTreeResult {
cancelled: boolean;
editorText?: string;
summaryEntry?: BranchSummaryEntry;
}
/** Settings that control automatic context compaction. */
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
/** Prepared compaction inputs exposed to hooks before a summary is generated. */
export interface CompactionPreparation {
firstKeptEntryId: string;
messagesToSummarize: AgentMessage[];
@@ -781,12 +815,14 @@ export interface CompactionPreparation {
settings: CompactionSettings;
}
/** File operations accumulated from summarized transcript ranges. */
export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
/** Prepared branch navigation inputs exposed to hooks before a summary is generated. */
export interface TreePreparation {
targetId: string;
oldLeafId: string | null;
@@ -798,6 +834,7 @@ export interface TreePreparation {
label?: string;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
model: Model;
apiKey: string;
@@ -810,12 +847,14 @@ export interface GenerateBranchSummaryOptions {
reserveTokens?: number;
}
/** Generated branch summary text and file-operation metadata. */
export interface BranchSummaryResult {
summary: string;
readFiles: string[];
modifiedFiles: string[];
}
/** Construction options for AgentHarness. */
export interface AgentHarnessOptions<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,

View File

@@ -1,17 +1,8 @@
/**
* Shared truncation utilities for tool outputs.
*
* Truncation is based on two independent limits - whichever is hit first wins:
* - Line limit (default: 2000 lines)
* - Byte limit (default: 50KB)
*
* Never returns partial lines (except bash tail truncation edge case).
*/
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
/** Result metadata for content truncated by line count, byte count, or both. */
export interface TruncationResult {
/** The truncated content */
content: string;
@@ -37,6 +28,7 @@ export interface TruncationResult {
maxBytes: number;
}
/** Byte and line ceilings used by the truncation helpers. */
export interface TruncationOptions {
/** Maximum number of lines (default: 2000) */
maxLines?: number;
@@ -134,7 +126,7 @@ function replaceUnpairedSurrogates(content: string): string {
}
/**
* Format bytes as human-readable size.
* Format byte counts for compact tool-output diagnostics.
*/
export function formatSize(bytes: number): string {
if (bytes < 1024) {
@@ -190,11 +182,10 @@ function buildTruncationResult(
}
/**
* Truncate content from the head (keep first N lines/bytes).
* Suitable for file reads where you want to see the beginning.
* Keep the beginning of content while respecting independent line and byte ceilings.
*
* Never returns partial lines. If first line exceeds byte limit,
* returns empty content with firstLineExceedsLimit=true.
* Head truncation preserves complete lines; a first line that exceeds the byte
* ceiling produces empty output and sets firstLineExceedsLimit.
*/
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
const input = resolveTruncationInput(content, options);
@@ -257,10 +248,10 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
}
/**
* Truncate content from the tail (keep last N lines/bytes).
* Suitable for bash output where you want to see the end (errors, final results).
* Keep the end of content while respecting independent line and byte ceilings.
*
* May return partial first line if the last line of original content exceeds byte limit.
* Tail truncation preserves recent output for command errors and may keep a
* partial first line when one final line alone exceeds the byte ceiling.
*/
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
const input = resolveTruncationInput(content, options);
@@ -366,8 +357,7 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
}
/**
* Truncate a single line to max characters, adding [truncated] suffix.
* Used for grep match lines.
* Trim a single display line and mark it with the grep-style truncation suffix.
*/
export function truncateLine(
line: string,

View File

@@ -1,11 +1,16 @@
import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js";
/** Runtime functions injected by host packages so agent-core stays provider-agnostic. */
export interface AgentCoreRuntimeDeps {
/** Streaming completion implementation used for normal agent turns. */
streamSimple: StreamFn;
/** Non-streaming completion implementation used by summarization helpers. */
completeSimple: CompleteSimpleFn;
}
/** Runtime dependency subset required by streaming agent loops. */
export type AgentCoreStreamRuntimeDeps = Pick<AgentCoreRuntimeDeps, "streamSimple">;
/** Runtime dependency subset required by summarization helpers. */
export type AgentCoreCompletionRuntimeDeps = Pick<AgentCoreRuntimeDeps, "completeSimple">;
function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error {
@@ -14,6 +19,7 @@ function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error {
);
}
/** Resolve the stream function, preferring an explicit override over injected runtime deps. */
export function resolveAgentCoreStreamFn(
runtime: AgentCoreStreamRuntimeDeps | undefined,
streamFn?: StreamFn,
@@ -27,6 +33,7 @@ export function resolveAgentCoreStreamFn(
throw missingRuntimeDep("streamSimple");
}
/** Resolve the completion function used by non-streaming helper flows. */
export function resolveAgentCoreCompleteFn(
runtime: AgentCoreCompletionRuntimeDeps | undefined,
): CompleteSimpleFn {

View File

@@ -288,40 +288,66 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export interface BashExecutionMessage {
/** Harness role for shell command transcripts. */
role: "bashExecution";
/** Command line that was executed. */
command: string;
/** Captured command output, usually already truncated for context. */
output: string;
/** Process exit code when the command reached process exit. */
exitCode: number | undefined;
/** True when the command was interrupted before normal completion. */
cancelled: boolean;
/** True when output was shortened for transcript/context storage. */
truncated: boolean;
/** Optional path containing the complete output when truncation occurred. */
fullOutputPath?: string;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
/** Exclude this command transcript from model context while keeping it in session history. */
excludeFromContext?: boolean;
}
export interface CustomMessage<T = unknown> {
/** Harness role for application-defined transcript content. */
role: "custom";
/** Application-defined discriminator for rendering or handling this message. */
customType: string;
/** Content replayed into model context when this message is included. */
content: string | (TextContent | ImageContent)[];
/** Whether UI surfaces should display this message. */
display: boolean;
/** Optional application-specific metadata. */
details?: T;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
}
export interface BranchSummaryMessage {
/** Harness role for summaries produced when returning from another branch. */
role: "branchSummary";
/** Summary text inserted back into model context. */
summary: string;
/** Entry id of the branch root or source leaf being summarized. */
fromId: string;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
}
export interface CompactionSummaryMessage {
/** Harness role for summaries that replace compacted transcript history. */
role: "compactionSummary";
/** Summary text inserted back into model context. */
summary: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Timestamp may be numeric in memory or string when loaded from older persisted rows. */
timestamp: number | string;
/** Optional estimated context tokens after compaction. */
tokensAfter?: number;
/** Optional first retained entry id from the compaction range. */
firstKeptEntryId?: string;
/** Optional implementation-specific compaction metadata. */
details?: unknown;
}

View File

@@ -1,5 +1,6 @@
import { resolveFiniteTimeoutDelayMs } from "./timeouts.js";
/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */
export type EventLoopReadyResult = {
ready: boolean;
elapsedMs: number;
@@ -8,6 +9,7 @@ export type EventLoopReadyResult = {
aborted: boolean;
};
/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */
export type EventLoopReadyOptions = {
maxWaitMs?: number;
intervalMs?: number;
@@ -25,6 +27,7 @@ function resolvePositiveInteger(value: number | undefined, fallback: number): nu
return Number.isFinite(value) && value !== undefined ? Math.max(1, Math.floor(value)) : fallback;
}
/** Waits until timer drift stays low for consecutive checks, or aborts/times out. */
export async function waitForEventLoopReady(
options: EventLoopReadyOptions = {},
): Promise<EventLoopReadyResult> {

View File

@@ -10,10 +10,12 @@ export type GatewayClientStartable = {
start(): void;
};
/** Injectable readiness waiter used by tests and alternate event-loop probes. */
export type EventLoopReadyWaiter = (
options?: EventLoopReadyOptions,
) => Promise<EventLoopReadyResult>;
/** Timeout and abort controls for delaying client start until the loop can process IO. */
export type GatewayClientStartReadinessOptions = {
timeoutMs?: number;
clientOptions?: Pick<
@@ -43,6 +45,7 @@ function resolveGatewayClientStartReadinessTimeoutMs(
});
}
/** Starts a gateway client only after the supplied readiness probe succeeds. */
export async function startGatewayClientWithReadinessWait(
waitForReady: EventLoopReadyWaiter,
client: GatewayClientStartable,
@@ -58,6 +61,7 @@ export async function startGatewayClientWithReadinessWait(
return readiness;
}
/** Starts a gateway client after the default event-loop readiness probe succeeds. */
export async function startGatewayClientWhenEventLoopReady(
client: GatewayClientStartable,
options: GatewayClientStartReadinessOptions = {},

View File

@@ -7,11 +7,16 @@ function parseStrictPositiveInteger(value: string): number | undefined {
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
/** Maximum delay Node timers can represent without overflow warnings. */
export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;
/** Default server-side window for gateway preauth handshakes. */
export const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000;
/** Minimum client watchdog delay for connect challenge setup. */
export const MIN_CONNECT_CHALLENGE_TIMEOUT_MS = 250;
/** Default maximum client watchdog delay, aligned with the preauth server timeout. */
export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS;
/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
const rawMinMs = opts?.minMs ?? 1;
const minMs = Math.min(
@@ -22,6 +27,7 @@ export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: numb
return Math.min(MAX_SAFE_TIMEOUT_DELAY_MS, Math.max(minMs, candidateMs));
}
/** Adds grace time while preserving safe timer bounds if inputs overflow or are invalid. */
export function addSafeTimeoutDelayGraceMs(
delayMs: number,
graceMs: number,
@@ -37,6 +43,7 @@ export function addSafeTimeoutDelayGraceMs(
);
}
/** Resolves optional timeout values through a fallback and safe timer clamp. */
export function resolveFiniteTimeoutDelayMs(
delayMs: number | null | undefined,
fallbackMs: number,
@@ -47,6 +54,7 @@ export function resolveFiniteTimeoutDelayMs(
return resolveSafeTimeoutDelayMs(candidateMs, opts);
}
/** Clamps connect challenge watchdog timeouts to the gateway-supported range. */
export function clampConnectChallengeTimeoutMs(
timeoutMs: number,
maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
@@ -57,6 +65,7 @@ export function clampConnectChallengeTimeoutMs(
);
}
/** Reads the connect challenge watchdog override from the process environment. */
export function getConnectChallengeTimeoutMsFromEnv(
env: NodeJS.ProcessEnv = process.env,
): number | undefined {
@@ -76,6 +85,7 @@ function normalizePositiveTimeoutMs(timeoutMs: unknown): number | undefined {
: undefined;
}
/** Resolves the client watchdog timeout using explicit, env, then preauth defaults. */
export function resolveConnectChallengeTimeoutMs(
timeoutMs?: number | null,
params?: {
@@ -100,6 +110,7 @@ export function resolveConnectChallengeTimeoutMs(
return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs);
}
/** Reads the preauth handshake timeout override from environment variables. */
export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
const configuredTimeout =
env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (env.VITEST && env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);
@@ -112,6 +123,7 @@ export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = pro
return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS;
}
/** Resolves the server preauth timeout from env, explicit config, or default. */
export function resolvePreauthHandshakeTimeoutMs(params?: {
env?: NodeJS.ProcessEnv;
configuredTimeoutMs?: number | null;

View File

@@ -22,10 +22,12 @@ export const GATEWAY_CLIENT_IDS = {
PROBE: "openclaw-probe",
} as const;
/** Stable gateway client ids used on the wire during hello/connect handshakes. */
export type GatewayClientId = (typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS];
// Back-compat naming (internal): these values are IDs, not display names.
export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS;
/** Compatibility alias for internal callers that still use "name" terminology. */
export type GatewayClientName = GatewayClientId;
export const GATEWAY_CLIENT_MODES = {
@@ -38,8 +40,10 @@ export const GATEWAY_CLIENT_MODES = {
TEST: "test",
} as const;
/** Coarse client category used for gateway policy and diagnostics. */
export type GatewayClientMode = (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES];
/** Client metadata sent during gateway connection setup. */
export type GatewayClientInfo = {
id: GatewayClientId;
displayName?: string;
@@ -55,11 +59,13 @@ export const GATEWAY_CLIENT_CAPS = {
TOOL_EVENTS: "tool-events",
} as const;
/** Optional capability advertised by clients during gateway handshake. */
export type GatewayClientCap = (typeof GATEWAY_CLIENT_CAPS)[keyof typeof GATEWAY_CLIENT_CAPS];
const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS));
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES));
/** Normalizes untrusted client ids and rejects unknown values. */
export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined {
const normalized = normalizeOptionalLowercaseString(raw);
if (!normalized) {
@@ -70,10 +76,12 @@ export function normalizeGatewayClientId(raw?: string | null): GatewayClientId |
: undefined;
}
/** Normalizes legacy client-name fields through the canonical client-id registry. */
export function normalizeGatewayClientName(raw?: string | null): GatewayClientName | undefined {
return normalizeGatewayClientId(raw);
}
/** Normalizes untrusted client modes and rejects unknown values. */
export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined {
const normalized = normalizeOptionalLowercaseString(raw);
if (!normalized) {
@@ -84,6 +92,7 @@ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMo
: undefined;
}
/** Checks a client-advertised capability list without treating missing caps as errors. */
export function hasGatewayClientCap(
caps: string[] | null | undefined,
cap: GatewayClientCap,

View File

@@ -1,7 +1,12 @@
/** Canonical id for file secret providers that expose exactly one value. */
export const SINGLE_VALUE_FILE_REF_ID = "value";
/** Shared alias grammar for env/file/exec secret provider names. */
export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
/** JSON-schema fragment that rejects absolute file secret ref ids. */
export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/";
/** JSON-schema fragment that rejects invalid JSON-pointer escape sequences. */
export const FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN = "~(?:[^01]|$)";
/** JSON-schema pattern for exec secret ref ids, excluding dot-path traversal. */
export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN =
"^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$";

View File

@@ -1,15 +1,22 @@
/** Structured error reason used while gateway startup sidecars are still initializing. */
export const GATEWAY_STARTUP_UNAVAILABLE_REASON = "startup-sidecars";
/** Internal close cause that distinguishes startup retry closes from generic disconnects. */
export const GATEWAY_STARTUP_PENDING_CLOSE_CAUSE = "startup-sidecars-pending";
/** WebSocket close code for temporary gateway unavailability. */
export const GATEWAY_STARTUP_CLOSE_CODE = 1013;
/** Human-readable WebSocket close reason for temporary gateway startup unavailability. */
export const GATEWAY_STARTUP_CLOSE_REASON = "gateway starting";
/** Default retry-after hint sent with startup-unavailable handshake errors. */
export const GATEWAY_STARTUP_RETRY_AFTER_MS = 500;
const GATEWAY_STARTUP_RETRY_MIN_MS = 100;
const GATEWAY_STARTUP_RETRY_MAX_MS = 2_000;
/** Details payload attached to retryable startup-unavailable gateway errors. */
export type GatewayStartupUnavailableDetails = {
reason: typeof GATEWAY_STARTUP_UNAVAILABLE_REASON;
};
/** Builds the canonical startup-unavailable details payload. */
export function gatewayStartupUnavailableDetails(): GatewayStartupUnavailableDetails {
return { reason: GATEWAY_STARTUP_UNAVAILABLE_REASON };
}
@@ -24,6 +31,7 @@ function isGatewayStartupUnavailableDetails(
);
}
/** Detects the structured retryable error emitted while startup sidecars are pending. */
export function isRetryableGatewayStartupUnavailableError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
@@ -42,6 +50,7 @@ export function isRetryableGatewayStartupUnavailableError(error: unknown): boole
);
}
/** Resolves a bounded retry-after delay from a startup-unavailable error. */
export function resolveGatewayStartupRetryAfterMs(error: unknown): number | null {
if (!isRetryableGatewayStartupUnavailableError(error)) {
return null;

View File

@@ -1,3 +1,6 @@
/** Current gateway protocol version emitted by modern clients and servers. */
export const PROTOCOL_VERSION = 4 as const;
/** Lowest client protocol version accepted by the gateway. */
export const MIN_CLIENT_PROTOCOL_VERSION = 4 as const;
/** Lowest lightweight probe protocol version accepted by the gateway. */
export const MIN_PROBE_PROTOCOL_VERSION = 4 as const;

View File

@@ -281,6 +281,7 @@ function formatValidationPath(error: TLocalizedValidationError): string {
return path || "root";
}
/** Finds the target tool and validates/coerces a model-emitted tool call. */
export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
const tool = tools.find((t) => t.name === toolCall.name);
if (!tool) {
@@ -289,6 +290,7 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
return validateToolArguments(tool, toolCall);
}
/** Validates tool arguments against TypeBox or plain JSON-schema parameters. */
export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
const args = structuredClone(toolCall.arguments);
Value.Convert(tool.parameters, args);

View File

@@ -8,21 +8,21 @@ import type {
StreamOptions,
} from "../../llm-core/src/index.js";
// Type-only source import keeps plugin SDK declarations self-contained; package
// runtime emits no llm-core import from this module.
/** Runtime stream adapter signature stored in the API provider registry. */
export type ApiStreamFunction = (
model: Model,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStreamContract;
/** Runtime simple-stream adapter signature stored in the API provider registry. */
export type ApiStreamSimpleFunction = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamContract;
/** Provider implementation registered by core or plugins for a specific model API. */
export interface ApiProvider<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
@@ -69,6 +69,7 @@ function wrapStreamSimple<TApi extends Api>(
};
}
/** Registers or replaces the provider implementation for an API id. */
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
@@ -83,14 +84,17 @@ export function registerApiProvider<TApi extends Api, TOptions extends StreamOpt
});
}
/** Looks up a registered API provider by API id. */
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
}
/** Lists all currently registered API providers. */
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
}
/** Removes all providers registered by a plugin/source id. */
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
if (entry.sourceId === sourceId) {
@@ -99,6 +103,7 @@ export function unregisterApiProviders(sourceId: string): void {
}
}
/** Clears the registry for test teardown and runtime reset flows. */
export function clearApiProviders(): void {
apiProviderRegistry.clear();
}

View File

@@ -39,6 +39,7 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi
return { lastNewline, lastWhitespace };
}
/** Splits plain text at readable boundaries while avoiding breaks inside parentheses. */
export function chunkText(text: string, limit: number): string[] {
const early = resolveChunkEarlyReturn(text, limit);
if (early) {

View File

@@ -17,6 +17,7 @@ export type FenceScanState = {
};
};
/** Scans fenced-code spans incrementally so chunking can carry an open fence forward. */
export function scanFenceSpans(
buffer: string,
state?: FenceScanState,
@@ -102,10 +103,12 @@ export function scanFenceSpans(
return { spans, state: nextState };
}
/** Parses all fenced-code spans in a complete markdown buffer. */
export function parseFenceSpans(buffer: string): FenceSpan[] {
return scanFenceSpans(buffer).spans;
}
/** Looks up the fence containing an offset; spans must be sorted by start offset. */
export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined {
let low = 0;
let high = spans.length - 1;
@@ -130,6 +133,7 @@ export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan |
return undefined;
}
/** True when a chunk boundary would not split a fenced-code block. */
export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean {
return !findFenceSpanAt(spans, index);
}

View File

@@ -195,6 +195,7 @@ function extractFrontmatterBlock(content: string): string | undefined {
return normalized.slice(4, endIndex);
}
/** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
const block = extractFrontmatterBlock(content);
if (!block) {

View File

@@ -10,6 +10,7 @@ const MARKDOWN_STYLE_MARKERS = {
code_block: { open: "```\n", close: "```" },
} as const;
/** Converts markdown tables into the configured plaintext/code rendering mode. */
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
if (!markdown || mode === "off") {
return markdown;

View File

@@ -1,5 +1,6 @@
import { normalizeOptionalString } from "./string.js";
/** Provider catalog entry shape used when resolving capability-scoped model references. */
export type CapabilityModelProviderCandidate = {
id: string;
aliases?: readonly string[];
@@ -7,6 +8,7 @@ export type CapabilityModelProviderCandidate = {
models?: readonly string[];
};
/** Normalized provider/model reference selected for a media capability. */
export type CapabilityModelRef = {
provider: string;
model: string;
@@ -22,6 +24,7 @@ function normalizeProviderForMatch(
return normalized && normalizeProviderId ? normalizeProviderId(normalized) : normalized;
}
/** Finds a provider by id or alias using the caller's provider-id normalization rules. */
export function findCapabilityProviderById<T extends CapabilityModelProviderCandidate>(params: {
providers: readonly T[];
providerId?: string;
@@ -43,6 +46,7 @@ export function findCapabilityProviderById<T extends CapabilityModelProviderCand
});
}
/** Resolves a bare model name to the provider that advertises it for this capability. */
export function resolveCapabilityProviderModelOnlyRef(params: {
providers: readonly CapabilityModelProviderCandidate[];
raw?: string;
@@ -58,6 +62,7 @@ export function resolveCapabilityProviderModelOnlyRef(params: {
return provider ? { provider: provider.id, model } : null;
}
/** Resolves provider/model refs first, then falls back to model-only catalog matching. */
export function resolveCapabilityModelRefForProviders(params: {
providers: readonly CapabilityModelProviderCandidate[];
raw?: string;

View File

@@ -1,10 +1,12 @@
import { normalizeOptionalString } from "./string.js";
/** Provider/model pair parsed from a generation model reference like `provider/model`. */
export type ParsedGenerationModelRef = {
provider: string;
model: string;
};
/** Parses strict generation model refs and rejects missing provider or model segments. */
export function parseGenerationModelRef(raw: string | undefined): ParsedGenerationModelRef | null {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {

View File

@@ -1,5 +1,7 @@
/** Primitive value types reported in media generation normalization metadata. */
export type MediaNormalizationValue = string | number | boolean;
/** Requested/applied value pair plus provenance for a normalized media option. */
export type MediaNormalizationEntry<TValue extends MediaNormalizationValue> = {
requested?: TValue;
applied?: TValue;
@@ -7,6 +9,7 @@ export type MediaNormalizationEntry<TValue extends MediaNormalizationValue> = {
supportedValues?: readonly TValue[];
};
/** Normalization metadata shared by media generation responses. */
export type MediaGenerationNormalizationMetadataInput = {
size?: MediaNormalizationEntry<string>;
aspectRatio?: MediaNormalizationEntry<string>;
@@ -14,6 +17,7 @@ export type MediaGenerationNormalizationMetadataInput = {
durationSeconds?: MediaNormalizationEntry<number>;
};
/** True when a normalization entry contains any user-visible normalization metadata. */
export function hasMediaNormalizationEntry<TValue extends MediaNormalizationValue>(
entry: MediaNormalizationEntry<TValue> | undefined,
): entry is MediaNormalizationEntry<TValue> {

View File

@@ -3,6 +3,7 @@ import type { MediaUnderstandingOutput } from "./types.js";
const MEDIA_PLACEHOLDER_RE = /^<media:[^>]+>(\s*\([^)]*\))?$/i;
const MEDIA_PLACEHOLDER_TOKEN_RE = /^<media:[^>]+>(\s*\([^)]*\))?\s*/i;
/** Extracts user-authored text while ignoring synthetic media placeholder tokens. */
export function extractMediaUserText(body?: string): string | undefined {
const trimmed = body?.trim() ?? "";
if (!trimmed) {
@@ -29,6 +30,7 @@ function formatSection(
return lines.join("\n");
}
/** Formats media-understanding outputs into the chat body sent back to the model. */
export function formatMediaUnderstandingBody(params: {
body?: string;
outputs: MediaUnderstandingOutput[];
@@ -90,6 +92,7 @@ export function formatMediaUnderstandingBody(params: {
return sections.join("\n\n").trim();
}
/** Formats one or more audio transcript outputs for legacy transcript-only callers. */
export function formatAudioTranscripts(outputs: MediaUnderstandingOutput[]): string {
if (outputs.length === 1) {
return outputs[0].text;

View File

@@ -1,7 +1,9 @@
/** Returns a number only when the input is already finite. */
export function asFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Returns a finite number only when it satisfies the supplied inclusive/exclusive bounds. */
export function asFiniteNumberInRange(
value: unknown,
range: {
@@ -28,6 +30,7 @@ export function asFiniteNumberInRange(
return number;
}
/** Returns a safe integer only when it satisfies the supplied inclusive bounds. */
export function asSafeIntegerInRange(
value: unknown,
range: {
@@ -52,6 +55,7 @@ function normalizeNumericString(value: string): string | undefined {
return trimmed ? trimmed : undefined;
}
/** Parses finite numbers from number values or strict numeric string tokens. */
export function parseFiniteNumber(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
@@ -59,6 +63,7 @@ export function parseFiniteNumber(value: unknown): number | undefined {
return parseStrictFiniteNumber(value);
}
/** Parses only safe integer numbers or base-10 integer strings. */
export function parseStrictInteger(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isSafeInteger(value) ? value : undefined;
@@ -74,6 +79,7 @@ export function parseStrictInteger(value: unknown): number | undefined {
return Number.isSafeInteger(parsed) ? parsed : undefined;
}
/** Parses only finite decimal/scientific string tokens, rejecting partial numbers. */
export function parseStrictFiniteNumber(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
@@ -89,15 +95,21 @@ export function parseStrictFiniteNumber(value: unknown): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
/** Returns positive safe integers without string coercion. */
export function asPositiveSafeInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
/** Conservative upper bound for Node timer delays. */
export const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
/** Timer bound expressed in whole seconds for env/config inputs. */
export const MAX_TIMER_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_TIMEOUT_MS / 1000);
/** Largest timestamp accepted by JavaScript Date. */
export const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
/** Fallback ISO value for invalid timestamp inputs. */
export const UNIX_EPOCH_ISO_STRING = "1970-01-01T00:00:00.000Z";
/** Returns a Date-valid millisecond timestamp. */
export function asDateTimestampMs(value: unknown): number | undefined {
return asFiniteNumberInRange(value, {
min: -MAX_DATE_TIMESTAMP_MS,
@@ -105,6 +117,7 @@ export function asDateTimestampMs(value: unknown): number | undefined {
});
}
/** Checks whether a Date-valid timestamp is after the supplied/current time. */
export function isFutureDateTimestampMs(
value: unknown,
opts: { nowMs?: number } = {},
@@ -114,11 +127,13 @@ export function isFutureDateTimestampMs(
return timestampMs !== undefined && nowMs !== undefined && timestampMs > nowMs;
}
/** Converts Date-valid millisecond timestamps to ISO strings. */
export function timestampMsToIsoString(value: unknown): string | undefined {
const timestampMs = asDateTimestampMs(value);
return timestampMs === undefined ? undefined : new Date(timestampMs).toISOString();
}
/** Resolves a Date-valid timestamp with a Date-valid fallback. */
export function resolveDateTimestampMs(
value: unknown,
fallbackValue: unknown = Date.now(),
@@ -126,6 +141,7 @@ export function resolveDateTimestampMs(
return asDateTimestampMs(value) ?? asDateTimestampMs(fallbackValue) ?? 0;
}
/** Resolves a Date-valid timestamp to ISO, falling back to Unix epoch if needed. */
export function resolveTimestampMsToIsoString(
value: unknown,
fallbackValue: unknown = Date.now(),
@@ -135,6 +151,7 @@ export function resolveTimestampMsToIsoString(
);
}
/** Formats Date-valid timestamps for filenames by replacing colon separators. */
export function timestampMsToIsoFileStamp(
value: unknown,
fallbackValue: unknown = Date.now(),
@@ -142,6 +159,7 @@ export function timestampMsToIsoFileStamp(
return resolveTimestampMsToIsoString(value, fallbackValue).replaceAll(":", "-");
}
/** Clamps finite millisecond values into the Node-safe timer range. */
export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undefined {
const value = asFiniteNumber(valueMs);
if (value === undefined) {
@@ -151,6 +169,7 @@ export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undef
return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS);
}
/** Clamps positive finite millisecond values into the Node-safe timer range. */
export function clampPositiveTimerTimeoutMs(valueMs: unknown): number | undefined {
const value = asFiniteNumber(valueMs);
if (value === undefined || value <= 0) {
@@ -159,10 +178,12 @@ export function clampPositiveTimerTimeoutMs(valueMs: unknown): number | undefine
return clampTimerTimeoutMs(value);
}
/** Resolves a positive timer timeout or falls back through safe timer clamping. */
export function resolvePositiveTimerTimeoutMs(valueMs: unknown, fallbackMs: number): number {
return clampPositiveTimerTimeoutMs(valueMs) ?? resolveTimerTimeoutMs(fallbackMs, 1);
}
/** Resolves arbitrary timeout input with fallback and minimum timer bounds. */
export function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: number, minMs = 1): number {
const value = asFiniteNumber(valueMs) ?? asFiniteNumber(fallbackMs);
const min = Math.max(0, Math.floor(minMs));
@@ -172,6 +193,7 @@ export function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: number, minM
return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS);
}
/** Adds grace time to a finite timeout and clamps the result to Node-safe bounds. */
export function addTimerTimeoutGraceMs(timeoutMs: unknown, graceMs = 5_000): number | undefined {
const timeout = asFiniteNumber(timeoutMs);
const grace = asFiniteNumber(graceMs);
@@ -182,6 +204,7 @@ export function addTimerTimeoutGraceMs(timeoutMs: unknown, graceMs = 5_000): num
return Number.isFinite(withGrace) ? clampTimerTimeoutMs(withGrace) : MAX_TIMER_TIMEOUT_MS;
}
/** Converts finite positive seconds to Node-safe milliseconds. */
export function finiteSecondsToTimerSafeMilliseconds(
value: unknown,
opts: { floorSeconds?: boolean } = {},
@@ -198,6 +221,7 @@ export function finiteSecondsToTimerSafeMilliseconds(
return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS);
}
/** Resolves an integer option from finite numeric input or fallback, then clamps bounds. */
export function resolveIntegerOption(
value: unknown,
fallback: number,
@@ -212,6 +236,7 @@ export function resolveIntegerOption(
return range.max === undefined ? minBounded : Math.min(range.max, minBounded);
}
/** Resolves an optional integer option, returning undefined for non-finite input. */
export function resolveOptionalIntegerOption(
value: unknown,
range: {
@@ -225,20 +250,24 @@ export function resolveOptionalIntegerOption(
return resolveIntegerOption(value, value, range);
}
/** Resolves an integer option with a non-negative lower bound. */
export function resolveNonNegativeIntegerOption(value: unknown, fallback: number): number {
return resolveIntegerOption(value, fallback, { min: 0 });
}
/** Parses strict positive integer values from numbers or strings. */
export function parseStrictPositiveInteger(value: unknown): number | undefined {
const parsed = parseStrictInteger(value);
return parsed !== undefined && parsed > 0 ? parsed : undefined;
}
/** Parses strict non-negative integer values from numbers or strings. */
export function parseStrictNonNegativeInteger(value: unknown): number | undefined {
const parsed = parseStrictInteger(value);
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
}
/** Converts strict positive seconds to safe millisecond counts. */
export function positiveSecondsToSafeMilliseconds(value: unknown): number | undefined {
const seconds = parseStrictPositiveInteger(value);
if (seconds === undefined) {
@@ -248,6 +277,7 @@ export function positiveSecondsToSafeMilliseconds(value: unknown): number | unde
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
}
/** Converts strict non-negative seconds to safe millisecond counts. */
export function nonNegativeSecondsToSafeMilliseconds(value: unknown): number | undefined {
const seconds = parseStrictNonNegativeInteger(value);
if (seconds === undefined) {
@@ -257,6 +287,7 @@ export function nonNegativeSecondsToSafeMilliseconds(value: unknown): number | u
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
}
/** Resolves an absolute expiration timestamp from a positive duration in milliseconds. */
export function resolveExpiresAtMsFromDurationMs(
value: unknown,
opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {},
@@ -285,6 +316,7 @@ export function resolveExpiresAtMsFromDurationMs(
return Math.max(expiresAt, minExpiresAt);
}
/** Resolves an absolute expiration timestamp from a positive duration in seconds. */
export function resolveExpiresAtMsFromDurationSeconds(
value: unknown,
opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {},
@@ -293,6 +325,7 @@ export function resolveExpiresAtMsFromDurationSeconds(
return durationMs === undefined ? undefined : resolveExpiresAtMsFromDurationMs(durationMs, opts);
}
/** Resolves an absolute expiration timestamp from Unix epoch seconds. */
export function resolveExpiresAtMsFromEpochSeconds(
value: unknown,
opts: { bufferMs?: number; maxMs?: number } = {},
@@ -315,6 +348,7 @@ export function resolveExpiresAtMsFromEpochSeconds(
return maxMs === undefined || expiresAt <= maxMs ? expiresAt : undefined;
}
/** Resolves expiration input that may be relative seconds, epoch seconds, or epoch milliseconds. */
export function resolveExpiresAtMsFromDurationOrEpoch(
value: unknown,
opts: {

View File

@@ -1,12 +1,15 @@
// Keep this local so browser bundles do not pull in src/utils.ts and its Node-only side effects.
/** Type guard for non-array object records at browser-safe boundaries. */
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/** Coerces object-like values to records, falling back to an empty record. */
export function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
/** Reads a field only when it exists as a string. */
export function readStringField(
record: Record<string, unknown> | null | undefined,
key: string,
@@ -15,18 +18,22 @@ export function readStringField(
return typeof value === "string" ? value : undefined;
}
/** Returns a non-array record or undefined. */
export function asOptionalRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
/** Returns a non-array record or null. */
export function asNullableRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
/** Returns any object-backed record, including arrays, or undefined. */
export function asOptionalObjectRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
/** Returns any object-backed record, including arrays, or null. */
export function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}

View File

@@ -1,7 +1,9 @@
/** Reads a value only when it is already a string, preserving whitespace. */
export function readStringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/** Trims string input and returns null for non-strings or empty strings. */
export function normalizeNullableString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
@@ -10,10 +12,12 @@ export function normalizeNullableString(value: unknown): string | null {
return trimmed ? trimmed : null;
}
/** Trims string input and returns undefined for non-strings or empty strings. */
export function normalizeOptionalString(value: unknown): string | undefined {
return normalizeNullableString(value) ?? undefined;
}
/** Stringifies primitive ids/flags before applying optional string normalization. */
export function normalizeStringifiedOptionalString(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
@@ -24,20 +28,24 @@ export function normalizeStringifiedOptionalString(value: unknown): string | und
return undefined;
}
/** Normalizes an optional array of primitive-ish values into non-empty strings. */
export function normalizeStringifiedEntries(values?: ReadonlyArray<unknown>): string[] {
return (values ?? [])
.map((entry) => normalizeStringifiedOptionalString(entry))
.filter((entry): entry is string => Boolean(entry));
}
/** Lowercases a normalized optional string. */
export function normalizeOptionalLowercaseString(value: unknown): string | undefined {
return normalizeOptionalString(value)?.toLowerCase();
}
/** Lowercases a normalized string or returns an empty string when absent. */
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
return normalizeOptionalLowercaseString(value) ?? "";
}
/** Parses loose boolean/fast-mode flags from strings or booleans. */
export function normalizeFastMode(raw?: string | boolean | null): boolean | undefined {
if (typeof raw === "boolean") {
return raw;
@@ -55,14 +63,17 @@ export function normalizeFastMode(raw?: string | boolean | null): boolean | unde
return undefined;
}
/** Lowercases text while intentionally preserving surrounding whitespace. */
export function lowercasePreservingWhitespace(value: string): string {
return value.toLowerCase();
}
/** Locale-aware lowercase helper that still preserves surrounding whitespace. */
export function localeLowercasePreservingWhitespace(value: string): string {
return value.toLocaleLowerCase();
}
/** Reads a string directly or from an object's `primary` field. */
export function resolvePrimaryStringValue(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
@@ -73,6 +84,7 @@ export function resolvePrimaryStringValue(value: unknown): string | undefined {
return normalizeOptionalString((value as { primary?: unknown }).primary);
}
/** Normalizes thread ids that may be numeric or string-backed. */
export function normalizeOptionalThreadValue(value: unknown): string | number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? Math.trunc(value) : undefined;
@@ -80,11 +92,13 @@ export function normalizeOptionalThreadValue(value: unknown): string | number |
return normalizeOptionalString(value);
}
/** Normalizes a thread/id value and stringifies finite numeric ids. */
export function normalizeOptionalStringifiedId(value: unknown): string | undefined {
const normalized = normalizeOptionalThreadValue(value);
return normalized == null ? undefined : String(normalized);
}
/** Type guard for strings that remain non-empty after trimming. */
export function hasNonEmptyString(value: unknown): value is string {
return normalizeOptionalString(value) !== undefined;
}

View File

@@ -1,41 +1,50 @@
import { normalizeOptionalLowercaseString, normalizeOptionalString } from "./string-coerce.js";
/** Coerces entries to strings, trims them, and drops empty results. */
export function normalizeStringEntries(list?: ReadonlyArray<unknown>) {
return (list ?? []).map((entry) => normalizeOptionalString(String(entry)) ?? "").filter(Boolean);
}
/** Normalizes string entries and lowercases each retained value. */
export function normalizeStringEntriesLower(list?: ReadonlyArray<unknown>) {
return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? "");
}
/** Returns first-seen unique values while preserving insertion order. */
export function uniqueValues<T>(values: Iterable<T>): T[] {
return [...new Set(values)];
}
/** Returns first-seen unique strings while preserving insertion order. */
export function uniqueStrings(values: Iterable<string>): string[] {
return uniqueValues(values);
}
/** Returns unique strings sorted with stable ASCII comparison. */
export function sortUniqueStrings(values: Iterable<string>): string[] {
return uniqueStrings(values).toSorted((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
}
/** Normalizes entries, removes duplicates, and preserves first-seen order. */
export function normalizeUniqueStringEntries(values?: Iterable<unknown>): string[] {
return uniqueStrings(normalizeStringEntries(values ? [...values] : undefined));
}
/** Lowercases normalized entries, removes empties/duplicates, and preserves first-seen order. */
export function normalizeUniqueStringEntriesLower(values?: Iterable<unknown>): string[] {
return uniqueStrings(
normalizeStringEntriesLower(values ? [...values] : undefined).filter(Boolean),
);
}
/** Normalizes entries, removes duplicates, and returns sorted output. */
export function normalizeSortedUniqueStringEntries(values?: Iterable<unknown>): string[] {
return sortUniqueStrings(normalizeUniqueStringEntries(values));
}
/** Normalizes array-backed string lists and rejects non-array input as empty. */
export function normalizeTrimmedStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@@ -46,19 +55,23 @@ export function normalizeTrimmedStringList(value: unknown): string[] {
});
}
/** Normalizes an array-backed string list and removes duplicates. */
export function normalizeUniqueTrimmedStringList(value: unknown): string[] {
return uniqueStrings(normalizeTrimmedStringList(value));
}
/** Normalizes an array-backed string list, removes duplicates, and sorts it. */
export function normalizeSortedUniqueTrimmedStringList(value: unknown): string[] {
return sortUniqueStrings(normalizeTrimmedStringList(value));
}
/** Returns undefined instead of an empty normalized array-backed string list. */
export function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined {
const normalized = normalizeTrimmedStringList(value);
return normalized.length > 0 ? normalized : undefined;
}
/** Returns undefined for non-arrays but preserves an empty array for explicit arrays. */
export function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -66,6 +79,7 @@ export function normalizeArrayBackedTrimmedStringList(value: unknown): string[]
return normalizeTrimmedStringList(value);
}
/** Normalizes either a single string-like value or an array-backed string list. */
export function normalizeSingleOrTrimmedStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return normalizeTrimmedStringList(value);
@@ -74,10 +88,12 @@ export function normalizeSingleOrTrimmedStringList(value: unknown): string[] {
return normalized ? [normalized] : [];
}
/** Normalizes single-or-array string input and removes duplicates. */
export function normalizeUniqueSingleOrTrimmedStringList(value: unknown): string[] {
return uniqueStrings(normalizeSingleOrTrimmedStringList(value));
}
/** Parses either array entries or comma-separated string entries into trimmed values. */
export function normalizeCsvOrLooseStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return normalizeStringEntries(value);
@@ -95,6 +111,7 @@ function normalizeSlugInput(raw?: string | null) {
return (normalizeOptionalLowercaseString(raw) ?? "").normalize("NFC");
}
/** Normalizes user-facing names into permissive lowercase hyphen slugs. */
export function normalizeHyphenSlug(raw?: string | null) {
const trimmed = normalizeSlugInput(raw);
if (!trimmed) {
@@ -105,6 +122,7 @@ export function normalizeHyphenSlug(raw?: string | null) {
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
/** Normalizes @/#-prefixed names into lowercase hyphen slugs without the prefix. */
export function normalizeAtHashSlug(raw?: string | null) {
const trimmed = normalizeSlugInput(raw);
if (!trimmed) {

View File

@@ -1,20 +1,28 @@
/** Legacy marker some models emit after a serialized JSON tool request. */
export const END_TOOL_REQUEST = "[END_TOOL_REQUEST]";
/** Harmony stream marker that introduces the target channel before a tool call. */
export const HARMONY_CHANNEL_MARKER = "<|channel|>";
/** Harmony stream marker that may separate the header from the JSON payload. */
export const HARMONY_MESSAGE_MARKER = "<|message|>";
/** Harmony stream marker that may close a serialized tool-call payload. */
export const HARMONY_CALL_MARKER = "<|call|>";
/** Accepts either a complete literal or a still-streaming prefix of that literal. */
export function matchesLiteralPrefix(text: string, literal: string): boolean {
return literal.startsWith(text) || text.startsWith(literal);
}
/** Tool names in bracket/plain-text repairs intentionally match provider-safe ids only. */
export function isPlainTextToolNameChar(char: string | undefined): boolean {
return Boolean(char && /[A-Za-z0-9_-]/.test(char));
}
/** XML-ish function tags allow namespace punctuation used by some model families. */
export function isXmlishNameChar(char: string | undefined): boolean {
return Boolean(char && /[A-Za-z0-9_.:-]/.test(char));
}
/** Skips spaces and tabs only, preserving line boundaries for grammar decisions. */
export function skipHorizontalWhitespace(text: string, start: number): number {
let index = start;
while (index < text.length && (text[index] === " " || text[index] === "\t")) {
@@ -23,6 +31,7 @@ export function skipHorizontalWhitespace(text: string, start: number): number {
return index;
}
/** Skips all JavaScript whitespace when line structure is no longer meaningful. */
export function skipWhitespace(text: string, start: number): number {
let index = start;
while (index < text.length && /\s/.test(text[index] ?? "")) {
@@ -31,6 +40,7 @@ export function skipWhitespace(text: string, start: number): number {
return index;
}
/** Consumes either Unix or Windows line endings and returns the first offset after them. */
export function consumeLineBreak(text: string, start: number): number | null {
if (text[start] === "\r") {
return text[start + 1] === "\n" ? start + 2 : start + 1;
@@ -41,6 +51,7 @@ export function consumeLineBreak(text: string, start: number): number | null {
return null;
}
/** Finds the exclusive end offset of a balanced JSON object starting at `start`. */
export function findJsonObjectEnd(
text: string,
start: number,
@@ -82,11 +93,13 @@ export function findJsonObjectEnd(
return null;
}
/** Consumes one optional line break after a repaired serialized tool-call fragment. */
export function skipSerializedToolCallTrailingLineBreak(text: string, cursor: number): number {
const afterLineBreak = consumeLineBreak(text, cursor);
return afterLineBreak ?? cursor;
}
/** Accepts the legacy closing markers models append after JSON tool-call payloads. */
export function consumeJsonToolClosingMarker(text: string, cursor: number): number {
let markerStart = cursor;
while (markerStart < text.length && /\s/.test(text[markerStart] ?? "")) {
@@ -106,6 +119,7 @@ export function consumeJsonToolClosingMarker(text: string, cursor: number): numb
return skipSerializedToolCallTrailingLineBreak(text, cursor);
}
/** Finds JSON after bracketed tool syntax such as `[tool_name]\n{...}`. */
export function findBracketedJsonPayloadStart(text: string): number | null {
if (!text.startsWith("[")) {
return null;
@@ -121,6 +135,7 @@ export function findBracketedJsonPayloadStart(text: string): number | null {
return text[cursor] === "{" ? cursor : null;
}
/** Finds JSON after Harmony channel/tool headers while tolerating optional message markers. */
export function findHarmonyJsonPayloadStart(text: string): number | null {
let cursor = 0;
if (text.startsWith(HARMONY_CHANNEL_MARKER)) {
@@ -158,6 +173,7 @@ export function findHarmonyJsonPayloadStart(text: string): number | null {
return text[cursor] === "{" ? cursor : null;
}
/** Case-insensitive marker compare for ASCII protocol tags without locale rules. */
export function startsWithAsciiMarkerIgnoreCase(
text: string,
cursor: number,
@@ -166,6 +182,7 @@ export function startsWithAsciiMarkerIgnoreCase(
return text.slice(cursor, cursor + marker.length).toLowerCase() === marker;
}
/** Case-insensitive marker search for ASCII protocol tags without allocating regexes. */
export function indexOfAsciiMarkerIgnoreCase(text: string, marker: string, start: number): number {
let cursor = start;
while (cursor < text.length) {
@@ -181,6 +198,7 @@ export function indexOfAsciiMarkerIgnoreCase(text: string, marker: string, start
return -1;
}
/** Returns the end offset for a complete XML-ish or bracketed plain-text tool call. */
export function findXmlishToolCallEnd(text: string): number | null {
let cursor: number;
const xmlFunction = /^<function=[A-Za-z0-9_.:-]+>/i.exec(text);

View File

@@ -1,15 +1,18 @@
import { parseStandalonePlainTextToolCallBlocks, type PlainTextToolCallBlock } from "./payload.js";
/** Resolves model-emitted tool names to the exact names allowed by the provider request. */
export type ToolCallRepairNameResolver = (
rawName: string,
allowedToolNames: Set<string>,
) => string | null;
/** Builds a provider-native tool-call block from a repaired plain-text payload. */
export type PromotedPlainTextToolCallBlockFactory = (
block: PlainTextToolCallBlock,
resolvedName: string,
) => Record<string, unknown>;
/** Controls when standalone assistant text may be rewritten as tool-call content. */
export type PlainTextToolCallPromotionOptions = {
allowedStopReasons?: ReadonlySet<unknown>;
allowedToolNames: Set<string>;
@@ -70,6 +73,8 @@ function createTextPartPromotionCandidates(
textParts: readonly string[],
exactText: string,
): string[] {
// Some providers split structural markers across text parts; try repaired and exact joins
// before falling back to newline joins so valid payloads promote without changing content.
const repairedText = joinTextPartsWithStructuralLineBreaks(textParts).trim();
const newlineJoinedText = textParts.join("\n").trim();
return [...new Set([repairedText, exactText, newlineJoinedText].filter(Boolean))];
@@ -111,6 +116,7 @@ function shouldPromoteMessage(options: PlainTextToolCallPromotionOptions): boole
return !options.allowedStopReasons || options.allowedStopReasons.has(messageRecord.stopReason);
}
/** Extracts candidate standalone tool-call text while rejecting mixed unsafe content. */
export function extractStandalonePlainTextToolCallText(params: {
allowOtherNonTextBlocks?: boolean;
allowedStopReasons?: ReadonlySet<unknown>;
@@ -163,6 +169,7 @@ export function extractStandalonePlainTextToolCallText(params: {
return text || undefined;
}
/** Promotes standalone plain-text tool-call messages into provider-native content blocks. */
export function promoteStandalonePlainTextToolCallMessage(
options: PlainTextToolCallPromotionOptions,
): Record<string, unknown> | undefined {