/** * 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 } from "@earendil-works/pi-agent-core"; import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; import type { AgentCronJob } from "./cron-jobs.js"; import type { AppliedRefinementEdit, HarnessScope, 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 = `The conversation history before this point was compacted into the following summary: `; export const COMPACTION_SUMMARY_SUFFIX = ` `; export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: `; export const BRANCH_SUMMARY_SUFFIX = ``; 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 REFINEMENT_OUTCOME_CUSTOM_TYPE = "refinement_outcome"; 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 = ` 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. `; 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 { customType: typeof SESSION_SLASH_COMMAND_CUSTOM_TYPE; content: string; details: SessionSlashCommandDetails; } export interface SessionSlashCommandResultMessage extends CustomMessage { 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 { 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 { customType: typeof REFINEMENT_OUTCOME_CUSTOM_TYPE; content: string; details: RefinementOutcomeDetails; } export function createManualContinuationMessage(timestamp = Date.now()): CustomMessage { return { role: "custom", customType: MANUAL_CONTINUATION_CUSTOM_TYPE, content: MANUAL_CONTINUATION_PROMPT, display: false, timestamp, }; } export interface RlmChildFailureDetails { childId: string; sessionName: string; error: string; } export type RlmChildTerminalNoticeDetails = | { kind: "cancelled"; childId: string; sessionName: string; reason?: string; } | { kind: "completed_without_reply"; childId: string; sessionName: string; lastAssistantTextPreview?: string; }; export interface AsyncBashCompletionDetails { pid: number; command: string; exitCode: number; } interface AsyncBashCompletionMessage extends CustomMessage { 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: `${ASYNC_BASH_COMPLETION_PREVIEW_LABEL}. Source: bash Command completed (pid ${details.pid}, exit code ${details.exitCode}). Command: ${JSON.stringify(details.command)} Inspect the saved BashHandle with .poll(), .output(), or .tail(), then continue the task.`, display: true, details, timestamp, }; } export function createRlmChildFailureMessage( details: RlmChildFailureDetails, timestamp = Date.now(), ): CustomMessage { return { role: "custom", customType: RLM_CHILD_FAILURE_CUSTOM_TYPE, content: `RLM child ${details.sessionName} (${details.childId}) failed: ${details.error}`, display: true, details, timestamp, }; } export function createRlmChildTerminalNoticeMessage( details: RlmChildTerminalNoticeDetails, timestamp = Date.now(), ): CustomMessage { const content = details.kind === "cancelled" ? `RLM child ${details.sessionName} (${details.childId}) was cancelled${details.reason ? `: ${details.reason}` : ""}` : `RLM child ${details.sessionName} (${details.childId}) completed without sending a reply${details.lastAssistantTextPreview ? `. Last 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 { 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 `) */ customInstructions?: 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, ): 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, ): CompactionSummaryMessage { return { role: "compactionSummary", summary, tokensBefore, retainedMessageCount, customInstructions, 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 = {}, 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, }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function hasValidCustomMessageEnvelope(message: Record, 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) ); } export function createHeartbeatPromptMessage( job: AgentCronJob, timestamp = Date.now(), ): CustomMessage { return { role: "custom", customType: HEARTBEAT_PROMPT_CUSTOM_TYPE, content: 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 === 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": return { role: "user", content: [ { type: "text" as const, text: 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); }