diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3c4d50b9..a069f87682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +### Runtime kernel extraction + +This change set turns the runtime execution path from a large implicit +`SessionManager` / `AiSdkBackend` flow into an internal runtime-kernel shape. +It keeps the existing desktop, renderer, IPC, session JSONL, settings, bot, and +gateway surfaces stable while moving model, tool, trace, run-ledger, and +startup-recovery responsibilities behind explicit internal boundaries. + +| Area | Summary | +| --- | --- | +| Tool runtime | Extracted an internal `ToolRuntime` around tool input validation, permission checks, watchdog pause/resume, abort propagation, telemetry, artifact recording, and failure classification. | +| Model adapter | Extracted a minimal `ModelAdapter` so provider stream/error/usage normalization no longer lives directly in the backend orchestration shell. | +| Runtime trace | Added best-effort `RunTrace` events for model, tool, permission, abort, and usage milestones without changing renderer-visible `SessionEvent` behavior. | +| AgentRun ledger | Added core `AgentRun` types and a file-backed `AgentRunStore` at `sessions//runs//run.json` plus `events.jsonl`. | +| AgentRun execution | Moved the heavy turn execution lifecycle from `SessionManager.sendMessage()` into internal `AgentRun.execute()`, including user-message append, backend stream drive, status projection, abort/failure handling, and durable trace writes. | +| Startup recovery | Made `recoverInterruptedSessions()` prefer the AgentRun ledger when available, repairing stale non-terminal runs and preserving the legacy message/turn-state fallback for older sessions. | + +See `docs/runtime-kernel.md` for the design rationale, boundaries, and +verification details. + ### Hardening phases 1-5 This change set collects the first five maintenance hardening phases from the @@ -17,6 +37,9 @@ Rive deep-read follow-up work. ### Verification +- Runtime package typecheck/build and full runtime test suite. +- Desktop main build/typecheck. +- Storage package build and AgentRun store tests. - Runtime package typecheck/build and focused runtime tests. - Storage package build and focused session-store tests. - Desktop main build/typecheck and focused bot/OpenGateway, credential-store, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ab94e5a355..b2a3049442 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -149,7 +149,7 @@ import { PROVIDER_DEFAULTS, type LlmConnection, } from '@maka/core/llm-connections'; -import { createArtifactStore, createConnectionStore, createPlanReminderStore, createSessionStore, createSettingsStore, createTelemetryRepo, resolveArtifactPath } from '@maka/storage'; +import { createAgentRunStore, createArtifactStore, createConnectionStore, createPlanReminderStore, createSessionStore, createSettingsStore, createTelemetryRepo, resolveArtifactPath } from '@maka/storage'; import { ensureSessionCanSendOrRebind, errorCode, @@ -221,6 +221,7 @@ const visualSmokeFixture = resolveVisualSmokeFixture( ); const workspaceRoot = join(app.getPath('userData'), 'workspaces', visualSmokeFixture?.workspaceName ?? 'default'); const store = createSessionStore(workspaceRoot); +const runStore = createAgentRunStore(workspaceRoot); const connectionStore = createConnectionStore(workspaceRoot); const settingsStore = createSettingsStore(workspaceRoot); const telemetryRepo = createTelemetryRepo(workspaceRoot); @@ -747,6 +748,7 @@ backends.register('ai-sdk', async (ctx) => { : event, ), recordToolArtifacts: (event) => persistToolArtifacts(ctx.header.cwd, event), + recordRunTrace: ctx.recordRunTrace, newId: randomUUID, now: Date.now, }); @@ -943,6 +945,7 @@ backends.register('fake', (ctx) => const runtime = new SessionManager({ store, + runStore, backends, newId: randomUUID, now: Date.now, diff --git a/docs/runtime-kernel.md b/docs/runtime-kernel.md new file mode 100644 index 0000000000..ec1a764234 --- /dev/null +++ b/docs/runtime-kernel.md @@ -0,0 +1,277 @@ +# Runtime Kernel Extraction + +This document explains the runtime-kernel work in this change set: what changed, +why it was needed, what stayed stable, and how it was verified. + +## Summary + +Maka already had the pieces of a local desktop coding agent: sessions, model +streams, tool calls, permission prompts, abort handling, usage telemetry, bot and +gateway entry points, and persisted session messages. The problem was that the +core execution responsibilities were still concentrated in a few large runtime +paths, especially `AiSdkBackend` and `SessionManager.sendMessage()`. + +This change set keeps the product surfaces stable but introduces clearer +internal runtime boundaries: + +```text +SessionManager + -> AgentRun + -> AiSdkBackend + -> ModelAdapter + -> ToolRuntime + -> RunTrace + -> AgentRunStore +``` + +The intent is not to rewrite the runtime or replace the Vercel AI SDK. The goal +is to make the existing runtime easier to reason about, easier to recover after +interruption, and easier to extend with future backends or workflow integrations. + +## What Changed + +### ToolRuntime + +`ToolRuntime` is now the internal boundary for the lifecycle of model-requested +tools. The extracted runtime owns the work that used to be interleaved inside the +AI SDK backend: + +- validate tool input before execution +- evaluate permission policy +- wait for parked permission decisions with timeout behavior +- pause and resume the stream watchdog around tool execution +- propagate abort signals into tools +- classify tool failures +- record tool telemetry and artifacts +- emit tool and permission trace events + +`AiSdkBackend` still bridges model stream behavior, but it no longer needs to own +every detail of tool execution. + +### ModelAdapter + +`ModelAdapter` is the provider-facing stream and error normalization layer. It +keeps AI SDK-specific stream chunks, provider setup, usage normalization, and +provider error mapping out of the higher-level backend orchestration shell. + +This makes the boundary explicit: + +```text +provider / AI SDK details + -> ModelAdapter + -> Maka runtime events and usage records +``` + +That separation matters because future providers should not need to duplicate +permission, tool, run, or session-state behavior. + +### RunTrace + +`RunTrace` is an internal best-effort trace path for runtime events. It records +milestones such as: + +- turn started +- model resolved / stream started / stream completed / stream failed +- tool started / completed / failed +- permission requested / decided / failed +- usage recorded +- abort requested + +Trace recorder failures are intentionally non-fatal. A failed trace write must +not alter model or tool execution. + +### AgentRun Types and Store + +The core package now defines internal AgentRun contracts: + +- `AgentRunHeader` +- `AgentRunEvent` +- `AgentRunStatus` +- `AgentRunStore` + +The storage package adds a file-backed run store: + +```text +sessions//runs//run.json +sessions//runs//events.jsonl +``` + +The store provides: + +- atomic `run.json` writes +- append-only run event JSONL +- same-run write serialization +- session run listing +- corrupt committed event-line recovery via `event_corrupt` +- malformed unterminated tail tolerance + +This ledger is separate from the existing session message JSONL so runtime +diagnostics and recovery state do not pollute user-visible conversation history. + +### AgentRun Execution + +`SessionManager.sendMessage()` now delegates the heavy turn lifecycle to +internal `AgentRun.execute()`. + +`AgentRun` owns: + +- generating and recording run identity +- appending the user message +- writing initial turn state +- locking the connection snapshot for the run +- ensuring the active backend exists +- driving backend stream events +- projecting session status changes +- writing turn completion, failure, abort, or permission-wait states +- recording run started/completed/failed/cancelled status +- routing `RunTrace` events into the durable run ledger +- preserving abort source diagnostics such as `renderer.stop_button` + +`SessionManager` remains the public runtime API and continues to own session +CRUD, backend registry orchestration, active run lookup, and legacy recovery +entry points. + +### Active Run Registry + +The old active stream counters and turn-id side maps were replaced with: + +```ts +activeRuns: Map +turnToRunId: Map +``` + +This makes overlapping run behavior explicit. It also avoids masking active +stream accounting bugs with defensive counter clamping. + +### Startup Recovery + +`recoverInterruptedSessions()` now prefers the AgentRun ledger when run rows are +available. It scans persisted run headers and events, classifies stale +non-terminal runs, repairs them, and then converges the existing session/turn +projection. + +Recovered cases include: + +- stale `created` or `running` runs +- runs whose last event is `run_started` or `model_stream_started` +- stale tool tails after `tool_started` +- stale permission waits after `permission_requested` +- `model_stream_completed` without a terminal run event +- corrupt run event lines represented as `event_corrupt` + +Legacy sessions without run ledger rows still use the prior message and +turn-state recovery path. + +## What Stayed Stable + +This work intentionally does not change: + +- `window.maka.*` preload API +- Electron IPC channel names +- renderer-visible `SessionEvent` behavior +- user-visible permission modes +- session message JSONL compatibility +- builtin tool names or public tool behavior +- model provider settings UI +- bot and OpenGateway public entry points +- Rive workflow integration semantics + +The runtime kernel is internal. Public behavior should remain compatible while +the internals become more explicit and recoverable. + +## Why This Was Needed + +Before this work, a single turn execution was spread across session management, +backend stream handling, tool wrapping, permission policy, telemetry, and abort +logic. That made several questions difficult to answer after a failure: + +- Did the model stream start? +- Which backend/model/connection did the turn use? +- Was the run waiting for permission? +- Which tool was running when the app exited? +- Did the user press stop? +- Was usage recorded? +- Did a stale session status reflect a real active run or a crashed process? + +The AgentRun ledger gives the runtime a durable fact record for those answers. +ToolRuntime and ModelAdapter then reduce the amount of model/tool/provider logic +that has to be understood at once. + +## Recovery Model + +The new recovery path is conservative. It does not replay model streams or tools. +Instead, it repairs stale state into deterministic terminal states so the app +does not reopen with sessions permanently stuck in `running` or +`waiting_for_user`. + +When a stale non-terminal run is recovered, the runtime: + +1. Updates the AgentRun header to terminal state. +2. Appends a durable recovery event such as `run_failed` or `run_completed`. +3. Writes the existing session `turn_state` projection. +4. Updates the session header out of active running/waiting states. + +The failure class for app-restart recovery is recorded as `app_restarted`. +Diagnostics are limited to small reason-code fields such as `recovered`, +`failureClass`, `recoveryReason`, `lastEventType`, and `eventCorrupt`; raw user +text and raw event payloads are not copied into recovery diagnostics. + +## Files Added + +- `packages/runtime/src/tool-runtime.ts` +- `packages/runtime/src/model-adapter.ts` +- `packages/runtime/src/run-trace.ts` +- `packages/core/src/agent-run.ts` +- `packages/storage/src/agent-run-store.ts` +- `packages/runtime/src/agent-run.ts` +- `packages/runtime/src/agent-run-recovery.ts` + +## Tests Added or Expanded + +The runtime test suite now covers: + +- ToolRuntime permission allow/block/prompt/timeout paths +- watchdog pause/resume behavior around tools +- tool abort and failure classification behavior +- tool telemetry and artifact recording behavior +- ModelAdapter stream, usage, and provider error normalization +- RunTrace recording and best-effort failure behavior +- overlapping AgentRuns +- backend build failure after user-message append +- permission handoff without stuck run state +- stop button abort source preservation +- late complete after stop not overwriting aborted state +- durable trace redaction +- AgentRunStore create/read/update/list behavior +- same-run AgentRun event append serialization +- corrupt AgentRun event JSONL recovery +- startup recovery from stale AgentRun ledger states +- legacy startup recovery fallback +- terminal AgentRun recovery idempotency + +## Verification + +The changes were verified with: + +```sh +npm --workspace @maka/core run typecheck +npm --workspace @maka/storage run test +npm --workspace @maka/runtime run typecheck +npm --workspace @maka/runtime run test +npm --workspace @maka/desktop run build:main +git diff --check +``` + +The final runtime suite included 315 passing tests after the AgentRun recovery +work. + +## Follow-Up Work + +This PR establishes the internal runtime-kernel shape, but it does not finish +every possible cleanup. Good follow-up slices are: + +- merge usage and cost accounting more tightly with the run ledger +- expose an internal run-inspection/debug read model +- reduce the remaining `SessionManager` hook surface used by `AgentRun` +- consider Gateway/Bot policy unification through the same run policy layer +- consider Rive workflow/run mapping only after there is a product need diff --git a/packages/core/package.json b/packages/core/package.json index aef3f6d8fb..825b2bea6e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,7 @@ ".": "./dist/index.js", "./events": "./dist/events.js", "./session": "./dist/session.js", + "./agent-run": "./dist/agent-run.js", "./session-event-health": "./dist/session-event-health.js", "./permission": "./dist/permission.js", "./permission-request-health": "./dist/permission-request-health.js", diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts new file mode 100644 index 0000000000..ffb8c70bdd --- /dev/null +++ b/packages/core/src/agent-run.ts @@ -0,0 +1,85 @@ +import type { PermissionMode } from './permission.js'; +import type { BackendKind } from './session.js'; + +export const AGENT_RUN_STATUSES = [ + 'created', + 'running', + 'waiting_permission', + 'completed', + 'failed', + 'cancelled', +] as const; + +export type AgentRunStatus = typeof AGENT_RUN_STATUSES[number]; + +export interface AgentRunHeader { + runId: string; + sessionId: string; + turnId: string; + status: AgentRunStatus; + backendKind: BackendKind; + llmConnectionSlug: string; + modelId: string; + cwd: string; + permissionMode: PermissionMode; + createdAt: number; + updatedAt: number; + completedAt?: number; + parentTurnId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + parentSessionId?: string; + failureClass?: string; + failureMessage?: string; + traceWriteError?: string; +} + +export interface AgentRunInputSummary { + textLength: number; + attachmentCount: number; +} + +export type AgentRunEventType = + | 'run_created' + | 'run_started' + | 'turn_started' + | 'run_status_changed' + | 'model_resolved' + | 'model_resolve_failed' + | 'model_stream_started' + | 'model_stream_completed' + | 'model_stream_failed' + | 'tool_started' + | 'tool_completed' + | 'tool_failed' + | 'permission_requested' + | 'permission_decided' + | 'permission_failed' + | 'usage_recorded' + | 'abort_requested' + | 'run_completed' + | 'run_failed' + | 'run_cancelled' + | 'trace_write_failed' + | 'event_corrupt'; + +export interface AgentRunEvent { + type: AgentRunEventType; + id: string; + runId: string; + sessionId: string; + turnId: string; + ts: number; + message?: string; + data?: Record; +} + +export interface AgentRunStore { + createRun(header: AgentRunHeader): Promise; + updateRun(sessionId: string, runId: string, patch: Partial): Promise; + readRun(sessionId: string, runId: string): Promise; + listSessionRuns(sessionId: string): Promise; + appendEvent(sessionId: string, runId: string, event: AgentRunEvent): Promise; + readEvents(sessionId: string, runId: string): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d043d2926d..05f7dbfcfc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,17 @@ export { isTurnStatus, } from './session.js'; +// agent-run.ts +export type { + AgentRunEvent, + AgentRunEventType, + AgentRunHeader, + AgentRunInputSummary, + AgentRunStatus, + AgentRunStore, +} from './agent-run.js'; +export { AGENT_RUN_STATUSES } from './agent-run.js'; + // session-event-health.ts export type { SessionEventStreamSnapshot, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 609aee74ed..f6688a3f80 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -15,6 +15,7 @@ import { normalizeAiSdkUsage, repairMakaToolCall, type MakaTool, + type RunTraceEvent, } from '../ai-sdk-backend.js'; import { PermissionEngine } from '../permission-engine.js'; @@ -327,7 +328,351 @@ describe('AiSdkBackend usage telemetry', () => { }); }); +describe('AiSdkBackend RunTrace', () => { + test('records turn, model, usage, and completion trace events without changing SessionEvents', async () => { + const trace: RunTraceEvent[] = []; + const events: SessionEvent[] = []; + const model = new MockLanguageModelV3({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'hello' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 4, + noCache: 4, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 2, + text: 1, + reasoning: 1, + }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + recordRunTrace: (event) => { + trace.push(event); + }, + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.deepEqual( + trace.map((event) => event.type), + ['turn_started', 'model_resolved', 'model_stream_started', 'usage_recorded', 'model_stream_completed'], + ); + assert.deepEqual( + trace.map((event) => event.phase), + ['turn', 'model', 'model', 'usage', 'model'], + ); + assert.equal(trace[0]?.sessionId, 'session-1'); + assert.equal(trace[0]?.turnId, 'turn-1'); + assert.equal(trace.find((event) => event.type === 'usage_recorded')?.data?.inputTokens, 4); + assert.equal(trace.find((event) => event.type === 'usage_recorded')?.data?.reasoningTokens, 1); + assert.deepEqual( + events.map((event) => event.type).filter((type) => type === 'text_delta' || type === 'token_usage' || type === 'complete'), + ['text_delta', 'token_usage', 'complete'], + ); + }); + + test('trace recorder failures are best-effort and do not change model execution', async () => { + const events: SessionEvent[] = []; + const model = new MockLanguageModelV3({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'hello' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { + total: 1, + noCache: 1, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { + total: 1, + text: 1, + reasoning: 0, + }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + recordRunTrace: () => { + throw new Error('trace sink unavailable'); + }, + }); + + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.deepEqual( + events.map((event) => event.type).filter((type) => type === 'text_delta' || type === 'token_usage' || type === 'complete'), + ['text_delta', 'token_usage', 'complete'], + ); + }); + + + test('records permission and tool trace events for denied tools', async () => { + const trace: RunTraceEvent[] = []; + const events: SessionEvent[] = []; + const permissionEngine = new PermissionEngine({ newId: idGenerator(), now: () => 1 }); + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('ask'), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine, + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + permissionTimeoutMs: 1_000, + }); + (backend as unknown as { + currentRunTrace: { emit(eventPhase: string, eventType: string, message: string, data?: Record): void }; + currentWatchdog: { pause(): void; resume(): void }; + }).currentRunTrace = { + emit: (phase, type, message, data) => { + trace.push({ + id: `trace-${trace.length + 1}`, + sessionId: 'session-1', + turnId: 'turn-1', + ts: trace.length + 1, + phase: phase as RunTraceEvent['phase'], + type: type as RunTraceEvent['type'], + message, + ...(data ? { data } : {}), + }); + }, + }; + (backend as unknown as { + currentWatchdog: { pause(): void; resume(): void }; + }).currentWatchdog = { pause() {}, resume() {} }; + const tool: MakaTool = { + name: 'Write', + description: 'write file', + parameters: {}, + permissionRequired: true, + impl: async () => ({ ok: true }), + }; + const execute = (backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): (args: unknown, ctx: { toolCallId: string; abortSignal: AbortSignal }) => Promise; + }).wrapToolExecute(tool, 'turn-1', { push: (event) => events.push(event) }); + + const pending = execute( + { path: 'notes.md', content: 'hello' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + await waitFor(() => events.some((event) => event.type === 'permission_request')); + const request = events.find((event) => event.type === 'permission_request') as + | Extract + | undefined; + assert.ok(request); + permissionEngine.recordResponse('turn-1', { + requestId: request.requestId, + decision: 'deny', + }); + await pending; + + assert.deepEqual( + trace.map((event) => event.type), + ['tool_started', 'permission_requested', 'permission_decided', 'tool_failed'], + ); + assert.deepEqual( + trace.map((event) => event.phase), + ['tool', 'permission', 'permission', 'tool'], + ); + assert.equal(trace.find((event) => event.type === 'permission_decided')?.data?.decision, 'deny'); + assert.equal(trace.find((event) => event.type === 'tool_failed')?.data?.errorClass, 'Permission'); + }); + + test('records abort trace when stop is requested', async () => { + const trace: RunTraceEvent[] = []; + const permissionEngine = new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }); + permissionEngine.beginTurn('turn-1'); + const verdict = permissionEngine.evaluate({ + sessionId: 'session-1', + turnId: 'turn-1', + toolUseId: 'tool-1', + toolName: 'Write', + args: { path: 'notes.md', content: 'hello' }, + mode: 'ask', + }); + assert.equal(verdict.kind, 'prompt'); + const parked = verdict.kind === 'prompt' + ? verdict.parked.then( + () => 'resolved', + (error: Error) => error.message, + ) + : Promise.resolve('not-prompt'); + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine, + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + (backend as unknown as { + currentTurnId: string; + currentRunTrace: { abortRequested(reason: string): void }; + }).currentTurnId = 'turn-1'; + (backend as unknown as { + currentRunTrace: { abortRequested(reason: string): void }; + }).currentRunTrace = { + abortRequested: (reason) => { + trace.push({ + id: 'trace-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + phase: 'abort', + type: 'abort_requested', + message: 'Abort requested', + data: { reason }, + }); + }, + }; + + await backend.stop('redirect'); + + assert.equal(trace.length, 1); + assert.equal(trace[0]?.type, 'abort_requested'); + assert.equal(trace[0]?.data?.reason, 'redirect'); + assert.match(await parked, /Turn turn-1 aborted before permission request permission-id was answered/); + assert.equal(permissionEngine.pendingCount('turn-1'), 0); + }); +}); + describe('AiSdkBackend tool permission category hints', () => { + test('permissionRequired=false fast path preserves tool-call/result ordering and telemetry', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const telemetry: Array<{ status: string; toolCallId?: string }> = []; + let implCalled = false; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('ask'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + recordToolInvocation: (record) => { + telemetry.push({ status: record.status, toolCallId: record.toolCallId }); + }, + }); + const tool: MakaTool = { + name: 'Read', + description: 'read file', + parameters: {}, + permissionRequired: false, + impl: async () => { + implCalled = true; + return { kind: 'text', text: 'hello' }; + }, + }; + const execute = (backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): (args: unknown, ctx: { toolCallId: string; abortSignal: AbortSignal }) => Promise; + }).wrapToolExecute(tool, 'turn-1', { push: (event) => events.push(event) }); + + const result = await execute( + { path: 'notes.md' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + + assert.equal(implCalled, true); + assert.deepEqual(result, { kind: 'text', text: 'hello' }); + assert.deepEqual( + messages + .map((message) => (message as { type?: string }).type) + .filter((type) => type === 'tool_call' || type === 'tool_result'), + ['tool_call', 'tool_result'], + ); + assert.deepEqual( + events + .map((event) => event.type) + .filter((type) => type === 'tool_start' || type === 'tool_result'), + ['tool_start', 'tool_result'], + ); + assert.equal(events.some((event) => event.type === 'permission_request'), false); + assert.deepEqual(telemetry, [ + { status: 'success', toolCallId: 'tool-1' }, + ]); + }); + test('permission prompt timeout expires one request, resumes watchdog, and writes an error result', async () => { const messages: unknown[] = []; const events: SessionEvent[] = []; @@ -412,6 +757,230 @@ describe('AiSdkBackend tool permission category hints', () => { ); }); + test('permission denial records decision ack, resumes watchdog, and never runs impl', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const permissionEngine = new PermissionEngine({ newId: idGenerator(), now: () => 1 }); + let implCalled = false; + let pauseCount = 0; + let resumeCount = 0; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('ask'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine, + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: () => 1, + permissionTimeoutMs: 1_000, + }); + const tool: MakaTool = { + name: 'Write', + description: 'write file', + parameters: {}, + permissionRequired: true, + impl: async () => { + implCalled = true; + return { ok: true }; + }, + }; + (backend as unknown as { + currentWatchdog: { pause(): void; resume(): void }; + }).currentWatchdog = { + pause: () => { + pauseCount += 1; + }, + resume: () => { + resumeCount += 1; + }, + }; + const execute = (backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): (args: unknown, ctx: { toolCallId: string; abortSignal: AbortSignal }) => Promise; + }).wrapToolExecute(tool, 'turn-1', { push: (event) => events.push(event) }); + + const pending = execute( + { path: 'notes.md', content: 'hello' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + await waitFor(() => events.some((event) => event.type === 'permission_request')); + const request = events.find((event) => event.type === 'permission_request') as + | Extract + | undefined; + assert.ok(request); + + const accepted = permissionEngine.recordResponse('turn-1', { + requestId: request.requestId, + decision: 'deny', + rememberForTurn: true, + }); + assert.ok(accepted); + const result = await pending; + + assert.equal(implCalled, false); + assert.equal(pauseCount, 1); + assert.equal(resumeCount, 1); + assert.deepEqual(result, { error: '用户已拒绝权限请求' }); + assert.equal(messages.some((message) => (message as { type?: string }).type === 'tool_call'), true); + assert.equal( + messages.some((message) => + (message as { type?: string; decision?: string; rememberForTurn?: boolean }).type === 'permission_decision' && + (message as { decision?: string }).decision === 'deny' && + (message as { rememberForTurn?: boolean }).rememberForTurn === true, + ), + true, + ); + assert.equal( + events.some((event) => + event.type === 'permission_decision_ack' && + event.decision === 'deny' && + event.rememberForTurn === true, + ), + true, + ); + assert.equal( + events.some((event) => event.type === 'tool_result' && event.toolUseId === 'tool-1' && event.isError === true), + true, + ); + }); + + test('tool failure telemetry classifies and redacts generic implementation errors', async () => { + const messages: unknown[] = []; + const events: SessionEvent[] = []; + const telemetry: Array<{ status: string; errorClass?: string; bytesOut: number }> = []; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('ask'), + appendMessage: async (message) => { + messages.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + recordToolInvocation: (record) => { + telemetry.push({ + status: record.status, + errorClass: record.errorClass, + bytesOut: record.bytesOut ?? 0, + }); + }, + }); + const tool: MakaTool = { + name: 'Write', + description: 'write file', + parameters: {}, + permissionRequired: false, + impl: async () => { + const error = new Error('401 Authorization: Bearer sk-live-secret-token-value'); + Object.assign(error, { code: 401 }); + throw error; + }, + }; + const execute = (backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): (args: unknown, ctx: { toolCallId: string; abortSignal: AbortSignal }) => Promise; + }).wrapToolExecute(tool, 'turn-1', { push: (event) => events.push(event) }); + + const result = await execute( + { path: 'notes.md', content: 'hello' }, + { toolCallId: 'tool-1', abortSignal: new AbortController().signal }, + ); + const resultText = (result as { error?: string }).error ?? ''; + const serialized = JSON.stringify({ messages, events, result }); + + assert.match(resultText, /Authorization: Bearer \[redacted\]/); + assert.equal(serialized.includes('sk-live-secret-token-value'), false); + assert.equal( + events.some((event) => event.type === 'tool_result' && event.toolUseId === 'tool-1' && event.isError === true), + true, + ); + assert.deepEqual(telemetry, [ + { status: 'error', errorClass: 'Auth', bytesOut: 0 }, + ]); + }); + + test('flushes output deltas before successful and failed tool results', async () => { + const events: SessionEvent[] = []; + const backend = new AiSdkBackend({ + sessionId: 'session-1', + header: header('ask'), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), + modelFactory: () => ({}), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const successTool: MakaTool = { + name: 'Streamer', + description: 'streams output', + parameters: {}, + permissionRequired: false, + impl: async (_args, ctx) => { + ctx.emitOutput('stdout', 'success chunk'); + return { ok: true }; + }, + }; + const failureTool: MakaTool = { + name: 'Streamer', + description: 'streams then fails', + parameters: {}, + permissionRequired: false, + impl: async (_args, ctx) => { + ctx.emitOutput('stderr', 'failure chunk'); + throw new Error('tool failed'); + }, + }; + const wrap = (tool: MakaTool) => (backend as unknown as { + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: { push(event: SessionEvent): void }, + ): (args: unknown, ctx: { toolCallId: string; abortSignal: AbortSignal }) => Promise; + }).wrapToolExecute(tool, 'turn-1', { push: (event) => events.push(event) }); + + await wrap(successTool)({}, { + toolCallId: 'tool-success', + abortSignal: new AbortController().signal, + }); + await wrap(failureTool)({}, { + toolCallId: 'tool-failure', + abortSignal: new AbortController().signal, + }); + const eventKeys = events.map((event) => `${event.type}:${'toolUseId' in event ? event.toolUseId : ''}`); + + assert.ok( + eventKeys.indexOf('tool_output_delta:tool-success') < + eventKeys.indexOf('tool_result:tool-success'), + 'successful tool output must flush before its result event', + ); + assert.ok( + eventKeys.indexOf('tool_output_delta:tool-failure') < + eventKeys.indexOf('tool_result:tool-failure'), + 'failed tool output must flush before its result event', + ); + }); + test('passes categoryHint through PermissionEngine before tool execution', async () => { const messages: unknown[] = []; const events: SessionEvent[] = []; @@ -556,7 +1125,9 @@ describe('AiSdkBackend tool permission category hints', () => { parameters: {}, permissionRequired: true, categoryHint: 'subagent', - impl: async (args: { reason?: string }) => ({ + impl: async (args: unknown) => { + const input = args as { reason?: string }; + return { kind: 'explore_agent', ok: false, mode: 'read_only', @@ -570,9 +1141,10 @@ describe('AiSdkBackend tool permission category hints', () => { candidateFiles: [], matches: [], notes: [], - reason: args.reason === 'aborted' ? 'aborted' : 'invalid_root', - message: args.reason === 'aborted' ? '只读探索已取消。' : '范围无效。', - }), + reason: input.reason === 'aborted' ? 'aborted' : 'invalid_root', + message: input.reason === 'aborted' ? '只读探索已取消。' : '范围无效。', + }; + }, }; const execute = (backend as unknown as { wrapToolExecute( @@ -758,3 +1330,11 @@ function monotonicClock(): () => number { let value = 1_000; return () => ++value; } + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail('condition was not met before timeout'); +} diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts new file mode 100644 index 0000000000..b4e6caf2ec --- /dev/null +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; + +import { AsyncEventQueue } from '../async-queue.js'; +import { + ModelAdapter, + normalizeAiSdkUsage, + type AiSdkStreamChunk, +} from '../model-adapter.js'; + +describe('ModelAdapter stream and error normalization', () => { + test('normalizes provider text, reasoning, ignored tool chunks, and errors into SessionEvents', () => { + const events: SessionEvent[] = []; + const queue = new AsyncEventQueue(); + const adapter = newAdapter(); + const callbacks = { + text: '', + thinking: '', + onText(text: string) { + this.text += text; + }, + onTextComplete(text: string) { + this.text = text; + }, + onThinking(text: string) { + this.thinking += text; + }, + onThinkingComplete(text: string) { + this.thinking = text; + }, + }; + const push = queue.push.bind(queue); + queue.push = (event: SessionEvent) => { + events.push(event); + push(event); + }; + + const chunks: AiSdkStreamChunk[] = [ + { type: 'text-delta', text: 'hello ' }, + { type: 'text-delta', textDelta: 'world' }, + { type: 'reasoning', delta: 'think ' }, + { type: 'reasoning-delta', text: 'more' }, + { type: 'tool-call', toolCallId: 'tool-1', toolName: 'Read' }, + { type: 'tool-result', toolCallId: 'tool-1', result: { ok: true } }, + { type: 'error', error: Object.assign(new Error('429 rate limit'), { code: 429 }) }, + { type: 'unknown-provider-chunk' }, + ]; + + for (const chunk of chunks) { + adapter.handleStreamChunk(chunk, 'turn-1', 'assistant-1', queue, callbacks); + } + + assert.equal(callbacks.text, 'hello world'); + assert.equal(callbacks.thinking, 'think more'); + assert.deepEqual( + events.map((event) => event.type), + ['text_delta', 'text_delta', 'thinking_delta', 'thinking_delta', 'error'], + ); + assert.deepEqual( + events + .filter((event) => event.type === 'text_delta') + .map((event) => event.text), + ['hello ', 'world'], + ); + assert.deepEqual( + events + .filter((event) => event.type === 'thinking_delta') + .map((event) => event.text), + ['think ', 'more'], + ); + const error = events.find((event) => event.type === 'error') as Extract | undefined; + assert.equal(error?.reason, 'rate_limit'); + assert.equal(error?.code, '429'); + assert.equal(error?.message, 'Rate limit exceeded'); + }); + + test('classifies provider errors and maps finish reasons through adapter-owned helpers', () => { + const adapter = newAdapter(); + + assert.equal(adapter.classifyError(Object.assign(new Error('401 Authorization'), { code: 401 })), 'Auth'); + assert.equal(adapter.makeErrorEvent('turn-1', new Error('Model stream idle timeout after 120000ms')).reason, 'timeout'); + assert.equal(adapter.mapFinishReason('stop'), 'end_turn'); + assert.equal(adapter.mapFinishReason('length'), 'max_tokens'); + assert.equal(adapter.mapFinishReason('content-filter'), 'error'); + assert.equal(adapter.mapFinishReason('error'), 'error'); + assert.equal(adapter.mapFinishReason('tool-calls'), 'end_turn'); + assert.equal(adapter.mapFinishReason('provider-new-reason'), 'end_turn'); + }); + + test('normalizes cache and reasoning usage variants in the adapter module', () => { + assert.deepEqual( + normalizeAiSdkUsage({ + promptTokens: 20, + completionTokens: 5, + totalTokens: 30, + cacheReadInputTokens: 7, + cacheCreationInputTokens: 3, + inputTokenDetails: { + reasoningTokens: 2, + }, + }), + { + inputTokens: 20, + outputTokens: 5, + cachedInputTokens: 7, + cacheWriteInputTokens: 3, + reasoningTokens: 2, + totalTokens: 30, + }, + ); + }); +}); + +function newAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'anthropic-main', + name: 'Anthropic', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + enabled: true, + createdAt: 1, + updatedAt: 1, + }, + apiKey: 'sk-test', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + maxSteps: 50, + newId: idGenerator(), + now: monotonicClock(), + }); +} + +function idGenerator(): () => string { + let index = 0; + return () => `id-${++index}`; +} + +function monotonicClock(): () => number { + let value = 1_000; + return () => ++value; +} diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 8514d5155c..989d6ffd2d 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3,6 +3,9 @@ import { DEEP_RESEARCH_SESSION_LABEL, deriveTurnRecords } from '@maka/core'; import type { CreateSessionInput, PermissionMode, + AgentRunEvent, + AgentRunHeader, + AgentRunStore, SessionEvent, SessionHeader, SessionListFilter, @@ -74,12 +77,13 @@ describe('SessionManager permission mode updates', () => { test('keeps mode changes blocked until all overlapping turns finish', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const firstGate = makeGate(); const secondGate = makeGate(); const gates = [firstGate, secondGate]; backends.register('fake', (ctx) => new TestBackend(ctx, gates.shift())); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(4_000) }); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(4_000) }); const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); const first = manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })[Symbol.asyncIterator](); @@ -91,6 +95,9 @@ describe('SessionManager permission mode updates', () => { await first.next(); await first.next(); expect((await store.readHeader(session.id)).status).toBe('running'); + const afterFirstRuns = await runStore.listSessionRuns(session.id); + expect(afterFirstRuns.find((run) => run.turnId === 'turn-1')?.status).toBe('completed'); + expect(afterFirstRuns.find((run) => run.turnId === 'turn-2')?.status).toBe('running'); await expectRejects( manager.setPermissionMode(session.id, 'execute'), @@ -101,6 +108,15 @@ describe('SessionManager permission mode updates', () => { await second.next(); await second.next(); expect((await store.readHeader(session.id)).status).toBe('active'); + const finalRuns = await runStore.listSessionRuns(session.id); + expect(finalRuns.map((run) => [run.turnId, run.status])).toEqual([ + ['turn-1', 'completed'], + ['turn-2', 'completed'], + ]); + const firstEvents = await runStore.readEvents(session.id, finalRuns[0]!.runId); + expect(firstEvents.map((event) => event.type)).toContain('run_created'); + expect(firstEvents.map((event) => event.type)).toContain('run_started'); + expect(firstEvents.map((event) => event.type)).toContain('run_completed'); const summary = await manager.setPermissionMode(session.id, 'execute'); expect(summary.permissionMode).toBe('execute'); @@ -221,11 +237,12 @@ describe('SessionManager permission mode updates', () => { test('backend build failure after user append marks turn failed and session blocked', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); backends.register('fake', () => { throw new Error('backend init failed'); }); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(7_500) }); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(7_500) }); const session = await manager.createSession(makeInput()); await expectRejects( @@ -240,6 +257,11 @@ describe('SessionManager permission mode updates', () => { expect(messages.some((message) => message.type === 'user' && message.turnId === 'turn-1')).toBe(true); const turn = (await store.listTurns(session.id)).find((candidate) => candidate.turnId === 'turn-1'); expect(turn?.status).toBe('failed'); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('failed'); + expect(run?.failureClass).toBe('Error'); + const events = await runStore.readEvents(session.id, run!.runId); + expect(events.map((event) => event.type)).toContain('run_failed'); }); test('marks a session running while a turn is in flight and active after completion', async () => { @@ -266,12 +288,13 @@ describe('SessionManager permission mode updates', () => { test('marks permission handoff as waiting_for_user', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); backends.register('fake', (ctx) => new EventBackend(ctx, [ { type: 'permission_request', requestId: 'pr-1', toolUseId: 'tool-1', toolName: 'Bash', category: 'shell_safe', reason: 'custom', args: {} }, { type: 'complete', stopReason: 'permission_handoff' }, ])); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(9_000) }); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(9_000) }); const session = await manager.createSession(makeInput()); await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); @@ -279,6 +302,12 @@ describe('SessionManager permission mode updates', () => { const header = await store.readHeader(session.id); expect(header.status).toBe('waiting_for_user'); expect(header.blockedReason).toBe(undefined); + const [run] = await runStore.listSessionRuns(session.id); + const events = await runStore.readEvents(session.id, run!.runId); + expect(events.some((event) => + event.type === 'run_status_changed' && + event.data?.sessionStatus === 'waiting_for_user' + )).toBe(true); }); test('rejects mode changes while a tool permission request is waiting', async () => { @@ -402,10 +431,11 @@ describe('SessionManager permission mode updates', () => { test('stopSession keeps aborted state even if the backend emits a late completion', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const gate = makeGate(); backends.register('fake', (ctx) => new TestBackend(ctx, gate)); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(12_700) }); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_700) }); const session = await manager.createSession(makeInput()); const iterator = manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })[Symbol.asyncIterator](); @@ -420,6 +450,33 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); expect(turn?.status).toBe('aborted'); expect(turn?.abortSource).toBe('renderer.stop_button'); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('cancelled'); + const events = await runStore.readEvents(session.id, run!.runId); + expect(events.map((event) => event.type)).toContain('run_cancelled'); + }); + + test('durable run ledger records lifecycle trace events and redacts obvious secrets', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TraceBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_750) }); + const session = await manager.createSession(makeInput()); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.backendKind).toBe('fake'); + expect(run?.llmConnectionSlug).toBe('fake'); + expect(run?.modelId).toBe('fake-model'); + expect(run?.permissionMode).toBe('ask'); + expect(run?.status).toBe('completed'); + const events = await runStore.readEvents(session.id, run!.runId); + expect(events.map((event) => event.type)).toContain('model_stream_started'); + expect(events.map((event) => event.type)).toContain('usage_recorded'); + expect(events.map((event) => event.type)).toContain('run_completed'); + expect(JSON.stringify(events).includes('sk-live-secret-token-value')).toBe(false); }); test('startup recovery marks persisted running turns as failed instead of leaving them stuck', async () => { @@ -480,6 +537,214 @@ describe('SessionManager permission mode updates', () => { expect(activeTurn?.status).toBe('completed'); }); + test('startup recovery uses AgentRun ledger to fail stale running model-started runs', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_810) }); + const session = await manager.createSession(makeInput({ status: 'running' })); + await seedRunningTurn(store, session.id, 'turn-1'); + await seedRun(runStore, makeRunHeader({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + status: 'running', + }), [ + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'run_started', ts: 11 }), + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'model_stream_started', ts: 12 }), + ]); + + const recovered = await manager.recoverInterruptedSessions(); + + expect(recovered).toEqual([session.id]); + expect((await store.readHeader(session.id)).status).toBe('active'); + const [turn] = await store.listTurns(session.id); + expect(turn?.status).toBe('failed'); + expect(turn?.errorClass).toBe('app_restarted'); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('failed'); + expect(run?.failureClass).toBe('app_restarted'); + const events = await runStore.readEvents(session.id, 'run-1'); + expect(events.map((event) => event.type)).toContain('run_failed'); + }); + + test('startup recovery fails stale tool tails while preserving partial output retention', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_820) }); + const session = await manager.createSession(makeInput({ status: 'running' })); + await seedRunningTurn(store, session.id, 'turn-1'); + await store.appendMessage(session.id, { + type: 'assistant', + id: 'partial-assistant', + turnId: 'turn-1', + ts: 13, + text: 'partial output', + modelId: 'fake-model', + }); + await seedRun(runStore, makeRunHeader({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + status: 'running', + }), [ + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'run_started', ts: 11 }), + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'tool_started', ts: 12 }), + ]); + + await manager.recoverInterruptedSessions(); + + const [turn] = await store.listTurns(session.id); + expect(turn?.status).toBe('failed'); + expect(turn?.errorClass).toBe('app_restarted'); + expect(turn?.partialOutputRetained).toBe(true); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('failed'); + }); + + test('startup recovery does not leave stale permission waits stuck', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_830) }); + const session = await manager.createSession(makeInput({ status: 'waiting_for_user' })); + await seedRunningTurn(store, session.id, 'turn-1'); + await seedRun(runStore, makeRunHeader({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + status: 'waiting_permission', + }), [ + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'permission_requested', ts: 12 }), + ]); + + await manager.recoverInterruptedSessions(); + + expect((await store.readHeader(session.id)).status).toBe('active'); + const [turn] = await store.listTurns(session.id); + expect(turn?.status).toBe('failed'); + expect(turn?.errorClass).toBe('app_restarted'); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('failed'); + expect(run?.failureClass).toBe('app_restarted'); + }); + + test('startup recovery repairs stale completed model tails without leaving running runs', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_840) }); + const session = await manager.createSession(makeInput({ status: 'running' })); + await seedRunningTurn(store, session.id, 'turn-1'); + await seedRun(runStore, makeRunHeader({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + status: 'running', + }), [ + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'model_stream_started', ts: 11 }), + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'model_stream_completed', ts: 12 }), + ]); + + await manager.recoverInterruptedSessions(); + + expect((await store.readHeader(session.id)).status).toBe('active'); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status === 'running' || run?.status === 'waiting_permission').toBe(false); + const [turn] = await store.listTurns(session.id); + expect(turn?.status === 'running').toBe(false); + }); + + test('startup recovery tolerates corrupt AgentRun events and records a conservative failed state', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_850) }); + const session = await manager.createSession(makeInput({ status: 'running' })); + await seedRunningTurn(store, session.id, 'turn-1'); + await seedRun(runStore, makeRunHeader({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + status: 'running', + }), [ + makeRunEvent({ sessionId: session.id, runId: 'run-1', turnId: 'turn-1', type: 'run_started', ts: 11 }), + makeRunEvent({ + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + type: 'event_corrupt', + ts: 12, + message: 'Invalid AgentRun event JSONL line', + }), + ]); + + const recovered = await manager.recoverInterruptedSessions(); + + expect(recovered).toEqual([session.id]); + const [run] = await runStore.listSessionRuns(session.id); + expect(run?.status).toBe('failed'); + expect(run?.failureClass).toBe('app_restarted'); + const events = await runStore.readEvents(session.id, 'run-1'); + expect(events.map((event) => event.type)).toContain('event_corrupt'); + expect(events.map((event) => event.type)).toContain('run_failed'); + }); + + test('startup recovery keeps terminal AgentRun ledger entries idempotent', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ store, runStore, backends, newId: nextId(), now: nextNow(12_860) }); + const completed = await manager.createSession(makeInput({ status: 'active' })); + const failed = await manager.createSession(makeInput({ status: 'active' })); + const cancelled = await manager.createSession(makeInput({ status: 'active' })); + await seedRun(runStore, makeRunHeader({ + sessionId: completed.id, + runId: 'completed-run', + turnId: 'completed-turn', + status: 'completed', + completedAt: 20, + }), [ + makeRunEvent({ sessionId: completed.id, runId: 'completed-run', turnId: 'completed-turn', type: 'run_completed', ts: 20 }), + ]); + await seedRun(runStore, makeRunHeader({ + sessionId: failed.id, + runId: 'failed-run', + turnId: 'failed-turn', + status: 'failed', + failureClass: 'tool_failed', + completedAt: 21, + }), [ + makeRunEvent({ sessionId: failed.id, runId: 'failed-run', turnId: 'failed-turn', type: 'run_failed', ts: 21, data: { failureClass: 'tool_failed' } }), + ]); + await seedRun(runStore, makeRunHeader({ + sessionId: cancelled.id, + runId: 'cancelled-run', + turnId: 'cancelled-turn', + status: 'cancelled', + completedAt: 22, + }), [ + makeRunEvent({ sessionId: cancelled.id, runId: 'cancelled-run', turnId: 'cancelled-turn', type: 'run_cancelled', ts: 22 }), + ]); + + const recovered = await manager.recoverInterruptedSessions(); + + expect(recovered).toEqual([]); + expect((await runStore.readRun(completed.id, 'completed-run')).status).toBe('completed'); + expect((await runStore.readEvents(completed.id, 'completed-run')).map((event) => event.type)).toEqual(['run_completed']); + expect((await runStore.readRun(failed.id, 'failed-run')).failureClass).toBe('tool_failed'); + expect((await runStore.readEvents(failed.id, 'failed-run')).map((event) => event.type)).toEqual(['run_failed']); + expect((await runStore.readRun(cancelled.id, 'cancelled-run')).status).toBe('cancelled'); + expect((await runStore.readEvents(cancelled.id, 'cancelled-run')).map((event) => event.type)).toEqual(['run_cancelled']); + }); + test('startup recovery does not leave persisted running sessions stuck when message read fails', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); @@ -643,6 +908,47 @@ class PartialAbortBackend implements AgentBackend { async dispose(): Promise {} } +class TraceBackend implements AgentBackend { + readonly kind = 'fake' as const; + readonly sessionId: string; + + constructor(private readonly ctx: BackendFactoryContext) { + this.sessionId = ctx.sessionId; + } + + async *send(input: BackendSendInput): AsyncIterable { + this.ctx.recordRunTrace?.({ + id: `${input.turnId}-trace-start`, + sessionId: this.sessionId, + turnId: input.turnId, + ts: 1, + phase: 'model', + type: 'model_stream_started', + message: 'Model stream started with Bearer sk-live-secret-token-value', + data: { + activeTools: ['Read'], + credential: 'sk-live-secret-token-value', + }, + }); + yield { type: 'text_delta', id: `${input.turnId}-delta`, turnId: input.turnId, ts: 2, messageId: `${input.turnId}-m`, text: 'ok' }; + this.ctx.recordRunTrace?.({ + id: `${input.turnId}-trace-usage`, + sessionId: this.sessionId, + turnId: input.turnId, + ts: 3, + phase: 'usage', + type: 'usage_recorded', + message: 'Token usage recorded', + data: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }); + yield { type: 'complete', id: `${input.turnId}-complete`, turnId: input.turnId, ts: 4, stopReason: 'end_turn' }; + } + + async stop(): Promise {} + async respondToPermission(_decision: PermissionDecision): Promise {} + async dispose(): Promise {} +} + class MemorySessionStore implements SessionStore { private headers = new Map(); private messages = new Map(); @@ -734,6 +1040,45 @@ class MemorySessionStore implements SessionStore { } } +class MemoryAgentRunStore implements AgentRunStore { + private headers = new Map(); + private events = new Map(); + + async createRun(header: AgentRunHeader): Promise { + this.headers.set(key(header.sessionId, header.runId), { ...header }); + return { ...header }; + } + + async updateRun(sessionId: string, runId: string, patch: Partial): Promise { + const current = await this.readRun(sessionId, runId); + const next = { ...current, ...patch, sessionId, runId }; + this.headers.set(key(sessionId, runId), next); + return { ...next }; + } + + async readRun(sessionId: string, runId: string): Promise { + const header = this.headers.get(key(sessionId, runId)); + if (!header) throw new Error(`Unknown run ${runId}`); + return { ...header }; + } + + async listSessionRuns(sessionId: string): Promise { + return Array.from(this.headers.values()) + .filter((header) => header.sessionId === sessionId) + .sort((a, b) => a.createdAt - b.createdAt || a.runId.localeCompare(b.runId)) + .map((header) => ({ ...header })); + } + + async appendEvent(sessionId: string, runId: string, event: AgentRunEvent): Promise { + const eventKey = key(sessionId, runId); + this.events.set(eventKey, [...(this.events.get(eventKey) ?? []), copyEvent(event)]); + } + + async readEvents(sessionId: string, runId: string): Promise { + return (this.events.get(key(sessionId, runId)) ?? []).map(copyEvent); + } +} + interface Gate { promise: Promise; release(): void; @@ -760,6 +1105,53 @@ function makeInput(overrides: Partial = {}): CreateSessionIn }; } +function makeRunHeader(overrides: Partial = {}): AgentRunHeader { + return { + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'running', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/tmp/cwd', + permissionMode: 'ask', + createdAt: 10, + updatedAt: 10, + ...overrides, + }; +} + +function makeRunEvent(overrides: Partial = {}): AgentRunEvent { + return { + type: 'run_started', + id: `${overrides.runId ?? 'run-1'}-${overrides.type ?? 'run_started'}-${overrides.ts ?? 10}`, + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 10, + ...overrides, + }; +} + +async function seedRun( + runStore: AgentRunStore, + header: AgentRunHeader, + events: AgentRunEvent[], +): Promise { + await runStore.createRun(header); + for (const event of events) { + await runStore.appendEvent(header.sessionId, header.runId, event); + } +} + +async function seedRunningTurn(store: MemorySessionStore, sessionId: string, turnId: string): Promise { + await store.appendMessages(sessionId, [ + { type: 'user', id: `${turnId}-user`, turnId, ts: 9, text: 'interrupted turn' }, + { type: 'turn_state', id: `${turnId}-state`, turnId, ts: 10, status: 'running', partialOutputRetained: false }, + ]); +} + function nextId(): () => string { let id = 0; return () => `id-${++id}`; @@ -785,3 +1177,14 @@ async function expectRejects(promise: Promise, pattern: RegExp): Promis } throw new Error('Expected promise to reject'); } + +function key(sessionId: string, runId: string): string { + return `${sessionId}:${runId}`; +} + +function copyEvent(event: AgentRunEvent): AgentRunEvent { + return { + ...event, + ...(event.data ? { data: { ...event.data } } : {}), + }; +} diff --git a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts new file mode 100644 index 0000000000..6248e37c32 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { describe, test } from 'node:test'; + +const REPO_ROOT = resolveRepoRoot(); + +async function readRepo(path: string): Promise { + return readFile(join(REPO_ROOT, path), 'utf8'); +} + +function resolveRepoRoot(): string { + const cwd = resolve(process.cwd()); + if (existsSync(join(cwd, 'packages', 'runtime', 'src', 'ai-sdk-backend.ts'))) return cwd; + const fromWorkspace = resolve(cwd, '..', '..'); + if (existsSync(join(fromWorkspace, 'packages', 'runtime', 'src', 'ai-sdk-backend.ts'))) return fromWorkspace; + return cwd; +} + +describe('ToolRuntime extraction contract', () => { + test('AiSdkBackend keeps only the ai-sdk loop and delegates tool execution internally', async () => { + const backend = await readRepo('packages/runtime/src/ai-sdk-backend.ts'); + + assert.match(backend, /from '\.\/tool-runtime\.js'/); + assert.match(backend, /private readonly toolRuntime: ToolRuntime;/); + assert.match( + backend, + /private wrapToolExecute\([\s\S]*?\)\s*\{\s*return this\.toolRuntime\.wrapToolExecute\(tool, turnId, queue\);\s*\}/, + 'AiSdkBackend.wrapToolExecute must be a narrow compatibility shim', + ); + assert.match( + backend, + /cleanupAfterTurn\(turnId: string\): void \{[\s\S]*?this\.toolRuntime\.resetTurnState\(\);[\s\S]*?\}/, + 'turn cleanup must reset ToolRuntime-owned per-turn state', + ); + + assert.doesNotMatch(backend, /private async writeSyntheticToolResult/); + assert.doesNotMatch(backend, /private coerceResultContent/); + assert.doesNotMatch(backend, /private coerceTerminalFailure/); + assert.doesNotMatch(backend, /private async awaitPermissionDecision/); + assert.doesNotMatch(backend, /private activeSubagentToolCount/); + assert.doesNotMatch(backend, /private reserveSubagentSlot/); + assert.doesNotMatch(backend, /private releaseSubagentSlot/); + }); + + test('ToolRuntime owns permission, watchdog pause, telemetry, artifacts, and result classification', async () => { + const runtime = await readRepo('packages/runtime/src/tool-runtime.ts'); + + assert.match(runtime, /export class ToolRuntime/); + assert.match(runtime, /wrapToolExecute\(/); + assert.match(runtime, /permissionEngine\.evaluate/); + assert.match(runtime, /getPermissionPauseTarget/); + assert.match(runtime, /recordToolInvocation/); + assert.match(runtime, /recordToolArtifactsSafely/); + assert.match(runtime, /deriveToolResultStatus/); + assert.match(runtime, /coerceTerminalFailure/); + assert.match(runtime, /formatSyntheticToolErrorText/); + assert.match(runtime, /activeSubagentToolCount/); + }); +}); + +describe('ModelAdapter extraction contract', () => { + test('AiSdkBackend delegates provider model, stream, error, finish, and usage normalization', async () => { + const backend = await readRepo('packages/runtime/src/ai-sdk-backend.ts'); + + assert.match(backend, /from '\.\/model-adapter\.js'/); + assert.match(backend, /private readonly modelAdapter: ModelAdapter;/); + assert.match(backend, /this\.modelAdapter\.resolveModel\(\)/); + assert.match(backend, /this\.modelAdapter\.startStream\(/); + assert.match(backend, /this\.modelAdapter\.handleStreamChunk\(/); + assert.match(backend, /normalizeAiSdkUsage\(await result\.usage\)/); + assert.match(backend, /this\.modelAdapter\.classifyError\(/); + assert.match( + backend, + /private mapFinishReason\(reason: unknown\): CompleteEvent\['stopReason'\] \{\s*return this\.modelAdapter\.mapFinishReason\(reason\);\s*\}/, + 'AiSdkBackend.mapFinishReason must remain a compatibility shim', + ); + assert.match( + backend, + /private makeErrorEvent\(turnId: string, err: unknown\): ErrorEvent \{\s*return this\.modelAdapter\.makeErrorEvent\(turnId, err\);\s*\}/, + 'AiSdkBackend.makeErrorEvent must remain a compatibility shim', + ); + + assert.doesNotMatch(backend, /await import\('ai'\)/); + assert.doesNotMatch(backend, /const \{ streamText, stepCountIs \}/); + assert.doesNotMatch(backend, /switch \(chunk\.type\)/); + assert.doesNotMatch(backend, /case 'reasoning-delta'/); + assert.doesNotMatch(backend, /function finiteToken/); + }); + + test('ModelAdapter owns provider stream, error, finish, and usage normalization', async () => { + const adapter = await readRepo('packages/runtime/src/model-adapter.ts'); + + assert.match(adapter, /export class ModelAdapter/); + assert.match(adapter, /resolveModel\(\)/); + assert.match(adapter, /startStream\(/); + assert.match(adapter, /await import\('ai'\)/); + assert.match(adapter, /streamText\(/); + assert.match(adapter, /stepCountIs\(this\.input\.maxSteps\)/); + assert.match(adapter, /handleStreamChunk\(/); + assert.match(adapter, /switch \(chunk\.type\)/); + assert.match(adapter, /case 'reasoning-delta'/); + assert.match(adapter, /makeErrorEvent\(/); + assert.match(adapter, /mapFinishReason\(/); + assert.match(adapter, /export function normalizeAiSdkUsage/); + assert.match(adapter, /function finiteToken/); + }); +}); + +describe('RunTrace extraction contract', () => { + test('AiSdkBackend owns turn/model/usage/abort tracing as an internal hook', async () => { + const backend = await readRepo('packages/runtime/src/ai-sdk-backend.ts'); + const barrel = await readRepo('packages/runtime/src/index.ts'); + + assert.match(backend, /from '\.\/run-trace\.js'/); + assert.match(backend, /recordRunTrace\?: RunTraceRecorder/); + assert.match(backend, /private currentRunTrace: RunTrace \| null = null;/); + assert.match(backend, /trace\.turnStarted\(\)/); + assert.match(backend, /trace\.modelResolved\(\)/); + assert.match(backend, /trace\.modelStreamStarted\(activeTools\)/); + assert.match(backend, /trace\.usageRecorded\(tokenUsage\)/); + assert.match(backend, /this\.currentRunTrace\?\.abortRequested\(_reason\)/); + assert.match(barrel, /RunTraceEvent/); + assert.match(barrel, /RunTraceRecorder/); + }); + + test('ToolRuntime traces permission and tool lifecycle without owning model tracing', async () => { + const runtime = await readRepo('packages/runtime/src/tool-runtime.ts'); + + assert.match(runtime, /getRunTrace\?: \(\) => RunTraceLike \| null/); + assert.match(runtime, /'tool_started'/); + assert.match(runtime, /'tool_completed'/); + assert.match(runtime, /'tool_failed'/); + assert.match(runtime, /'permission_requested'/); + assert.match(runtime, /'permission_decided'/); + assert.match(runtime, /'permission_failed'/); + assert.doesNotMatch(runtime, /modelStreamStarted/); + assert.doesNotMatch(runtime, /usageRecorded/); + }); + + test('RunTrace stays diagnostic-only and does not extend SessionEvent', async () => { + const trace = await readRepo('packages/runtime/src/run-trace.ts'); + const events = await readRepo('packages/core/src/events.ts'); + const adapter = await readRepo('packages/runtime/src/model-adapter.ts'); + const preload = await readRepo('apps/desktop/src/preload/preload.ts'); + const main = await readRepo('apps/desktop/src/main/main.ts'); + const settings = await readRepo('apps/desktop/src/renderer/settings/SettingsModal.tsx'); + + assert.match(trace, /export class RunTrace/); + assert.match(trace, /export interface RunTraceEvent/); + assert.match(trace, /type RunTracePhase = 'turn' \| 'model' \| 'tool' \| 'permission' \| 'abort' \| 'usage'/); + assert.doesNotMatch(events, /RunTrace/); + assert.doesNotMatch(events, /trace_/); + assert.doesNotMatch(adapter, /RunTrace|recordRunTrace/); + assert.doesNotMatch(preload, /RunTrace|recordRunTrace|runTrace|trace_/); + assert.doesNotMatch(main, /ipcMain\.handle\([^)]*(?:RunTrace|recordRunTrace|runTrace|trace_)/); + assert.doesNotMatch(settings, /RunTrace|recordRunTrace|runTrace|trace_/); + }); +}); diff --git a/packages/runtime/src/agent-run-recovery.ts b/packages/runtime/src/agent-run-recovery.ts new file mode 100644 index 0000000000..60f24493ae --- /dev/null +++ b/packages/runtime/src/agent-run-recovery.ts @@ -0,0 +1,151 @@ +import type { AgentRunEvent, AgentRunHeader } from '@maka/core'; +import type { StoredMessage } from '@maka/core/session'; +import type { UserMessageInput } from '@maka/core/runtime-inputs'; + +export interface AgentRunRecoveryDecision { + runId: string; + turnId: string; + status: 'failed' | 'completed'; + failureClass?: string; + diagnostic?: Record; + lineage: Partial>; +} + +export function classifyAgentRunRecovery( + header: AgentRunHeader, + events: readonly AgentRunEvent[], + sessionMessages: readonly StoredMessage[], +): AgentRunRecoveryDecision | undefined { + if (isTerminalRunStatus(header.status)) return undefined; + + const lastEvent = lastNonCorruptEvent(events); + const hasCorruptEvent = events.some((event) => event.type === 'event_corrupt'); + const lastEventType = lastEvent?.type; + const lastTurnState = latestTurnState(sessionMessages, header.turnId); + const hasAssistantOutput = sessionMessages.some((message) => + message.type === 'assistant' && message.turnId === header.turnId && message.text.trim().length > 0, + ); + const hasToolOutput = sessionMessages.some((message) => + message.type === 'tool_result' && message.turnId === header.turnId, + ); + + if (lastEventType === 'model_stream_completed' && !hasTerminalRunEvent(events)) { + if (hasAssistantOutput || lastTurnState?.status === 'completed') { + return { + runId: header.runId, + turnId: header.turnId, + status: 'completed', + diagnostic: diagnostic('stale_completed_run', lastEventType, hasCorruptEvent), + lineage: headerLineage(header), + }; + } + return failedDecision(header, 'app_restarted', diagnostic('model_stream_completed_without_projection', lastEventType, hasCorruptEvent)); + } + + if ( + header.status === 'waiting_permission' || + lastEventType === 'permission_requested' || + lastEventType === 'permission_failed' + ) { + return failedDecision(header, 'app_restarted', diagnostic('stale_permission_wait', lastEventType, hasCorruptEvent)); + } + + if (lastEventType === 'tool_started') { + return failedDecision( + header, + 'app_restarted', + diagnostic('tool_interrupted', lastEventType, hasCorruptEvent, { partialOutputRetained: hasToolOutput }), + ); + } + + if ( + header.status === 'created' || + header.status === 'running' || + lastEventType === undefined || + lastEventType === 'run_created' || + lastEventType === 'run_started' || + lastEventType === 'turn_started' || + lastEventType === 'model_resolved' || + lastEventType === 'model_stream_started' || + lastEventType === 'run_status_changed' + ) { + return failedDecision(header, 'app_restarted', diagnostic('run_interrupted', lastEventType, hasCorruptEvent)); + } + + return failedDecision(header, 'app_restarted', diagnostic('non_terminal_run_recovered', lastEventType, hasCorruptEvent)); +} + +function failedDecision( + header: AgentRunHeader, + failureClass: string, + diagnostic?: Record, +): AgentRunRecoveryDecision { + return { + runId: header.runId, + turnId: header.turnId, + status: 'failed', + failureClass, + diagnostic, + lineage: headerLineage(header), + }; +} + +function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'cancelled'; +} + +function hasTerminalRunEvent(events: readonly AgentRunEvent[]): boolean { + return events.some((event) => + event.type === 'run_completed' || + event.type === 'run_failed' || + event.type === 'run_cancelled', + ); +} + +function lastNonCorruptEvent(events: readonly AgentRunEvent[]): AgentRunEvent | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event && event.type !== 'event_corrupt') return event; + } + return undefined; +} + +function latestTurnState( + messages: readonly StoredMessage[], + turnId: string, +): Extract | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type === 'turn_state' && message.turnId === turnId) return message; + } + return undefined; +} + +function diagnostic( + reason: string, + lastEventType: AgentRunEvent['type'] | undefined, + hasCorruptEvent: boolean, + extra: Record = {}, +): Record { + return { + recoveryReason: reason, + ...(lastEventType ? { lastEventType } : {}), + ...(hasCorruptEvent ? { eventCorrupt: true } : {}), + ...extra, + }; +} + +function headerLineage( + header: AgentRunHeader, +): Partial> { + return { + ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), + ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), + ...(header.regeneratedFromTurnId ? { regeneratedFromTurnId: header.regeneratedFromTurnId } : {}), + ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + }; +} diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts new file mode 100644 index 0000000000..e618c5e71f --- /dev/null +++ b/packages/runtime/src/agent-run.ts @@ -0,0 +1,519 @@ +import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core'; +import { redactSecrets } from '@maka/core/redaction'; +import type { + SessionBlockedReason, + SessionHeader, + SessionStatus, + StoredMessage, + SystemNoteMessage, + TurnRecord, + UserMessage, +} from '@maka/core/session'; +import type { UserMessageInput } from '@maka/core/runtime-inputs'; +import type { SessionEvent } from '@maka/core/events'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { AgentBackend } from './ai-sdk-backend.js'; +import type { RunTraceEvent } from './run-trace.js'; +import type { SessionStore, StopSessionInput } from './session-manager.js'; + +export interface AgentRunActiveSession { + sessionId: string; + backend: AgentBackend; + cachedHeader: SessionHeader; + activeRuns: Map; + turnToRunId: Map; +} + +export interface AgentRunHooks { + ensureActive(sessionId: string, header: SessionHeader): Promise; + registerRun(active: AgentRunActiveSession, run: AgentRun): void; + unregisterRun(active: AgentRunActiveSession, run: AgentRun): void; + updateHeader(sessionId: string, patch: Partial): Promise; + updateStatus(sessionId: string, status: SessionStatus, blockedReason?: SessionBlockedReason, ts?: number): Promise; + appendTurnState( + sessionId: string, + turnId: string, + status: TurnRecord['status'], + lineage?: AgentRunLineage, + options?: { ts?: number; errorClass?: string; abortSource?: string }, + ): Promise; +} + +export type AgentRunLineage = Partial>; + +export interface AgentRunInput { + sessionId: string; + header: SessionHeader; + userInput: UserMessageInput; + store: SessionStore; + runStore?: AgentRunStore; + newId: () => string; + now: () => number; + hooks: AgentRunHooks; +} + +export class AgentRun { + readonly runId: string; + readonly sessionId: string; + readonly turnId: string; + readonly lineage: AgentRunLineage; + + private header: SessionHeader; + private active: AgentRunActiveSession | undefined; + private stopped = false; + private abortSource: string | undefined; + private traceQueue: Promise = Promise.resolve(); + private runStoreAvailable = true; + private failureClass: string | undefined; + private failureMessage: string | undefined; + + constructor(private readonly input: AgentRunInput) { + this.runId = input.newId(); + this.sessionId = input.sessionId; + this.turnId = input.userInput.turnId; + this.header = input.header; + this.lineage = { + ...(input.userInput.parentTurnId ? { parentTurnId: input.userInput.parentTurnId } : {}), + ...(input.userInput.retriedFromTurnId ? { retriedFromTurnId: input.userInput.retriedFromTurnId } : {}), + ...(input.userInput.regeneratedFromTurnId ? { regeneratedFromTurnId: input.userInput.regeneratedFromTurnId } : {}), + ...(input.userInput.branchOfTurnId ? { branchOfTurnId: input.userInput.branchOfTurnId } : {}), + ...(input.userInput.parentSessionId ? { parentSessionId: input.userInput.parentSessionId } : {}), + }; + } + + stop(source: StopSessionInput['source'] | undefined): void { + this.stopped = true; + this.abortSource = normalizeStopSessionSource(source); + } + + recordRunTrace(event: RunTraceEvent): void { + if (!this.input.runStore || !this.runStoreAvailable) return; + this.enqueueRunStore('append trace event', async () => { + await this.input.runStore?.appendEvent(this.sessionId, this.runId, traceToRunEvent(event, this.runId)); + }); + } + + async *execute(): AsyncIterable { + await this.createRunRecord(); + + const userMsg: UserMessage = { + type: 'user', + id: this.input.newId(), + turnId: this.turnId, + ts: this.input.now(), + text: this.input.userInput.text, + ...(this.input.userInput.attachments ? { attachments: this.input.userInput.attachments } : {}), + }; + await this.input.store.appendMessage(this.sessionId, userMsg); + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage); + + let lastTs = this.input.now(); + let sawCompletion = false; + let finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined; + let turnFailed = false; + + try { + if (!this.header.connectionLocked) { + this.header = await this.input.hooks.updateHeader(this.sessionId, { connectionLocked: true }); + } + + this.active = await this.input.hooks.ensureActive(this.sessionId, this.header); + this.input.hooks.registerRun(this.active, this); + await this.markRunStarted(lastTs); + + await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, lastTs); + + const backendInput: BackendSendInput = { + turnId: this.turnId, + text: this.input.userInput.text, + ...(this.input.userInput.attachments ? { attachments: this.input.userInput.attachments } : {}), + context: await this.input.store.readMessages(this.sessionId), + }; + for await (const ev of this.active.backend.send(backendInput)) { + lastTs = ev.ts; + const transition = statusFromEvent(ev); + if (transition && !this.stopped) { + await this.input.hooks.updateStatus(this.sessionId, transition.status, transition.blockedReason, ev.ts); + this.recordStatusFromTransition(ev, transition, ev.ts); + } + if ((ev.type === 'complete' || ev.type === 'abort') && !turnFailed) { + sawCompletion = true; + finalStatus = this.stopped + ? { status: 'aborted' } + : (transition ?? { status: 'active' }); + const turnStatus = turnStatusFromEvent(ev); + if (turnStatus && !this.stopped) { + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, turnStatus.status, this.lineage, { + ts: ev.ts, + errorClass: turnStatus.errorClass, + }); + } + } + if (ev.type === 'error') { + turnFailed = true; + finalStatus = transition ?? { status: 'blocked', blockedReason: 'unknown' }; + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { + ts: ev.ts, + errorClass: ev.reason ?? ev.code ?? 'unknown', + }); + this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message, ev.ts); + } + yield ev; + } + } catch (error) { + finalStatus = { status: 'blocked', blockedReason: 'unknown' }; + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { + errorClass: error instanceof Error ? error.name : 'unknown', + }).catch(() => {}); + this.markRunFailed(error instanceof Error ? error.name : 'unknown', errorMessage(error), this.input.now()); + throw error; + } finally { + if (this.active) { + this.input.hooks.unregisterRun(this.active, this); + if (this.stopped) finalStatus = { status: 'aborted' }; + } + const nextStatus = this.active && this.active.activeRuns.size > 0 + ? { status: 'running' as const } + : (finalStatus ?? { status: 'active' as const }); + try { + await this.input.hooks.updateHeader(this.sessionId, { + lastUsedAt: lastTs, + lastMessageAt: lastTs, + hasUnread: true, + ...statusPatch(nextStatus.status, lastTs, nextStatus.blockedReason), + }); + } catch { + // The user-visible turn already completed; preserve existing behavior. + } + if (sawCompletion) { + await this.input.store.appendMessage(this.sessionId, { + type: 'system_note', + id: this.input.newId(), + turnId: this.turnId, + ts: lastTs, + kind: 'session_resume', + } satisfies SystemNoteMessage).catch(() => {}); + } + await this.finishRun(finalStatus, lastTs); + } + } + + private async createRunRecord(): Promise { + if (!this.input.runStore) return; + const createdAt = this.input.now(); + const header: AgentRunHeader = { + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + status: 'created', + backendKind: this.header.backend, + llmConnectionSlug: this.header.llmConnectionSlug, + modelId: this.header.model, + cwd: this.header.cwd, + permissionMode: this.header.permissionMode, + createdAt, + updatedAt: createdAt, + ...this.lineage, + }; + try { + await this.input.runStore.createRun(header); + await this.input.runStore.appendEvent(this.sessionId, this.runId, { + type: 'run_created', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: createdAt, + data: { + textLength: this.input.userInput.text.length, + attachmentCount: this.input.userInput.attachments?.length ?? 0, + }, + }); + } catch (error) { + this.runStoreAvailable = false; + this.enqueueTraceWriteFailure(error); + } + } + + private async markRunStarted(ts: number): Promise { + if (!this.input.runStore || !this.runStoreAvailable) return; + this.enqueueRunStore('mark run started', async () => { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { status: 'running', updatedAt: ts }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: 'run_started', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + }); + }); + } + + private recordStatusFromTransition( + ev: SessionEvent, + transition: { status: SessionStatus; blockedReason?: SessionBlockedReason }, + ts: number, + ): void { + if (!this.input.runStore || !this.runStoreAvailable) return; + const status = transition.status === 'waiting_for_user' + ? 'waiting_permission' + : transition.status === 'aborted' + ? 'cancelled' + : transition.status === 'blocked' + ? 'failed' + : transition.status === 'active' + ? 'completed' + : 'running'; + this.enqueueRunStore('record run status', async () => { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { status, updatedAt: ts }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: 'run_status_changed', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + data: { sessionStatus: transition.status, ...(transition.blockedReason ? { blockedReason: transition.blockedReason } : {}) }, + }); + }); + if (ev.type === 'abort') { + this.markRunCancelled(ev.reason, ts); + } + } + + private markRunFailed(failureClass: string, message: string, ts: number): void { + if (!this.input.runStore || !this.runStoreAvailable) return; + this.failureClass = failureClass; + this.failureMessage = redactTraceString(message); + this.enqueueRunStore('mark run failed', async () => { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { + status: 'failed', + updatedAt: ts, + completedAt: ts, + failureClass, + failureMessage: this.failureMessage, + }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: 'run_failed', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + message: redactTraceString(message), + data: { failureClass }, + }); + }); + } + + private markRunCancelled(reason: string | undefined, ts: number): void { + if (!this.input.runStore || !this.runStoreAvailable) return; + this.enqueueRunStore('mark run cancelled', async () => { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { + status: 'cancelled', + updatedAt: ts, + completedAt: ts, + }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: 'run_cancelled', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + ...(reason ? { message: redactTraceString(reason) } : {}), + }); + }); + } + + private async finishRun( + finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined, + ts: number, + ): Promise { + await this.traceQueue.catch(() => {}); + if (!this.input.runStore || !this.runStoreAvailable) return; + const status = this.stopped || finalStatus?.status === 'aborted' + ? 'cancelled' + : finalStatus?.status === 'blocked' + ? 'failed' + : finalStatus?.status === 'waiting_for_user' + ? 'waiting_permission' + : 'completed'; + const isTerminal = status === 'completed' || status === 'failed' || status === 'cancelled'; + await this.enqueueRunStore('finish run', async () => { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { + status, + updatedAt: ts, + ...(isTerminal ? { completedAt: ts } : {}), + ...(status === 'failed' + ? { + failureClass: this.failureClass ?? finalStatus?.blockedReason ?? 'unknown', + ...(this.failureMessage ? { failureMessage: this.failureMessage } : {}), + } + : {}), + }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: status === 'cancelled' + ? 'run_cancelled' + : status === 'failed' + ? 'run_failed' + : status === 'completed' + ? 'run_completed' + : 'run_status_changed', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + ...(status === 'failed' + ? { data: { failureClass: this.failureClass ?? finalStatus?.blockedReason ?? 'unknown' } } + : status === 'waiting_permission' + ? { data: { sessionStatus: 'waiting_for_user', blockedReason: finalStatus?.blockedReason ?? 'permission_required' } } + : {}), + }); + }); + await this.traceQueue.catch(() => {}); + } + + private enqueueRunStore(label: string, operation: () => Promise): Promise { + if (!this.input.runStore || !this.runStoreAvailable) return Promise.resolve(); + const next = this.traceQueue.then(operation, operation).catch(async (error) => { + this.runStoreAvailable = false; + await this.enqueueTraceWriteFailure(error, label); + }); + this.traceQueue = next.catch(() => {}); + return next; + } + + private async enqueueTraceWriteFailure(error: unknown, label = 'agent run store write'): Promise { + const message = errorMessage(error); + try { + await this.input.runStore?.updateRun(this.sessionId, this.runId, { + traceWriteError: `${label}: ${message}`, + updatedAt: this.input.now(), + }); + await this.input.runStore?.appendEvent(this.sessionId, this.runId, { + type: 'trace_write_failed', + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: this.input.now(), + message, + }); + } catch { + // Diagnostic persistence failed too; never perturb model/tool execution. + } + } +} + +function traceToRunEvent(event: RunTraceEvent, runId: string): AgentRunEvent { + return { + type: event.type, + id: event.id, + runId, + sessionId: event.sessionId, + turnId: event.turnId, + ts: event.ts, + message: redactTraceString(event.message), + data: sanitizeTraceData(event.data), + }; +} + +function sanitizeTraceData(data: Record | undefined): Record | undefined { + if (!data) return undefined; + return Object.fromEntries( + Object.entries(data) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, sanitizeTraceValue(value)]), + ); +} + +function sanitizeTraceValue(value: unknown): unknown { + if (typeof value === 'string') return redactTraceString(value); + if (Array.isArray(value)) return value.slice(0, 50).map(sanitizeTraceValue); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .slice(0, 50) + .map(([key, nested]) => [key, sanitizeTraceValue(nested)]), + ); + } + return value; +} + +function redactTraceString(value: string): string { + const redacted = redactSecrets(value); + return redacted.length > 2_000 ? `${redacted.slice(0, 2_000)}...[truncated]` : redacted; +} + +function errorMessage(error: unknown): string { + return redactTraceString(error instanceof Error ? error.message : String(error)); +} + +function statusPatch( + status: SessionStatus, + ts: number, + blockedReason?: SessionBlockedReason, +): Pick { + return { + status, + blockedReason: status === 'blocked' ? (blockedReason ?? 'unknown') : undefined, + statusUpdatedAt: ts, + }; +} + +function statusFromEvent(event: SessionEvent): { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined { + switch (event.type) { + case 'permission_request': + return { status: 'waiting_for_user', blockedReason: 'permission_required' }; + case 'permission_decision_ack': + return event.decision === 'allow' ? { status: 'running' } : { status: 'aborted' }; + case 'error': + return { status: 'blocked', blockedReason: blockedReasonFromErrorReason(event.reason) }; + case 'abort': + return { status: 'aborted' }; + case 'complete': + if (event.stopReason === 'permission_handoff') return { status: 'waiting_for_user', blockedReason: 'permission_required' }; + if (event.stopReason === 'user_stop') return { status: 'aborted' }; + if (event.stopReason === 'error') return { status: 'blocked', blockedReason: 'unknown' }; + return { status: 'active' }; + default: + return undefined; + } +} + +function turnStatusFromEvent(event: SessionEvent): { status: TurnRecord['status']; errorClass?: string } | undefined { + switch (event.type) { + case 'abort': + return { status: 'aborted' }; + case 'error': + return { status: 'failed', errorClass: event.reason ?? event.code ?? 'unknown' }; + case 'complete': + if (event.stopReason === 'user_stop') return { status: 'aborted' }; + if (event.stopReason === 'error') return { status: 'failed', errorClass: 'unknown' }; + if (event.stopReason === 'permission_handoff') return { status: 'running' }; + return { status: 'completed' }; + default: + return undefined; + } +} + +function blockedReasonFromErrorReason(reason: string | undefined): SessionBlockedReason { + if (!reason) return 'unknown'; + if (reason === 'permission_required') return 'permission_required'; + if (reason === 'tool_failed') return 'tool_failed'; + if (reason === 'auth' || reason.includes('api_key') || reason.includes('connection')) return 'NO_REAL_CONNECTION'; + return 'unknown'; +} + +function normalizeStopSessionSource(source: StopSessionInput['source'] | undefined): string | undefined { + switch (source) { + case 'stop_button': return 'renderer.stop_button'; + case undefined: return undefined; + } +} diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 146ccd750e..37c0102045 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -37,14 +37,7 @@ import type { CompleteEvent, AbortEvent, ErrorEvent, - ToolStartEvent, - ToolResultEvent, - ToolResultContent, - ToolOutputStream, - TextDeltaEvent, TextCompleteEvent, - ThinkingDeltaEvent, - ThinkingCompleteEvent, TokenUsageEvent, AttachmentRef, } from '@maka/core/events'; @@ -62,20 +55,42 @@ import type { BackendSendInput, PermissionDecision, } from '@maka/core/backend-types'; -import type { ToolCategory } from '@maka/core/permission'; -import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; -import { generalizedErrorMessage, redactSecrets } from '@maka/core/redaction'; +import type { LlmConnection } from '@maka/core/llm-connections'; import type { LlmCallRecord, ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { z } from 'zod'; import { PermissionEngine } from './permission-engine.js'; import { AsyncEventQueue } from './async-queue.js'; -import { - recordToolArtifactsSafely, - type ToolArtifactRecorder, -} from './tool-artifacts.js'; -import { createToolOutputDeltaEmitter } from './tool-output-delta.js'; import { StreamWatchdog, formatStreamWatchdogError } from './stream-watchdog.js'; +import { + MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, + TOOL_ERROR_RESULT_MAX_CHARS, + ToolRuntime, + formatSyntheticToolErrorText, + type MakaTool, + type MakaToolContext, +} from './tool-runtime.js'; +import { + ModelAdapter, + normalizeAiSdkUsage, + type ModelFactory, + type ModelFactoryInput, + type NormalizedAiSdkUsage, + type RepairableAiSdkToolCall, +} from './model-adapter.js'; +import type { ToolArtifactRecorder } from './tool-artifacts.js'; +import { RunTrace, type RunTraceRecorder } from './run-trace.js'; + +export { + DEFAULT_PERMISSION_TIMEOUT_MS, + MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, + TOOL_ERROR_RESULT_MAX_CHARS, + formatSyntheticToolErrorText, +} from './tool-runtime.js'; +export type { MakaTool, MakaToolContext } from './tool-runtime.js'; +export { normalizeAiSdkUsage } from './model-adapter.js'; +export type { ModelFactory, ModelFactoryInput, RepairableAiSdkToolCall } from './model-adapter.js'; +export type { RunTraceEvent, RunTraceRecorder } from './run-trace.js'; // ============================================================================ // AgentBackend interface @@ -90,78 +105,7 @@ export interface AgentBackend { dispose(): Promise; } -// ============================================================================ -// MakaTool: our wrapper around ai-sdk's tool definition. -// -// We carry the Zod schema and an `impl` callback. The backend wraps `impl` -// with permission gating before passing to ai-sdk's `streamText({ tools })`. -// ============================================================================ - -export interface MakaTool

