import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { type AgentObserveAgentSummary, createAgentObserveMessagePreview } from "./agent-observe.js"; import type { HostRequestHandler } from "./kernel/index.js"; export const AGENT_SESSIONS_HOST_PREFIX = "agent.sessions"; export type AgentSessionCapability = "send" | "recent_turns" | "stop" | "restart"; export type AgentSessionDelivery = "steer" | "follow_up"; export type AgentSessionSummary = AgentObserveAgentSummary & { capabilities: readonly AgentSessionCapability[]; }; export interface AgentSessionRecentTurnsInput { target: string; count?: number; maxChars?: number; } export interface AgentSessionTurnSummary { user: string; assistant: string; toolCalls: number; startedAt?: number; endedAt?: number; messageCount: number; truncated: boolean; } export interface AgentSessionRecentTurnsResult { agent: AgentSessionSummary; turns: AgentSessionTurnSummary[]; count: number; maxChars: number; truncated: boolean; } export interface AgentSessionSendInput { target: string; message: string; delivery: AgentSessionDelivery; } export interface AgentSessionSendResult { operationId: string; status: "accepted" | "queued"; delivery: AgentSessionDelivery; target: { activeSessionId: string; sessionId: string; sessionName?: string; }; } export interface AgentSessionOperationResult { operationId: string; status: "accepted"; } export interface AgentSessionController { listSessions(): AgentSessionSummary[] | Promise; getSession(target: string): AgentSessionSummary | Promise; recentTurns( input: AgentSessionRecentTurnsInput, ): AgentSessionRecentTurnsResult | Promise; send(input: AgentSessionSendInput): AgentSessionSendResult | Promise; stop(target: string): AgentSessionOperationResult | Promise; restart(target: string): AgentSessionOperationResult | Promise; } export function createAgentSessionHostHandlers(controller: AgentSessionController): Record { return { "agent.sessions.list": async () => ({ sessions: await controller.listSessions() }), "agent.sessions.get": async (payload: Record = {}) => ({ session: await controller.getSession(requireString(payload, "target", "agent.sessions.get target")), }), "agent.sessions.recent_turns": async (payload: Record = {}) => (await controller.recentTurns({ target: requireString(payload, "target", "agent.sessions.recent_turns target"), count: normalizeOptionalInteger(payload.count, "agent.sessions.recent_turns count"), maxChars: normalizeOptionalInteger( payload.max_chars ?? payload.maxChars, "agent.sessions.recent_turns max_chars", ), })) as unknown as Record, "agent.sessions.send": async (payload: Record = {}) => (await controller.send({ target: requireString(payload, "target", "agent.sessions.send target"), message: requireString(payload, "message", "agent.sessions.send message"), delivery: normalizeDelivery(payload.delivery), })) as unknown as Record, "agent.sessions.stop": async (payload: Record = {}) => (await controller.stop(requireString(payload, "target", "agent.sessions.stop target"))) as unknown as Record< string, unknown >, "agent.sessions.restart": async (payload: Record = {}) => (await controller.restart( requireString(payload, "target", "agent.sessions.restart target"), )) as unknown as Record, }; } export function normalizeAgentSessionTurnCount(count: number | undefined): number { return clampInteger(count ?? 3, 1, 20, "agent.sessions.recent_turns count"); } export function normalizeAgentSessionMaxChars(maxChars: number | undefined): number { return clampInteger(maxChars ?? 6_000, 80, 12_000, "agent.sessions.recent_turns max_chars"); } export function createAgentSessionTurnSummaries( messages: AgentMessage[], count: number, maxChars: number, ): { turns: AgentSessionTurnSummary[]; truncated: boolean } { const previews = messages.map((message, index) => createAgentObserveMessagePreview(message, index, maxChars)); const allTurns: MutableTurn[] = []; let current: MutableTurn | undefined; for (const preview of previews) { if (preview.role === "user" && current?.hasUser) { current = undefined; } if (!current) { current = createMutableTurn(); allTurns.push(current); } current.messageCount += 1; if (current.startedAt === undefined && preview.timestamp !== undefined) current.startedAt = preview.timestamp; if (preview.timestamp !== undefined) current.endedAt = preview.timestamp; current.truncated ||= preview.truncated; if (preview.role === "user") { current.hasUser = true; const result = appendText(current.user, preview.text, maxChars); current.user = result.text; current.truncated ||= result.truncated; } else if (preview.role === "assistant") { const result = appendText(current.assistant, preview.text, maxChars); current.assistant = result.text; current.truncated ||= result.truncated; current.toolCalls += preview.toolCalls?.length ?? 0; } } const selected = allTurns.slice(-count).map(({ hasUser: _hasUser, ...turn }) => turn); return { turns: selected, truncated: allTurns.length > selected.length || selected.some((turn) => turn.truncated), }; } type MutableTurn = AgentSessionTurnSummary & { hasUser: boolean }; function createMutableTurn(): MutableTurn { return { user: "", assistant: "", toolCalls: 0, messageCount: 0, truncated: false, hasUser: false, }; } function appendText(current: string, next: string, maxChars: number): { text: string; truncated: boolean } { if (!next) return { text: current, truncated: false }; const combined = current ? `${current}\n${next}` : next; return combined.length <= maxChars ? { text: combined, truncated: false } : { text: combined.slice(0, maxChars), truncated: true }; } function requireString(payload: Record, key: string, label: string): string { const value = payload[key]; if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string`); return value.trim(); } function normalizeOptionalInteger(value: unknown, label: string): number | undefined { if (value === undefined) return undefined; if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${label} must be an integer when provided`); return value; } function normalizeDelivery(value: unknown): AgentSessionDelivery { if (value === "steer" || value === "follow_up") return value; throw new Error("agent.sessions.send delivery must be 'steer' or 'follow_up'"); } function clampInteger(value: number, min: number, max: number, label: string): number { if (!Number.isInteger(value)) throw new Error(`${label} must be an integer`); if (value < min || value > max) throw new Error(`${label} must be between ${min} and ${max}`); return value; }