UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

107 lines • 5.05 kB
/** * Rough token count for one message: ~4 characters per token over its content and its serialized * tool calls/results, plus a small per-message envelope allowance for the role and part framing. * * A heuristic on purpose. A real tokenizer is model-specific and would drag a provider dependency * into the package, while a budget only has to be approximately right to keep a thread clear of the * provider's hard limit — and it must be a PURE function, because it runs inside `select`. Pass * `estimate` to {@link SlidingWindowHistory} to substitute a real one. */ export function estimateMessageTokens(message) { const extras = (message.toolCalls !== undefined ? JSON.stringify(message.toolCalls).length : 0) + (message.toolResults !== undefined ? JSON.stringify(message.toolResults).length : 0); return Math.ceil((message.content.length + extras) / 4) + 4; } /** * Default {@link HistoryWindow}: keeps the newest messages that fit a message count and/or a token * budget, and (optionally) folds the rest into a summary. Whichever limit cuts more wins. * * A naive slice is safe because each persisted message is self-contained: a tool call's result is * never a separate message the loop hands the model (see `agent-loop.ts`'s mapping of * `StoredMessage`), so a cut cannot orphan a tool result from its call. */ export class SlidingWindowHistory { /** * Always a row count, so the bound always reaches the store: the token budget below can only cut * MORE than this, and it cuts from the same end. */ maxMessages; maxTokens; estimate; summarize; renderSummary; constructor(options = {}) { this.maxMessages = options.maxMessages ?? 40; this.maxTokens = options.maxTokens; this.estimate = options.estimate ?? estimateMessageTokens; if (options.summarize !== undefined) { this.summarize = options.summarize; } if (options.renderSummary !== undefined) { this.renderSummary = options.renderSummary; } } select(messages, _ctx) { let cut = Math.max(0, messages.length - this.maxMessages); if (this.maxTokens !== undefined) { cut = Math.max(cut, tokenCut(messages, this.maxTokens, this.estimate)); } // The newest message always rides, whatever the limits say — a turn with nothing to answer is // worse than a turn slightly over budget. cut = Math.min(cut, Math.max(0, messages.length - 1)); return { keep: messages.slice(cut), drop: messages.slice(0, cut) }; } } /** Index of the oldest message that still fits `maxTokens`, walking newest-first. */ function tokenCut(messages, maxTokens, estimate) { let index = messages.length; let total = 0; while (index > 0) { const message = messages[index - 1]; if (message === undefined) { break; } const cost = estimate(message); if (index < messages.length && total + cost > maxTokens) { break; } total += cost; index -= 1; } return index; } /** * What a summarizer is told to produce when none is supplied. Written for a reader who will continue * the conversation without seeing the messages themselves, so it asks for the parts a later turn * still has to act on rather than a readable recap. */ export const DEFAULT_HISTORY_SUMMARY_INSTRUCTION = 'Summarize this conversation for an assistant that will continue it without seeing these messages. Preserve decisions made, facts and constraints the user stated, identifiers and names mentioned, and anything left unresolved. Omit pleasantries. Reply with the summary only — no preamble, no headings.'; /** * The default wording of a folded-history summary in the system prompt: the summary in a delimited * block, followed by a note that the messages it stands in for are no longer sent verbatim. */ export function renderHistorySummary(summary) { return `<conversation_summary>\n${summary}\n</conversation_summary>\nEarlier messages in this thread are no longer included verbatim. Treat the summary above as an accurate record of them.`; } /** * A {@link HistoryWindow.summarize} backed by one extra, non-streamed model call. Writes to a * discarding sink so the summary's tokens never reach the user's live stream, and reports its usage * so the call lands in the ledger (and against the quota) like any other. */ export function summarizeWithModel(model, instruction = DEFAULT_HISTORY_SUMMARY_INSTRUCTION) { return async (dropped) => { const discard = { write: () => { }, end: () => { } }; const turn = await model.runTurn({ system: instruction, messages: dropped, tools: [], sink: discard, }); return { text: turn.text, usage: turn.usage, ...(turn.modelId !== undefined ? { modelId: turn.modelId } : {}), }; }; } //# sourceMappingURL=history-window.js.map