basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { Content, Part } from '@google/genai';8import { isSystemReminderContent } from '../utils/environmentContext.js';9 10/**11 * Classification of how a session's last turn ended, computed from persisted12 * chat history alone (no in-memory request refs), so it works across process13 * restarts — unlike the Ctrl+Y retry path, which depends on14 * `lastPromptRef` surviving in the same process.15 *16 * The history tail determines the classification:17 * - `interrupted_prompt`: the tail is one or more non-structural `user`18 * entries — a prompt (or a tool_result submission) whose model response19 * never landed. Continuing means re-submitting their `parts` with Retry20 * semantics: the send path strips the orphaned trailing user entries and21 * re-pushes the same content under the same logical turn, so the transcript22 * gains no new user message.23 * - `interrupted_turn`: the tail is a `model` entry carrying `functionCall`s24 * that no `functionResponse` ever answered (crash/abort mid tool run).25 * Continuing means closing each pair with a synthesized error26 * `functionResponse` submitted as a ToolResult — a legal continuation27 * signal that needs no synthetic user text.28 * - `none`: the turn ended cleanly (model text tail), the tail is a29 * structural pure system-reminder entry (strip refuses to pop those, so a30 * Retry would duplicate content), or history is empty.31 *32 * A model text tail that was truncated mid-stream is indistinguishable from33 * a clean finish without persisted stop_reason metadata, so it classifies as34 * `none` here; recovering that case needs provider prefill support and is35 * tracked separately.36 */37export type TurnInterruption =38 | { kind: 'none' }39 | { kind: 'interrupted_prompt'; parts: Part[] }40 | {41 kind: 'interrupted_turn';42 danglingCalls: Array<{ callId: string; name: string }>;43 };44 45// Continue detection only walks the final run of trailing user/model entries.46// A bounded tail avoids deep-cloning long daemon histories for each probe while47// still leaving ample room for repeated failed sends and tool-result retries.48export const TURN_INTERRUPTION_HISTORY_TAIL_COUNT = 50;49 50/**51 * Detect whether the last turn of `history` was left unfinished, and if so52 * what kind of continuation applies. Pure read — never mutates `history`.53 *54 * Callers should pass enough tail entries to include all consecutive trailing55 * user entries. Accepting the full array keeps the function composable with56 * raw transcript fixtures in tests.57 *58 * @param history - Chat history in Gemini `Content[]` form, oldest first.59 * @returns The interruption classification; see {@link TurnInterruption}.60 */61export function detectTurnInterruption(history: Content[]): TurnInterruption {62 const last = history[history.length - 1];63 if (!last) {64 return { kind: 'none' };65 }66 67 if (last.role === 'user') {68 const trailingUserEntries: Content[] = [];69 for (let i = history.length - 1; i >= 0; i--) {70 const entry = history[i];71 if (!entry || entry.role !== 'user') {72 break;73 }74 // Structural reminder entries are not orphaned turns; the strip pass75 // refuses to pop them, so re-submitting would duplicate the prompt.76 if (isSystemReminderContent(entry)) {77 break;78 }79 trailingUserEntries.unshift(entry);80 }81 // Capture every part, including any per-turn system-reminder parts riding82 // alongside the prompt. The Retry send path does not re-inject per-turn83 // reminders, so replaying them keeps the continued turn complete. When a84 // continuation includes tool results, keep functionResponse parts first:85 // Anthropic-compatible backends require tool_result blocks before text.86 const allParts = trailingUserEntries.flatMap((entry) => entry.parts ?? []);87 const parts = [88 ...allParts.filter((part) => part.functionResponse),89 ...allParts.filter((part) => !part.functionResponse),90 ];91 if (parts.length === 0) {92 return { kind: 'none' };93 }94 // Public helper boundary: callers may pass raw history, so return detached95 // parts even when current continuation callers only read them.96 return { kind: 'interrupted_prompt', parts: structuredClone(parts) };97 }98 99 if (last.role === 'model') {100 // Nothing follows the final entry, so every id'd functionCall in it is101 // by definition unanswered. Calls without an id can't be paired on the102 // wire at all — the repair pass skips them too — so they're ignored.103 const danglingCalls: Array<{ callId: string; name: string }> = [];104 for (const part of last.parts ?? []) {105 const fc = part.functionCall;106 if (fc?.id) {107 danglingCalls.push({ callId: fc.id, name: fc.name ?? 'unknown' });108 }109 }110 if (danglingCalls.length > 0) {111 return { kind: 'interrupted_turn', danglingCalls };112 }113 }114 115 return { kind: 'none' };116}117 118/**119 * Build the error `functionResponse` parts that close the dangling120 * `functionCall`s of an `interrupted_turn`. Shape matches the repair pass's121 * synthesized responses (`applyRepair` in geminiChat.ts) so downstream122 * dedup and telemetry treat both identically.123 *124 * @param danglingCalls - The unanswered calls from {@link detectTurnInterruption}.125 * @param reason - Error text placed in each response; callers pass126 * `ORPHAN_TOOL_USE_REPAIR_REASON` for consistency with the repair pass.127 * @returns One `functionResponse` part per dangling call, in input order.128 */129export function buildSyntheticToolResponseParts(130 danglingCalls: Array<{ callId: string; name: string }>,131 reason: string,132): Part[] {133 return danglingCalls.map(({ callId, name }) => ({134 functionResponse: { id: callId, name, response: { error: reason } },135 }));136}137 