UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

159 lines (158 loc) • 8.28 kB
import { readMember } from "./thrown-value.js"; export const closeNeverExecuted = (errorKind) => errorKind === "interrupted_never_started"; const INTERRUPTED_UNKNOWN = "[INTERRUPTED] The previous run ended before this tool call's result was recorded. Its outcome " + "is UNKNOWN — the action may have completed, partially completed, or never run. Do not assume " + "success or failure. Before relying on it: if it is safe to read, query the current state to " + "check; if this was a write or irreversible action, confirm with the user before repeating it."; const INTERRUPTED_SAFE = "[INTERRUPTED] The previous run ended before this tool call's result was recorded. This tool is " + "read-only/idempotent, so its outcome carries no risk: simply call it again if you still need " + "the result."; const INTERRUPTED_IDEMPOTENT = "[INTERRUPTED] The previous run ended before this tool call's result was recorded. The call may or " + "may not have taken effect. It is safe to REPLAY: re-issuing the same call converges the state to " + "what you intended. If you decide not to re-issue it, verify the current state first."; const INTERRUPTED_NEVER_STARTED = "[INTERRUPTED] The run was aborted before this tool call started. It was never executed and had " + "no side effects. If the run was stopped by a pending approval or an unresolved gate, wait for it " + "to be resolved before re-issuing; re-issuing is otherwise safe if you still need this call."; const INTERRUPTED_REPEAT_UNKNOWN = "[INTERRUPTED] Same as an earlier interrupted call in this batch: no result was recorded and the outcome is " + "UNKNOWN — verify before relying on it."; const INTERRUPTED_REPEAT_SAFE = "[INTERRUPTED] Same as an earlier interrupted call in this batch: this tool is read-only/idempotent, so just " + "call it again if you still need the result."; const INTERRUPTED_REPEAT_IDEMPOTENT = "[INTERRUPTED] Same as an earlier interrupted call in this batch: it is safe to REPLAY — re-issue it, or verify " + "the current state if you decide not to."; const INTERRUPTED_REPEAT_NEVER_STARTED = "[INTERRUPTED] Same as an earlier interrupted call in this batch: it never started and had no side effects — " + "re-issuing is safe once whatever stopped the run is resolved."; const REPEAT_TEXT = { never_started: INTERRUPTED_REPEAT_NEVER_STARTED, read: INTERRUPTED_REPEAT_SAFE, idempotent: INTERRUPTED_REPEAT_IDEMPOTENT, unknown: INTERRUPTED_REPEAT_UNKNOWN, }; export function findOrphanToolCalls(messages, suspendedBatch) { const resolvedAt = new Map(); const callSitesAt = new Map(); messages.forEach((m, i) => { if (m.role === "toolResult") { const at = resolvedAt.get(m.toolCallId); if (at === undefined) resolvedAt.set(m.toolCallId, [i]); else at.push(i); } else if (m.role === "assistant") { for (const part of m.content) { if (part.type !== "toolCall") continue; const at = callSitesAt.get(part.id); if (at === undefined) callSitesAt.set(part.id, [i]); else at.push(i); } } }); const nextReuseAfter = (id, from) => callSitesAt.get(id)?.find((j) => j > from) ?? Number.POSITIVE_INFINITY; const orphans = []; messages.forEach((m, i) => { if (m.role === "assistant") { for (const part of m.content) { if (part.type !== "toolCall" || suspendedBatch?.has(part.id)) continue; const bound = nextReuseAfter(part.id, i); const closed = (resolvedAt.get(part.id) ?? []).some((at) => at > i && at < bound); if (!closed) orphans.push({ toolCallId: part.id, toolName: part.name }); } } else if (m.role === "toolResult") { if (suspendedBatch?.has(m.toolCallId)) return; const sites = callSitesAt.get(m.toolCallId) ?? []; const results = resolvedAt.get(m.toolCallId) ?? []; let paired = false; for (const j of sites) { if (j >= i) break; if (!results.some((at) => at > j && at < i)) { paired = true; break; } } if (!paired) orphans.push({ toolCallId: m.toolCallId, toolName: m.toolName, kind: "result" }); } }); return orphans; } export const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]"; export const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]"; export async function appendInterruptionMarker(session, opts) { return await session.appendMessage({ role: "user", content: [ { type: "text", text: opts.toolUseInFlight ? INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER : INTERRUPTED_BY_USER_MARKER, }, ], provenance: "engine-note", timestamp: Date.now(), }); } export function interruptedResults(calls, toolEffects, startedToolCallIds) { const statedInFull = new Set(); return calls.map(({ toolCallId, toolName }) => { const effect = toolEffects?.get(toolName) ?? "write"; const effectText = effect === "read" ? INTERRUPTED_SAFE : effect === "idempotent" ? INTERRUPTED_IDEMPOTENT : INTERRUPTED_UNKNOWN; const neverStarted = startedToolCallIds !== undefined && !startedToolCallIds.has(toolCallId); const cls = neverStarted ? "never_started" : effect === "read" ? "read" : effect === "idempotent" ? "idempotent" : "unknown"; const text = statedInFull.has(cls) ? REPEAT_TEXT[cls] : neverStarted ? INTERRUPTED_NEVER_STARTED : effectText; statedInFull.add(cls); return { toolCallId, toolName, text, errorKind: neverStarted ? "interrupted_never_started" : "interrupted_outcome_unknown" }; }); } export function isInterruptedClose(message) { const kind = readMember(readMember(message, "details"), "errorKind"); return kind === "interrupted_never_started" || kind === "interrupted_outcome_unknown"; } export function interruptedResultMessage(result, timestamp) { return { role: "toolResult", toolCallId: result.toolCallId, toolName: result.toolName, content: [{ type: "text", text: result.text }], details: { errorKind: result.errorKind, ...(closeNeverExecuted(result.errorKind) ? { notExecuted: true } : {}) }, isError: true, timestamp, }; } function closableByAppend(messages, suspendedBatch) { const lastSite = new Map(); const lastResult = new Map(); let order = 0; messages.forEach((m, i) => { if (m.role === "assistant") { for (const part of m.content) { if (part.type === "toolCall" && !suspendedBatch?.has(part.id) && lastSite.get(part.id)?.at !== i) lastSite.set(part.id, { at: i, order: order++, toolName: part.name, args: part.arguments }); } } else if (m.role === "toolResult") { lastResult.set(m.toolCallId, i); } }); return [...lastSite] .filter(([id, site]) => (lastResult.get(id) ?? -1) < site.at) .sort((a, b) => a[1].order - b[1].order) .map(([toolCallId, site]) => ({ toolCallId, toolName: site.toolName, args: site.args })); } export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) { const { messages } = await session.buildContext(); const orphans = closableByAppend(messages, suspendedBatch); const recovered = []; const results = interruptedResults(orphans, toolEffects, startedToolCallIds); for (const [i, result] of results.entries()) { const entryId = await session.appendMessage(interruptedResultMessage(result, Date.now())); recovered.push({ toolCallId: result.toolCallId, toolName: result.toolName, entryId, text: result.text, errorKind: result.errorKind, args: orphans[i].args }); } return { recovered }; }