blob: 0f2276fb9c409e28871d3e48404a4e8dfdec04b1 [file]
/**
* Model history projection — build the model-visible message history from a
* RuntimeEvent stream.
*
* Architecture: docs/architecture/llm-compaction-events-log-projection-draft.md
*
* Phase 1 scope: pure, synchronous projection. Replaces the ad-hoc
* StoredMessage filtering in AiSdkBackend.materializePriorMessages with an
* explicit, policy-driven filter over canonical events. The output is a
* neutral `ModelHistoryEntry[]` that callers (ai-sdk backend, flow runner)
* translate into provider-specific message shapes.
*
* Policy (why an event is KEPT):
* - non-partial (final content, not a transient streaming chunk)
* - model-visible content kind: text / thinking / function_call /
* function_response (per runtimeEventHasModelVisibleContent)
* - role is user, model, or tool (system excluded unless opted in)
*
* Policy (why an event is DROPPED):
* - partial === true (streaming chunks superseded by a later final event)
* - error-only content (a tool error surfaced to the model is a
* function_response with isError, which stays visible)
* - actions-only / refs-only events (token usage, permission acks,
* state deltas, end-invocation markers)
* - system-role events by default (UI-only notes; system instructions
* are injected fresh by the runner, not replayed from history)
*
* Thinking and tool events are opt-in/opt-out so callers can match the
* replay contract of their provider (V0.1 text-only replay cannot use
* them; Anthropic replay can re-use signed thinking, etc.).
*
* The canonical event contract is imported from the `@maka/core` public API.
*/
import {
isPartialRuntimeEvent,
isTerminalRuntimeEvent,
runtimeEventHasModelVisibleContent,
type RuntimeEvent,
type RuntimeEventTextContent,
type RuntimeEventContent,
type RuntimeEventRole,
} from '@maka/core';
import {
formatAttachmentResourceRef,
normalizeShellToolResultContent,
normalizeToolResultContentForRead,
} from '@maka/core';
import type { AttachmentRef, QuoteRef } from '@maka/core';
import type { ModelMessage, UserContent, UserModelMessage } from './model-protocol.js';
import { projectBashToolResultForModel } from './bash-model-output.js';
import { projectFileWriteToolResultForModel } from './file-tool-model-output.js';
export const PROVIDER_REPLAY_PROJECTION_VERSION = 1;
// ============================================================================
// Output type
// ============================================================================
/**
* One model-facing history entry. `content` is the canonical
* RuntimeEventContent (discriminated by `kind`); `role` is the
* model-history lane the entry plays for the next model call.
*/
export interface ModelHistoryEntry {
role: RuntimeEventRole;
content: RuntimeEventContent;
ts: number;
eventId: string;
}
export interface TextModelMessage {
role: 'user' | 'assistant' | 'system';
content: string;
/**
* Structured steering identity (see steeringProviderOptions): a steering
* message keeps its ledger event id even in the text-only projection, so
* id-based dedupe against the live injection set holds on every base.
*/
providerOptions?: NonNullable<ModelMessage['providerOptions']>;
}
export type RuntimeEventReplayFallbackGate =
| 'runtime_replay_text_only'
| 'runtime_replay_provider_native'
| 'runtime_replay_unsupported_semantics';
export type RuntimeEventReplayDiagnosticCode =
| 'partial_skipped'
| 'unsupported_role'
| 'unsupported_content'
| 'system_runtime_fact_diagnostic_only'
| 'terminal_fact_diagnostic_only'
| 'error_content_diagnostic_only'
| 'empty_text_skipped'
| 'model_hidden_skipped'
| 'signed_thinking_in_tool_turn_skipped'
| 'unmatched_tool_result'
| 'unmatched_tool_call'
| 'tool_id_mismatch';
export interface RuntimeEventReplayDiagnostic {
code: RuntimeEventReplayDiagnosticCode;
message: string;
eventId?: string;
turnId?: string;
detail?: Record<string, unknown>;
}
export type RuntimeEventReplaySemanticKind = 'text' | 'thinking' | 'tool_call' | 'tool_result';
export type RuntimeEventModelReplayItem =
| {
kind: 'text';
role: 'user' | 'assistant' | 'system';
content: string;
providerOptions?: NonNullable<ModelMessage['providerOptions']>;
/** Original attachments (if any) so replay can render image parts. */
attachments?: AttachmentRef[];
/** Assistant step id (model-role text only); groups a step's parts. */
stepId?: string;
/**
* Set when this user text replays a steered mid-turn message. `content`
* is already envelope-wrapped; materializers MUST carry the structured
* steering marker (see steeringModelMessage) onto the ModelMessage so
* dedupe works on identity, never on text.
*/
steering?: { eventId: string };
eventId: string;
ts: number;
}
| {
kind: 'thinking';
text: string;
signature?: string;
providerOptions?: NonNullable<ModelMessage['providerOptions']>;
/** Assistant step id; pairs this reasoning with its step's tool calls. */
stepId?: string;
eventId: string;
ts: number;
}
| {
kind: 'tool_call';
toolCallId: string;
toolName: string;
input: unknown;
providerOptions?: NonNullable<ModelMessage['providerOptions']>;
providerExecuted?: boolean;
/** Assistant step id (from tool_start); groups the call with its step. */
stepId?: string;
eventId: string;
ts: number;
}
| {
kind: 'tool_result';
toolCallId: string;
toolName: string;
output: unknown;
isError: boolean;
providerExecuted?: boolean;
eventId: string;
ts: number;
};
export interface RuntimeEventModelReplayPlan {
items: RuntimeEventModelReplayItem[];
textMessages: TextModelMessage[];
semanticKinds: RuntimeEventReplaySemanticKind[];
diagnostics: RuntimeEventReplayDiagnostic[];
hasProviderNativeSemantics: boolean;
}
// ============================================================================
// Options
// ============================================================================
export interface BuildModelHistoryOptions {
/**
* Include function_call / function_response entries. Default `true`.
* Set `false` for providers whose replay format cannot represent prior
* tool turns (the V0.1 ai-sdk text-only replay path).
*/
includeToolEvents?: boolean;
/**
* Include system-role events (system notes / instructions). Default
* `false`. System instructions are normally injected fresh by the
* runner each turn, not replayed from durable history.
*/
includeSystemEvents?: boolean;
/**
* Include thinking-content entries. Default `false`. Thinking replay
* is provider-specific (Anthropic signed signatures); callers that
* need it opt in and reattach signatures from the event content.
*/
includeThinking?: boolean;
}
// ============================================================================
// Projection
// ============================================================================
/**
* Build the model-visible history from a RuntimeEvent stream.
*
* Events SHOULD be supplied in causal order; the projection preserves
* input order. Partial events are always excluded — callers MUST NOT
* replay transient streaming chunks into the next model call.
*
* The default options match the durable-history policy: user/model text
* and tool calls/responses are kept; thinking, system notes, token usage,
* permission acks, and diagnostics are dropped.
*/
export function buildModelHistoryFromRuntimeEvents(
events: readonly RuntimeEvent[],
options: BuildModelHistoryOptions = {},
): ModelHistoryEntry[] {
const includeToolEvents = options.includeToolEvents ?? true;
const includeSystemEvents = options.includeSystemEvents ?? false;
const includeThinking = options.includeThinking ?? false;
const out: ModelHistoryEntry[] = [];
for (const event of events) {
// 1. Never replay transient streaming chunks.
if (isPartialRuntimeEvent(event)) continue;
// 2. Only model-visible content kinds (text/thinking/function_*).
if (!runtimeEventHasModelVisibleContent(event)) continue;
const content = event.content;
if (!content) continue;
// 3. System-role events are UI notes by default; opt in for
// model-injected system instructions.
if (event.role === 'system' && !includeSystemEvents) continue;
// 4. Thinking replay is provider-specific; opt in.
if (content.kind === 'thinking' && !includeThinking) continue;
// 5. Tool function_call / function_response; opt out for text-only.
if (
!includeToolEvents &&
(content.kind === 'function_call' || content.kind === 'function_response')
) {
continue;
}
out.push({
role: event.role,
content,
ts: event.ts,
eventId: event.id,
});
}
return out;
}
export interface RuntimeEventTextMessageOptions {
includeSystemEvents?: boolean;
}
export interface BuildRuntimeEventModelReplayPlanOptions {
includeSystemEvents?: boolean;
/**
* Turn IDs known — from the FULL prior ledger — to contain tool activity.
*
* The signed-thinking-in-tool-turn skip (see `turnsWithToolActivity` in the
* planner) is a whole-history invariant, but `events` here may be a
* budget-pruned / history-search slice that dropped a turn's
* tool_call/tool_response while keeping its (query-matched) signed thinking.
* Scanning only the slice would then miss the tool activity and wrongly
* replay that thinking provider-native. Callers that slice MUST pass the
* full-ledger tool-turn ids (see `collectToolActivityTurnIds`); the planner
* unions them with tool activity found in `events`.
*/
toolActivityTurnIds?: ReadonlySet<string>;
}
/**
* Collect the turn ids that contain tool activity (function_call /
* function_response) from a RuntimeEvent ledger. Pass the result as
* `toolActivityTurnIds` when the events handed to the replay planner are a
* slice of this ledger.
*/
export function collectToolActivityTurnIds(events: readonly RuntimeEvent[]): Set<string> {
const ids = new Set<string>();
for (const event of events) {
if (isPartialRuntimeEvent(event)) continue;
if (event.modelVisibility === 'hidden') continue;
const kind = event.content?.kind;
if ((kind === 'function_call' || kind === 'function_response') && event.turnId) {
ids.add(event.turnId);
}
}
return ids;
}
function assistantReplayStepId(event: RuntimeEvent): string | undefined {
return event.refs?.providerEventId ?? event.refs?.storedMessageId;
}
export function buildRuntimeEventModelReplayPlan(
events: readonly RuntimeEvent[],
options: BuildRuntimeEventModelReplayPlanOptions = {},
): RuntimeEventModelReplayPlan {
const includeSystemEvents = options.includeSystemEvents ?? false;
const items: RuntimeEventModelReplayItem[] = [];
const diagnostics: RuntimeEventReplayDiagnostic[] = [];
const callsById = new Map<
string,
{
name: string;
eventId: string;
item: Extract<RuntimeEventModelReplayItem, { kind: 'tool_call' }>;
}
>();
// Signed thinking in a tool turn is only replayable when the turn's tool calls
// carry a step id (RuntimeEventRefs.stepId, stamped from tool_start): the
// materializer then merges the step's reasoning + tool calls into one provider
// assistant message. Legacy per-turn history has no step id — its single
// end-of-turn reasoning lands after the tool events and cannot be reattached
// to the tool-use assistant message (Anthropic 400), so it is still skipped.
//
// Classify each tool turn: paired (all its function_call events carry a
// stepId) vs unpaired (any lacks one). Union caller-supplied whole-ledger
// tool-turn ids that this (possibly sliced) `events` view cannot confirm as
// paired, so a sliced-away tool turn degrades safely to the legacy skip.
//
// The judgment is deliberately TURN-granular, not per-step: one turn is
// written by one backend build, so old (no stepId) and new (stepId) tool
// calls cannot mix within a turn — per-step classification would add
// complexity for a state that cannot exist. And pairedToolTurnIds is not
// dead state: it is what lets a caller-supplied tool-turn id (from the FULL
// ledger) stay replayable when this sliced view can prove the turn's calls
// are step-paired — without it every sliced tool turn would degrade.
const pairedToolTurnIds = new Set<string>();
const unpairedToolTurnIds = new Set<string>();
for (const event of events) {
if (isPartialRuntimeEvent(event)) continue;
if (event.modelVisibility === 'hidden') continue;
if (event.content?.kind === 'function_call' && event.turnId) {
if (event.refs?.stepId) pairedToolTurnIds.add(event.turnId);
else unpairedToolTurnIds.add(event.turnId);
}
}
for (const id of options.toolActivityTurnIds ?? []) {
if (!pairedToolTurnIds.has(id)) unpairedToolTurnIds.add(id);
}
for (const event of events) {
if (isPartialRuntimeEvent(event)) {
diagnostics.push(
diagnostic(event, 'partial_skipped', 'partial RuntimeEvent skipped for model replay'),
);
continue;
}
if (event.modelVisibility === 'hidden') {
diagnostics.push(
diagnostic(
event,
'model_hidden_skipped',
'explicitly model-hidden RuntimeEvent skipped for model replay',
),
);
continue;
}
if (isTerminalRuntimeEvent(event)) {
diagnostics.push(
diagnostic(
event,
'terminal_fact_diagnostic_only',
'terminal RuntimeEvent status is diagnostic-only for model replay',
{ status: event.status },
),
);
}
if (!event.content) {
if (event.actions && !isTerminalRuntimeEvent(event)) {
diagnostics.push(
diagnostic(
event,
'system_runtime_fact_diagnostic_only',
'RuntimeEvent actions are diagnostic-only for model replay',
{ actionKeys: Object.keys(event.actions) },
),
);
}
continue;
}
if (!runtimeEventHasModelVisibleContent(event)) {
// A model-role empty text event is the step closer of a thinking-only /
// tool-only step (the backend emits text_complete with '' so the
// read-model gets an assistant row for the step's reasoning). It carries
// nothing to replay but is NOT unsupported history — flagging it
// unsupported_content would block provider-native replay of the whole
// ledger (hasBlockingReplayDiagnostics). Skip it benignly; the
// materializer pairs the step's parked reasoning with its tool calls by
// stepId at flush time, so no text closer is needed.
if (event.content.kind === 'text' && event.role === 'model') {
diagnostics.push(
diagnostic(
event,
'empty_text_skipped',
'empty model text RuntimeEvent (thinking/tool-only step closer) skipped for model replay',
),
);
continue;
}
// Error content is a run/turn failure fact (a flow error event or a
// terminal recovery commit), not model conversation. It must stay
// diagnostic-only: `unsupported_content` is a BLOCKING diagnostic (see
// hasBlockingReplayDiagnostics), and one persisted failure would
// otherwise degrade every later turn of the session to the
// stored-message projection.
if (event.content.kind === 'error') {
diagnostics.push(
diagnostic(
event,
'error_content_diagnostic_only',
'error RuntimeEvent content is diagnostic-only for model replay',
),
);
continue;
}
diagnostics.push(
diagnostic(
event,
'unsupported_content',
'RuntimeEvent content kind is not model-replayable',
{ kind: event.content.kind },
),
);
continue;
}
if (event.role === 'system' && !includeSystemEvents) {
diagnostics.push(
diagnostic(
event,
'system_runtime_fact_diagnostic_only',
'system RuntimeEvent content is diagnostic-only unless system replay is enabled',
),
);
continue;
}
switch (event.content.kind) {
case 'text': {
const role = modelTextRole(event.role);
if (!role) {
diagnostics.push(
diagnostic(
event,
'unsupported_role',
'text RuntimeEvent role is not model-replayable',
{
role: event.role,
},
),
);
continue;
}
const steeringReplay = event.content.steering === true && role === 'user';
const assistantStepId = role === 'assistant' ? assistantReplayStepId(event) : undefined;
items.push({
kind: 'text',
role,
// A steered user event replays in its canonical provider form (the
// envelope); the raw text is a UI/transcript projection only.
content: steeringReplay
? buildSteeringEnvelope(formatTextWithInlineRefs(event.content))
: formatTextWithInlineRefs(event.content),
...(steeringReplay ? { steering: { eventId: event.id } } : {}),
...(event.content.attachments ? { attachments: event.content.attachments } : {}),
...(event.content.providerOptions !== undefined
? {
providerOptions: event.content.providerOptions as NonNullable<
ModelMessage['providerOptions']
>,
}
: {}),
// Live events carry providerEventId; missing-ledger recovery carries
// the same assistant message identity as storedMessageId.
...(assistantStepId ? { stepId: assistantStepId } : {}),
eventId: event.id,
ts: event.ts,
});
break;
}
case 'thinking': {
if (event.role !== 'model') {
diagnostics.push(
diagnostic(event, 'unsupported_role', 'thinking RuntimeEvent must use model role', {
role: event.role,
}),
);
continue;
}
if (event.content.signature && event.turnId && unpairedToolTurnIds.has(event.turnId)) {
// Its turn has tool calls with no step id to pair against (legacy
// per-turn history) — the end-of-turn reasoning cannot be
// reattached to the tool-use assistant message. Keep it in the
// read-model for the UI; skip it from replay without downgrading the
// whole history. Per-step history (paired tool calls) is not skipped:
// the materializer merges each step's reasoning with its tool calls.
diagnostics.push(
diagnostic(
event,
'signed_thinking_in_tool_turn_skipped',
'signed thinking RuntimeEvent skipped for model replay: its turn calls tools with no step id to pair the reasoning to a tool-use assistant message',
),
);
continue;
}
const thinkingStepId = assistantReplayStepId(event);
items.push({
kind: 'thinking',
text: event.content.text,
...(event.content.signature ? { signature: event.content.signature } : {}),
...(event.content.providerOptions !== undefined
? {
providerOptions: event.content.providerOptions as NonNullable<
ModelMessage['providerOptions']
>,
}
: {}),
...(thinkingStepId ? { stepId: thinkingStepId } : {}),
eventId: event.id,
ts: event.ts,
});
break;
}
case 'function_call': {
if (event.role !== 'model') {
diagnostics.push(
diagnostic(
event,
'unsupported_role',
'function_call RuntimeEvent must use model role',
{
role: event.role,
},
),
);
continue;
}
const item: Extract<RuntimeEventModelReplayItem, { kind: 'tool_call' }> = {
kind: 'tool_call',
toolCallId: event.content.id,
toolName: event.content.name,
input: event.content.args,
...(event.content.providerOptions !== undefined
? {
providerOptions: event.content.providerOptions as NonNullable<
ModelMessage['providerOptions']
>,
}
: {}),
...(event.content.providerExecuted !== undefined
? { providerExecuted: event.content.providerExecuted }
: {}),
...(event.refs?.stepId ? { stepId: event.refs.stepId } : {}),
eventId: event.id,
ts: event.ts,
};
callsById.set(event.content.id, {
name: event.content.name,
eventId: event.id,
item,
});
items.push(item);
break;
}
case 'function_response': {
if (event.role !== 'tool') {
diagnostics.push(
diagnostic(
event,
'unsupported_role',
'function_response RuntimeEvent must use tool role',
{
role: event.role,
},
),
);
continue;
}
const shellResult = normalizeShellToolResultContent(event.content.result);
let invalidResultMessage: string | undefined;
let normalizedResult: unknown =
event.content.providerExecuted && event.content.providerOutput !== undefined
? event.content.providerOutput
: event.content.result;
if (shellResult.state === 'invalid') {
invalidResultMessage = 'function_response contains an invalid shell tool result';
} else if (
shellResult.state === 'valid' ||
isLegacySubagentToolResultCandidate(event.content.name, event.content.result)
) {
try {
normalizedResult = normalizeToolResultContentForRead(event.content.result);
} catch {
invalidResultMessage =
'function_response contains an invalid legacy subagent tool result';
}
}
if (!invalidResultMessage && event.content.name === 'Bash') {
normalizedResult = projectBashToolResultForModel(normalizedResult);
} else if (!invalidResultMessage) {
normalizedResult = projectFileWriteToolResultForModel(
event.content.name,
normalizedResult,
);
}
if (invalidResultMessage) {
const call = callsById.get(event.content.id);
if (call) {
const callIndex = items.indexOf(call.item);
if (callIndex >= 0) items.splice(callIndex, 1);
callsById.delete(event.content.id);
}
diagnostics.push(diagnostic(event, 'unsupported_content', invalidResultMessage));
continue;
}
const call = callsById.get(event.content.id);
if (!call) {
diagnostics.push(
diagnostic(
event,
'unmatched_tool_result',
'function_response has no prior matching function_call',
{
toolCallId: event.content.id,
},
),
);
} else if (call.name !== event.content.name) {
diagnostics.push(
diagnostic(
event,
'tool_id_mismatch',
'function_response name differs from matching function_call',
{
toolCallId: event.content.id,
callName: call.name,
resultName: event.content.name,
callEventId: call.eventId,
},
),
);
}
items.push({
kind: 'tool_result',
toolCallId: event.content.id,
toolName: event.content.name,
output: normalizedResult,
isError: event.content.isError === true,
...(event.content.providerExecuted !== undefined
? { providerExecuted: event.content.providerExecuted }
: {}),
eventId: event.id,
ts: event.ts,
});
callsById.delete(event.content.id);
break;
}
default:
diagnostics.push(
diagnostic(
event,
'unsupported_content',
'RuntimeEvent content kind is not model-replayable',
{ kind: (event.content as RuntimeEventContent).kind },
),
);
break;
}
}
// A call whose result never landed (the app died during tool execution;
// recovery appends the terminal error but cannot invent the result) must not
// replay: a tool_use with no tool_result is a provider 400. Drop it — the
// deliberately non-blocking mirror of unmatched_tool_result — so consumers
// that read `items` directly (materializer, compact summarizer) stay valid.
for (const [toolCallId, call] of callsById) {
const index = items.indexOf(call.item);
if (index >= 0) items.splice(index, 1);
diagnostics.push({
code: 'unmatched_tool_call',
message: 'function_call has no matching function_response; dropped from model replay',
eventId: call.eventId,
detail: { toolCallId },
});
}
const textMessages = items
.filter(
(item): item is Extract<RuntimeEventModelReplayItem, { kind: 'text' }> =>
item.kind === 'text',
)
.map((item) =>
item.steering
? {
role: item.role,
content: item.content,
providerOptions: steeringProviderOptions(item.steering.eventId),
}
: { role: item.role, content: item.content },
);
const semanticKinds = [...new Set(items.map((item) => item.kind))];
return {
items,
textMessages,
semanticKinds,
diagnostics,
hasProviderNativeSemantics:
semanticKinds.includes('thinking') ||
semanticKinds.includes('tool_call') ||
semanticKinds.includes('tool_result'),
};
}
/**
* Convert projected RuntimeEvent history into the current AI SDK text-only
* message shape. Tool/function and thinking entries are intentionally skipped.
*/
export function buildTextModelMessagesFromRuntimeEvents(
events: readonly RuntimeEvent[],
options: RuntimeEventTextMessageOptions = {},
): TextModelMessage[] {
const history = buildModelHistoryFromRuntimeEvents(events, {
includeToolEvents: false,
includeSystemEvents: options.includeSystemEvents ?? false,
includeThinking: false,
});
const out: TextModelMessage[] = [];
for (const entry of history) {
if (entry.content.kind !== 'text') continue;
if (entry.role === 'tool') continue;
if (entry.role === 'system' && !options.includeSystemEvents) continue;
const role =
entry.role === 'model'
? 'assistant'
: entry.role === 'user'
? 'user'
: entry.role === 'system'
? 'system'
: undefined;
if (!role) continue;
const steering = entry.content.steering === true && role === 'user';
out.push({
role,
content: steering
? buildSteeringEnvelope(formatTextWithInlineRefs(entry.content))
: formatTextWithInlineRefs(entry.content),
// Keep the structured identity even in the text-only shape: dedupe
// against the live injection set works by ledger event id.
...(steering ? { providerOptions: steeringProviderOptions(entry.eventId) } : {}),
});
}
return out;
}
function modelTextRole(role: RuntimeEventRole): TextModelMessage['role'] | undefined {
switch (role) {
case 'user':
return 'user';
case 'model':
return 'assistant';
case 'system':
return 'system';
default:
return undefined;
}
}
function diagnostic(
event: RuntimeEvent,
code: RuntimeEventReplayDiagnosticCode,
message: string,
detail?: Record<string, unknown>,
): RuntimeEventReplayDiagnostic {
return {
code,
message,
eventId: event.id,
turnId: event.turnId,
...(detail ? { detail } : {}),
};
}
/**
* Wrap a steered user message so the model does not confuse a mid-turn
* interjection with a tool result (mirrors the grok-build steering envelope).
* This is THE canonical provider projection of a steering event: replay
* plans and the live injection both emit this exact form, so a steering
* message has one identity in every provider request — the envelope text —
* and dedupe never has to guess against bare user text.
*/
export function buildSteeringEnvelope(text: string): string {
return `The user sent a message while you were working:\n<user_query>\n${text}\n</user_query>`;
}
/**
* Structured steering identity on a provider message. Carried in a
* Maka-namespaced providerOptions entry — provider adapters only read their
* own namespace, so it never reaches a provider request body — keyed by the
* ledger event id. Identity, not text: user text can equal the envelope
* verbatim and must never be mistaken for (or cancel) a real steering
* message.
*/
const STEERING_PROVIDER_OPTIONS_NAMESPACE = 'maka';
/** The structured steering marker for a provider message. */
export function steeringProviderOptions(
eventId: string,
): NonNullable<ModelMessage['providerOptions']> {
return {
[STEERING_PROVIDER_OPTIONS_NAMESPACE]: { steeringEventId: eventId },
};
}
/** Add structured steering identity to already-canonical provider content. */
export function steeringModelMessage(
eventId: string,
providerContent: UserContent,
): UserModelMessage {
return {
role: 'user',
content: providerContent,
providerOptions: steeringProviderOptions(eventId),
};
}
/** The ledger event id of a steering-marked message, else undefined. */
export function steeringEventIdOf(message: ModelMessage): string | undefined {
if (message.role !== 'user') return undefined;
const namespace = (message.providerOptions as Record<string, unknown> | undefined)?.[
STEERING_PROVIDER_OPTIONS_NAMESPACE
];
if (!namespace || typeof namespace !== 'object') return undefined;
const eventId = (namespace as Record<string, unknown>).steeringEventId;
return typeof eventId === 'string' ? eventId : undefined;
}
/**
* The injected steering messages whose ledger event id is not already present
* in the request base. Ledger-derived bases (mid-turn capacity replacement,
* overflow rebuild, degraded-projection fallbacks) carry the same structured
* marker, so membership is exact — by id, never by text.
*/
export function steeringMessagesMissingFromBase(
injected: readonly ModelMessage[],
baseMessages: readonly ModelMessage[],
): ModelMessage[] {
if (injected.length === 0) return [];
const present = new Set<string>();
for (const message of baseMessages) {
const eventId = steeringEventIdOf(message);
if (eventId !== undefined) present.add(eventId);
}
return injected.filter((message) => {
const eventId = steeringEventIdOf(message);
return eventId === undefined || !present.has(eventId);
});
}
/**
* The messages with THIS TURN'S injected steering removed (transport-retry
* base). Only the injected set may be stripped: the retry attempt's own
* request projection re-appends exactly that accumulator, while a historical,
* ledger-replayed steering message (same marker, different event id) is part
* of the base that nothing re-appends — stripping it would erase it from
* every post-retry request.
*/
export function stripSteeringMessages(
messages: readonly ModelMessage[],
injected: readonly ModelMessage[],
): ModelMessage[] {
const ids = new Set<string>();
for (const message of injected) {
const eventId = steeringEventIdOf(message);
if (eventId !== undefined) ids.add(eventId);
}
if (ids.size === 0) return [...messages];
return messages.filter((message) => {
const eventId = steeringEventIdOf(message);
return eventId === undefined || !ids.has(eventId);
});
}
const LEGACY_SUBAGENT_RESULT_TOOL_NAMES = new Set(['Task', 'agent_spawn']);
function isLegacySubagentToolResultCandidate(toolName: string, value: unknown): boolean {
if (!LEGACY_SUBAGENT_RESULT_TOOL_NAMES.has(toolName)) return false;
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
return candidate.kind === 'subagent' && candidate.status === 'waiting_permission';
}
/**
* Fold a user turn's inline references into its model-facing text. Attachments
* render as a name/type block with exact Read instructions when the reference
* is safely addressable (their bytes, when the model can see them, are appended
* separately as image parts); quotes carry their excerpt inline, since a quote
* has no backing storage — the text IS the reference. Presentation layers
* render both as chips and never show this folded form.
*/
export function formatTextWithInlineRefs(
textOrContent: string | RuntimeEventTextContent,
refs?: { attachments?: AttachmentRef[]; quotes?: QuoteRef[] },
): string {
const fromContent = typeof textOrContent !== 'string';
const text = fromContent ? textOrContent.text : textOrContent;
const attachments = fromContent ? textOrContent.attachments : refs?.attachments;
const quotes = fromContent ? textOrContent.quotes : refs?.quotes;
const blocks: string[] = [];
if (quotes && quotes.length > 0) blocks.push(formatQuoteRefs(quotes));
if (attachments && attachments.length > 0) blocks.push(formatAttachmentRefs(attachments));
if (blocks.length === 0) return text;
return [text, ...blocks].join('\n\n');
}
function formatAttachmentRefs(attachments: readonly AttachmentRef[]): string {
return attachments
.map((attachment) => {
const resourceRef = formatAttachmentResourceRef(attachment.ref);
const readArgument = resourceRef
? { ref: resourceRef }
: attachment.ref.kind === 'workspace_file'
? { path: attachment.ref.relativePath }
: attachment.ref.kind === 'external_file'
? { path: attachment.ref.absolutePath }
: null;
const access = readArgument
? resourceRef
? [
`Read argument: ${JSON.stringify(readArgument)}`,
'This is a Session resource, not a workspace file. Use the ref above; never use the display name as a path.',
].join('\n')
: `Read argument: ${JSON.stringify(readArgument)}`
: 'The attachment content is unavailable to Read.';
return [
'<attachment>',
access,
`name: ${JSON.stringify(attachment.name)}`,
`mime_type: ${JSON.stringify(attachment.mimeType)}`,
'</attachment>',
].join('\n');
})
.join('\n');
}
function formatQuoteRefs(quotes: readonly QuoteRef[]): string {
return quotes
.map((q) => {
const label = q.label === undefined ? '' : ` label="${q.label.replace(/"/g, "'")}"`;
return `<quoted_excerpt${label}>\n${q.text}\n</quoted_excerpt>`;
})
.join('\n');
}