@sema-agent/core
Version:
Stateless, task-oriented AI agent core
169 lines (166 loc) • 6.71 kB
JavaScript
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);
}