Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
38 kB · 1093 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094/** * Custom message types and transformers for the coding agent. * * Extends the base AgentMessage type with coding-agent specific message types, * and provides a transformer to convert them to LLM-compatible messages. */
import type { AgentMessage, AgentToolCall } from "@earendil-works/pi-agent-core";import type { ImageContent, Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";import type { AgentCronJob } from "./cron-jobs.js";import { type AppliedRefinementEdit, formatRefinementNoticeBody, type HarnessScope, type RefinementResult,} from "./refinement/refinement.js";import { isSessionSlashCommandName, parseSessionSlashCommand, type SessionSlashCommand } from "./slash-commands.js";import { formatToolDuration } from "./tools/render-utils.js";
export const COMPACTION_SUMMARY_PREFIX = `[compaction-summary]
The conversation history before this point was compacted into the following summary:
<summary>`;
export const COMPACTION_SUMMARY_SUFFIX = `</summary>
A compaction mid-task is routine: continue from this summary without an apology, without restating the plan, and without re-deriving what it already carries. Context size is not a budget you manage, and it is never a reason to shorten the work.`;
export const BRANCH_SUMMARY_PREFIX = `[branch-summary]
The following is a summary of a branch that this conversation came back from:
<summary>`;
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
export const HEARTBEAT_PROMPT_CUSTOM_TYPE = "heartbeat_prompt";export const HEARTBEAT_PROMPT_PREVIEW_LABEL = "Heartbeat prompt";export const IPYTHON_STATE_RESTORED_CUSTOM_TYPE = "ipython_state_restored";export const SESSION_SLASH_COMMAND_CUSTOM_TYPE = "session_slash_command";export const SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE = "session_slash_command_result";export const COMPACTION_OUTCOME_CUSTOM_TYPE = "compaction_outcome";export const MCP_CONNECTION_OUTCOME_CUSTOM_TYPE = "mcp_connection_outcome";export const REFINEMENT_OUTCOME_CUSTOM_TYPE = "refinement_outcome";export const REFINEMENT_NOTICE_CUSTOM_TYPE = "refinement_notice";export const HARNESS_DIGEST_CUSTOM_TYPE = "harness_digest";export const RLM_CHILD_FAILURE_CUSTOM_TYPE = "rlm_child_failure";export const RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE = "rlm_child_terminal_notice";export const ASYNC_BASH_COMPLETION_CUSTOM_TYPE = "async_bash_completion";export const ASYNC_BASH_COMPLETION_PREVIEW_LABEL = "Shell message received";export const MANUAL_CONTINUATION_CUSTOM_TYPE = "manual_continuation";export const MANUAL_CONTINUATION_PROMPT = `<system-notice>Continue the current task from where you stopped. Resume the most recent unfinished step. Do not pause to summarize progress, reconfirm the plan, or ask whether to proceed.</system-notice>`;
/** * Names and other metadata interpolated into a `[<kind> ...]` header line must not * carry the characters that delimit the header itself (brackets, newlines, commas, * or the relationship separator ":"). */export function sanitizeMessageHeaderValue(value: string): string { return value.replace(/[\s,:[\]]+/g, " ").trim();}
export interface SessionSlashCommandDetails { command: SessionSlashCommand; commandEntryId?: string;}
export interface SessionSlashCommandResultDetails { command: SessionSlashCommand; success: boolean; severity: "info" | "warning" | "error"; error?: string; commandEntryId?: string;}
export interface SessionSlashCommandMessage extends CustomMessage<SessionSlashCommandDetails> { customType: typeof SESSION_SLASH_COMMAND_CUSTOM_TYPE; content: string; details: SessionSlashCommandDetails;}
export interface SessionSlashCommandResultMessage extends CustomMessage<SessionSlashCommandResultDetails> { customType: typeof SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE; content: string; details: SessionSlashCommandResultDetails;}
export type CompactionOutcomeReason = "threshold" | "overflow" | "requested";export type CompactionOutcome = "skipped" | "cancelled" | "failed";
export interface CompactionOutcomeDetails { reason: CompactionOutcomeReason; outcome: CompactionOutcome;}
export interface CompactionOutcomeMessage extends CustomMessage<CompactionOutcomeDetails> { customType: typeof COMPACTION_OUTCOME_CUSTOM_TYPE; content: string; details: CompactionOutcomeDetails;}
export interface RefinementOutcomeDetails { refinementId: string; summary: string; scope: HarnessScope; rollbackOf?: string; edits: AppliedRefinementEdit[];}
export interface RefinementOutcomeMessage extends CustomMessage<RefinementOutcomeDetails> { customType: typeof REFINEMENT_OUTCOME_CUSTOM_TYPE; content: string; details: RefinementOutcomeDetails;}
/** How a refinement was initiated: reviewer-triggered auto-refine, the /refine slash command, or the model's own refine.run(). */export type RefinementSource = "auto" | "user" | "self";
export interface RefinementNoticeDetails extends RefinementOutcomeDetails { source: RefinementSource;}
export interface RefinementNoticeMessage extends CustomMessage<RefinementNoticeDetails> { customType: typeof REFINEMENT_NOTICE_CUSTOM_TYPE; content: string; details: RefinementNoticeDetails;}
/** How an MCP connection attempt finished: verified handshake, saved but unverified, or unrecorded result. */export type McpConnectionVerificationState = "connected" | "unverified" | "unsaved";
/** Which flow produced the outcome: a completed login, an inline token paste, or a pending-account retry verification. */export type McpConnectionOutcomeSource = "login" | "paste" | "retry";
/** Whether the saved connection change is live in the current session. */export type McpConnectionActivationState = "active" | "inactive";
export interface McpConnectionOutcomeDetails { /** Absent on entries written before disconnect outcomes existed; both mean "connect". */ kind?: "connect"; /** Display label the outcome line names, e.g. "Linear" or "Acme (acme-2)". */ label: string; source: McpConnectionOutcomeSource; verification: McpConnectionVerificationState; /** Tools verified by the MCP handshake; present only when verification is "connected". */ toolCount?: number; /** Human-readable reason the handshake did not complete; present only when verification is "unverified". */ issue?: string; /** Probe error category behind `issue` (e.g. "http-unauthorized"), so the renderer can name the fix. */ issueCategory?: string; /** Account connection id when the outcome is account-scoped. */ connectionId?: string; /** True when the login added a new account to a multi-account service. */ addedAccount?: boolean; /** Whether the saved change is live in this session; absent until activation resolves. */ activation?: McpConnectionActivationState;}
/** * How a disconnect finished. Only outcomes that actually removed the stored * credential are representable: a failed or partial removal never gets a * durable "Disconnected" entry. */export type McpDisconnectionState = "removed" | "credential-only" | "preserved";
export interface McpDisconnectionOutcomeDetails { kind: "disconnect"; /** Display label the outcome line names, e.g. "Granola" or "account acme-2". */ label: string; removal: McpDisconnectionState; /** Account connection id when the outcome is account-scoped. */ connectionId?: string; /** Whether the saved change is live in this session; absent until activation resolves. */ activation?: McpConnectionActivationState;}
/** Either side of the MCP connection lifecycle, carried by one durable entry type. */export type McpOutcomeDetails = McpConnectionOutcomeDetails | McpDisconnectionOutcomeDetails;
/** Connect details predate the disconnect entry, so a missing `kind` means "connect". */export function isMcpDisconnectionOutcome(details: McpOutcomeDetails): details is McpDisconnectionOutcomeDetails { return (details as Partial<McpDisconnectionOutcomeDetails>).kind === "disconnect";}
export interface McpConnectionOutcomeMessage extends CustomMessage<McpOutcomeDetails> { customType: typeof MCP_CONNECTION_OUTCOME_CUSTOM_TYPE; content: string; details: McpOutcomeDetails;}
export interface HarnessDigestDetails { digest: string;}
export const HARNESS_DIGEST_PREFIX = `[harness-digest]
The persistent memories produced across this session so far:
<harness_state>`;
export const HARNESS_DIGEST_SUFFIX = `</harness_state>`;
export function createHarnessDigestMessage( digest: string, timestamp = Date.now(),): CustomMessage<HarnessDigestDetails> { return { role: "custom", customType: HARNESS_DIGEST_CUSTOM_TYPE, content: HARNESS_DIGEST_PREFIX + digest + HARNESS_DIGEST_SUFFIX, display: false, details: { digest }, timestamp, };}
export function createManualContinuationMessage(timestamp = Date.now()): CustomMessage { return { role: "custom", customType: MANUAL_CONTINUATION_CUSTOM_TYPE, content: MANUAL_CONTINUATION_PROMPT, display: false, timestamp, };}
export const INTERRUPTED_TOOL_CALL_PREFIX = "<interrupted_tool_call>";
/** * Tool calls in the tail message that no result ever answered. * * Only the tail is considered. A result is persisted as soon as the loop produces one, so a * call left unanswered there means the process ended mid-call; and appending its repair is * only order-correct when the call itself is last, which keeps tool_use/tool_result adjacent * for providers that require it. */export function unansweredToolCalls(messages: readonly AgentMessage[]): AgentToolCall[] { const tail = messages.at(-1); if (tail?.role !== "assistant") { return []; } return tail.content.filter((part): part is AgentToolCall => part.type === "toolCall");}
/** The result a call never received, so a resumed session reads the loss at the call site. */export function createInterruptedToolResultMessage(toolCall: AgentToolCall, timestamp = Date.now()): ToolResultMessage { const reason = toolCall.name === "ipython" ? "This cell did not complete: the process running the session ended while it ran, so no result was recorded and nothing it did was applied. Its kernel was revived from its snapshot, and output it had already written is kept." : `This ${toolCall.name} call did not complete: the process running the session ended while it ran, so no result was recorded.`; return { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, content: [ { type: "text", text: `${INTERRUPTED_TOOL_CALL_PREFIX}\n${reason}\nRe-run it if you still need it.\n</interrupted_tool_call>`, }, ], isError: true, timestamp, };}
export const LANGUAGE_DRIFT_NOTICE_CUSTOM_TYPE = "language_drift_notice";
/** * R3-style drift signal: the reply came back mostly in a script the user is not writing in * (the audit recorded replies drifting out of the conversation's language). Advisory only: * it never blocks a turn, and it stays silent for a user who is themselves writing in that * script or when the text is too short or too full of code to judge. */const SCRIPT_RANGES: readonly { readonly name: string; readonly pattern: RegExp }[] = [ { name: "simplified chinese", pattern: /[\u3400-\u4dbf\u4e00-\u9fff]/ }, { name: "kana", pattern: /[\u3040-\u30ff]/ }, { name: "hangul", pattern: /[\uac00-\ud7af]/ }, { name: "cyrillic", pattern: /[\u0400-\u04ff]/ }, { name: "greek", pattern: /[\u0370-\u03ff]/ }, { name: "hebrew", pattern: /[\u0590-\u05ff]/ }, { name: "arabic", pattern: /[\u0600-\u06ff]/ }, { name: "devanagari", pattern: /[\u0900-\u097f]/ },];
const NON_LATIN_SCRIPT_PATTERN = /[\u0370-\u03ff\u0400-\u04ff\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/;
const LATIN_SCRIPT_PATTERN = /[A-Za-z\u00c0-\u024f]/;
/** * Detect language drift in a reply relative to a reference message, returning the name * of the detected non-Latin script if drift occurred, or undefined otherwise. */export function detectLanguageDriftScript(reply: string, reference: string): string | undefined { let latin = 0; const scriptCounts = new Map<string, number>(); for (const { name } of SCRIPT_RANGES) { scriptCounts.set(name, 0); }
for (const char of reply) { if (LATIN_SCRIPT_PATTERN.test(char)) { latin += 1; } else { for (const { name, pattern } of SCRIPT_RANGES) { if (pattern.test(char)) { scriptCounts.set(name, (scriptCounts.get(name) ?? 0) + 1); break; } } } }
let other = 0; for (const count of scriptCounts.values()) { other += count; }
const total = latin + other; // Short or code-heavy replies say nothing about the conversation's language. if (total < 40) return undefined; if (other / total < 0.5) return undefined;
let referenceLatin = 0; let referenceOther = 0; for (const char of reference) { if (LATIN_SCRIPT_PATTERN.test(char)) referenceLatin += 1; else if (NON_LATIN_SCRIPT_PATTERN.test(char)) referenceOther += 1; } const referenceTotal = referenceLatin + referenceOther; if (referenceTotal < 20) return undefined; // The user is writing that script: nothing drifted. if (referenceLatin / referenceTotal < 0.5) return undefined;
// Kana never appears in Chinese; combined with CJK ideographs (Kanji), the script is Japanese (kana). const kanaCount = scriptCounts.get("kana") ?? 0; const cjkCount = scriptCounts.get("simplified chinese") ?? 0; if (kanaCount > 0 && cjkCount > 0) { scriptCounts.set("kana", kanaCount + cjkCount); scriptCounts.set("simplified chinese", 0); }
let bestScript = "another script"; let maxCount = 0; for (const [name, count] of scriptCounts.entries()) { if (count > maxCount) { maxCount = count; bestScript = name; } }
return bestScript;}
/** * R3-style drift signal: the reply came back mostly in a script the user is not writing in * (the audit recorded replies drifting out of the conversation's language). Advisory only: * it never blocks a turn, and it stays silent for a user who is themselves writing in that * script or when the text is too short or too full of code to judge. */export function detectLanguageDrift(reply: string, reference: string): boolean { return detectLanguageDriftScript(reply, reference) !== undefined;}
export function createLanguageDriftNoticeMessage( details: { detected: string; expected: string }, timestamp = Date.now(),): CustomMessage<{ detected: string; expected: string }> { return { role: "custom", customType: LANGUAGE_DRIFT_NOTICE_CUSTOM_TYPE, content: `[language-drift]
Your last reply was mostly in ${details.detected} while the user writes in ${details.expected}. Answer in the language the user is writing in; keep code, identifiers, and quoted text as they are.`, display: true, details, timestamp, };}
export interface RlmChildFailureDetails { childId: string; sessionName: string; error: string; /** * Autopsy fields. Optional and additive so an older client or daemon renders the * plain `[child-failed ...]` notice and degrades locally. */ status?: string; durationMs?: number; toolUseCount?: number; lastAssistantTextPreview?: string; /** One-line next step for the parent, e.g. checking quota before respawning. */ hint?: string;}
export type RlmChildTerminalNoticeDetails = | { kind: "cancelled"; childId: string; sessionName: string; reason?: string; } | { kind: "completed_without_reply"; childId: string; sessionName: string; lastAssistantTextPreview?: string; } | { /** * A child that finished without calling `agent_message.send`, so its last * assistant text is handed to the parent instead of the parent waiting for a * reply that will never come. The tag keeps the parent from mistaking scratch * for an answer. */ kind: "harvested_fallback"; childId: string; sessionName: string; text: string; };
export interface AsyncBashCompletionDetails { pid: number; command: string; exitCode: number;}
interface AsyncBashCompletionMessage extends CustomMessage<AsyncBashCompletionDetails> { customType: typeof ASYNC_BASH_COMPLETION_CUSTOM_TYPE; content: string;}
export function createAsyncBashCompletionMessage( details: AsyncBashCompletionDetails, timestamp = Date.now(),): AsyncBashCompletionMessage { return { role: "custom", customType: ASYNC_BASH_COMPLETION_CUSTOM_TYPE, content: `[bash-done pid:${details.pid} exit:${details.exitCode}]
Command: ${JSON.stringify(details.command)}`, display: true, details, timestamp, };}
/** * A failed child gets an autopsy header so the parent can tell a stillborn spawn from * a crash in the middle of the task without reading the child's transcript. The header * is omitted entirely when no autopsy fields are present, which keeps the bare * `[child-failed ...]\n\n<error>` shape for hand-built notices. */function rlmChildFailureAutopsy(details: RlmChildFailureDetails): string[] { const lines: string[] = []; if (details.status) lines.push(`status: ${details.status}`); if (details.durationMs !== undefined) lines.push(`ran: ${(details.durationMs / 1000).toFixed(1)}s`); if (details.toolUseCount !== undefined) lines.push(`tool calls: ${details.toolUseCount}`); if (details.lastAssistantTextPreview) { // Model-authored text: collapse newlines and drop brackets so it cannot forge a // header, but keep punctuation such as ":" that carries meaning in a preview. const preview = details.lastAssistantTextPreview.replace(/[\r\n]+/g, " ").replace(/[[\]]/g, ""); lines.push(`last assistant text: ${preview.trim()}`); } return lines;}
export function createRlmChildFailureMessage( details: RlmChildFailureDetails, timestamp = Date.now(),): CustomMessage<RlmChildFailureDetails> { const autopsy = rlmChildFailureAutopsy(details); const header = `[child-failed child:${sanitizeMessageHeaderValue(details.sessionName)}]`; const body = autopsy.length === 0 ? details.error : `${autopsy.join("\n")}\n\n${details.error}`; const content = details.hint ? `${header}\n\n${body}\n\nnext: ${details.hint}` : `${header}\n\n${body}`; return { role: "custom", customType: RLM_CHILD_FAILURE_CUSTOM_TYPE, content, display: true, details, timestamp, };}
export function createRlmChildTerminalNoticeMessage( details: RlmChildTerminalNoticeDetails, timestamp = Date.now(),): CustomMessage<RlmChildTerminalNoticeDetails> { const childName = sanitizeMessageHeaderValue(details.sessionName); const content = details.kind === "cancelled" ? `[child-exited: cancelled child:${childName}]${details.reason ? `\n\n${details.reason}` : ""}` : details.kind === "harvested_fallback" ? `[auto-harvested fallback from ${childName}]
This child finished without replying through agent_message, so its last assistant text is forwarded as-is. Treat it as unreviewed and not as an answer.
${details.text}` : `[child-exited: no-reply child:${childName}]${details.lastAssistantTextPreview ? `\n\nLast assistant text: ${details.lastAssistantTextPreview}` : ""}`; return { role: "custom", customType: RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE, content, display: true, details, timestamp, };}
/** * Message type for bash executions via the ! command. */export interface BashExecutionMessage { role: "bashExecution"; command: string; output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string; timestamp: number; /** If true, this message is excluded from LLM context (!! prefix) */ excludeFromContext?: boolean;}
/** * Message type for extension-injected messages via sendMessage(). * These are custom messages that extensions can inject into the conversation. */export interface CustomMessage<T = unknown> { role: "custom"; customType: string; content: string | (TextContent | ImageContent)[]; display: boolean; details?: T; timestamp: number;}
export interface HeartbeatPromptDetails { jobId: string; schedule: string; status: AgentCronJob["status"]; runCount: number; nextRunAt?: string; lastRunAt?: string;}
export interface IpythonStateRestoredDetails { restored: boolean;}
export interface BranchSummaryMessage { role: "branchSummary"; summary: string; fromId: string; timestamp: number;}
export interface CompactionSummaryMessage { role: "compactionSummary"; summary: string; tokensBefore: number; /** Number of retained messages that precede this summary in transcript presentation. */ retainedMessageCount?: number; /** User instructions that guided the summary (from `/compact <instructions>`) */ customInstructions?: string; /** Harness digest snapshot rendered before the summary in LLM context. Attached mechanically at compaction, never summarized. */ harnessDigest?: string; timestamp: number;}
declare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; custom: CustomMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; }}
/** * Format bash output for LLM context. The fence must be longer than any * backtick run in the output so command output cannot terminate it early. */export function bashOutputToText( msg: Pick<BashExecutionMessage, "output" | "exitCode" | "cancelled" | "truncated" | "fullOutputPath">,): string { let text = ""; if (msg.output) { let longestBacktickRun = 0; for (const match of msg.output.matchAll(/`+/g)) { longestBacktickRun = Math.max(longestBacktickRun, match[0].length); } const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); text += `${fence}\n${msg.output}\n${fence}`; } else { text += "(no output)"; } if (msg.cancelled) { text += "\n\n(command cancelled)"; } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { text += `\n\nCommand exited with code ${msg.exitCode}`; } if (msg.truncated) { text += msg.fullOutputPath ? `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]` : "\n\n[Output truncated.]"; } return text;}
/** * Convert a BashExecutionMessage to user message text for LLM context. */export function bashExecutionToText(msg: BashExecutionMessage): string { return `Ran \`${msg.command}\`\n${bashOutputToText(msg)}`;}
export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { return { role: "branchSummary", summary, fromId, timestamp: new Date(timestamp).getTime(), };}
export function createCompactionSummaryMessage( summary: string, tokensBefore: number, timestamp: string, customInstructions?: string, retainedMessageCount?: number, harnessDigest?: string,): CompactionSummaryMessage { return { role: "compactionSummary", summary, tokensBefore, retainedMessageCount, customInstructions, harnessDigest, timestamp: new Date(timestamp).getTime(), };}
/** Convert CustomMessageEntry to AgentMessage format */export function createCustomMessage( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string,): CustomMessage { return { role: "custom", customType, content, display, details, timestamp: new Date(timestamp).getTime(), };}
export function createSessionSlashCommandMessage( command: SessionSlashCommand, details: Omit<SessionSlashCommandDetails, "command"> = {}, display = true, timestamp = Date.now(),): SessionSlashCommandMessage { return { role: "custom", customType: SESSION_SLASH_COMMAND_CUSTOM_TYPE, content: command.text, display, details: { ...details, command: { ...command } }, timestamp, };}
export function createSessionSlashCommandResultMessage( content: string, details: SessionSlashCommandResultDetails, display = true, timestamp = Date.now(),): SessionSlashCommandResultMessage { return { role: "custom", customType: SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE, content, display, details: { ...details, command: { ...details.command } }, timestamp, };}
export function createCompactionOutcomeMessage( content: string, details: CompactionOutcomeDetails, display = true, timestamp = Date.now(),): CompactionOutcomeMessage { return { role: "custom", customType: COMPACTION_OUTCOME_CUSTOM_TYPE, content, display, details: { ...details }, timestamp, };}
export function createRefinementOutcomeMessage( result: RefinementResult, display = true, timestamp = Date.now(),): RefinementOutcomeMessage { return { role: "custom", customType: REFINEMENT_OUTCOME_CUSTOM_TYPE, content: `Refinement complete: ${result.summary}`, display, details: { refinementId: result.id, summary: result.summary, scope: result.scope ?? "local", ...(result.rollbackOf ? { rollbackOf: result.rollbackOf } : {}), edits: result.appliedEdits, }, timestamp, };}
/** Model-facing refinement notice: passes convertToLlm (unlike the refinement_outcome audit entry); display false because the TUI renders the outcome message. */export function createRefinementNoticeMessage( result: RefinementResult, source: RefinementSource, timestamp = Date.now(),): RefinementNoticeMessage { return { role: "custom", customType: REFINEMENT_NOTICE_CUSTOM_TYPE, content: `[${source}-refinement]\n\n${formatRefinementNoticeBody(result)}`, display: false, details: { refinementId: result.id, summary: result.summary, scope: result.scope ?? "local", ...(result.rollbackOf ? { rollbackOf: result.rollbackOf } : {}), edits: result.appliedEdits, source, }, timestamp, };}
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null;}
/** The saved-but-not-live tail every outcome line shares. */function activationSuffix(details: McpOutcomeDetails): string { return details.activation === "inactive" ? " The change remains saved, but it is not active in this session." : "";}
/** Plain-text outcome line for a completed MCP disconnect. */function formatMcpDisconnectionNotice(details: McpDisconnectionOutcomeDetails): string { const suffix = activationSuffix(details); switch (details.removal) { case "removed": return `Disconnected ${details.label}.${suffix}`; case "credential-only": return `Disconnected ${details.label}. No saved connection entry existed; the stored credential was removed.${suffix}`; case "preserved": return `Disconnected ${details.label}. The saved connection entry was kept and now shows as not connected.${suffix}`; }}
/** * Plain-text outcome line for an MCP connect or disconnect. This is the * durable wording the flows already reported: it is what the entry persists as * `content` and what the transient fallback shows, so it stays a full sentence * even where the rendered entry splits it into a header and a detail line. */export function formatMcpConnectionOutcomeNotice(details: McpOutcomeDetails): string { if (isMcpDisconnectionOutcome(details)) return formatMcpDisconnectionNotice(details); const prefix = details.source === "login" && details.addedAccount === true && details.connectionId ? `Added account ${details.connectionId}. ` : ""; const suffix = activationSuffix(details); switch (details.verification) { case "connected": return `${prefix}Connected ${details.label}${ details.toolCount !== undefined ? ` (${details.toolCount} tools verified)` : "" }.${suffix}`; case "unverified": if (details.source === "retry") { return `Verification did not complete: ${details.issue}. The connection is saved; retry from /plugins.${suffix}`; } if (details.source === "paste") { // No login happened: the user pasted a token. "Login succeeded" // would be a false claim here. return `Token saved for ${details.label}, but connection verification did not complete: ${details.issue}. The connection is saved; retry from /plugins.${suffix}`; } return `${prefix}Login succeeded for ${details.label}, but connection verification did not complete: ${details.issue}. The connection is saved; retry from /plugins.${suffix}`; case "unsaved": if (details.source === "retry") { return `The verification result could not be saved. The connection is saved; retry from /plugins.${suffix}`; } if (details.source === "paste") { return `Token saved for ${details.label}, but the verification result could not be saved. The connection is saved; retry from /plugins.${suffix}`; } return `${prefix}Login succeeded for ${details.label}, but the verification result could not be saved. The connection is saved; retry from /plugins.${suffix}`; }}
export function createMcpConnectionOutcomeMessage( details: McpOutcomeDetails, display = true, timestamp = Date.now(),): McpConnectionOutcomeMessage { return { role: "custom", customType: MCP_CONNECTION_OUTCOME_CUSTOM_TYPE, content: formatMcpConnectionOutcomeNotice(details), display, details: { ...details }, timestamp, };}
function hasValidCustomMessageEnvelope(message: Record<string, unknown>, customType: string): boolean { return ( message.role === "custom" && message.customType === customType && typeof message.content === "string" && typeof message.display === "boolean" && typeof message.timestamp === "number" && Number.isFinite(message.timestamp) );}
export function isSessionSlashCommand(value: unknown): value is SessionSlashCommand { if ( !isRecord(value) || !isSessionSlashCommandName(value.name) || typeof value.args !== "string" || typeof value.text !== "string" ) { return false; } const parsed = parseSessionSlashCommand(value.text); return ( parsed !== undefined && parsed.name === value.name && parsed.args === value.args && parsed.text === value.text );}
function isValidCommandEntryId(value: unknown): value is string | undefined { return value === undefined || (typeof value === "string" && value.length > 0);}
export function isSessionSlashCommandMessage(message: unknown): message is SessionSlashCommandMessage { if ( !isRecord(message) || !hasValidCustomMessageEnvelope(message, SESSION_SLASH_COMMAND_CUSTOM_TYPE) || typeof message.content !== "string" ) { return false; } if (!isRecord(message.details) || !isSessionSlashCommand(message.details.command)) return false; return message.content === message.details.command.text && isValidCommandEntryId(message.details.commandEntryId);}
export function isSessionSlashCommandResultMessage(message: unknown): message is SessionSlashCommandResultMessage { if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE)) return false; if (!isRecord(message.details) || !isSessionSlashCommand(message.details.command)) return false; return ( typeof message.details.success === "boolean" && (message.details.severity === "info" || message.details.severity === "warning" || message.details.severity === "error") && (message.details.error === undefined || typeof message.details.error === "string") && isValidCommandEntryId(message.details.commandEntryId) );}
export function isCompactionOutcomeMessage(message: unknown): message is CompactionOutcomeMessage { if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, COMPACTION_OUTCOME_CUSTOM_TYPE)) return false; if (!isRecord(message.details)) return false; return ( (message.details.reason === "threshold" || message.details.reason === "overflow" || message.details.reason === "requested") && (message.details.outcome === "skipped" || message.details.outcome === "cancelled" || message.details.outcome === "failed") );}
function isAppliedRefinementEdit(value: unknown): value is AppliedRefinementEdit { return ( isRecord(value) && (value.action === "create" || value.action === "update" || value.action === "delete") && typeof value.kind === "string" && typeof value.id === "string" && typeof value.applied === "boolean" );}
export function isRefinementOutcomeMessage(message: unknown): message is RefinementOutcomeMessage { if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, REFINEMENT_OUTCOME_CUSTOM_TYPE)) return false; if (!isRecord(message.details)) return false; return ( typeof message.details.summary === "string" && (message.details.scope === "local" || message.details.scope === "global") && Array.isArray(message.details.edits) && message.details.edits.every(isAppliedRefinementEdit) );}
function isValidMcpActivation(activation: unknown): boolean { return activation === undefined || activation === "active" || activation === "inactive";}
function isValidMcpDisconnectionDetails(details: Record<string, unknown>): boolean { return ( typeof details.label === "string" && (details.removal === "removed" || details.removal === "credential-only" || details.removal === "preserved") && (details.connectionId === undefined || typeof details.connectionId === "string") && isValidMcpActivation(details.activation) );}
export function isMcpConnectionOutcomeMessage(message: unknown): message is McpConnectionOutcomeMessage { if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, MCP_CONNECTION_OUTCOME_CUSTOM_TYPE)) return false; if (!isRecord(message.details)) return false; if (message.details.kind === "disconnect") return isValidMcpDisconnectionDetails(message.details); return ( (message.details.kind === undefined || message.details.kind === "connect") && typeof message.details.label === "string" && (message.details.source === "login" || message.details.source === "retry" || message.details.source === "paste") && (message.details.verification === "connected" || message.details.verification === "unverified" || message.details.verification === "unsaved") && (message.details.toolCount === undefined || (typeof message.details.toolCount === "number" && Number.isInteger(message.details.toolCount))) && (message.details.issue === undefined || typeof message.details.issue === "string") && (message.details.issueCategory === undefined || typeof message.details.issueCategory === "string") && (message.details.connectionId === undefined || typeof message.details.connectionId === "string") && (message.details.addedAccount === undefined || typeof message.details.addedAccount === "boolean") && isValidMcpActivation(message.details.activation) );}
export interface HeartbeatPromptMessage extends CustomMessage<HeartbeatPromptDetails> { customType: typeof HEARTBEAT_PROMPT_CUSTOM_TYPE; content: string;}
export function createHeartbeatPromptMessage(job: AgentCronJob, timestamp = Date.now()): HeartbeatPromptMessage { const labelIntro = job.label ? `Harness heartbeat: ${job.label}.` : "Harness heartbeat."; return { role: "custom", customType: HEARTBEAT_PROMPT_CUSTOM_TYPE, content: `[heartbeat: ${sanitizeMessageHeaderValue(job.schedule.expression)} run#${job.runCount}]\n\n${labelIntro}\nInstruction: ${job.prompt}`, display: true, details: { jobId: job.id, schedule: job.schedule.expression, status: job.status, runCount: job.runCount, nextRunAt: job.nextRunAt, lastRunAt: job.lastRunAt, }, timestamp, };}
/** * Transform AgentMessages (including custom types) to LLM-compatible Messages. * * This is used by: * - Agent's transormToLlm option (for prompt calls and queued messages) * - Compaction's generateSummary (for summarization) * - Custom extensions and tools */function withToolDurationAnnotation( content: (TextContent | ImageContent)[], durationMs: number,): (TextContent | ImageContent)[] { const annotation = `[Took ${formatToolDuration(durationMs)}]`; let lastTextIndex = -1; for (let index = content.length - 1; index >= 0; index--) { if (content[index].type === "text") { lastTextIndex = index; break; } } if (lastTextIndex === -1) { return [...content, { type: "text", text: annotation }]; } return content.map((block, index) => { if (index !== lastTextIndex || block.type !== "text") { return block; } return { type: "text", text: block.text ? `${block.text}\n\n${annotation}` : annotation }; });}
export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | undefined => { switch (m.role) { case "bashExecution": if (m.excludeFromContext) { return undefined; } return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp, }; case "custom": { if ( m.customType === SESSION_SLASH_COMMAND_CUSTOM_TYPE || m.customType === SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE || m.customType === COMPACTION_OUTCOME_CUSTOM_TYPE || m.customType === MCP_CONNECTION_OUTCOME_CUSTOM_TYPE || m.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE ) { return undefined; } const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; return { role: "user", content, timestamp: m.timestamp, }; } case "branchSummary": return { role: "user", content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], timestamp: m.timestamp, }; case "compactionSummary": { const digestBlock = m.harnessDigest ? `${HARNESS_DIGEST_PREFIX}${m.harnessDigest}${HARNESS_DIGEST_SUFFIX}\n\n` : ""; return { role: "user", content: [ { type: "text" as const, text: digestBlock + COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX, }, ], timestamp: m.timestamp, }; } case "user": case "assistant": return m; case "toolResult": { if (m.durationMs === undefined) { return m; } // The model sees the duration; the stored message stays untouched. return { ...m, content: withToolDurationAnnotation(m.content, m.durationMs) }; } default: // biome-ignore lint/correctness/noSwitchDeclarations: fine const _exhaustiveCheck: never = m; return undefined; } }) .filter((m) => m !== undefined);}