{ - /** Canonical (Claude-SDK-style) name. Pi adapter → translate to canonical. */ - name: string; - /** Human-readable description shown to the model. */ - description: string; - /** Zod schema describing the tool's argument shape. Carried as `unknown` - * here so this file does not have a hard zod dependency; runtime callers - * may pass a `z.ZodTypeAny`. */ - parameters: unknown; - /** - * If `false`, the wrap layer skips PermissionEngine.evaluate() entirely - * and runs impl directly. Use for read-only / search tools (Read / Glob / - * Grep). Defaults to `true` (always go through the engine). - */ - permissionRequired?: boolean; - /** Optional UI display name. */ - displayName?: string; - /** Optional trusted category override for custom tools. */ - categoryHint?: ToolCategory; - /** Real tool implementation. Called only after permission allows. */ - impl: (args: any, ctx: MakaToolContext) => Promise | R; -} - -export interface MakaToolContext { - sessionId: string; - turnId: string; - /** Session working directory. */ - cwd: string; - toolCallId: string; - abortSignal: AbortSignal; - emitOutput: (stream: ToolOutputStream, chunk: string) => void; -} - -// ============================================================================ -// Model factory contract (implemented elsewhere — @kabi) -// ============================================================================ - -/** - * Build an ai-sdk LanguageModel from a single input object. - * Matches the signature exported by `runtime/model-factory.ts` (@kabi): - * `getAIModel(input: ModelFactoryInput): LanguageModelV2` - * - * We type-erase the return as `unknown` here to avoid pulling ai-sdk's - * `LanguageModelV2` type into core's dependency graph. - */ -export interface ModelFactoryInput { - connection: LlmConnection; - apiKey: string; - modelId: string; -} -export type ModelFactory = (input: ModelFactoryInput) => unknown; - -export const TOOL_ERROR_RESULT_MAX_CHARS = 4000; export const INVALID_TOOL_NAME = 'invalid'; -export const MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN = 5; -export const DEFAULT_PERMISSION_TIMEOUT_MS = 300_000; -const SUBAGENT_TOOL_LIMIT_MESSAGE = '只读探索并发过多:同一轮最多 5 个子代理。请等待已有探索完成后再继续。'; - -export interface RepairableAiSdkToolCall { - toolCallId: string; - toolName: string; - input: string; - providerExecuted?: boolean; - providerMetadata?: unknown; -} // ============================================================================ // Constructor input — single object matches @kabi's BackendRegistry call site @@ -214,6 +158,8 @@ export interface AiSdkBackendInput { /** Optional fire-and-forget telemetry hooks. Tool implementations remain unaware. */ recordLlmCall?: LlmTelemetryRecorder; recordToolInvocation?: ToolTelemetryRecorder; + /** Optional diagnostic trace hook for explaining a runtime turn without changing renderer events. */ + recordRunTrace?: RunTraceRecorder; /** * Optional artifact recorder. Runtime derives only deterministic candidates * from structured tool results / explicit redirects; desktop main owns @@ -241,6 +187,8 @@ export class AiSdkBackend implements AgentBackend { private readonly newId: () => string; private readonly now: () => number; private readonly maxSteps: number; + private readonly toolRuntime: ToolRuntime; + private readonly modelAdapter: ModelAdapter; private aborted = false; private abortController: AbortController | null = null; @@ -249,8 +197,7 @@ export class AiSdkBackend implements AgentBackend { private currentQueue: AsyncEventQueue | null = null; /** Paused while the backend is waiting on a user permission decision. */ private currentWatchdog: StreamWatchdog | null = null; - /** PawWork borrow: keep read-only subagent fan-out bounded per active turn. */ - private activeSubagentToolCount = 0; + private currentRunTrace: RunTrace | null = null; constructor(input: AiSdkBackendInput) { this.input = input; @@ -258,6 +205,31 @@ export class AiSdkBackend implements AgentBackend { this.newId = input.newId ?? (() => crypto.randomUUID()); this.now = input.now ?? (() => Date.now()); this.maxSteps = input.maxSteps ?? 50; + this.modelAdapter = new ModelAdapter({ + connection: input.connection, + apiKey: input.apiKey, + modelId: input.modelId, + modelFactory: input.modelFactory, + providerOptions: input.providerOptions, + maxSteps: this.maxSteps, + newId: this.newId, + now: this.now, + }); + this.toolRuntime = new ToolRuntime({ + sessionId: input.sessionId, + header: input.header, + connection: input.connection, + modelId: input.modelId, + appendMessage: input.appendMessage, + permissionEngine: input.permissionEngine, + newId: this.newId, + now: this.now, + getPermissionPauseTarget: () => this.currentWatchdog, + getRunTrace: () => this.currentRunTrace, + permissionTimeoutMs: input.permissionTimeoutMs, + recordToolInvocation: input.recordToolInvocation, + recordToolArtifacts: input.recordToolArtifacts, + }); } // -------------------------------------------------------------------------- @@ -281,19 +253,26 @@ export class AiSdkBackend implements AgentBackend { let tokenUsage: NormalizedAiSdkUsage | undefined; let streamStatus: LlmCallRecord['status'] = 'success'; let streamErrorClass: string | undefined; + const trace = new RunTrace({ + sessionId: this.sessionId, + turnId, + connectionSlug: this.input.connection.slug, + providerId: this.input.connection.providerType, + modelId: this.input.modelId, + newId: this.newId, + now: this.now, + record: this.input.recordRunTrace, + }); + this.currentRunTrace = trace; + trace.turnStarted(); // --- Resolve model (API key already attached at construct time) --- let model: unknown; try { - if (PROVIDER_DEFAULTS[this.input.connection.providerType].authKind !== 'none' && !this.input.apiKey) { - throw new Error(`No API key stored for connection "${this.input.connection.slug}"`); - } - model = this.input.modelFactory({ - connection: this.input.connection, - apiKey: this.input.apiKey, - modelId: this.input.modelId, - }); + model = this.modelAdapter.resolveModel(); + trace.modelResolved(); } catch (err) { + trace.modelResolveFailed(err); queue.push(this.makeErrorEvent(turnId, err)); queue.push({ type: 'complete', @@ -308,15 +287,6 @@ export class AiSdkBackend implements AgentBackend { return; } - // --- Lazy import of ai-sdk (~150ms cold) --- - const ai = await import('ai').catch((err) => { - throw new Error(`Failed to load 'ai' package. Run \`npm install ai\`. Inner: ${(err as Error).message}`); - }); - const { streamText, stepCountIs } = ai as unknown as { - streamText: (opts: Record) => StreamTextResult; - stepCountIs: (n: number) => unknown; - }; - // --- Build ai-sdk tools dict with permission-wrapped execute --- const aiSdkTools: Record = {}; const allTools = [...this.input.tools, buildInvalidMakaTool()]; @@ -350,18 +320,21 @@ export class AiSdkBackend implements AgentBackend { const message = formatStreamWatchdogError(timeout); watchdogTimeoutError = new Error(message); queue.push(this.makeErrorEvent(turnId, watchdogTimeoutError)); + trace.modelStreamFailed('Timeout', watchdogTimeoutError); this.abortController?.abort(watchdogTimeoutError); }, }); this.currentWatchdog = watchdog; watchdog.start(); + const activeTools = this.input.tools.map((tool) => tool.name); + trace.modelStreamStarted(activeTools); - const result = streamText({ + const result = await this.modelAdapter.startStream({ model, messages, tools: aiSdkTools, - activeTools: this.input.tools.map((tool) => tool.name), - experimental_repairToolCall: async ( + activeTools, + repairToolCall: async ( { toolCall, error }: { toolCall: RepairableAiSdkToolCall; error: unknown }, ) => { return repairMakaToolCall({ @@ -371,15 +344,13 @@ export class AiSdkBackend implements AgentBackend { }); }, system: await this.resolveSystemPrompt(), - providerOptions: this.input.providerOptions, - stopWhen: stepCountIs(this.maxSteps), abortSignal: this.abortController!.signal, }); for await (const chunk of result.fullStream) { if (this.aborted) break; watchdog.markActivity(); - this.handleStreamChunk(chunk, turnId, assistantMessageId, queue, { + this.modelAdapter.handleStreamChunk(chunk, turnId, assistantMessageId, queue, { onText: (t) => { assistantText += t; }, onTextComplete: (t) => { assistantText = t; }, onThinking: (t) => { thinkingText += t; }, @@ -439,6 +410,7 @@ export class AiSdkBackend implements AgentBackend { try { tokenUsage = normalizeAiSdkUsage(await result.usage); if (tokenUsage) { + trace.usageRecorded(tokenUsage); const tu: TokenUsageMessage = { type: 'token_usage', id: this.newId(), @@ -466,16 +438,18 @@ export class AiSdkBackend implements AgentBackend { } const finishReason = await result.finishReason.catch(() => 'stop'); + const stopReason = this.mapFinishReason(finishReason); + trace.modelStreamCompleted(stopReason); queue.push({ type: 'complete', id: this.newId(), turnId, ts: this.now(), - stopReason: this.mapFinishReason(finishReason), + stopReason, } satisfies CompleteEvent); } catch (err) { streamStatus = this.aborted ? 'aborted' : 'error'; - streamErrorClass = classifyError(watchdogTimeoutError ?? err); + streamErrorClass = this.modelAdapter.classifyError(watchdogTimeoutError ?? err); if (this.aborted) { queue.push({ type: 'abort', @@ -494,6 +468,7 @@ export class AiSdkBackend implements AgentBackend { } else { if (!watchdogTimeoutError) { queue.push(this.makeErrorEvent(turnId, err)); + trace.modelStreamFailed(streamErrorClass, err); } queue.push({ type: 'complete', @@ -544,332 +519,7 @@ export class AiSdkBackend implements AgentBackend { turnId: string, queue: AsyncEventQueue, ) { - return async ( - args: unknown, - ctx: { toolCallId: string; abortSignal: AbortSignal }, - ): Promise => { - const toolUseId = ctx.toolCallId; - const now = this.now(); - const toolIntent = describeToolIntent(tool, args); - - // 1. Always write tool_call FIRST (§6.2 invariant). - const callMsg: ToolCallMessage = { - type: 'tool_call', - id: toolUseId, - turnId, - ts: now, - toolName: tool.name, - ...(tool.displayName ? { displayName: tool.displayName } : {}), - ...(toolIntent ? { intent: toolIntent } : {}), - args, - }; - await this.input.appendMessage(callMsg); - const startEv: ToolStartEvent = { - type: 'tool_start', - id: this.newId(), - turnId, - ts: now, - toolUseId, - toolName: tool.name, - args, - ...(tool.displayName ? { displayName: tool.displayName } : {}), - ...(toolIntent ? { intent: toolIntent } : {}), - }; - queue.push(startEv); - - // 2. PermissionEngine evaluate — skipped for tools marked permissionRequired=false - // (Read / Glob / Grep). We still write tool_call/tool_result messages - // so the materializer renders them just like permission-gated tools. - if (tool.permissionRequired === false) { - // Fast path: jump straight to impl. Fall through to step 3 below. - } else { - const verdict = this.input.permissionEngine.evaluate({ - sessionId: this.sessionId, - turnId, - toolUseId, - toolName: tool.name, - args, - ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), - mode: this.input.header.permissionMode, - }); - - if (verdict.kind === 'block') { - await this.writeSyntheticToolResult(toolUseId, turnId, verdict.reason, queue); - return this.errorReturn(verdict.reason); - } - - if (verdict.kind === 'prompt') { - // Surface request to UI via queue, then await user response. - queue.push(verdict.event); - let response: PermissionDecision; - try { - response = await this.awaitPermissionDecision(verdict, turnId); - } catch (err) { - const msg = formatSyntheticToolErrorText(err); - const reason = formatSyntheticToolErrorText(`Permission flow aborted: ${msg}`); - await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); - return this.errorReturn(reason); - } - - // Persist the decision and ack it on the event stream. - const decisionMsg: PermissionDecisionMessage = { - type: 'permission_decision', - id: response.requestId, - turnId, - ts: this.now(), - toolUseId, - toolName: tool.name, - decision: response.decision, - ...(response.rememberForTurn !== undefined ? { rememberForTurn: response.rememberForTurn } : {}), - }; - await this.input.appendMessage(decisionMsg); - queue.push({ - type: 'permission_decision_ack', - id: this.newId(), - turnId, - ts: this.now(), - requestId: response.requestId, - toolUseId, - decision: response.decision, - ...(response.rememberForTurn !== undefined ? { rememberForTurn: response.rememberForTurn } : {}), - }); - - if (response.decision === 'deny') { - const reason = '用户已拒绝权限请求'; - await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); - return this.errorReturn(reason); - } - } - } // end of: permissionRequired === false ? skip : evaluate - - // 3. Permission allowed (or skipped) → run the real impl. - const reservedSubagentSlot = this.reserveSubagentSlot(tool); - if (!reservedSubagentSlot) { - await this.writeSyntheticToolResult(toolUseId, turnId, SUBAGENT_TOOL_LIMIT_MESSAGE, queue); - return this.errorReturn(SUBAGENT_TOOL_LIMIT_MESSAGE); - } - const startedAt = this.now(); - const output = createToolOutputDeltaEmitter({ - sessionId: this.sessionId, - turnId, - toolUseId, - newId: this.newId, - now: this.now, - push: (event) => queue.push(event), - }); - try { - const result = await tool.impl(args as never, { - sessionId: this.sessionId, - turnId, - cwd: this.input.header.cwd, - toolCallId: toolUseId, - abortSignal: ctx.abortSignal, - emitOutput: output.emit, - }); - output.flush(); - const durationMs = this.now() - startedAt; - - // Coerce impl's return into ToolResultContent for storage + event. - const content = this.coerceResultContent(result); - const toolResultStatus = deriveToolResultStatus(content); - const resultMsg: ToolResultMessage = { - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: toolResultStatus !== 'success', - content, - durationMs, - }; - await this.input.appendMessage(resultMsg); - queue.push({ - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: toolResultStatus !== 'success', - content, - durationMs, - } satisfies ToolResultEvent); - - this.input.recordToolInvocation?.({ - sessionId: this.sessionId, - turnId, - toolCallId: toolUseId, - toolName: tool.name, - providerId: this.input.connection.providerType, - modelId: this.input.modelId, - durationMs, - status: toolResultStatus, - argsSummary: summarizeArgs(args), - bytesIn: byteLength(args), - bytesOut: byteLength(result), - startedAt, - }); - - void recordToolArtifactsSafely( - { - sessionId: this.sessionId, - turnId, - toolUseId, - toolName: tool.name, - cwd: this.input.header.cwd, - args, - result, - }, - this.input.recordToolArtifacts, - (message) => { - queue.push({ - type: 'tool_progress', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - chunk: message, - }); - }, - ); - - return result; - } catch (err) { - output.flush(); - const terminalFailure = this.coerceTerminalFailure(tool, args, err); - if (terminalFailure) { - const durationMs = Math.max(0, this.now() - startedAt); - const resultMsg: ToolResultMessage = { - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: true, - content: terminalFailure.content, - durationMs, - }; - await this.input.appendMessage(resultMsg); - queue.push({ - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: true, - content: terminalFailure.content, - durationMs, - } satisfies ToolResultEvent); - this.input.recordToolInvocation?.({ - sessionId: this.sessionId, - turnId, - toolCallId: toolUseId, - toolName: tool.name, - providerId: this.input.connection.providerType, - modelId: this.input.modelId, - durationMs, - status: 'error', - errorClass: classifyError(err), - argsSummary: summarizeArgs(args), - bytesIn: byteLength(args), - bytesOut: byteLength(terminalFailure.content), - startedAt, - }); - return this.errorReturn(terminalFailure.message); - } - const msg = formatSyntheticToolErrorText(err); - await this.writeSyntheticToolResult(toolUseId, turnId, msg, queue); - this.input.recordToolInvocation?.({ - sessionId: this.sessionId, - turnId, - toolCallId: toolUseId, - toolName: tool.name, - providerId: this.input.connection.providerType, - modelId: this.input.modelId, - durationMs: Math.max(0, this.now() - startedAt), - status: 'error', - errorClass: classifyError(err), - argsSummary: summarizeArgs(args), - bytesIn: byteLength(args), - bytesOut: 0, - startedAt, - }); - return this.errorReturn(msg); - } finally { - if (reservedSubagentSlot) this.releaseSubagentSlot(tool); - } - }; - } - - // -------------------------------------------------------------------------- - // Stream chunk normalizer — ai-sdk fullStream → SessionEvent - // -------------------------------------------------------------------------- - - private handleStreamChunk( - chunk: AiSdkStreamChunk, - turnId: string, - assistantMessageId: string, - queue: AsyncEventQueue, - cb: { - onText: (t: string) => void; - onTextComplete: (t: string) => void; - onThinking: (t: string) => void; - onThinkingComplete: (t: string, sig?: string) => void; - }, - ): void { - const ts = this.now(); - switch (chunk.type) { - case 'text-delta': { - const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; - cb.onText(text); - queue.push({ - type: 'text_delta', - id: this.newId(), - turnId, - ts, - messageId: assistantMessageId, - text, - } satisfies TextDeltaEvent); - break; - } - case 'reasoning': - case 'reasoning-delta': { - // Anthropic / OpenAI o-series style thinking - const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; - cb.onThinking(text); - queue.push({ - type: 'thinking_delta', - id: this.newId(), - turnId, - ts, - messageId: assistantMessageId, - text, - } satisfies ThinkingDeltaEvent); - break; - } - case 'step-finish': { - // ai-sdk fires step-finish after each turn step (incl. between tool calls). - // We don't need to emit anything here; tool results already streamed. - break; - } - case 'finish': { - // Final usage handled in pump's await result.usage path. Emit text_complete - // (we don't have a per-message complete in ai-sdk; aggregate from cb). - // Note: aggregated text is captured by the pump via cb.onText. - break; - } - case 'tool-call': - case 'tool-result': - // These are emitted by ai-sdk after our wrapped execute() runs. - // Our wrapped execute already emitted tool_start + tool_result to the - // queue, so we ignore these chunks to avoid double-emission. - break; - case 'error': - queue.push(this.makeErrorEvent(turnId, chunk.error)); - break; - default: - // Unrecognized chunk type — forward-compat: ignore. - break; - } + return this.toolRuntime.wrapToolExecute(tool, turnId, queue); } // -------------------------------------------------------------------------- @@ -882,6 +532,7 @@ export class AiSdkBackend implements AgentBackend { if (this.currentTurnId !== null) { this.input.permissionEngine.endTurn(this.currentTurnId, 'aborted'); } + this.currentRunTrace?.abortRequested(_reason); } async respondToPermission(decision: PermissionDecision): Promise { @@ -895,115 +546,22 @@ export class AiSdkBackend implements AgentBackend { if (!this.aborted) await this.stop('user_stop'); } - private async writeSyntheticToolResult( + private writeSyntheticToolResult( toolUseId: string, turnId: string, text: string, queue: AsyncEventQueue, ): Promise { - const content: ToolResultContent = { kind: 'text', text: formatSyntheticToolErrorText(text) }; - const msg: ToolResultMessage = { - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: true, - content, - }; - await this.input.appendMessage(msg); - queue.push({ - type: 'tool_result', - id: this.newId(), - turnId, - ts: this.now(), - toolUseId, - isError: true, - content, - } satisfies ToolResultEvent); - } - - /** Coerce arbitrary tool impl return into ToolResultContent for storage. */ - private coerceResultContent(raw: unknown): ToolResultContent { - if (typeof raw === 'string') return { kind: 'text', text: raw }; - if (raw && typeof raw === 'object') { - const obj = raw as { kind?: string; text?: string }; - if (typeof obj.kind === 'string') return raw as ToolResultContent; - if (typeof obj.text === 'string') return { kind: 'text', text: obj.text }; - return { kind: 'json', value: raw }; - } - return { kind: 'text', text: String(raw ?? '') }; - } - - private coerceTerminalFailure( - tool: MakaTool, - args: unknown, - err: unknown, - ): { content: Extract; message: string } | null { - if (tool.name !== 'Bash' || !err || typeof err !== 'object') return null; - const error = err as { code?: unknown; stdout?: unknown; stderr?: unknown }; - if (typeof error.code !== 'number') return null; - const command = args && typeof args === 'object' && typeof (args as { command?: unknown }).command === 'string' - ? (args as { command: string }).command - : ''; - return { - content: { - kind: 'terminal', - cwd: this.input.header.cwd, - cmd: redactSecrets(command), - exitCode: error.code, - stdout: redactSecrets(String(error.stdout ?? '')), - stderr: redactSecrets(String(error.stderr ?? '')), - }, - message: `命令退出码 ${error.code}`, - }; - } - - private reserveSubagentSlot(tool: MakaTool): boolean { - if (tool.categoryHint !== 'subagent') return true; - if (this.activeSubagentToolCount >= MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN) return false; - this.activeSubagentToolCount += 1; - return true; - } - - private releaseSubagentSlot(tool: MakaTool): void { - if (tool.categoryHint !== 'subagent') return; - this.activeSubagentToolCount = Math.max(0, this.activeSubagentToolCount - 1); - } - - /** Build the value we return to ai-sdk from a synthetic-error tool call. */ - private errorReturn(message: string): unknown { - return { error: message }; + return this.toolRuntime.writeSyntheticToolResult(toolUseId, turnId, text, queue); } /** Map ai-sdk finishReason → our CompleteEvent.stopReason. */ private mapFinishReason(reason: unknown): CompleteEvent['stopReason'] { - switch (reason) { - case 'stop': return 'end_turn'; - case 'length': return 'max_tokens'; - case 'content-filter': return 'error'; - case 'error': return 'error'; - case 'tool-calls': return 'end_turn'; // ai-sdk auto-loops; if this leaks out, treat as end - default: return 'end_turn'; - } + return this.modelAdapter.mapFinishReason(reason); } private makeErrorEvent(turnId: string, err: unknown): ErrorEvent { - const message = generalizedErrorMessage(err); - const reason = errorReasonFromClass(classifyError(err)); - const code = err instanceof Error && 'code' in err - ? String((err as { code?: unknown }).code) - : undefined; - return { - type: 'error', - id: this.newId(), - turnId, - ts: this.now(), - recoverable: false, - ...(code !== undefined ? { code } : {}), - ...(reason !== undefined ? { reason } : {}), - message, - }; + return this.modelAdapter.makeErrorEvent(turnId, err); } /** Materialize stored messages into ai-sdk's message format. @@ -1045,200 +603,17 @@ export class AiSdkBackend implements AgentBackend { for await (const ev of queue) yield ev; } - private async awaitPermissionDecision( - verdict: Extract, { kind: 'prompt' }>, - turnId: string, - ): Promise { - const timeoutMs = this.input.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; - this.currentWatchdog?.pause(); - try { - if (timeoutMs <= 0) return await verdict.parked; - let timer: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - const reason = `Permission request ${verdict.event.requestId} timed out after ${timeoutMs}ms`; - this.input.permissionEngine.expireRequest(turnId, verdict.event.requestId, reason); - reject(new Error(reason)); - }, timeoutMs); - }); - try { - return await Promise.race([verdict.parked, timeout]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } - } finally { - this.currentWatchdog?.resume(); - } - } - private cleanupAfterTurn(turnId: string): void { this.input.permissionEngine.endTurn(turnId, this.aborted ? 'aborted' : 'completed'); this.abortController = null; this.currentQueue = null; this.currentTurnId = null; - this.activeSubagentToolCount = 0; + this.currentRunTrace = null; + this.toolRuntime.resetTurnState(); this.aborted = false; } } -// ============================================================================ -// Loose stream-chunk shape (intentionally lenient — ai-sdk evolves) -// ============================================================================ - -interface AiSdkStreamChunk { - type: string; - text?: string; - delta?: string; - textDelta?: string; - toolCallId?: string; - toolName?: string; - args?: unknown; - result?: unknown; - usage?: AiSdkUsageLike; - finishReason?: string; - error?: unknown; -} - -interface StreamTextResult { - fullStream: AsyncIterable; - usage: Promise; - finishReason: Promise; -} - -interface AiSdkUsageLike { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - inputTokens?: number; - outputTokens?: number; - cachedInputTokens?: number; - cacheWriteInputTokens?: number; - reasoningTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - inputTokenDetails?: { - cachedTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - reasoningTokens?: number; - }; - outputTokenDetails?: { - reasoningTokens?: number; - }; -} - -interface NormalizedAiSdkUsage { - inputTokens: number; - outputTokens: number; - cachedInputTokens: number; - cacheWriteInputTokens: number; - reasoningTokens: number; - totalTokens: number; -} - -export function normalizeAiSdkUsage(usage: AiSdkUsageLike | undefined): NormalizedAiSdkUsage | undefined { - if (!usage) return undefined; - const inputTokens = finiteToken(usage.inputTokens) ?? finiteToken(usage.promptTokens) ?? 0; - const outputTokens = finiteToken(usage.outputTokens) ?? finiteToken(usage.completionTokens) ?? 0; - const cachedInputTokens = - finiteToken(usage.cachedInputTokens) - ?? finiteToken(usage.cacheReadInputTokens) - ?? finiteToken(usage.inputTokenDetails?.cacheReadTokens) - ?? finiteToken(usage.inputTokenDetails?.cachedTokens) - ?? 0; - const cacheWriteInputTokens = - finiteToken(usage.cacheWriteInputTokens) - ?? finiteToken(usage.cacheCreationInputTokens) - ?? finiteToken(usage.inputTokenDetails?.cacheWriteTokens) - ?? 0; - const reasoningTokens = - finiteToken(usage.reasoningTokens) - ?? finiteToken(usage.outputTokenDetails?.reasoningTokens) - ?? finiteToken(usage.inputTokenDetails?.reasoningTokens) - ?? 0; - const totalTokens = finiteToken(usage.totalTokens) ?? inputTokens + outputTokens; - return { - inputTokens, - outputTokens, - cachedInputTokens, - cacheWriteInputTokens, - reasoningTokens, - totalTokens, - }; -} - -function finiteToken(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; -} - -function classifyError(error: unknown): string { - if (!(error instanceof Error)) return 'Other'; - const code = 'code' in error ? String((error as { code?: unknown }).code) : ''; - const text = `${error.name} ${code} ${error.message}`.toLowerCase(); - if (text.includes('abort')) return 'Abort'; - if (text.includes('rate') || code === '429') return 'RateLimit'; - if (text.includes('auth') || code === '401' || code === '403') return 'Auth'; - if (text.includes('timeout')) return 'Timeout'; - if (text.includes('network') || text.includes('fetch')) return 'Network'; - return error.name || 'Other'; -} - -function errorReasonFromClass(errorClass: string): string | undefined { - switch (errorClass) { - case 'Timeout': - return 'timeout'; - case 'Auth': - return 'auth'; - case 'RateLimit': - return 'rate_limit'; - case 'Network': - return 'network'; - default: - return undefined; - } -} - -export function formatSyntheticToolErrorText(error: unknown): string { - const raw = error instanceof Error ? error.message : String(error); - const redacted = redactSecrets(raw || 'Tool failed'); - if (redacted.length <= TOOL_ERROR_RESULT_MAX_CHARS) return redacted; - return `${redacted.slice(0, TOOL_ERROR_RESULT_MAX_CHARS - 1)}…`; -} - -function deriveToolResultStatus(content: ToolResultContent): ToolInvocationRecord['status'] { - if (content.kind === 'explore_agent' && content.ok === false) { - return content.reason === 'aborted' ? 'aborted' : 'error'; - } - if (content.kind === 'rive_workflow' && content.ok === false) return 'error'; - if (content.kind === 'web_search_error') return 'error'; - if (content.kind === 'office_document' && content.ok === false) { - return content.reason === 'officecli_aborted' ? 'aborted' : 'error'; - } - return 'success'; -} - -function summarizeArgs(args: unknown): string { - const text = typeof args === 'string' ? args : JSON.stringify(args ?? null); - return text.length <= 512 ? text : `${text.slice(0, 511)}…`; -} - -function describeToolIntent(tool: MakaTool, args: unknown): string | undefined { - if (tool.categoryHint !== 'subagent' || tool.name !== 'ExploreAgent') return undefined; - if (!args || typeof args !== 'object') return undefined; - const objective = (args as { objective?: unknown }).objective; - if (typeof objective !== 'string') return undefined; - const normalized = redactSecrets(objective.replace(/\s+/g, ' ').trim()); - if (normalized.length === 0) return undefined; - const capped = normalized.length <= 180 ? normalized : `${normalized.slice(0, 179)}…`; - return `只读探索:${capped}`; -} - -function byteLength(value: unknown): number { - if (value === undefined) return 0; - const text = typeof value === 'string' ? value : JSON.stringify(value ?? null); - return Buffer.byteLength(text, 'utf8'); -} - export function repairMakaToolCall(input: { toolCall: RepairableAiSdkToolCall; availableToolNames: readonly string[]; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 70aecf6983..38c6f89094 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -33,6 +33,8 @@ export type { MakaToolContext, ModelFactory, ModelFactoryInput, + RunTraceEvent, + RunTraceRecorder, } from './ai-sdk-backend.js'; export { PiAgentBackend, normalizePiAgentFrame } from './pi-agent-backend.js'; export type { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts new file mode 100644 index 0000000000..3b429b602a --- /dev/null +++ b/packages/runtime/src/model-adapter.ts @@ -0,0 +1,272 @@ +import type { + ErrorEvent, + SessionEvent, + TextDeltaEvent, + ThinkingDeltaEvent, + CompleteEvent, +} from '@maka/core/events'; +import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; +import { generalizedErrorMessage } from '@maka/core/redaction'; + +import type { AsyncEventQueue } from './async-queue.js'; +import { classifyError, errorReasonFromClass } from './tool-runtime.js'; + +/** + * Build an ai-sdk LanguageModel from a single input object. + * Matches the signature exported by `runtime/model-factory.ts` (@kabi): + * `getAIModel(input: ModelFactoryInput): LanguageModelV2` + * + * We type-erase the return as `unknown` here to avoid pulling ai-sdk's + * `LanguageModelV2` type into core's dependency graph. + */ +export interface ModelFactoryInput { + connection: LlmConnection; + apiKey: string; + modelId: string; +} +export type ModelFactory = (input: ModelFactoryInput) => unknown; + +export interface RepairableAiSdkToolCall { + toolCallId: string; + toolName: string; + input: string; + providerExecuted?: boolean; + providerMetadata?: unknown; +} + +export interface ModelAdapterInput { + connection: LlmConnection; + apiKey: string; + modelId: string; + modelFactory: ModelFactory; + providerOptions?: Record; + maxSteps: number; + newId: () => string; + now: () => number; +} + +export interface ModelAdapterStreamInput { + model: unknown; + messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>; + tools: Record; + activeTools: string[]; + system?: string; + abortSignal: AbortSignal; + repairToolCall: (input: { + toolCall: RepairableAiSdkToolCall; + error: unknown; + }) => RepairableAiSdkToolCall | null | Promise; +} + +export interface ModelAdapterStreamCallbacks { + onText: (text: string) => void; + onTextComplete: (text: string) => void; + onThinking: (text: string) => void; + onThinkingComplete: (text: string, signature?: string) => void; +} + +export class ModelAdapter { + constructor(private readonly input: ModelAdapterInput) {} + + resolveModel(): unknown { + if (PROVIDER_DEFAULTS[this.input.connection.providerType].authKind !== 'none' && !this.input.apiKey) { + throw new Error(`No API key stored for connection "${this.input.connection.slug}"`); + } + return this.input.modelFactory({ + connection: this.input.connection, + apiKey: this.input.apiKey, + modelId: this.input.modelId, + }); + } + + async startStream(input: ModelAdapterStreamInput): Promise { + const ai = await import('ai').catch((err) => { + throw new Error(`Failed to load 'ai' package. Run \`npm install ai\`. Inner: ${(err as Error).message}`); + }); + const { streamText, stepCountIs } = ai as unknown as { + streamText: (opts: Record) => StreamTextResult; + stepCountIs: (n: number) => unknown; + }; + + return streamText({ + model: input.model, + messages: input.messages, + tools: input.tools, + activeTools: input.activeTools, + experimental_repairToolCall: input.repairToolCall, + system: input.system, + providerOptions: this.input.providerOptions, + stopWhen: stepCountIs(this.input.maxSteps), + abortSignal: input.abortSignal, + }); + } + + handleStreamChunk( + chunk: AiSdkStreamChunk, + turnId: string, + assistantMessageId: string, + queue: AsyncEventQueue, + callbacks: ModelAdapterStreamCallbacks, + ): void { + const ts = this.input.now(); + switch (chunk.type) { + case 'text-delta': { + const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; + callbacks.onText(text); + queue.push({ + type: 'text_delta', + id: this.input.newId(), + turnId, + ts, + messageId: assistantMessageId, + text, + } satisfies TextDeltaEvent); + break; + } + case 'reasoning': + case 'reasoning-delta': { + const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; + callbacks.onThinking(text); + queue.push({ + type: 'thinking_delta', + id: this.input.newId(), + turnId, + ts, + messageId: assistantMessageId, + text, + } satisfies ThinkingDeltaEvent); + break; + } + case 'step-finish': + case 'finish': + break; + case 'tool-call': + case 'tool-result': + break; + case 'error': + queue.push(this.makeErrorEvent(turnId, chunk.error)); + break; + default: + break; + } + } + + makeErrorEvent(turnId: string, err: unknown): ErrorEvent { + const message = generalizedErrorMessage(err); + const reason = errorReasonFromClass(classifyError(err)); + const code = err instanceof Error && 'code' in err + ? String((err as { code?: unknown }).code) + : undefined; + return { + type: 'error', + id: this.input.newId(), + turnId, + ts: this.input.now(), + recoverable: false, + ...(code !== undefined ? { code } : {}), + ...(reason !== undefined ? { reason } : {}), + message, + }; + } + + classifyError(error: unknown): string { + return classifyError(error); + } + + mapFinishReason(reason: unknown): CompleteEvent['stopReason'] { + switch (reason) { + case 'stop': return 'end_turn'; + case 'length': return 'max_tokens'; + case 'content-filter': return 'error'; + case 'error': return 'error'; + case 'tool-calls': return 'end_turn'; + default: return 'end_turn'; + } + } +} + +export interface AiSdkStreamChunk { + type: string; + text?: string; + delta?: string; + textDelta?: string; + toolCallId?: string; + toolName?: string; + args?: unknown; + result?: unknown; + usage?: AiSdkUsageLike; + finishReason?: string; + error?: unknown; +} + +export interface StreamTextResult { + fullStream: AsyncIterable; + usage: Promise; + finishReason: Promise; +} + +export interface AiSdkUsageLike { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedInputTokens?: number; + cacheWriteInputTokens?: number; + reasoningTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + inputTokenDetails?: { + cachedTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + }; +} + +export interface NormalizedAiSdkUsage { + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + cacheWriteInputTokens: number; + reasoningTokens: number; + totalTokens: number; +} + +export function normalizeAiSdkUsage(usage: AiSdkUsageLike | undefined): NormalizedAiSdkUsage | undefined { + if (!usage) return undefined; + const inputTokens = finiteToken(usage.inputTokens) ?? finiteToken(usage.promptTokens) ?? 0; + const outputTokens = finiteToken(usage.outputTokens) ?? finiteToken(usage.completionTokens) ?? 0; + const cachedInputTokens = + finiteToken(usage.cachedInputTokens) + ?? finiteToken(usage.cacheReadInputTokens) + ?? finiteToken(usage.inputTokenDetails?.cacheReadTokens) + ?? finiteToken(usage.inputTokenDetails?.cachedTokens) + ?? 0; + const cacheWriteInputTokens = + finiteToken(usage.cacheWriteInputTokens) + ?? finiteToken(usage.cacheCreationInputTokens) + ?? finiteToken(usage.inputTokenDetails?.cacheWriteTokens) + ?? 0; + const reasoningTokens = + finiteToken(usage.reasoningTokens) + ?? finiteToken(usage.outputTokenDetails?.reasoningTokens) + ?? finiteToken(usage.inputTokenDetails?.reasoningTokens) + ?? 0; + const totalTokens = finiteToken(usage.totalTokens) ?? inputTokens + outputTokens; + return { + inputTokens, + outputTokens, + cachedInputTokens, + cacheWriteInputTokens, + reasoningTokens, + totalTokens, + }; +} + +function finiteToken(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} diff --git a/packages/runtime/src/run-trace.ts b/packages/runtime/src/run-trace.ts new file mode 100644 index 0000000000..44d40a6379 --- /dev/null +++ b/packages/runtime/src/run-trace.ts @@ -0,0 +1,152 @@ +import { generalizedErrorMessage } from '@maka/core/redaction'; + +export type RunTracePhase = 'turn' | 'model' | 'tool' | 'permission' | 'abort' | 'usage'; + +export type RunTraceEventType = + | 'turn_started' + | 'model_resolved' + | 'model_resolve_failed' + | 'model_stream_started' + | 'model_stream_completed' + | 'model_stream_failed' + | 'tool_started' + | 'tool_completed' + | 'tool_failed' + | 'permission_requested' + | 'permission_decided' + | 'permission_failed' + | 'abort_requested' + | 'usage_recorded'; + +export interface RunTraceEvent { + id: string; + sessionId: string; + turnId: string; + ts: number; + phase: RunTracePhase; + type: RunTraceEventType; + message: string; + data?: Record; +} + +export type RunTraceRecorder = (event: RunTraceEvent) => void; + +export interface RunTraceInput { + sessionId: string; + turnId: string; + connectionSlug: string; + providerId: string; + modelId: string; + newId: () => string; + now: () => number; + record?: RunTraceRecorder; +} + +export class RunTrace { + constructor(private readonly input: RunTraceInput) {} + + emit( + phase: RunTracePhase, + type: RunTraceEventType, + message: string, + data?: Record, + ): void { + const event: RunTraceEvent = { + id: this.input.newId(), + sessionId: this.input.sessionId, + turnId: this.input.turnId, + ts: this.input.now(), + phase, + type, + message, + ...(data ? { data: sanitizeTraceData(data) } : {}), + }; + try { + this.input.record?.(event); + } catch { + // Tracing is diagnostic-only and must not perturb model/tool execution. + } + } + + turnStarted(): void { + this.emit('turn', 'turn_started', 'Turn started', { + connectionSlug: this.input.connectionSlug, + providerId: this.input.providerId, + modelId: this.input.modelId, + }); + } + + modelResolved(): void { + this.emit('model', 'model_resolved', 'Model resolved', { + connectionSlug: this.input.connectionSlug, + providerId: this.input.providerId, + modelId: this.input.modelId, + }); + } + + modelResolveFailed(error: unknown): void { + this.emit('model', 'model_resolve_failed', 'Model resolution failed', { + error: explainError(error), + }); + } + + modelStreamStarted(activeTools: readonly string[]): void { + this.emit('model', 'model_stream_started', 'Model stream started', { + activeTools: [...activeTools], + }); + } + + modelStreamCompleted(stopReason: string): void { + this.emit('model', 'model_stream_completed', 'Model stream completed', { + stopReason, + }); + } + + modelStreamFailed(errorClass: string | undefined, error: unknown): void { + this.emit('model', 'model_stream_failed', 'Model stream failed', { + ...(errorClass ? { errorClass } : {}), + error: explainError(error), + }); + } + + usageRecorded(usage: { + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + cacheWriteInputTokens: number; + reasoningTokens: number; + totalTokens: number; + }): void { + this.emit('usage', 'usage_recorded', 'Token usage recorded', { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedInputTokens: usage.cachedInputTokens, + cacheWriteInputTokens: usage.cacheWriteInputTokens, + reasoningTokens: usage.reasoningTokens, + totalTokens: usage.totalTokens, + }); + } + + abortRequested(reason: string): void { + this.emit('abort', 'abort_requested', 'Abort requested', { reason }); + } +} + +export interface RunTraceLike { + emit( + phase: RunTracePhase, + type: RunTraceEventType, + message: string, + data?: Record, + ): void; +} + +export function explainError(error: unknown): string { + return generalizedErrorMessage(error); +} + +function sanitizeTraceData(data: Record): Record { + return Object.fromEntries( + Object.entries(data).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 2b4045f560..9deae6258d 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -46,8 +46,12 @@ import type { import type { PermissionResponse } from '@maka/core/permission'; import type { PermissionMode } from '@maka/core/permission'; import { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from '@maka/core'; +import type { AgentRunStore } from '@maka/core'; import type { AgentBackend } from './ai-sdk-backend.js'; +import type { RunTraceRecorder } from './run-trace.js'; +import { AgentRun, type AgentRunActiveSession, type AgentRunLineage } from './agent-run.js'; +import { classifyAgentRunRecovery, type AgentRunRecoveryDecision } from './agent-run-recovery.js'; export interface StopSessionInput { source?: 'stop_button'; @@ -82,6 +86,7 @@ export interface BackendFactoryContext { workspaceRoot: string; header: SessionHeader; store: SessionStore; + recordRunTrace?: RunTraceRecorder; } export type BackendFactory = (ctx: BackendFactoryContext) => AgentBackend | Promise; @@ -110,20 +115,19 @@ export class BackendRegistry { export interface SessionManagerDeps { store: SessionStore; + runStore?: AgentRunStore; backends: BackendRegistry; newId: () => string; now: () => number; } -interface ActiveSession { +interface ActiveSession extends AgentRunActiveSession { sessionId: string; backend: AgentBackend; /** Tracks the latest header we've read (used to short-circuit some reads). */ cachedHeader: SessionHeader; - activeStreams: number; - activeTurnIds: Set; - activeTurnLineage: Map>>; - stoppedTurnIds: Set; + activeRuns: Map; + turnToRunId: Map; } export class SessionManager { @@ -158,16 +162,36 @@ export class SessionManager { const recovered: string[] = []; for (const session of interrupted) { if (this.active.has(session.id)) continue; - let messages: StoredMessage[]; + let messages: StoredMessage[] = []; + let messagesReadable = true; try { messages = await this.deps.store.readMessages(session.id); } catch { + messagesReadable = false; + } + + if (this.deps.runStore) { + const runRecovery = await this.recoverAgentRunsFromLedger(session.id, messages).catch(() => undefined); + if (runRecovery?.hasLedger) { + if (runRecovery.recovered) { + await this.updateStatus(session.id, 'active').catch(() => {}); + recovered.push(session.id); + } else if (!messagesReadable && (session.status === 'running' || session.status === 'waiting_for_user')) { + await this.updateStatus(session.id, 'active').catch(() => {}); + recovered.push(session.id); + } + continue; + } + } + + if (!messagesReadable) { if (session.status === 'running' || session.status === 'waiting_for_user') { await this.updateStatus(session.id, 'active').catch(() => {}); recovered.push(session.id); } continue; } + const recoveries = interruptedTurnRecoveries(messages); if (recoveries.length === 0) continue; for (const recovery of recoveries) { @@ -189,7 +213,7 @@ export class SessionManager { ): Promise { const active = this.active.get(sessionId); const backendConfigChanged = changesBackendConfig(patch); - if (active && backendConfigChanged && active.activeStreams > 0) { + if (active && backendConfigChanged && active.activeRuns.size > 0) { throw new Error('Cannot change backend configuration while a turn is running'); } @@ -245,7 +269,7 @@ export class SessionManager { if (previous.permissionMode === mode && !leavingDeepResearch) return headerToSummary(previous); const active = this.active.get(sessionId); - if (active && active.activeStreams > 0) { + if (active && active.activeRuns.size > 0) { throw new Error('当前对话正在运行,等结束后再切换权限模式。'); } if (previous.status === 'waiting_for_user') { @@ -300,134 +324,27 @@ export class SessionManager { sessionId: string, input: UserMessageInput, ): AsyncIterable { - // 1. Read header (for backend kind + permissionMode + cwd + model). - let header = await this.deps.store.readHeader(sessionId); - - // 2. Append the user message FIRST, before any backend startup. JSONL is - // the source of truth; even if backend init fails the message is - // recorded. - const userMsg: UserMessage = { - type: 'user', - id: this.deps.newId(), - turnId: input.turnId, - ts: this.deps.now(), - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - }; - await this.deps.store.appendMessage(sessionId, userMsg); - await this.appendTurnState(sessionId, input.turnId, 'running', input); - - let lastTs = this.deps.now(); - let sawCompletion = false; - let finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined; - let active: ActiveSession | undefined; - let activeStreamTracked = false; - let turnFailed = false; - - try { - // 3. Lock connection right after the user message is flushed (§9 Step 2.3). - // Even if backend startup fails next, the session's backend choice is - // committed and won't drift. - if (!header.connectionLocked) { - header = await this.deps.store.updateHeader(sessionId, { connectionLocked: true }); - } - - // 4. Resolve / build backend. - active = await this.ensureActive(sessionId, header); - - // 5. Stream events from backend, side-tracking the latest ts for header - // bookkeeping when the turn completes. - await this.updateStatus(sessionId, 'running', undefined, lastTs); - active.activeStreams += 1; - activeStreamTracked = true; - active.activeTurnIds.add(input.turnId); - active.activeTurnLineage.set(input.turnId, { - ...(input.parentTurnId ? { parentTurnId: input.parentTurnId } : {}), - ...(input.retriedFromTurnId ? { retriedFromTurnId: input.retriedFromTurnId } : {}), - ...(input.regeneratedFromTurnId ? { regeneratedFromTurnId: input.regeneratedFromTurnId } : {}), - ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), - ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), - }); - - for await (const ev of active.backend.send({ - turnId: input.turnId, - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - context: await this.deps.store.readMessages(sessionId), - })) { - lastTs = ev.ts; - const transition = statusFromEvent(ev); - const stoppedDuringTurn = active.stoppedTurnIds.has(input.turnId); - if (transition && !stoppedDuringTurn) { - await this.updateStatus(sessionId, transition.status, transition.blockedReason, ev.ts); - } - if ((ev.type === 'complete' || ev.type === 'abort') && !turnFailed) { - sawCompletion = true; - finalStatus = stoppedDuringTurn - ? { status: 'aborted' } - : (transition ?? { status: 'active' }); - const turnStatus = turnStatusFromEvent(ev); - if (turnStatus && !stoppedDuringTurn) { - await this.appendTurnState(sessionId, input.turnId, turnStatus.status, input, { - ts: ev.ts, - errorClass: turnStatus.errorClass, - }); - } - } - if (ev.type === 'error') { - turnFailed = true; - finalStatus = transition ?? { status: 'blocked', blockedReason: 'unknown' }; - await this.appendTurnState(sessionId, input.turnId, 'failed', input, { - ts: ev.ts, - errorClass: ev.reason ?? ev.code ?? 'unknown', - }); - } - yield ev; - } - } catch (error) { - finalStatus = { status: 'blocked', blockedReason: 'unknown' }; - await this.appendTurnState(sessionId, input.turnId, 'failed', input, { - errorClass: error instanceof Error ? error.name : 'unknown', - }).catch(() => {}); - throw error; - } finally { - if (active && activeStreamTracked) { - const stoppedDuringTurn = active.stoppedTurnIds.has(input.turnId); - active.activeStreams = Math.max(0, active.activeStreams - 1); - active.activeTurnIds.delete(input.turnId); - active.activeTurnLineage.delete(input.turnId); - active.stoppedTurnIds.delete(input.turnId); - if (stoppedDuringTurn) { - finalStatus = { status: 'aborted' }; - } - } - const nextStatus = active && active.activeStreams > 0 - ? { status: 'running' as const } - : (finalStatus ?? { status: 'active' as const }); - // 6. Update header timestamps + unread flag exactly once per turn. - try { - await this.deps.store.updateHeader(sessionId, { - lastUsedAt: lastTs, - lastMessageAt: lastTs, - hasUnread: true, - ...statusPatch(nextStatus.status, lastTs, nextStatus.blockedReason), - }); - } catch { - // Swallow header-update failures; the turn already completed at the - // user-visible level. - } - // Persist a SystemNote marking the turn end (helps debug + recovery). - if (sawCompletion) { - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId: input.turnId, - ts: lastTs, - kind: 'session_resume', - }; - await this.deps.store.appendMessage(sessionId, note).catch(() => {}); - } - } + const header = await this.deps.store.readHeader(sessionId); + const run = new AgentRun({ + sessionId, + header, + userInput: input, + store: this.deps.store, + runStore: this.deps.runStore, + newId: this.deps.newId, + now: this.deps.now, + hooks: { + ensureActive: (targetSessionId, nextHeader) => this.ensureActive(targetSessionId, nextHeader), + registerRun: (active, activeRun) => this.registerRun(active, activeRun), + unregisterRun: (active, activeRun) => this.unregisterRun(active, activeRun), + updateHeader: (targetSessionId, patch) => this.updateHeader(targetSessionId, patch), + updateStatus: (targetSessionId, status, blockedReason, ts) => + this.updateStatus(targetSessionId, status, blockedReason, ts), + appendTurnState: (targetSessionId, turnId, status, lineage, options) => + this.appendTurnState(targetSessionId, turnId, status, lineage, options), + }, + }); + yield* run.execute(); } async stopSession(sessionId: string, input: StopSessionInput = {}): Promise { @@ -435,16 +352,17 @@ export class SessionManager { if (!active) return; const abortSource = normalizeStopSessionSource(input.source); await active.backend.stop('user_stop'); - for (const turnId of active.activeTurnIds) { - active.stoppedTurnIds.add(turnId); + const activeRuns = [...active.activeRuns.values()]; + for (const run of activeRuns) { + run.stop(input.source); } await this.updateStatus(sessionId, 'aborted'); - for (const turnId of active.activeTurnIds) { + for (const run of activeRuns) { await this.appendTurnState( sessionId, - turnId, + run.turnId, 'aborted', - active.activeTurnLineage.get(turnId) ?? {}, + run.lineage, { ts: this.deps.now(), abortSource }, ).catch(() => {}); } @@ -546,20 +464,36 @@ export class SessionManager { workspaceRoot: header.workspaceRoot, header, store: this.deps.store, + recordRunTrace: (event) => { + const active = this.active.get(sessionId); + const runId = active?.turnToRunId.get(event.turnId); + const run = runId ? active?.activeRuns.get(runId) : undefined; + run?.recordRunTrace(event); + }, }); const entry: ActiveSession = { sessionId, backend, cachedHeader: header, - activeStreams: 0, - activeTurnIds: new Set(), - activeTurnLineage: new Map(), - stoppedTurnIds: new Set(), + activeRuns: new Map(), + turnToRunId: new Map(), }; this.active.set(sessionId, entry); return entry; } + private registerRun(active: AgentRunActiveSession, run: AgentRun): void { + active.activeRuns.set(run.runId, run); + active.turnToRunId.set(run.turnId, run.runId); + } + + private unregisterRun(active: AgentRunActiveSession, run: AgentRun): void { + active.activeRuns.delete(run.runId); + if (active.turnToRunId.get(run.turnId) === run.runId) { + active.turnToRunId.delete(run.turnId); + } + } + private async disposeBackend(sessionId: string): Promise { const active = this.active.get(sessionId); if (!active) return; @@ -577,16 +511,24 @@ export class SessionManager { blockedReason?: SessionBlockedReason, ts = this.deps.now(), ): Promise { - const next = await this.deps.store.updateHeader(sessionId, statusPatch(status, ts, blockedReason)); + await this.updateHeader(sessionId, statusPatch(status, ts, blockedReason)); + } + + private async updateHeader( + sessionId: string, + patch: Partial, + ): Promise { + const next = await this.deps.store.updateHeader(sessionId, patch); const active = this.active.get(sessionId); if (active) active.cachedHeader = next; + return next; } private async appendTurnState( sessionId: string, turnId: string, status: TurnRecord['status'], - lineage: Partial> = {}, + lineage: AgentRunLineage = {}, options: { ts?: number; errorClass?: string; abortSource?: string } = {}, ): Promise { const ts = options.ts ?? this.deps.now(); @@ -636,6 +578,71 @@ export class SessionManager { if (!user) throw new Error(`Turn ${turnId} has no user message`); return user; } + + private async recoverAgentRunsFromLedger( + sessionId: string, + messages: readonly StoredMessage[], + ): Promise<{ hasLedger: boolean; recovered: boolean }> { + if (!this.deps.runStore) return { hasLedger: false, recovered: false }; + const runs = await this.deps.runStore.listSessionRuns(sessionId); + if (runs.length === 0) return { hasLedger: false, recovered: false }; + + let recovered = false; + for (const run of runs) { + const events = await this.deps.runStore.readEvents(sessionId, run.runId); + const decision = classifyAgentRunRecovery(run, events, messages); + if (!decision) continue; + await this.applyAgentRunRecovery(sessionId, decision); + recovered = true; + } + return { hasLedger: true, recovered }; + } + + private async applyAgentRunRecovery( + sessionId: string, + decision: AgentRunRecoveryDecision, + ): Promise { + const ts = this.deps.now(); + if (decision.status === 'completed') { + await this.deps.runStore?.updateRun(sessionId, decision.runId, { + status: 'completed', + completedAt: ts, + updatedAt: ts, + }); + await this.deps.runStore?.appendEvent(sessionId, decision.runId, { + type: 'run_completed', + id: this.deps.newId(), + runId: decision.runId, + sessionId, + turnId: decision.turnId, + ts, + data: { recovered: true, ...decision.diagnostic }, + }); + await this.appendTurnState(sessionId, decision.turnId, 'completed', decision.lineage, { ts }).catch(() => {}); + return; + } + + const failureClass = decision.failureClass ?? 'app_restarted'; + await this.deps.runStore?.updateRun(sessionId, decision.runId, { + status: 'failed', + completedAt: ts, + updatedAt: ts, + failureClass, + }); + await this.deps.runStore?.appendEvent(sessionId, decision.runId, { + type: 'run_failed', + id: this.deps.newId(), + runId: decision.runId, + sessionId, + turnId: decision.turnId, + ts, + data: { recovered: true, failureClass, ...decision.diagnostic }, + }); + await this.appendTurnState(sessionId, decision.turnId, 'failed', decision.lineage, { + ts, + errorClass: failureClass, + }).catch(() => {}); + } } // ============================================================================ diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts new file mode 100644 index 0000000000..3eb5ae71a6 --- /dev/null +++ b/packages/runtime/src/tool-runtime.ts @@ -0,0 +1,608 @@ +import type { + SessionEvent, + ToolOutputStream, + ToolResultContent, + ToolResultEvent, + ToolStartEvent, +} from '@maka/core/events'; +import type { + PermissionDecisionMessage, + ToolCallMessage, + ToolResultMessage, +} from '@maka/core/session'; +import type { PermissionDecision } from '@maka/core/backend-types'; +import type { ToolCategory } from '@maka/core/permission'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { SessionHeader } from '@maka/core/session'; +import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; +import { redactSecrets } from '@maka/core/redaction'; + +import type { PermissionEngine } from './permission-engine.js'; +import type { AsyncEventQueue } from './async-queue.js'; +import { + recordToolArtifactsSafely, + type ToolArtifactRecorder, +} from './tool-artifacts.js'; +import { createToolOutputDeltaEmitter } from './tool-output-delta.js'; +import type { RunTraceLike } from './run-trace.js'; + +export interface MakaTool

