- Projected full tool-call arguments down to a compact summary containing only `command` and `path`. - Truncated summarized argument fields to 200 characters to prevent inflating session log sizes. - Replaced routine clean session disposal warnings with debug logs to reduce noise. - Streamlined debug context in assistant message removal and agent continuation skip paths. - Extracted duplicate user-facing compaction warning strings into a helper function.
203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
import type { SessionEntry } from "./session-entries";
|
|
|
|
export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
|
|
export const SESSION_EXIT_CUSTOM_TYPE = "session_exit";
|
|
|
|
/**
|
|
* Compact projection of tool-call arguments persisted with the start marker.
|
|
* The assistant message already carries the full arguments; this exists only
|
|
* so `appendArgumentSummary` can name the command/path in resume warnings
|
|
* without duplicating whole argument payloads into the session JSONL.
|
|
*/
|
|
export interface ToolArgumentSummary {
|
|
command?: string;
|
|
path?: string;
|
|
}
|
|
|
|
/** Persisted marker written before a tool implementation starts running. */
|
|
export interface ToolExecutionStartData {
|
|
toolCallId: string;
|
|
toolName: string;
|
|
args?: ToolArgumentSummary;
|
|
intent?: string;
|
|
startedAt: string;
|
|
}
|
|
|
|
/** Tool call left without a matching toolResult at the end of a branch. */
|
|
export interface PendingToolCallDiagnostic {
|
|
toolCallId?: string;
|
|
toolName: string;
|
|
args?: unknown;
|
|
intent?: string;
|
|
assistantTimestamp?: number;
|
|
startedAt?: string;
|
|
}
|
|
|
|
/** Session shutdown marker written during normal and fatal process teardown. */
|
|
export interface SessionExitData {
|
|
reason: string;
|
|
kind: "normal" | "signal" | "fatal" | "process_exit";
|
|
recordedAt: string;
|
|
pendingToolCalls?: PendingToolCallDiagnostic[];
|
|
}
|
|
|
|
interface PendingToolCallRecord extends PendingToolCallDiagnostic {
|
|
key: string;
|
|
}
|
|
|
|
interface ToolCallContent {
|
|
type: "toolCall";
|
|
id?: string;
|
|
name?: string;
|
|
arguments?: unknown;
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
if (typeof value !== "object") return false;
|
|
return value !== null;
|
|
}
|
|
|
|
function isToolCallContent(value: unknown): value is ToolCallContent {
|
|
if (!isObject(value)) return false;
|
|
return value.type === "toolCall" && (typeof value.name === "string" || typeof value.id === "string");
|
|
}
|
|
|
|
/** Character cap for each summarized argument field. */
|
|
const ARGUMENT_SUMMARY_MAX_CHARS = 200;
|
|
|
|
function truncateSummaryField(value: string): string {
|
|
return value.length > ARGUMENT_SUMMARY_MAX_CHARS ? `${value.slice(0, ARGUMENT_SUMMARY_MAX_CHARS)}…` : value;
|
|
}
|
|
|
|
/**
|
|
* Project full tool-call arguments down to the fields the pending-tool-call
|
|
* resume warning actually renders (`command`/`path`), truncated. Returns
|
|
* `undefined` when the arguments carry neither, so callers can omit `args`
|
|
* entirely instead of persisting an empty object.
|
|
*/
|
|
export function summarizeToolArguments(args: unknown): ToolArgumentSummary | undefined {
|
|
if (!isObject(args)) return undefined;
|
|
const summary: ToolArgumentSummary = {};
|
|
if (typeof args.command === "string" && args.command.length > 0) {
|
|
summary.command = truncateSummaryField(args.command);
|
|
}
|
|
if (typeof args.path === "string" && args.path.length > 0) {
|
|
summary.path = truncateSummaryField(args.path);
|
|
}
|
|
return summary.command !== undefined || summary.path !== undefined ? summary : undefined;
|
|
}
|
|
|
|
function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | undefined {
|
|
if (entry.type !== "custom" || entry.customType !== TOOL_EXECUTION_START_CUSTOM_TYPE) return undefined;
|
|
const data = entry.data;
|
|
if (!isObject(data)) return undefined;
|
|
if (typeof data.toolCallId !== "string" || typeof data.toolName !== "string") return undefined;
|
|
const startedAt = typeof data.startedAt === "string" ? data.startedAt : entry.timestamp;
|
|
const result: ToolExecutionStartData = {
|
|
toolCallId: data.toolCallId,
|
|
toolName: data.toolName,
|
|
startedAt,
|
|
};
|
|
// Legacy sessions persisted full argument objects; project them down.
|
|
if ("args" in data) {
|
|
const args = summarizeToolArguments(data.args);
|
|
if (args) result.args = args;
|
|
}
|
|
if (typeof data.intent === "string") result.intent = data.intent;
|
|
return result;
|
|
}
|
|
|
|
function appendAssistantToolCalls(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
|
|
if (message.role !== "assistant") return;
|
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
const toolCalls: PendingToolCallRecord[] = [];
|
|
for (let index = 0; index < content.length; index++) {
|
|
const part = content[index];
|
|
if (!isToolCallContent(part)) continue;
|
|
const toolName = part.name ?? "unknown";
|
|
const key = part.id ?? `assistant:${message.timestamp ?? "unknown"}:${index}:${toolName}`;
|
|
const record: PendingToolCallRecord = {
|
|
key,
|
|
toolName,
|
|
};
|
|
if (typeof message.timestamp === "number") record.assistantTimestamp = message.timestamp;
|
|
if (part.id) record.toolCallId = part.id;
|
|
if ("arguments" in part) record.args = part.arguments;
|
|
toolCalls.push(record);
|
|
}
|
|
pending.clear();
|
|
for (const toolCall of toolCalls) pending.set(toolCall.key, toolCall);
|
|
}
|
|
|
|
function applyToolExecutionStart(pending: Map<string, PendingToolCallRecord>, marker: ToolExecutionStartData): void {
|
|
const existing = pending.get(marker.toolCallId);
|
|
if (existing) {
|
|
existing.startedAt = marker.startedAt;
|
|
// The assistant message carries the full arguments; the marker only has
|
|
// the command/path projection. Keep the richer copy when present.
|
|
existing.args ??= marker.args;
|
|
if (marker.intent) existing.intent = marker.intent;
|
|
return;
|
|
}
|
|
const record: PendingToolCallRecord = {
|
|
key: marker.toolCallId,
|
|
toolCallId: marker.toolCallId,
|
|
toolName: marker.toolName,
|
|
args: marker.args,
|
|
startedAt: marker.startedAt,
|
|
};
|
|
if (marker.intent) record.intent = marker.intent;
|
|
pending.set(marker.toolCallId, record);
|
|
}
|
|
|
|
function applyMessageEntry(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
|
|
if (message.role === "toolResult") {
|
|
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
|
|
if (toolCallId) pending.delete(toolCallId);
|
|
return;
|
|
}
|
|
appendAssistantToolCalls(pending, message);
|
|
}
|
|
|
|
/** Finds tool calls left pending at the end of a session branch. */
|
|
export function collectPendingToolCalls(entries: readonly SessionEntry[]): PendingToolCallDiagnostic[] {
|
|
const pending = new Map<string, PendingToolCallRecord>();
|
|
for (const entry of entries) {
|
|
if (entry.type === "message") {
|
|
applyMessageEntry(pending, entry.message);
|
|
continue;
|
|
}
|
|
const marker = readToolExecutionStart(entry);
|
|
if (marker) applyToolExecutionStart(pending, marker);
|
|
}
|
|
return [...pending.values()].map(({ key: _key, ...toolCall }) => toolCall);
|
|
}
|
|
|
|
function appendArgumentSummary(parts: string[], args: unknown): void {
|
|
if (!isObject(args)) return;
|
|
const command = args.command;
|
|
if (typeof command === "string" && command.length > 0) {
|
|
parts.push(`command \`${command}\``);
|
|
return;
|
|
}
|
|
const path = args.path;
|
|
if (typeof path === "string" && path.length > 0) parts.push(`path \`${path}\``);
|
|
}
|
|
|
|
function formatPendingToolCall(call: PendingToolCallDiagnostic): string {
|
|
const parts = [call.toolName];
|
|
if (call.toolCallId) parts.push(call.toolCallId);
|
|
appendArgumentSummary(parts, call.args);
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/** Builds the resume warning shown when a prior branch ended mid-tool-call. */
|
|
export function describePendingToolCalls(entries: readonly SessionEntry[]): string | undefined {
|
|
const pending = collectPendingToolCalls(entries);
|
|
if (pending.length === 0) return undefined;
|
|
const formatted = pending.map(formatPendingToolCall).join(", ");
|
|
const noun = pending.length === 1 ? "tool call" : "tool calls";
|
|
return `Previous session ended while ${pending.length} ${noun} remained pending: ${formatted}. The prior OMP process exited before recording tool result(s).`;
|
|
}
|