UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

169 lines (166 loc) • 6.71 kB
import { isSyntheticApiErrorMessage } from "./types.js"; import { parseSessionTimestampMs, requireSessionTimestampMs } from "../session/timestamps.js"; export function asAgentMessage(message) { return message; } function normalizeCompactionSummaryTimestamp(timestamp) { if (typeof timestamp === "number") { return timestamp; } const parsed = parseSessionTimestampMs(timestamp); return parsed ?? 0; } export const COMPACTION_SUMMARY_PREFIX = `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. <summary> `; export const COMPACTION_SUMMARY_SUFFIX = ` </summary> Recent messages are preserved verbatim below. Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.`; export function bashExecutionToText(msg) { let text = `Ran \`${msg.command}\`\n`; if (msg.output) { text += `\`\`\`\n${msg.output}\n\`\`\``; } 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 && msg.fullOutputPath) { text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; } return text; } export function createCompactionSummaryMessage(summary, tokensBefore, timestamp, elidedMessages, unsummarizedMessages) { return { role: "compactionSummary", summary, tokensBefore, timestamp: requireSessionTimestampMs(timestamp, "compaction summary timestamp"), ...(elidedMessages !== undefined ? { elidedMessages } : {}), ...(unsummarizedMessages !== undefined ? { unsummarizedMessages } : {}), }; } export function createCustomMessage(customType, content, display, details, timestamp) { return { role: "custom", customType, content, display, details, timestamp: requireSessionTimestampMs(timestamp, "custom message timestamp"), }; } const CONTENT_BLOCK_TYPES_BY_ROLE = { user: new Set(["text", "image"]), assistant: new Set(["text", "thinking", "toolCall"]), toolResult: new Set(["text", "image", "document"]), }; export const NORMALIZED_CONTENT_PREFIX = "[invalid content block normalized to text]"; function stringifyInvalidContent(value) { try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } } function toNormalizedTextBlock(value) { return { type: "text", text: `${NORMALIZED_CONTENT_PREFIX} ${stringifyInvalidContent(value)}` }; } function isStructurallyValidBlock(block, allowed) { return (typeof block === "object" && block !== null && !Array.isArray(block) && typeof block.type === "string" && allowed.has(block.type)); } export function normalizeLlmMessageContent(message) { if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult") { return message; } const content = message.content; if (typeof content === "string") { if (message.role === "user") { return message; } return { ...message, content: [{ type: "text", text: content }] }; } if (!Array.isArray(content)) { return { ...message, content: [toNormalizedTextBlock(content)] }; } const allowed = CONTENT_BLOCK_TYPES_BY_ROLE[message.role]; let changed = false; const blocks = content.map((block) => { if (isStructurallyValidBlock(block, allowed)) { return block; } changed = true; return toNormalizedTextBlock(block); }); return changed ? { ...message, content: blocks } : message; } export function convertToLlm(messages) { return messages .map((m) => { const message = m; switch (message.role) { case "bashExecution": if (message.excludeFromContext) { return undefined; } return { role: "user", content: [{ type: "text", text: bashExecutionToText(message) }], timestamp: message.timestamp, }; case "custom": { const content = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content; return normalizeLlmMessageContent({ role: "user", content, timestamp: message.timestamp, ...(message.provenance === "engine-note" ? { provenance: "engine-note" } : {}), }); } case "compactionSummary": { const n = message.elidedMessages; const uncovered = message.unsummarizedMessages; const uncoveredClause = n !== undefined && n > 0 && uncovered !== undefined && uncovered > 0 ? ` ${Math.min(uncovered, n)} of them could not be included in the summarization input and are NOT covered by this summary.` : ""; const disclosure = n !== undefined && n > 0 ? `\n\n(For scale: this summary stands in for ${n} earlier message${n === 1 ? "" : "s"}${message.tokensBefore > 0 ? ` — approximately ${message.tokensBefore} tokens of context before compaction` : ""}.${uncoveredClause})` : ""; return { role: "user", content: [ { type: "text", text: COMPACTION_SUMMARY_PREFIX + message.summary + COMPACTION_SUMMARY_SUFFIX + disclosure, }, ], timestamp: normalizeCompactionSummaryTimestamp(message.timestamp), }; } case "user": case "assistant": case "toolResult": if (isSyntheticApiErrorMessage(message)) { return undefined; } return normalizeLlmMessageContent(message); default: return undefined; } }) .filter((m) => m !== undefined); }