{ + /** Canonical (Claude-SDK-style) name. Pi adapter translates to canonical. */ + name: string; + /** Human-readable description shown to the model. */ + description: string; + /** Zod schema describing the tool's argument shape. */ + parameters: unknown; + /** + * If `false`, the wrap layer skips PermissionEngine.evaluate() entirely. + * Defaults to `true` (always go through the engine). + */ + permissionRequired?: boolean; + /** Optional UI display name. */ + displayName?: string; + /** Optional trusted category override for custom tools. */ + categoryHint?: ToolCategory; + /** Real tool implementation. Called only after permission allows. */ + impl: (args: P, ctx: MakaToolContext) => Promise | R; +} + +export interface MakaToolContext { + sessionId: string; + turnId: string; + /** Session working directory. */ + cwd: string; + toolCallId: string; + abortSignal: AbortSignal; + emitOutput: (stream: ToolOutputStream, chunk: string) => void; +} + +export type AppendMessageFn = (m: ToolCallMessage | ToolResultMessage | PermissionDecisionMessage) => Promise; +export type ToolTelemetryRecorder = (record: ToolInvocationRecord) => void; + +export const TOOL_ERROR_RESULT_MAX_CHARS = 4000; +export const MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN = 5; +export const DEFAULT_PERMISSION_TIMEOUT_MS = 300_000; + +const SUBAGENT_TOOL_LIMIT_MESSAGE = '只读探索并发过多:同一轮最多 5 个子代理。请等待已有探索完成后再继续。'; + +export interface ToolRuntimeInput { + sessionId: string; + header: SessionHeader; + connection: LlmConnection; + modelId: string; + appendMessage: AppendMessageFn; + permissionEngine: PermissionEngine; + newId: () => string; + now: () => number; + getPermissionPauseTarget: () => { pause(): void; resume(): void } | null; + getRunTrace?: () => RunTraceLike | null; + permissionTimeoutMs?: number; + recordToolInvocation?: ToolTelemetryRecorder; + recordToolArtifacts?: ToolArtifactRecorder; +} + +export class ToolRuntime { + private activeSubagentToolCount = 0; + + constructor(private readonly input: ToolRuntimeInput) {} + + wrapToolExecute( + tool: MakaTool, + turnId: string, + queue: AsyncEventQueue | { push(event: SessionEvent): void }, + ) { + return async ( + args: unknown, + ctx: { toolCallId: string; abortSignal: AbortSignal }, + ): Promise => this.executeTool(tool, turnId, queue, args, ctx); + } + + resetTurnState(): void { + this.activeSubagentToolCount = 0; + } + + async writeSyntheticToolResult( + toolUseId: string, + turnId: string, + text: string, + queue: AsyncEventQueue | { push(event: SessionEvent): void }, + ): Promise { + const content: ToolResultContent = { kind: 'text', text: formatSyntheticToolErrorText(text) }; + const msg: ToolResultMessage = { + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: true, + content, + }; + await this.input.appendMessage(msg); + queue.push({ + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: true, + content, + } satisfies ToolResultEvent); + } + + private async executeTool( + tool: MakaTool, + turnId: string, + queue: AsyncEventQueue | { push(event: SessionEvent): void }, + args: unknown, + ctx: { toolCallId: string; abortSignal: AbortSignal }, + ): Promise { + const toolUseId = ctx.toolCallId; + const now = this.input.now(); + const toolIntent = describeToolIntent(tool, args); + const trace = this.input.getRunTrace?.() ?? null; + + const callMsg: ToolCallMessage = { + type: 'tool_call', + id: toolUseId, + turnId, + ts: now, + toolName: tool.name, + ...(tool.displayName ? { displayName: tool.displayName } : {}), + ...(toolIntent ? { intent: toolIntent } : {}), + args, + }; + await this.input.appendMessage(callMsg); + const startEv: ToolStartEvent = { + type: 'tool_start', + id: this.input.newId(), + turnId, + ts: now, + toolUseId, + toolName: tool.name, + args, + ...(tool.displayName ? { displayName: tool.displayName } : {}), + ...(toolIntent ? { intent: toolIntent } : {}), + }; + queue.push(startEv); + trace?.emit('tool', 'tool_started', 'Tool execution started', { + toolUseId, + toolName: tool.name, + permissionRequired: tool.permissionRequired !== false, + ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), + }); + + if (tool.permissionRequired !== false) { + const verdict = this.input.permissionEngine.evaluate({ + sessionId: this.input.sessionId, + turnId, + toolUseId, + toolName: tool.name, + args, + ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), + mode: this.input.header.permissionMode, + }); + + if (verdict.kind === 'block') { + trace?.emit('permission', 'permission_failed', 'Permission blocked tool execution', { + toolUseId, + toolName: tool.name, + verdict: verdict.kind, + reason: verdict.reason, + }); + await this.writeSyntheticToolResult(toolUseId, turnId, verdict.reason, queue); + trace?.emit('tool', 'tool_failed', 'Tool execution failed before implementation', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'Permission', + }); + return this.errorReturn(verdict.reason); + } + + if (verdict.kind === 'prompt') { + queue.push(verdict.event); + trace?.emit('permission', 'permission_requested', 'Permission requested', { + requestId: verdict.event.requestId, + toolUseId, + toolName: tool.name, + category: verdict.event.category, + }); + let response: PermissionDecision; + try { + response = await this.awaitPermissionDecision(verdict, turnId); + } catch (err) { + const msg = formatSyntheticToolErrorText(err); + const reason = formatSyntheticToolErrorText(`Permission flow aborted: ${msg}`); + trace?.emit('permission', 'permission_failed', 'Permission flow failed', { + requestId: verdict.event.requestId, + toolUseId, + toolName: tool.name, + reason, + }); + await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); + trace?.emit('tool', 'tool_failed', 'Tool execution failed before implementation', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'Permission', + }); + return this.errorReturn(reason); + } + + const decisionMsg: PermissionDecisionMessage = { + type: 'permission_decision', + id: response.requestId, + turnId, + ts: this.input.now(), + toolUseId, + toolName: tool.name, + decision: response.decision, + ...(response.rememberForTurn !== undefined ? { rememberForTurn: response.rememberForTurn } : {}), + }; + await this.input.appendMessage(decisionMsg); + queue.push({ + type: 'permission_decision_ack', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId: response.requestId, + toolUseId, + decision: response.decision, + ...(response.rememberForTurn !== undefined ? { rememberForTurn: response.rememberForTurn } : {}), + }); + trace?.emit('permission', 'permission_decided', 'Permission decision recorded', { + requestId: response.requestId, + toolUseId, + toolName: tool.name, + decision: response.decision, + ...(response.rememberForTurn !== undefined ? { rememberForTurn: response.rememberForTurn } : {}), + }); + + if (response.decision === 'deny') { + const reason = '用户已拒绝权限请求'; + await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); + trace?.emit('tool', 'tool_failed', 'Tool execution failed before implementation', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'Permission', + }); + return this.errorReturn(reason); + } + } else { + trace?.emit('permission', 'permission_decided', 'Permission allowed tool execution', { + toolUseId, + toolName: tool.name, + decision: 'allow', + category: verdict.category, + }); + } + } + + const reservedSubagentSlot = this.reserveSubagentSlot(tool); + if (!reservedSubagentSlot) { + trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', { + toolUseId, + toolName: tool.name, + errorClass: 'RuntimeLimit', + }); + await this.writeSyntheticToolResult(toolUseId, turnId, SUBAGENT_TOOL_LIMIT_MESSAGE, queue); + return this.errorReturn(SUBAGENT_TOOL_LIMIT_MESSAGE); + } + const startedAt = this.input.now(); + const output = createToolOutputDeltaEmitter({ + sessionId: this.input.sessionId, + turnId, + toolUseId, + newId: this.input.newId, + now: this.input.now, + push: (event) => queue.push(event), + }); + try { + const result = await tool.impl(args as never, { + sessionId: this.input.sessionId, + turnId, + cwd: this.input.header.cwd, + toolCallId: toolUseId, + abortSignal: ctx.abortSignal, + emitOutput: output.emit, + }); + output.flush(); + const durationMs = this.input.now() - startedAt; + + const content = coerceResultContent(result); + const toolResultStatus = deriveToolResultStatus(content); + const resultMsg: ToolResultMessage = { + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: toolResultStatus !== 'success', + content, + durationMs, + }; + await this.input.appendMessage(resultMsg); + queue.push({ + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: toolResultStatus !== 'success', + content, + durationMs, + } satisfies ToolResultEvent); + + this.input.recordToolInvocation?.({ + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + toolName: tool.name, + providerId: this.input.connection.providerType, + modelId: this.input.modelId, + durationMs, + status: toolResultStatus, + argsSummary: summarizeArgs(args), + bytesIn: byteLength(args), + bytesOut: byteLength(result), + startedAt, + }); + trace?.emit('tool', 'tool_completed', 'Tool execution completed', { + toolUseId, + toolName: tool.name, + durationMs, + status: toolResultStatus, + }); + + void recordToolArtifactsSafely( + { + sessionId: this.input.sessionId, + turnId, + toolUseId, + toolName: tool.name, + cwd: this.input.header.cwd, + args, + result, + }, + this.input.recordToolArtifacts, + (message) => { + queue.push({ + type: 'tool_progress', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + chunk: message, + }); + }, + ); + + return result; + } catch (err) { + output.flush(); + const terminalFailure = coerceTerminalFailure(tool, this.input.header.cwd, args, err); + if (terminalFailure) { + const durationMs = Math.max(0, this.input.now() - startedAt); + const resultMsg: ToolResultMessage = { + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: true, + content: terminalFailure.content, + durationMs, + }; + await this.input.appendMessage(resultMsg); + queue.push({ + type: 'tool_result', + id: this.input.newId(), + turnId, + ts: this.input.now(), + toolUseId, + isError: true, + content: terminalFailure.content, + durationMs, + } satisfies ToolResultEvent); + this.input.recordToolInvocation?.({ + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + toolName: tool.name, + providerId: this.input.connection.providerType, + modelId: this.input.modelId, + durationMs, + status: 'error', + errorClass: classifyError(err), + argsSummary: summarizeArgs(args), + bytesIn: byteLength(args), + bytesOut: byteLength(terminalFailure.content), + startedAt, + }); + trace?.emit('tool', 'tool_failed', 'Tool execution failed', { + toolUseId, + toolName: tool.name, + durationMs, + status: 'error', + errorClass: classifyError(err), + }); + return this.errorReturn(terminalFailure.message); + } + const msg = formatSyntheticToolErrorText(err); + await this.writeSyntheticToolResult(toolUseId, turnId, msg, queue); + this.input.recordToolInvocation?.({ + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + toolName: tool.name, + providerId: this.input.connection.providerType, + modelId: this.input.modelId, + durationMs: Math.max(0, this.input.now() - startedAt), + status: 'error', + errorClass: classifyError(err), + argsSummary: summarizeArgs(args), + bytesIn: byteLength(args), + bytesOut: 0, + startedAt, + }); + trace?.emit('tool', 'tool_failed', 'Tool execution failed', { + toolUseId, + toolName: tool.name, + durationMs: Math.max(0, this.input.now() - startedAt), + status: 'error', + errorClass: classifyError(err), + }); + return this.errorReturn(msg); + } finally { + if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + } + } + + private async awaitPermissionDecision( + verdict: Extract, { kind: 'prompt' }>, + turnId: string, + ): Promise { + const timeoutMs = this.input.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + const pauseTarget = this.input.getPermissionPauseTarget(); + pauseTarget?.pause(); + try { + if (timeoutMs <= 0) return await verdict.parked; + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const reason = `Permission request ${verdict.event.requestId} timed out after ${timeoutMs}ms`; + this.input.permissionEngine.expireRequest(turnId, verdict.event.requestId, reason); + reject(new Error(reason)); + }, timeoutMs); + }); + try { + return await Promise.race([verdict.parked, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } finally { + pauseTarget?.resume(); + } + } + + private reserveSubagentSlot(tool: MakaTool): boolean { + if (tool.categoryHint !== 'subagent') return true; + if (this.activeSubagentToolCount >= MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN) return false; + this.activeSubagentToolCount += 1; + return true; + } + + private releaseSubagentSlot(tool: MakaTool): void { + if (tool.categoryHint !== 'subagent') return; + this.activeSubagentToolCount = Math.max(0, this.activeSubagentToolCount - 1); + } + + private errorReturn(message: string): unknown { + return { error: message }; + } +} + +export function formatSyntheticToolErrorText(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + const redacted = redactSecrets(raw || 'Tool failed'); + if (redacted.length <= TOOL_ERROR_RESULT_MAX_CHARS) return redacted; + return `${redacted.slice(0, TOOL_ERROR_RESULT_MAX_CHARS - 1)}…`; +} + +export function classifyError(error: unknown): string { + if (!(error instanceof Error)) return 'Other'; + const code = 'code' in error ? String((error as { code?: unknown }).code) : ''; + const text = `${error.name} ${code} ${error.message}`.toLowerCase(); + if (text.includes('abort')) return 'Abort'; + if (text.includes('rate') || code === '429') return 'RateLimit'; + if (text.includes('auth') || code === '401' || code === '403') return 'Auth'; + if (text.includes('timeout')) return 'Timeout'; + if (text.includes('network') || text.includes('fetch')) return 'Network'; + return error.name || 'Other'; +} + +export function errorReasonFromClass(errorClass: string): string | undefined { + switch (errorClass) { + case 'Timeout': + return 'timeout'; + case 'Auth': + return 'auth'; + case 'RateLimit': + return 'rate_limit'; + case 'Network': + return 'network'; + default: + return undefined; + } +} + +function coerceResultContent(raw: unknown): ToolResultContent { + if (typeof raw === 'string') return { kind: 'text', text: raw }; + if (raw && typeof raw === 'object') { + const obj = raw as { kind?: string; text?: string }; + if (typeof obj.kind === 'string') return raw as ToolResultContent; + if (typeof obj.text === 'string') return { kind: 'text', text: obj.text }; + return { kind: 'json', value: raw }; + } + return { kind: 'text', text: String(raw ?? '') }; +} + +function coerceTerminalFailure( + tool: MakaTool, + cwd: string, + args: unknown, + err: unknown, +): { content: Extract; message: string } | null { + if (tool.name !== 'Bash' || !err || typeof err !== 'object') return null; + const error = err as { code?: unknown; stdout?: unknown; stderr?: unknown }; + if (typeof error.code !== 'number') return null; + const command = args && typeof args === 'object' && typeof (args as { command?: unknown }).command === 'string' + ? (args as { command: string }).command + : ''; + return { + content: { + kind: 'terminal', + cwd, + cmd: redactSecrets(command), + exitCode: error.code, + stdout: redactSecrets(String(error.stdout ?? '')), + stderr: redactSecrets(String(error.stderr ?? '')), + }, + message: `命令退出码 ${error.code}`, + }; +} + +function deriveToolResultStatus(content: ToolResultContent): ToolInvocationRecord['status'] { + if (content.kind === 'explore_agent' && content.ok === false) { + return content.reason === 'aborted' ? 'aborted' : 'error'; + } + if (content.kind === 'rive_workflow' && content.ok === false) return 'error'; + if (content.kind === 'web_search_error') return 'error'; + if (content.kind === 'office_document' && content.ok === false) { + return content.reason === 'officecli_aborted' ? 'aborted' : 'error'; + } + return 'success'; +} + +function summarizeArgs(args: unknown): string { + const text = typeof args === 'string' ? args : JSON.stringify(args ?? null); + return text.length <= 512 ? text : `${text.slice(0, 511)}…`; +} + +function describeToolIntent(tool: MakaTool, args: unknown): string | undefined { + if (tool.categoryHint !== 'subagent' || tool.name !== 'ExploreAgent') return undefined; + if (!args || typeof args !== 'object') return undefined; + const objective = (args as { objective?: unknown }).objective; + if (typeof objective !== 'string') return undefined; + const normalized = redactSecrets(objective.replace(/\s+/g, ' ').trim()); + if (normalized.length === 0) return undefined; + const capped = normalized.length <= 180 ? normalized : `${normalized.slice(0, 179)}…`; + return `只读探索:${capped}`; +} + +function byteLength(value: unknown): number { + if (value === undefined) return 0; + const text = typeof value === 'string' ? value : JSON.stringify(value ?? null); + return Buffer.byteLength(text, 'utf8'); +} diff --git a/packages/storage/src/__tests__/agent-run-store.test.ts b/packages/storage/src/__tests__/agent-run-store.test.ts new file mode 100644 index 0000000000..9f370318cf --- /dev/null +++ b/packages/storage/src/__tests__/agent-run-store.test.ts @@ -0,0 +1,126 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createAgentRunStore } from '../agent-run-store.js'; +import type { AgentRunEvent, AgentRunHeader } from '@maka/core'; + +describe('AgentRunStore', () => { + it('creates, reads, updates, and lists runs under a session', async () => { + await withStore(async (store, root) => { + const first = makeHeader({ runId: 'run-1', createdAt: 1, updatedAt: 1 }); + const second = makeHeader({ runId: 'run-2', turnId: 'turn-2', createdAt: 2, updatedAt: 2 }); + + await store.createRun(second); + await store.createRun(first); + await store.updateRun('session-1', 'run-1', { + status: 'completed', + completedAt: 10, + updatedAt: 10, + }); + + const read = await store.readRun('session-1', 'run-1'); + assert.equal(read.status, 'completed'); + assert.equal(read.completedAt, 10); + assert.deepEqual((await store.listSessionRuns('session-1')).map((run) => run.runId), ['run-1', 'run-2']); + assert.equal( + JSON.parse(await readFile(join(root, 'sessions', 'session-1', 'runs', 'run-1', 'run.json'), 'utf8')).runId, + 'run-1', + ); + }); + }); + + it('serializes same-run event appends', async () => { + await withStore(async (store) => { + await store.createRun(makeHeader()); + + await Promise.all(Array.from({ length: 20 }, (_, index) => + store.appendEvent('session-1', 'run-1', makeEvent({ id: `event-${index}`, ts: index })), + )); + + const events = await store.readEvents('session-1', 'run-1'); + assert.equal(events.length, 20); + assert.equal(new Set(events.map((event) => event.id)).size, 20); + }); + }); + + it('recovers corrupt event lines without hiding later events', async () => { + await withStore(async (store, root) => { + await store.createRun(makeHeader()); + await store.appendEvent('session-1', 'run-1', makeEvent({ id: 'good-1', ts: 1 })); + const eventsPath = join(root, 'sessions', 'session-1', 'runs', 'run-1', 'events.jsonl'); + await writeFile(eventsPath, '{"type":"run_started"\n' + JSON.stringify(makeEvent({ id: 'good-2', ts: 2 })) + '\n', { + flag: 'a', + }); + + const events = await store.readEvents('session-1', 'run-1'); + assert.equal(events[0]?.id, 'good-1'); + assert.equal(events[1]?.type, 'event_corrupt'); + assert.equal(events[2]?.id, 'good-2'); + }); + }); + + it('drops an unterminated corrupt tail event', async () => { + await withStore(async (store, root) => { + await store.createRun(makeHeader()); + const eventsPath = join(root, 'sessions', 'session-1', 'runs', 'run-1', 'events.jsonl'); + await mkdir(join(root, 'sessions', 'session-1', 'runs', 'run-1'), { recursive: true }); + await writeFile(eventsPath, JSON.stringify(makeEvent({ id: 'good-1', ts: 1 })) + '\n{"type":"run_started"'); + + const events = await store.readEvents('session-1', 'run-1'); + assert.deepEqual(events.map((event) => event.id), ['good-1']); + }); + }); + + it('keeps newline-terminated corrupt tail events as durable corruption notes', async () => { + await withStore(async (store, root) => { + await store.createRun(makeHeader()); + const eventsPath = join(root, 'sessions', 'session-1', 'runs', 'run-1', 'events.jsonl'); + await mkdir(join(root, 'sessions', 'session-1', 'runs', 'run-1'), { recursive: true }); + await writeFile(eventsPath, JSON.stringify(makeEvent({ id: 'good-1', ts: 1 })) + '\n{"type":"run_started"\n'); + + const events = await store.readEvents('session-1', 'run-1'); + assert.deepEqual(events.map((event) => event.type), ['run_started', 'event_corrupt']); + assert.equal(events[1]?.data?.lineNumber, 2); + }); + }); +}); + +async function withStore(fn: (store: ReturnType, root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-store-')); + try { + await fn(createAgentRunStore(root), root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function makeHeader(overrides: Partial = {}): AgentRunHeader { + return { + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'created', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/tmp/cwd', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +function makeEvent(overrides: Partial = {}): AgentRunEvent { + return { + type: 'run_started', + id: 'event-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + ...overrides, + }; +} diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts new file mode 100644 index 0000000000..b10b49187e --- /dev/null +++ b/packages/storage/src/agent-run-store.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'node:crypto'; +import { appendFile, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core'; + +const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +export function createAgentRunStore(workspaceRoot: string): AgentRunStore { + return new FileAgentRunStore(workspaceRoot); +} + +class FileAgentRunStore implements AgentRunStore { + private readonly sessionsRoot: string; + private readonly writeQueues = new Map>(); + + constructor(workspaceRoot: string) { + this.sessionsRoot = join(workspaceRoot, 'sessions'); + } + + async createRun(header: AgentRunHeader): Promise { + assertSafeId(header.sessionId, 'Invalid session id'); + assertSafeId(header.runId, 'Invalid run id'); + await this.withQueue(header.sessionId, header.runId, async () => { + await mkdir(this.runDir(header.sessionId, header.runId), { recursive: true }); + await writeAtomic(this.runPath(header.sessionId, header.runId), JSON.stringify(header, sanitizeJson) + '\n'); + }); + return header; + } + + async updateRun(sessionId: string, runId: string, patch: Partial): Promise { + let next: AgentRunHeader | undefined; + await this.withQueue(sessionId, runId, async () => { + const current = await this.readRunUnlocked(sessionId, runId); + next = { ...current, ...patch, sessionId, runId }; + await writeAtomic(this.runPath(sessionId, runId), JSON.stringify(next, sanitizeJson) + '\n'); + }); + if (!next) throw new Error(`Failed to update run ${runId}`); + return next; + } + + async readRun(sessionId: string, runId: string): Promise { + return this.readRunUnlocked(sessionId, runId); + } + + async listSessionRuns(sessionId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + const runsRoot = this.runsRoot(sessionId); + let entries; + try { + entries = await readdir(runsRoot, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const headers: AgentRunHeader[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || !isSafeId(entry.name)) continue; + try { + headers.push(await this.readRunUnlocked(sessionId, entry.name)); + } catch { + // Malformed run folders should not hide the rest of the session. + } + } + return headers.sort((a, b) => a.createdAt - b.createdAt || a.runId.localeCompare(b.runId)); + } + + async appendEvent(sessionId: string, runId: string, event: AgentRunEvent): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + await this.withQueue(sessionId, runId, async () => { + await mkdir(this.runDir(sessionId, runId), { recursive: true }); + await appendFile(this.eventsPath(sessionId, runId), JSON.stringify(event, sanitizeJson) + '\n', 'utf8'); + }); + } + + async readEvents(sessionId: string, runId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + let text: string; + try { + text = await readFile(this.eventsPath(sessionId, runId), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const header = await this.readRunUnlocked(sessionId, runId); + const rawLines = text.split('\n'); + const endsWithNewline = text.endsWith('\n'); + const lines = rawLines + .map((line, index) => ({ line, lineNumber: index + 1 })) + .filter((entry) => entry.line.trim().length > 0); + const lastLineNumber = lines.at(-1)?.lineNumber; + const events: AgentRunEvent[] = []; + for (const entry of lines) { + try { + events.push(JSON.parse(entry.line) as AgentRunEvent); + } catch (error) { + if (!endsWithNewline && entry.lineNumber === lastLineNumber) continue; + events.push({ + type: 'event_corrupt', + id: `run-event-corrupt-${entry.lineNumber}`, + runId, + sessionId, + turnId: header.turnId, + ts: header.updatedAt, + message: error instanceof Error ? error.message : 'Invalid AgentRun event JSONL line', + data: { lineNumber: entry.lineNumber }, + }); + } + } + return events; + } + + private async readRunUnlocked(sessionId: string, runId: string): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + return JSON.parse(await readFile(this.runPath(sessionId, runId), 'utf8')) as AgentRunHeader; + } + + private runsRoot(sessionId: string): string { + assertSafeId(sessionId, 'Invalid session id'); + return join(this.sessionsRoot, sessionId, 'runs'); + } + + private runDir(sessionId: string, runId: string): string { + assertSafeId(runId, 'Invalid run id'); + return join(this.runsRoot(sessionId), runId); + } + + private runPath(sessionId: string, runId: string): string { + return join(this.runDir(sessionId, runId), 'run.json'); + } + + private eventsPath(sessionId: string, runId: string): string { + return join(this.runDir(sessionId, runId), 'events.jsonl'); + } + + private withQueue(sessionId: string, runId: string, operation: () => Promise): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(runId, 'Invalid run id'); + const key = `${sessionId}:${runId}`; + const previous = this.writeQueues.get(key) ?? Promise.resolve(); + const next = previous.then(operation, operation); + this.writeQueues.set( + key, + next.catch(() => { + // Keep the chain alive after failures. + }), + ); + return next; + } +} + +async function writeAtomic(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await writeFile(tempPath, content, 'utf8'); + await rename(tempPath, path); +} + +function assertSafeId(value: string, message: string): void { + if (!isSafeId(value)) throw new Error(message); +} + +function isSafeId(value: string): boolean { + return SAFE_ID_PATTERN.test(value); +} + +function sanitizeJson(_key: string, value: unknown): unknown { + return value === undefined ? undefined : value; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index baf4560fc7..568f85620a 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,4 +1,5 @@ export * from './session-store.js'; +export * from './agent-run-store.js'; export * from './connection-store.js'; export * from './settings-store.js'; export * from './telemetry-repo.js';