From dca1aaef22af51a1e2aad48785e042de72906e33 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 20:27:35 -0700 Subject: [PATCH 01/26] docs: add portable runtime v1 spec --- ...26-05-02-portable-runtime-engine-design.md | 1859 +++++++++++++++++ 1 file changed, 1859 insertions(+) create mode 100644 docs/specs/2026-05-02-portable-runtime-engine-design.md diff --git a/docs/specs/2026-05-02-portable-runtime-engine-design.md b/docs/specs/2026-05-02-portable-runtime-engine-design.md new file mode 100644 index 00000000..ac9acd36 --- /dev/null +++ b/docs/specs/2026-05-02-portable-runtime-engine-design.md @@ -0,0 +1,1859 @@ +# Portable Runtime Engine + +> Defines the portable agent runtime engine that replaces OpenCode, the Runner, and the SessionAgentDO's orchestration logic with a single, platform-agnostic TypeScript library deployable on Cloudflare Workers or Kubernetes. + +## Scope + +This spec covers: + +- Engine library architecture and abstraction boundaries +- The V1 feature superset required beyond Flue-style agent harness behavior +- Session, thread, and message hierarchy +- Agent loop, tool system, compaction, and event emission +- Per-thread prompt queue with modes +- Decision-gated execution (approvals, credential requests, questions) +- Provider interfaces (SessionStore, SandboxProvider, EventBus, BlobStore, CredentialStore) +- Schema ownership and migration strategy +- Platform adapter contracts (Cloudflare and Kubernetes) +- Channel transport contracts, with Slack as the required reference transport for V1 +- Shared API route layer +- Tool implementation and integration framework (ToolContext, ToolResult, credentials, OAuth) +- LLM provider layer (pi-ai and pi-agent-core adoption) +- Package structure + +### Boundary Rules + +- This spec does NOT cover individual tool implementations (GitHub, Slack, Linear, etc.) — those are ported separately against the ToolDef interface. +- This spec does NOT cover frontend component implementation details, but it DOES define the API and event contracts the frontend consumes. +- This spec does NOT cover sandbox image building (Dockerfiles, Modal image definitions, warm pools) — the sandbox image gets simpler but that's a separate concern. +- This spec does NOT cover auth, users, orgs, or billing — those stay in the API layer. +- This spec does NOT cover workflow execution details — a workflow step is "create a session, prompt it, read the result" and uses the engine's session API. +- This spec does NOT cover orchestrator persona or long-term memory product behavior — those are application-level concerns built on top of the engine. + +## Relationship to Flue + +This design is informed by Flue's runtime architecture and may reuse implementation ideas heavily, but V1 is specified as a Valet-owned engine built in-repo rather than a direct dependency on `@flue/sdk`. + +Flue is the baseline reference for: + +- a portable session runtime over `pi-ai` and `pi-agent-core` +- sandbox abstraction +- built-in file/shell/task tools +- DAG-style history with compaction +- Cloudflare-hosted session persistence and SSE streaming + +Valet V1 intentionally goes beyond that baseline in a few core areas: + +- multi-threaded sessions with concurrent per-thread queues +- channel-aware routing between web, Slack, Telegram, and child-session threads +- decision-gated execution via approvals, questions, and credential acquisition +- richer tool context (identity, credentials, sandbox, thread/session metadata, channel metadata) +- adapter-facing event contracts suitable for multiplayer clients and external channel transports + +Where Flue and this spec differ, this spec is authoritative for Valet V1. + +## Why: Contrast with Current Architecture + +### What Exists Today + +``` +Client + ↓ WebSocket +Cloudflare Worker (Hono, 50+ routes) + ↓ DO binding +SessionAgentDO (~3000 lines) + ├── Prompt queue (SQLite, alarm-based flush) + ├── Channel session routing (web/slack/telegram multiplexing) + ├── Decision gates (approvals, questions, expiry alarms) + ├── Model selection & credential resolution + ├── Message persistence (SQLite hot → D1 cold, debounced) + ├── Connected user tracking + ├── Health monitoring + ├── Hibernation/restore orchestration + ├── Analytics event buffering + ├── Child session coordination + ├── Tunnel URL management + ↓ WebSocket (custom protocol, ~680 lines of type defs) +Runner (~6000 lines across 4 files, runs inside Modal sandbox) + ├── WebSocket client to DO (reconnection, buffering, request/response tracking) + ├── ChannelSession state machine (per-channel OpenCode session isolation) + ├── OpenCode lifecycle management (spawn, health poll, crash recovery, restart) + ├── SSE event stream consumption & parsing + ├── Model failover chain (15+ retriable error patterns) + ├── Audio transcription + ├── Memory pre-compaction flush + ├── Auth gateway (JWT, proxying to 5 services, tunnel system) + ↓ HTTP + SSE +OpenCode (external dependency, runs inside Modal sandbox) + ├── LLM provider connections + ├── 73 registered tools + ├── Session state & context management + ├── Plugin system (personas, skills, tools) + └── Config hot-reload via filesystem watch +``` + +Total moving parts: 4 processes (Worker, DO, Runner, OpenCode), 3 transport protocols (HTTP, WebSocket, SSE), 2 custom message protocols (DO-to-Runner, Runner-to-OpenCode), ~10,000 lines of orchestration code. + +### What's Wrong With It + +**The DO is a god object.** SessionAgentDO does prompt queuing, channel routing, message persistence, credential resolution, health monitoring, alarm scheduling, WebSocket multiplexing, analytics buffering, and hibernation orchestration. These responsibilities accumulated because the DO is the only stateful coordination point, so everything that needs state ends up there. The result is 3000 lines of deeply coupled code where a change to prompt queuing can break alarm scheduling. + +**Three hops to execute a tool call.** When the LLM decides to read a file: LLM (in OpenCode) invokes tool handler, which hits the filesystem directly. Fine. But the prompt that led to that tool call traveled: Client, Worker, DO, WebSocket, Runner, HTTP, OpenCode. And the result travels back the same path. Six network hops round-trip for every user message. Each hop is a failure point, a latency penalty, and a protocol translation. + +**The Runner exists to bridge two things that shouldn't be separate.** The Runner's entire purpose is to translate between the DO's WebSocket protocol and OpenCode's HTTP/SSE protocol. It manages OpenCode's process lifecycle, consumes its event stream, tracks per-channel state, handles model failover, and reports back to the DO. It's 6000 lines of glue code. If the agent runtime talked directly to the sandbox, the Runner wouldn't need to exist. + +**Two sources of truth for session state.** The DO holds prompt queue state, channel mappings, and decision gates in SQLite. The Runner holds per-channel OpenCode session IDs, streaming state, tool call tracking, and model failover state in memory. D1 holds the canonical message history. When the Runner disconnects and reconnects, there's a complex resync protocol to reconcile these three state locations. This is fragile: the 60-second grace period, the session recreation logic, the "resync if busy, abort if stuck" flow all exist because state is scattered. + +**OpenCode is an opaque dependency.** We can't fix bugs in its agent loop or change how it handles tool calls, compaction, or context management. When it crashes, the Runner has to detect the crash, track crash counts, apply exponential backoff, and eventually declare a fatal state. We work around its limitations rather than fixing them: the memory pre-compaction flush at 70% context exists because we can't modify OpenCode's compaction behavior directly. + +**Platform lock-in is structural, not incidental.** The architecture doesn't just run on Cloudflare; it's shaped by Cloudflare. The DO's single-writer guarantee shapes the prompt queue design. Hibernatable WebSockets shape the connection model. DO alarms shape the timer system. SQLite in the DO shapes the hot storage pattern. To port to Kubernetes, you wouldn't just swap implementations; you'd have to redesign every subsystem that was shaped by a DO capability. + +**The prompt queue is session-wide, blocking cross-channel work.** A Slack conversation blocks web UI prompts. An orchestrator can't research in one thread while coding in another. This isn't a fundamental limitation; it's an artifact of the DO processing one prompt at a time because that's simpler in the single-writer model. + +### What Replaces It + +``` +Client + ↓ WebSocket / SSE +Platform Adapter (thin: ~200-400 lines) + ├── CF: Worker routes + SessionHostDO (just hosts engine) + └── K8s: Hono service + SessionPool (just hosts engine) + ↓ function call +Engine (portable, ~2000-3000 lines) + ├── Agent loop (pi-agent-core: prompt → LLM → tools → response) + ├── Thread management (per-thread queues, cross-visibility) + ├── Tool execution (built-in + custom ToolDef[]) + ├── Session state (DAG history, compaction) + ├── Model resolution & failover (pi-ai) + ├── Event emission + ↓ SandboxProvider interface +Sandbox (Modal / K8s Pod / Docker / Virtual) + └── filesystem + shell (no agent logic) +``` + +Total moving parts: 2 processes (adapter + sandbox), 1 transport protocol (HTTP to sandbox API), 0 custom message protocols, ~3000 lines of orchestration code. + +### Why It's Better + +**The engine is a library, not a distributed system.** Session state, prompt queuing, thread management, tool execution, and event emission all live in one process with one call stack. No WebSocket protocols, no message serialization, no reconnection logic, no state reconciliation. A prompt goes in, events come out. + +**One hop to execute a tool call.** Engine calls `sandbox.exec()` or `sandbox.readFile()`. The sandbox is just a filesystem and shell behind an interface. + +**Single source of truth for session state.** The engine holds all session state in memory during execution and persists through SessionStore. No split between DO SQLite, Runner memory, and D1. No resync protocol. No grace periods. If the engine process restarts, it rehydrates from SessionStore: one load, complete state. + +**Per-thread concurrency is natural.** Each thread has its own queue and executes independently. The engine manages concurrent threads within a session because it's just concurrent async operations in one process, not distributed coordination. + +**We own the agent loop.** Compaction behavior, tool call handling, context management, model failover: all modifiable. No working around an opaque dependency. + +**Platform is a configuration choice, not an architectural commitment.** The engine doesn't know about DOs, Workers, pods, or containers. It knows about SessionStore, SandboxProvider, EventBus, BlobStore, and CredentialStore. Porting to a new platform means implementing provider interfaces, not redesigning the session model. + +**The sandbox becomes simpler.** The sandbox runs only dev tools (code-server, VNC, TTYD) and a lightweight auth gateway. The agent brain is elsewhere. Sandbox boot time decreases. Sandbox crashes don't kill the agent; they just make tool calls fail temporarily until the sandbox recovers. + +**Testing becomes trivial.** The engine is a TypeScript library with injected interfaces. Test it with InMemorySessionStore, VirtualSandbox (just-bash), and InMemoryEventBus. No containers, no DOs, no network. Full integration tests run in milliseconds. + +## Architecture + +### Three Layers + +**1. Engine (`packages/engine/`)** — Portable TypeScript library, zero platform dependencies. Owns the agent loop, session/thread state, tool execution, prompt queuing, compaction, model failover, event emission, roles, and skills. + +**2. Provider interfaces** — Contracts defined by the engine, implemented per-platform. Five interfaces: SessionStore, SandboxProvider, EventBus, BlobStore, CredentialStore. + +**3. Platform adapters (`packages/adapter-cloudflare/`, `packages/adapter-k8s/`)** — Thin packages (~200-400 lines each) that implement the provider interfaces for a specific deployment target and host the engine process. + +``` +┌─────────────────────────────────────────────────────┐ +│ packages/engine/ │ +│ │ +│ ┌───────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ AgentLoop │ │ Session │ │ ToolRegistry │ │ +│ │(pi-agent- │ │ Manager │ │ │ │ +│ │ core) │ │ │ │ │ │ +│ └─────┬─────┘ └────┬─────┘ └───────┬───────┘ │ +│ │ │ │ │ +│ ┌─────▼─────────────▼───────────────▼───────────┐ │ +│ │ Provider Interfaces │ │ +│ │ SessionStore | SandboxProvider | EventBus │ │ +│ │ BlobStore | CredentialStore │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ │ +┌────────▼────────┐ ┌───────▼─────────┐ +│ adapter-cf/ │ │ adapter-k8s/ │ +│ D1, DO, R2, │ │ PG, Redis, S3, │ +│ Modal │ │ Modal/K8s Pods │ +└─────────────────┘ └─────────────────┘ +``` + +### Package Structure + +``` +packages/ + engine/ ← portable core (agent loop, tools, interfaces, schema) + src/ + schema/ ← Drizzle schema definitions (source of truth) + tools/ ← built-in tool implementations + session.ts ← session management + thread.ts ← thread lifecycle, cross-visibility + queue.ts ← per-thread prompt queue + agent-loop.ts ← pi-agent-core wrapper + compaction.ts ← context compression + events.ts ← typed event system + roles.ts ← role loading and resolution + skills.ts ← skill discovery and invocation + result.ts ← structured result extraction + types.ts ← all public types and interfaces + migrations/ + sqlite/ ← generated by drizzle-kit for D1 + postgresql/ ← generated by drizzle-kit for PG + api/ ← shared Hono route handlers, parameterized by store impls + adapter-cloudflare/ ← CF-specific wiring (DO host, D1/R2/DO providers) + adapter-k8s/ ← K8s-specific wiring (session pool, PG/Redis/S3 providers) +``` + +## V1 Completeness Contract + +V1 is complete when the engine can replace OpenCode, the Runner, and the SessionAgentDO orchestration path for normal interactive sessions on the Cloudflare adapter, while preserving the product-facing API/event behavior required by the web client and Slack reference transport. + +The V1 implementation must define and implement these contracts: + +| Contract | Owner | Required for V1 | +|---|---|---| +| Engine public API | `packages/engine` | Session creation/restoration, thread lookup, prompt submission, abort/pause/resume, decision resolution, event subscription | +| Session/thread/message model | `packages/engine` | DAG entries, thread metadata, queue state, compaction entries, decision gate entries, suspended turn checkpoints | +| Agent loop contract | `packages/engine` | pi-agent-core integration, model resolution, tool execution, failover, abort propagation, structured results | +| Tool contract | `packages/engine` + plugin packages | Built-in tools, plugin `ToolDef`s, command tools, action-policy wrapping, attachment handling | +| Decision gate contract | `packages/engine` + adapters | Approval, question, and credential-request gates, delivery refs, resolution, expiry, withdrawal, restart-safe resume | +| Provider contracts | adapters | SessionStore, SandboxProvider, EventBus, BlobStore, CredentialStore | +| Sandbox RPC contract | sandbox runtime + adapters | File operations, process execution, snapshots, tunnels, health, auth, request limits | +| Channel transport contract | SDK + adapters | Outbound messages, decision gate delivery/update, inbound action parsing, free-text gate resolution | +| API route contract | `packages/api` + adapters | Shared session/thread/prompt/history/decision/control routes | +| Client event contract | adapters | WebSocket/SSE event names and payloads for web UI consumption | +| Schema/migration contract | `packages/engine` | Drizzle schema, SQLite and PostgreSQL migrations, coexistence with current app tables during rollout | +| Observability contract | `packages/engine` + adapters | Audit events, analytics events, logs, status events, recoverable vs fatal errors | + +### V1 Exclusions + +The following are explicitly post-V1 unless needed to preserve an existing production workflow: + +- User-facing branch/replay controls beyond preserving DAG metadata. +- Kubernetes production deployment. The contract must exist, but Cloudflare is the V1 shipping adapter. +- Rewriting every plugin package by hand. V1 may use an `ActionSource` to `ToolDef` bridge. +- Replacing workflow execution internals. Workflows may continue to call the session API. +- Removing old tables immediately. V1 may run side-by-side with current tables while the migration completes. + +## Engine Public API + +The engine is a library. Platform adapters host it and expose HTTP/WebSocket entrypoints, but all session execution flows through this API. + +```typescript +interface Engine { + createSession(opts: CreateSessionOptions): Promise; + restoreSession(sessionId: string): Promise; + getSession(sessionId: string): Promise; + deleteSession(sessionId: string): Promise; + onEvent(listener: (event: BusEvent) => void): Unsubscribe; +} + +interface CreateSessionOptions { + id?: string; + userId: string; + orgId: string; + workspace: string; + purpose?: 'interactive' | 'orchestrator' | 'workflow' | 'child'; + parentSessionId?: string; + parentThreadId?: string; + sandbox: Sandbox | SandboxCreateOpts; + tools?: ToolDef[]; + commandTools?: CommandToolDef[]; + roles?: RoleSpec[]; + skills?: SkillSource[]; + model: string; + modelFailover?: string[]; + queueMode?: QueueMode; + metadata?: Record; +} + +interface SessionHandle { + id: string; + thread(key?: string): ThreadHandle; + prompt(content: PromptContent, opts?: PromptOptions): Promise; + resolveDecision(gateId: string, resolution: DecisionResolution): Promise; + withdrawDecision(gateId: string, reason: DecisionWithdrawReason): Promise; + abort(opts?: { threadId?: string }): Promise; + pause(opts?: { threadId?: string }): Promise; + resume(opts?: { threadId?: string }): Promise; + snapshot(): Promise; + destroy(): Promise; +} + +interface ThreadHandle { + id: string; + prompt(content: PromptContent, opts?: PromptOptions): Promise; + skill(name: string, opts?: SkillInvokeOptions): Promise; + shell(command: string, opts?: ExecOpts): Promise; + readThread(key: string, opts?: MessageQuery): Promise; + abort(): Promise; + pause(): Promise; + resume(): Promise; +} + +type QueueMode = 'followup' | 'steer' | 'collect'; + +type PromptContent = + | string + | { + text?: string; + attachments?: PromptAttachment[]; + }; + +interface PromptOptions { + author?: PromptAuthor; + channel?: ChannelTarget; + replyTarget?: ChannelTarget; + queueMode?: QueueMode; + model?: string; + role?: string; + resultSchema?: TSchema; + metadata?: Record; +} + +interface PromptAuthor { + id: string; + email?: string; + name?: string; + avatarUrl?: string; + externalId?: string; +} + +type PromptAttachment = + | { type: 'image'; url?: string; data?: Uint8Array; mimeType: string; name?: string } + | { type: 'file'; url?: string; data?: Uint8Array; mimeType: string; name: string } + | { type: 'audio'; url?: string; data?: Uint8Array; mimeType: string; name?: string }; + +interface PromptReceipt { + sessionId: string; + threadId: string; + queueItemId: string; + status: 'queued' | 'running' | 'blocked_on_decision_gate'; +} + +interface MessageQuery { + limit?: number; + cursor?: string; + afterEntryId?: string; + beforeEntryId?: string; + includeCompacted?: boolean; + includeSystemEntries?: boolean; +} + +interface ListOpts { + limit?: number; + cursor?: string; + status?: string; + createdAfter?: Date; + createdBefore?: Date; +} +``` + +The API is idempotent where identifiers are supplied by the caller. `createSession({ id })` must return the existing session if it has already been created with the same ID and compatible immutable fields. `resolveDecision()` must be safe to retry: resolving an already resolved gate with the same resolution is a no-op; resolving it with a different resolution returns a conflict error. + +`TSchema` refers to the TypeBox schema type used by pi-ai for structured parameters and results. API-layer adapters must serialize schemas as JSON Schema and preserve the original TypeScript type only inside package boundaries. + +## Data Model: Sessions, Threads, and Messages + +### Hierarchy + +``` +Session (sandbox, tools, roles, config) + ├── Thread 'web:default' ─── Messages (DAG) + ├── Thread 'slack:C123' ─── Messages (DAG) + ├── Thread 'task:research' ─── Messages (DAG) + │ + │ Threads can read from siblings (cross-thread visibility). + │ Threads execute concurrently (independent queues). + │ + └── Child Session (own or shared sandbox) + ├── Thread 'default' ─── Messages (DAG) + │ Can read from parent threads. + └── Parent can read child thread summaries. +``` + +### Session + +A session owns a sandbox instance, registered tools, roles, and configuration. It is the container for all agent work. + +- Created via the engine's API: `engine.createSession(opts)` +- Has a unique ID, a sandbox, a set of tools, optional roles and skills +- Can spawn child sessions (single-threaded or multi-threaded) +- Owns shared decision state used by its threads: pending decision gates, credentials, and child-session registry +- Session-wide controls: `abort()` aborts all threads, `pause()`/`resume()` freeze/unfreeze all thread queues + +### Thread + +A named conversation within a session. Each thread has its own message history (DAG-based), its own prompt queue, its own compaction state, and its own active model. Threads share the sandbox, tools, and roles from the parent session. + +- Created or retrieved via `session.thread(key)` +- `session.prompt()` is sugar for `session.thread('default').prompt()` +- Each channel target naturally maps to a thread key: `web:default`, `slack:C123`, `telegram:456`, `thread:` +- Threads can also be created explicitly for focused work: `task:research`, `review:pr-42` + +**Channel-aware thread identity:** A thread is the engine's concurrency and history boundary. Channel metadata is attached to prompts and messages, but channel transports do not define execution boundaries on their own. Multiple external channel targets may point at the same logical thread when the application intentionally converges them (for example, a Slack thread and the web UI both steering the same orchestrator thread). + +**Cross-thread visibility:** Threads can read messages from sibling threads via a built-in `thread_read` tool. The LLM can pull in context from another thread when it needs it, without paying the token cost of having it in context permanently. Cross-visibility also works across the session boundary: child session threads can read from parent threads, and parent threads can read child thread summaries. + +**Thread controls:** +- `thread.prompt(text, opts)` — submit a prompt +- `thread.abort()` — abort current prompt, clear this thread's queue +- `thread.pause()` / `thread.resume()` — freeze/unfreeze this thread's queue +- `thread.skill(name, opts)` — invoke a named skill +- `thread.shell(command)` — execute a shell command (recorded in history) +- `thread.readThread(key)` — read messages from a sibling thread + +### Messages + +Messages within a thread form a DAG (directed acyclic graph). Each message entry has a `parentId` pointing to its predecessor, enabling branching and replay. + +**Entry types:** +- `MessageEntry` — LLM or engine-authored messages (user, assistant, toolResult, system) with content, attachments, and source metadata +- `DecisionGateEntry` — a persisted decision point in the conversation DAG, including its status and any eventual resolution +- `CompactionEntry` — summarized context checkpoint inserted by the compaction system +- `BranchSummaryEntry` — summary of a branched conversation + +```typescript +interface BaseEntry { + id: string; + sessionId: string; + threadId: string; + parentId: string | null; + createdAt: number; + metadata?: Record; +} + +interface MessageEntry extends BaseEntry { + type: 'message'; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: string; + parts?: MessagePart[]; + author?: PromptAuthor; + channel?: ChannelTarget; + model?: string; +} + +type MessagePart = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool_call'; callId: string; toolName: string; status: 'running' | 'completed' | 'error'; args?: unknown; result?: unknown; error?: string } + | { type: 'attachment'; attachment: ToolAttachment } + | { type: 'error'; message: string; code?: string }; + +interface CompactionEntry extends BaseEntry { + type: 'compaction'; + summary: string; + coveredEntryIds: string[]; + tokenCountBefore: number; + tokenCountAfter: number; + fileContext?: { + read: string[]; + modified: string[]; + }; +} + +interface BranchSummaryEntry extends BaseEntry { + type: 'branch_summary'; + branchRootId: string; + branchLeafId: string; + summary: string; +} +``` + +The active conversation path is reconstructed by following `parentId` pointers from the leaf back to the root. Compaction inserts a summary without rewriting history. + +**Suspension history rules:** Decision-gated turns are represented in the DAG by a first-class `DecisionGateEntry`, not by synthetic system messages. The entry is created when the gate is opened and then updated in place as it moves through `pending`, `resolved`, `expired`, or `withdrawn` states. This keeps the history model explicit and replayable: gates are decision artifacts, not conversation utterances. + +**V1 branching stance:** The storage model remains DAG-based so future replay and alternate branches are possible without schema redesign, but V1 does not require exposing full user-facing branch/replay controls in the API. V1 must preserve enough metadata for later branching support without forcing branching UX to ship in the first implementation batch. + +## Engine Internals + +### Agent Loop + +The engine uses `@mariozechner/pi-agent-core` for the inner agent loop and `@mariozechner/pi-ai` for the LLM provider layer. The engine wraps these with session/thread management, tool context injection, and event routing. + +**Per-thread agent instance:** Each thread gets its own `Agent` instance (from pi-agent-core). The agent manages the LLM streaming, parallel tool execution, and turn lifecycle. The engine subscribes to the agent's events and translates them to `EngineEvent` emissions. + +**Loop flow:** + +``` +prompt received on thread + → compose context (system prompt + thread history + role instructions) + → build tool list (built-in + custom, with ToolContext injection) + → create/update Agent instance with context and tools + → agent runs: call LLM (streaming via pi-ai) + → for each tool call in response: + → execute tool via ToolDef.execute(args, ctx) + → if tool requests a decision gate: + → persist DecisionGate + SuspendedTurnState + → append DecisionGateEntry(status='pending') to the DAG + → emit decision_gate event + → stop only this thread's active turn + → when a decision gate is resolved: + → update the existing DecisionGateEntry with resolution metadata and status='resolved' + → reconstruct the suspended turn from persisted state + → re-run the suspended tool/turn from the checkpoint + → when a decision gate expires or is withdrawn: + → update the existing DecisionGateEntry with status='expired' or status='withdrawn' + → fail or cancel the suspended turn + → if tool returns attachments, handle per type: + → image attachments → route to LLM as vision content + → text attachments → include inline in tool result + → file attachments → store via BlobStore, reference in history + → append tool result to thread history + → if LLM wants to continue (more tool calls): loop + → if LLM emits end_turn: done + → check compaction threshold, compact if needed + → persist thread state via SessionStore + → emit events throughout +``` + +### LLM Provider Layer + +The engine adopts `@mariozechner/pi-ai` for model abstraction. pi-ai provides a unified streaming interface across 20+ providers (Anthropic, OpenAI, Google, Mistral, Bedrock, etc.), typed streaming events, tool type definitions, vision support detection, context serialization, and cross-provider handoffs. + +The engine adopts `@mariozechner/pi-agent-core` for the inner agent loop. pi-agent-core provides the `Agent` class that handles the LLM streaming, parallel tool execution, abort handling, and event emission cycle. + +**What pi-ai gives us:** +- Model discovery and provider configuration (`getModel('anthropic', 'claude-sonnet-4-6')`) +- Streaming with typed events (`text_delta`, `toolcall_start/delta/end`, `thinking_start/delta/end`) +- Token and cost tracking per call +- Context serialization for persistence +- Cross-provider context handoffs (enables model failover with automatic thinking-to-text conversion) +- Faux provider for deterministic testing (`registerFauxProvider()`) + +**What pi-agent-core gives us:** +- The `Agent` class: prompt → LLM → tool calls → execute → feed results → loop until end_turn +- Parallel tool execution (`toolExecution: 'parallel'`) +- Typed event subscription (`agent_start`, `message_update`, `tool_execution_start/end`, `turn_end`) +- Abort signal propagation +- State management (messages, model, tools) + +**What the engine adds on top:** +- Sessions and threads (pi-agent-core has no concept of persistence or multi-conversation) +- Per-thread prompt queue with modes +- Cross-thread visibility +- Decision gates and resumable user-interaction points +- Compaction (using pi-ai's token counts to decide when, pi-ai's streaming to generate summaries) +- Tool context injection (credentials, sandbox, user identity) +- Event routing from pi-agent-core events to EngineEvent emissions +- Model failover (catch retriable errors, hand off context to next model via pi-ai) +- Structured result extraction with schema validation + +**Model resolution:** Uses `provider/model` string convention (same as pi-ai and OpenRouter). Provider instances are registered at startup by the platform adapter. Model failover is configured per-session as an ordered list; on retriable errors, the engine advances to the next model and hands off the context using pi-ai's cross-provider serialization. + +#### Model Registry Contract + +Adapters register model providers before restoring or creating sessions. + +```typescript +interface ModelRegistry { + registerProvider(provider: ModelProviderConfig): void; + get(model: string): Promise; + list(opts?: { userId?: string; orgId?: string }): Promise; +} + +interface ModelProviderConfig { + id: string; + displayName: string; + apiKey?: string; + baseUrl?: string; + models?: ModelDescriptor[]; +} + +interface ModelDescriptor { + id: string; // provider/model + providerId: string; + modelId: string; + displayName?: string; + contextWindow?: number; + outputLimit?: number; + input: Array<'text' | 'image' | 'audio'>; + output: Array<'text' | 'tool_call'>; +} + +interface ModelHandle { + descriptor: ModelDescriptor; + provider: unknown; // pi-ai provider instance, hidden behind engine package boundaries +} +``` + +Model selection order is prompt override, role override, thread model, session model, then platform default. Failover never crosses into a model the user or org is not authorized to use. + +### Tool System + +Three categories of tools, merged at prompt time: + +**Built-in tools** (provided by the engine, always available): +- `read` — read file contents via SandboxProvider +- `write` — create/overwrite files via SandboxProvider +- `edit` — exact text replacement via SandboxProvider +- `bash` — shell execution via SandboxProvider +- `grep` — pattern search via SandboxProvider +- `glob` — file pattern matching via SandboxProvider +- `thread_read` — read messages from a sibling, parent, or child thread +- `task` — spawn a child session for delegated work (depth-limited) + +**Plugin tools** (`ToolDef[]`, registered at session creation): +- Custom tools from plugin packages (GitHub, Slack, Linear, memory, browser, etc.) +- Each is a `{ name, description, parameters, execute }` object +- Registered per-session or per-thread (thread-level overrides session-level on name conflict) + +**Command tools** (privileged CLI wrappers): +- Shell commands with injected environment variables +- Secrets are injected at the host level, never visible to the LLM +- Scoped per-prompt or per-session + +```typescript +interface CommandToolDef { + name: string; + description: string; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + riskLevel?: 'low' | 'medium' | 'high' | 'critical'; + requiresApproval?: boolean; + timeoutMs?: number; +} +``` + +Command tools execute through `Sandbox.exec`. The engine injects configured environment variables into the process environment and never serializes secret values into message history, tool arguments visible to the model, or events. + +#### ToolDef Interface + +```typescript +interface ToolDef { + name: string; + description: string; + parameters: TSchema; // TypeBox schema (pi-ai native) + riskLevel?: 'low' | 'medium' | 'high' | 'critical'; + requiresApproval?: boolean | ((args: Record, ctx: ToolContext) => Promise | boolean); + execute: (args: Record, ctx: ToolContext) => Promise; +} +``` + +Tool names are globally unique within a session after registration. Built-in tools use short names (`read`, `bash`); plugin tools use service-qualified names (`github.create_pr`, `linear.create_issue`). If two tools register the same name at the same scope, session creation fails unless a thread-level override intentionally replaces a session-level tool. + +#### ToolContext + +Every tool execution receives a context object from the engine: + +```typescript +interface ToolContext { + // Identity + userId: string; + orgId: string; + sessionId: string; + threadId: string; + sessionPurpose?: string; + actor?: { + id: string; + name?: string; + email?: string; + }; + + // Prompt/message routing + channelType?: string; + channelId?: string; + decisionGateId?: string; + replyChannelType?: string; + replyChannelId?: string; + + // Repo / workspace context + cwd?: string; + repo?: { + url?: string; + branch?: string; + ref?: string; + provider?: string; + }; + + // Credentials + credentials: CredentialProvider; + + // Sandbox (for tools that need file/shell access) + sandbox: Sandbox; + + // Structured runtime interactions + requestDecision: (gate: DecisionGate) => Promise; + emitArtifact?: (artifact: ToolArtifact) => Promise; + suspendedDecision?: SuspendedDecisionContext; + + // Abort + signal: AbortSignal; +} + +interface CredentialProvider { + get(service: string): Promise; + request(service: string, reason: string): Promise; +} + +interface Credential { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + metadata?: Record; +} + +type ToolArtifact = + | { type: 'file'; path?: string; blobKey?: string; title?: string } + | { type: 'link'; url: string; title: string } + | { type: 'diff'; path?: string; content: string }; + +interface SuspendedDecisionContext { + gateId: string; + resolution?: DecisionResolution; +} +``` + +When a tool calls `credentials.request()` for a credential that doesn't exist, the engine pauses tool execution and emits a `decision_gate` event to the user. Execution resumes when the credential is provided. If the user does not respond within a configurable timeout (default 10 minutes), the request fails and the tool receives a structured credential error. Same pattern as tool approvals. + +Approval-gated tools follow the same suspension model. A tool can return or throw a structured `approval_required` signal, which the engine converts into a `DecisionGate`, persists, emits, and resumes on resolution. + +**Restart-safe tool suspension contract:** The engine does not rely on preserving an in-memory JavaScript continuation across restarts. Tools that call `requestDecision(...)` must therefore be re-entrant up to their decision points. On first execution, `requestDecision(...)` persists the gate and suspends the turn. On resumed execution, the engine re-runs the tool from the start with `suspendedDecision` populated for the matching gate ID, and the same `requestDecision(...)` call returns the stored resolution instead of creating a new gate. + +#### Plugin Action Bridge + +V1 may continue using existing plugin action packages through an adapter: + +```typescript +interface ActionSource { + listActions(ctx?: { credentials?: Record }): ActionDefinition[] | Promise; + execute(actionId: string, params: unknown, ctx: ActionContext): Promise; +} + +interface ActionDefinition { + id: string; + name: string; + description: string; + riskLevel: RiskLevel; + params?: unknown; // Zod schema from current SDK packages + inputSchema?: Record; +} + +interface ActionContext { + credentials: Record; + userId: string; + orgId?: string; + callerIdentity?: { name: string; avatar?: string }; + analytics?: unknown; + attribution?: { name: string; email: string }; + guardConfig?: Record; +} + +interface ActionResult { + success: boolean; + data?: unknown; + error?: string; + images?: Array<{ data: string; mimeType: string; description: string }>; +} + +interface ActionSourceToolBridgeOptions { + service: string; + actions: ActionSource; + credentialService?: string; + defaultApprovalMode?: 'allow' | 'require_approval' | 'deny'; +} + +function actionSourceToTools(opts: ActionSourceToolBridgeOptions): Promise; +``` + +Bridge behavior: + +- Each `ActionDefinition` becomes one `ToolDef` named `${service}.${action.id}`. +- Zod parameters are converted to TypeBox/JSON Schema at registration time. +- `riskLevel` is copied onto the `ToolDef`. +- Current action policy is evaluated before execution. Denied actions return a tool error. Approval-required actions create a `DecisionGate` of type `approval`. +- Credentials are resolved through `CredentialProvider` and passed to the action as the current `ActionContext.credentials`. +- Existing action analytics are forwarded to the engine observability sink. +- Existing action images are converted to `ToolAttachment` objects and handled by the engine attachment pipeline. +- The bridge is a migration layer, not a permanent engine dependency. New plugins should export `ToolDef[]` directly. + +#### ToolResult + +```typescript +type ToolResult = { + text: string; + attachments?: ToolAttachment[]; +}; + +type ToolAttachment = + | { type: 'image'; data: Uint8Array; mimeType: string; name?: string } + | { type: 'file'; data: Uint8Array; mimeType: string; name: string } + | { type: 'text'; content: string; name?: string; language?: string }; +``` + +**Attachment handling by the engine:** +- `image` attachments are routed to the LLM as vision content (if the model supports it via `model.input.includes('image')`). +- `file` attachments are stored via BlobStore and referenced in the message history. Available to the LLM if requested but not injected into context automatically. +- `text` attachments are included inline in the tool result message. The `language` field enables syntax-aware formatting. + +### Compaction + +Token-aware context compression. When a thread's context approaches the model's context window limit, the engine summarizes older messages and inserts a CompactionEntry into the DAG, keeping recent messages intact. + +**Trigger conditions:** +- Threshold mode: `contextTokens > (contextWindow - reserveTokens)`. Compact before the next prompt. +- Overflow mode: LLM returned an error due to context overflow. Compact and retry. + +**Algorithm:** +1. Calculate cut point: keep most recent N tokens (`keepRecentTokens`, default 20k). Work backward to find a valid cut point (user or assistant message boundary, never split mid-turn). +2. Serialize messages before the cut point into a structured text representation. +3. Send to LLM with a summarization prompt. Output is structured markdown (goal, progress, key decisions, next steps, critical context). +4. Track file operations: extract paths from read/write/edit tool calls, distinguish read-only vs. modified files. +5. Insert CompactionEntry into the DAG. Future context reconstruction includes the summary plus recent messages. + +**Configuration:** +- `reserveTokens`: default 16384 (safety buffer) +- `keepRecentTokens`: default 20000 (minimum recent context to preserve) +- `enabled`: default true (can be disabled per-thread) + +### Per-Thread Prompt Queue + +Each thread owns its own prompt queue. Threads execute independently and concurrently within a session. + +**Concurrency model:** +- Each thread processes one prompt at a time (serialized within a thread) +- Multiple threads can be active simultaneously (parallel across threads) +- Sandbox access is shared: concurrent file ops and shell commands from different threads hit the same filesystem +- Tool execution is thread-safe by contract: tool authors handle their own concurrency if needed + +**Queue modes** (per-thread, switchable at runtime): + +- **Followup** (default) — prompts queue in FIFO order. When the current prompt completes, the next one starts. If the thread is idle, the prompt executes immediately. +- **Steer** — new prompt aborts the in-flight prompt and starts immediately. Previous prompt's partial work remains in the thread history. +- **Collect** — prompts buffer for a configurable window (default 5 seconds). When the window closes, all buffered prompts are concatenated into a single prompt and dispatched. If the thread is busy, the collected prompt enters the FIFO queue as normal. + +**Prompt metadata:** Each prompt carries `threadId`, `channelType`, `channelId`, `authorId`, optional attachments, and optional model override. + +**Routing semantics:** Queueing is keyed by thread, not by transport. `channelType` / `channelId` are routing metadata used for attribution, reply delivery, and decision gate resolution. They do not create extra isolation beyond the owning thread. + +**Steer semantics:** `steer` aborts only the current turn on the targeted thread. It must not affect other active threads in the session. Partial work already emitted by the aborted turn remains in history. + +**Collect semantics:** `collect` buffers by thread. Adapters may additionally preserve origin-channel metadata for each buffered prompt so the merged prompt can still attribute its constituent messages correctly. + +**Pending decision semantics:** When a thread is blocked on a pending decision gate, it is considered busy but interruptible. Behavior by mode: + +- `followup` — new prompts queue behind the blocked turn. +- `collect` — new prompts continue buffering and later queue behind the blocked turn. +- `steer` — new prompt cancels the blocked turn and expires or withdraws the outstanding decision gate before starting immediately. + +The engine must never allow an old gate resolution to resume a turn that was already superseded by `steer`. + +**Persisted runtime state:** A thread with a pending decision gate remains the active processing item in queue state, but with a distinct suspended status. V1 queue persistence must distinguish at least: + +- `queued` +- `running` +- `blocked_on_decision_gate` +- `paused` + +When a thread enters `blocked_on_decision_gate`, the engine persists a `SuspendedTurnState` checkpoint containing enough information to safely resume after restart: + +- session ID / thread ID / active queue item ID +- current model +- active leaf message ID +- pending gate ID +- pending tool call ID, tool name, and original tool args +- any deterministic resume context needed for `requestDecision(...)` to short-circuit on replay + +On restore, the engine reloads the blocked thread, reloads the decision gate, and waits for either resolution, expiry, or cancellation. Once resolved, the engine reconstructs the turn from the checkpoint and re-drives execution. + +**Persistence:** Queue state is persisted via SessionStore so it survives process restarts. On engine startup, pending queue entries are restored and dispatched. + +**Controls:** +- `thread.abort()` — abort current prompt on this thread, clear this thread's queue +- `thread.pause()` / `thread.resume()` — freeze/unfreeze this thread's queue +- `session.abort()` — abort all threads +- `session.pause()` / `session.resume()` — freeze/unfreeze all thread queues +- Session-wide idle = all threads idle + +### Decision Gates + +Decision gates are first-class engine primitives for "pause here and wait for an external human decision". Engine, adapter, SDK, API, client, and channel contracts use `DecisionGate` naming and payloads consistently. + +V1 uses one unified mechanism for: + +- tool approvals +- agent questions +- credential acquisition / re-authorization + +This replaces ad hoc transport- or adapter-specific waiting behavior. A gate is persisted, emits events, may be delivered to external channels by the adapter, and resumes or fails the waiting operation when resolved, expired, or withdrawn. + +**Gate model:** + +```typescript +interface DecisionGate { + id: string; + sessionId: string; + threadId: string; + type: 'approval' | 'question' | 'credential_request'; + title: string; + body?: string; + actions: DecisionAction[]; + expiresAt?: number; + status: 'pending' | 'resolved' | 'expired' | 'withdrawn'; + context?: Record; + origin?: { + channelType?: string; + channelId?: string; + messageId?: string; + }; + refs?: Array<{ + channelType: string; + ref: DecisionGateRef; + }>; +} + +interface DecisionAction { + id: string; + label: string; + style?: 'primary' | 'danger'; +} + +interface DecisionResolution { + actionId?: string; + value?: string; + resolvedBy: string; + resolvedAt: number; + source?: { + channelType?: string; + channelId?: string; + messageId?: string; + }; +} + +type DecisionWithdrawReason = 'steer' | 'abort' | 'cancel'; + +interface DecisionGateRef { + messageId: string; + channelId: string; + threadId?: string; + [key: string]: unknown; +} + +interface DecisionGateEntry { + type: 'decision_gate'; + id: string; + parentId: string | null; + timestamp: string; + gate: DecisionGate; + resolvedAt?: string; + resolution?: DecisionResolution; + withdrawnReason?: DecisionWithdrawReason; +} +``` + +**Gate types:** + +- `approval`: asks whether a tool or command may proceed. Required actions are `approve` and `deny` unless a custom action list is supplied. +- `question`: asks the user for an answer. May include option actions or accept free text when `actions` is empty. +- `credential_request`: asks the user to connect or re-authorize a service. Required context fields are `service`, `reason`, and optional `scopes`. + +**Gate delivery contract:** + +1. Engine creates and persists the gate with `status = 'pending'`. +2. Engine appends or updates the corresponding `DecisionGateEntry` in the thread DAG. +3. Engine publishes `decision_gate`. +4. Adapter delivers the gate to web clients and any matching channel targets. +5. Each channel delivery returns a `DecisionGateRef`; the adapter persists refs back through `SessionStore.saveDecisionGateRef`. +6. The first valid resolution wins. +7. Adapter calls `session.resolveDecision(gateId, resolution)`. +8. Engine updates gate status, updates the DAG entry, clears suspended state, and resumes or fails the blocked turn. +9. Adapter updates delivered channel messages via stored refs. + +The engine must treat missing channel delivery as non-fatal. A gate that cannot be delivered externally remains visible through the web/client event stream and API. + +**Execution semantics:** + +- A tool or agent loop may create a gate and suspend the waiting operation. +- Suspension is scoped to the waiting thread/turn, not the whole session. +- Other threads in the same session may continue running while one thread is blocked on a gate. +- Resolution resumes the suspended operation with typed input. +- Expiry fails the suspended operation with a structured error. +- Withdrawal cancels the suspended operation without permitting later resolution to resume it. + +The `DecisionGateEntry.id` should be the canonical DAG entry ID for the gate, while `DecisionGate.id` is the stable runtime identity used by transports, queue state, and suspended-turn checkpoints. In V1 these may be the same value for simplicity. + +**Deterministic gate identity:** A gate created from a tool execution must use a stable ID for that suspension point within the active turn. This is what allows the engine to re-run the tool after restart and have `requestDecision(...)` match the existing persisted gate instead of creating a duplicate. + +**Resolution paths:** + +- explicit action selection (`approve`, `deny`, option buttons) +- free-text reply from the web UI +- free-text reply from an external channel thread when the adapter matches the stored origin target + +The engine owns the gate lifecycle and persistence; adapters own delivery details for Slack, Telegram, web, etc. + +**Conflict handling:** + +- Resolving a non-pending gate returns `decision_gate_conflict` unless the supplied resolution exactly matches the stored resolution. +- Expiry and withdrawal are terminal states. +- A `steer` prompt on the same thread withdraws pending gates created by the superseded turn with reason `steer`. +- `thread.abort()` withdraws pending gates on that thread with reason `abort`. +- `session.abort()` withdraws all pending gates in the session with reason `abort`. +- Resolutions received after withdrawal or expiry must be acknowledged to the transport but must not resume execution. + +### Roles and Skills + +**Roles** — Markdown files with optional YAML frontmatter (`name`, `description`, `model`). Applied as system prompt overlays. Precedence: prompt-level > thread-level > session-level. If a role declares a `model`, it overrides the session's default model for that prompt. + +**Skills** — Markdown files discovered from the sandbox filesystem or a configured directory. Invoked explicitly via `thread.skill(name, { args })`. The skill's instructions become a focused prompt with the given arguments. Skill files use frontmatter (`name`, `description`) and support `{{variable}}` template syntax for argument injection. + +Both are loaded at runtime, not baked into the engine build. + +```typescript +interface RoleSpec { + name: string; + description?: string; + model?: string; + content: string; + source?: 'session' | 'thread' | 'prompt' | 'plugin' | 'sandbox'; +} + +interface SkillSource { + name: string; + description?: string; + content: string; + argsSchema?: TSchema; + source?: 'plugin' | 'sandbox' | 'repo' | 'user'; +} + +interface SkillInvokeOptions { + args?: Record; + model?: string; + author?: PromptAuthor; + channel?: ChannelTarget; + resultSchema?: TSchema; +} +``` + +Role and skill loading errors are non-fatal at session creation only when the source is optional. Prompt-level role or skill resolution errors fail the prompt before model invocation. + +### Event System + +The engine emits typed events through a callback. Platform adapters subscribe and relay events to clients via their transport (WebSocket, SSE, etc.). + +```typescript +type EngineEvent = + | { type: 'message_start'; threadId: string; messageId: string; role: 'assistant' | 'system' } + | { type: 'text_delta'; threadId: string; text: string } + | { type: 'message_update'; threadId: string; messageId: string; parts: MessagePart[]; content?: string } + | { type: 'message_end'; threadId: string; messageId: string; reason: 'end_turn' | 'error' | 'abort' } + | { type: 'tool_start'; threadId: string; tool: string; args: Record } + | { type: 'tool_end'; threadId: string; tool: string; result: string; isError: boolean } + | { type: 'turn_end'; threadId: string; reason: 'end_turn' | 'error' | 'abort' } + | { type: 'thread_start'; threadId: string; parentThreadId?: string } + | { type: 'queue_state'; threadId: string; state: QueueState } + | { type: 'compaction_start' | 'compaction_end'; threadId: string } + | { type: 'task_start' | 'task_end'; childSessionId: string; threadId: string } + | { type: 'status'; threadId: string; status: 'idle' | 'queued' | 'thinking' | 'tool_calling' | 'streaming' | 'blocked_on_decision_gate' } + | { type: 'error'; threadId?: string; code: string; error: string; recoverable: boolean } + | { type: 'decision_gate'; threadId: string; gate: DecisionGate } + | { type: 'decision_gate_resolved'; threadId: string; gateId: string; resolution: DecisionResolution } + | { type: 'decision_gate_expired'; threadId: string; gateId: string } + | { type: 'decision_gate_withdrawn'; threadId: string; gateId: string; reason: 'steer' | 'abort' | 'cancel' } + | { type: 'model_switched'; threadId: string; fromModel: string; toModel: string; reason: string } +``` + +The engine does not know about WebSockets, SSE, or any transport. It emits events; the adapter decides delivery. + +### Client Event Contract + +Clients consume decision-gate events directly. Adapters may deliver these events over WebSocket or SSE, but payloads are identical. + +```typescript +type ClientEvent = + | { type: 'init'; session: SessionData; threads: ThreadData[]; queue: QueueState[]; pendingDecisionGates: DecisionGate[] } + | { type: 'message'; sessionId: string; threadId: string; entry: MessageEntry } + | { type: 'message.updated'; sessionId: string; threadId: string; entryId: string; patch: Partial } + | { type: 'chunk'; sessionId: string; threadId: string; messageId: string; content: string } + | { type: 'agentStatus'; sessionId: string; threadId: string; status: EngineEventStatus; detail?: string } + | { type: 'queue.state'; sessionId: string; threadId: string; queue: QueueState } + | { type: 'decision_gate'; sessionId: string; threadId: string; gate: DecisionGate } + | { type: 'decision_gate_resolved'; sessionId: string; threadId: string; gateId: string; resolution: DecisionResolution } + | { type: 'decision_gate_expired'; sessionId: string; threadId: string; gateId: string } + | { type: 'decision_gate_withdrawn'; sessionId: string; threadId: string; gateId: string; reason: DecisionWithdrawReason } + | { type: 'error'; sessionId?: string; threadId?: string; code: string; message: string; recoverable: boolean }; + +type EngineEventStatus = + | 'idle' + | 'queued' + | 'thinking' + | 'tool_calling' + | 'streaming' + | 'blocked_on_decision_gate' + | 'error'; +``` + +Clients resolve a gate by calling the decision API route, not by sending transport-specific answer messages: + +```http +POST /api/sessions/:sessionId/decision-gates/:gateId/resolve +POST /api/sessions/:sessionId/decision-gates/:gateId/withdraw +``` + +Adapters must include all pending decision gates in the initial connection payload so reconnecting clients can render outstanding approvals, questions, and credential requests without waiting for a replayed event. + +### Structured Results + +Optional schema-validated output extraction. Any prompt or skill invocation can pass a result schema (Valibot or TypeBox). The engine instructs the LLM to emit a result in a delimited block, extracts it, and validates against the schema. + +- Delimiters: `---RESULT_START---` and `---RESULT_END---` +- If validation fails and no delimiters found: auto-retry with a follow-up prompt +- Returns typed data matching the schema + +## Provider Interfaces + +These are the contracts that platform adapters implement. The engine depends only on these interfaces. + +### SandboxProvider + +Creates and manages sandbox compute. The engine calls this to get a Sandbox handle, then uses it for all file and process operations. + +```typescript +interface SandboxProvider { + create(opts: SandboxCreateOpts): Promise; + restore(id: string): Promise; + destroy(id: string): Promise; + status(id: string): Promise; +} + +interface SandboxCreateOpts { + image?: string; + workspace?: string; + env?: Record; + timeout?: number; + resources?: { cpu?: number; memory?: string }; + metadata?: Record; +} + +interface Sandbox { + id: string; + + // Filesystem + readFile(path: string): Promise; + readBinary(path: string): Promise; + writeFile(path: string, content: string): Promise; + writeBinary(path: string, data: Uint8Array): Promise; + readdir(path: string): Promise; + stat(path: string): Promise<{ isFile: boolean; isDirectory: boolean; size: number }>; + mkdir(path: string): Promise; + rm(path: string, opts?: { recursive?: boolean }): Promise; + + // Process execution + exec(command: string, opts?: ExecOpts): Promise; + + // Lifecycle + snapshot(): Promise; + tunnels(): Promise>; + destroy(): Promise; +} + +interface ExecOpts { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + stdin?: string; + maxOutputBytes?: number; +} + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut?: boolean; + truncated?: boolean; +} + +interface SandboxStatus { + id: string; + state: 'creating' | 'running' | 'stopped' | 'error'; + startedAt?: number; + error?: string; +} +``` + +**Implementations:** +- `ModalSandbox` — wraps Modal's Python SDK (called via HTTP to the Modal backend) +- `K8sPodSandbox` — creates a K8s pod, exec via K8s API +- `DockerSandbox` — local Docker container (dev/testing) +- `LocalSandbox` — host filesystem + child_process (CI, local dev) +- `VirtualSandbox` — in-memory filesystem + just-bash (lightweight agents, no container) + +#### Sandbox RPC Contract + +Remote sandbox implementations expose an authenticated HTTP RPC surface to the adapter. The engine still calls the `Sandbox` TypeScript interface; this RPC is the required adapter-to-sandbox protocol for Modal and Kubernetes implementations. + +All requests include `Authorization: Bearer `. Tokens are scoped to one session and one sandbox ID. Paths are relative to the sandbox workspace unless explicitly absolute and allowed by adapter policy. + +| Method | Path | Request | Response | +|---|---|---|---| +| `GET` | `/health` | none | `{ ok: true, sandboxId, version }` | +| `GET` | `/files/stat?path=` | none | `{ isFile, isDirectory, size, mtimeMs }` | +| `GET` | `/files/read?path=&encoding=utf8` | none | `{ content, encoding }` | +| `GET` | `/files/read-binary?path=` | none | binary stream | +| `PUT` | `/files/write` | `{ path, content, encoding?: 'utf8' }` | `{ ok: true }` | +| `PUT` | `/files/write-binary?path=` | binary body | `{ ok: true }` | +| `GET` | `/files/list?path=` | none | `{ entries: Array<{ name, type, size }> }` | +| `POST` | `/files/mkdir` | `{ path, recursive?: boolean }` | `{ ok: true }` | +| `DELETE` | `/files` | `{ path, recursive?: boolean }` | `{ ok: true }` | +| `POST` | `/exec` | `{ command, cwd?, env?, stdin?, timeout?, maxOutputBytes? }` | `ExecResult` | +| `POST` | `/snapshot` | none | `{ snapshotId }` | +| `GET` | `/tunnels` | none | `{ tunnels: Record }` | + +RPC implementations must enforce output limits, command timeouts, workspace path policy, and token validation. `exec` is non-interactive in V1; long-running interactive terminal sessions remain a sandbox UI concern exposed through tunnels, not an engine tool protocol. + +### SessionStore + +Persists session state, thread state, message history, and queue state. Used by both the engine (writes) and the API layer (reads). One implementation per database backend, shared by engine and API. + +```typescript +interface SessionStore { + // === Engine writes === + saveSession(session: SessionData): Promise; + saveThread(sessionId: string, thread: ThreadData): Promise; + appendEntries(sessionId: string, threadId: string, entries: SessionEntry[]): Promise; + saveQueueState(sessionId: string, threadId: string, queue: QueueState): Promise; + saveDecisionGate(sessionId: string, threadId: string, gate: DecisionGate): Promise; + saveDecisionGateRef(sessionId: string, threadId: string, gateId: string, ref: { channelType: string; ref: DecisionGateRef }): Promise; + updateDecisionGateEntry(sessionId: string, threadId: string, gateId: string, patch: Partial): Promise; + saveSuspendedTurn(sessionId: string, threadId: string, suspended: SuspendedTurnState): Promise; + clearSuspendedTurn(sessionId: string, threadId: string): Promise; + updateSessionStatus(id: string, status: string, metadata?: Partial): Promise; + flush?(): Promise; + + // === API reads === + getSession(id: string): Promise; + listSessions(userId: string, opts?: ListOpts): Promise; + getThread(sessionId: string, threadId: string): Promise; + listThreads(sessionId: string): Promise; + getEntries(sessionId: string, threadId: string, opts?: MessageQuery): Promise; + listDecisionGates(sessionId: string, threadId?: string): Promise; + getSuspendedTurn(sessionId: string, threadId: string): Promise; + + // === Shared === + deleteSession(id: string): Promise; +} +``` + +```typescript +interface SuspendedTurnState { + sessionId: string; + threadId: string; + queueItemId: string; + gateId: string; + model: string; + leafMessageId?: string; + toolCallId: string; + toolName: string; + toolArgs: Record; + resumeKey: string; + attempt: number; + createdAt: number; +} +``` + +```typescript +interface SessionData { + id: string; + userId: string; + orgId: string; + workspace: string; + purpose: 'interactive' | 'orchestrator' | 'workflow' | 'child'; + status: 'initializing' | 'running' | 'paused' | 'hibernated' | 'terminated' | 'error'; + sandboxId?: string; + snapshotId?: string; + parentSessionId?: string; + metadata?: Record; + createdAt: number; + updatedAt: number; +} + +interface ThreadData { + id: string; + sessionId: string; + key: string; + status: 'active' | 'paused' | 'archived'; + activeLeafEntryId?: string; + queueMode: QueueMode; + model?: string; + summary?: string; + metadata?: Record; + createdAt: number; + updatedAt: number; +} + +interface QueueState { + threadId: string; + mode: QueueMode; + status: 'idle' | 'queued' | 'running' | 'blocked_on_decision_gate' | 'paused'; + activeItemId?: string; + pending: QueueItem[]; + collectBuffer?: QueueItem[]; + blockedGateId?: string; +} + +interface QueueItem { + id: string; + threadId: string; + content: PromptContent; + author?: PromptAuthor; + channel?: ChannelTarget; + replyTarget?: ChannelTarget; + model?: string; + metadata?: Record; + createdAt: number; +} + +type SessionEntry = + | MessageEntry + | DecisionGateEntry + | CompactionEntry + | BranchSummaryEntry; +``` + +**Data flow:** The engine writes through SessionStore during execution. The API layer reads through SessionStore for client queries (session lists, message history, etc.). Both hit the same underlying database. The engine is the writer, the API is the reader, the database is the shared state. + +Hot/cold storage tiering (e.g., DO SQLite as write-through cache for D1) is an implementation detail of the SessionStore, not a concern of the engine or API layer. The `flush()` method is called by the engine on session shutdown, giving the store a chance to drain any internal buffers. + +**Implementations:** +- `D1SessionStore` — Cloudflare D1 via Drizzle +- `PostgresSessionStore` — PostgreSQL via Drizzle +- `InMemorySessionStore` — for tests and ephemeral agents + +#### Required Tables + +The engine schema owns these tables. Existing application tables may mirror selected fields during rollout, but the engine must not depend on current `messages`, `session_threads`, or DO-local queue tables for correctness. + +| Table | Purpose | Key fields | +|---|---|---| +| `engine_sessions` | Canonical engine session state | `id`, `user_id`, `org_id`, `workspace`, `purpose`, `status`, `sandbox_id`, `snapshot_id`, `metadata`, timestamps | +| `engine_threads` | Thread metadata and active leaf | `id`, `session_id`, `key`, `status`, `active_leaf_entry_id`, `queue_mode`, `model`, `summary`, `metadata` | +| `engine_entries` | DAG history | `id`, `session_id`, `thread_id`, `parent_id`, `entry_type`, `role`, `content`, `parts`, `metadata`, `created_at` | +| `engine_queue_items` | Persisted per-thread queue | `id`, `session_id`, `thread_id`, `status`, `mode`, `content`, `author`, `channel`, `reply_target`, `model`, `metadata`, timestamps | +| `engine_decision_gates` | Pending and terminal gate state | `id`, `session_id`, `thread_id`, `type`, `status`, `title`, `body`, `actions`, `origin`, `context`, `resolution`, `expires_at`, timestamps | +| `engine_decision_gate_refs` | Delivered channel refs | `id`, `gate_id`, `channel_type`, `ref`, `created_at`, `updated_at` | +| `engine_suspended_turns` | Restart-safe blocked turn checkpoints | `session_id`, `thread_id`, `queue_item_id`, `gate_id`, `model`, `leaf_entry_id`, `tool_call_id`, `tool_name`, `tool_args`, `resume_key`, `attempt`, `created_at` | +| `engine_credentials` | Stored credentials when adapter uses engine schema | `id`, `owner_type`, `owner_id`, `service`, `credential_type`, `encrypted_data`, `scopes`, `expires_at`, timestamps | +| `engine_oauth_states` | OAuth handshake state | `state`, `user_id`, `service`, `redirect_uri`, `code_verifier`, `metadata`, `expires_at` | + +Indexes are required on `(session_id, thread_id, created_at)` for entries, `(session_id, thread_id, status)` for queue items and gates, and `(owner_type, owner_id, service)` for credentials. + +### EventBus + +Broadcasts engine events to external subscribers (clients, other services). The engine pushes events; the adapter subscribes and relays to clients. + +```typescript +interface EventBus { + publish(event: BusEvent): Promise; + subscribe(filter: EventFilter, callback: (event: BusEvent) => void): Unsubscribe; +} + +interface BusEvent { + sessionId: string; + threadId?: string; + userId?: string; + event: EngineEvent; + timestamp: number; +} + +interface EventFilter { + sessionId?: string; + userId?: string; + eventTypes?: string[]; +} + +type Unsubscribe = () => void; +``` + +**Implementations:** +- `DOEventBus` — posts to a thin EventBus Durable Object +- `RedisEventBus` — Redis pub/sub channels per session/user +- `InMemoryEventBus` — direct callback (single-process, tests) + +### Channel Transports + +Channel transports are in scope for V1 at the adapter boundary. The engine does not render Slack or Telegram payloads directly, but it does define the decision-gate and reply-routing contract that transports must implement. + +```typescript +interface ChannelTransport { + readonly channelType: string; + + verifySignature?(headers: Record, rawBody: string, secret?: string): boolean | Promise; + parseInbound?(headers: Record, rawBody: string, ctx: ChannelTransportContext): Promise; + + sendMessage(target: ChannelTarget, message: OutboundMessage, ctx: ChannelTransportContext): Promise; + updateMessage?(target: ChannelTarget, ref: ChannelMessageRef, message: OutboundMessage, ctx: ChannelTransportContext): Promise; + + sendDecisionGate?(target: ChannelTarget, gate: DecisionGate, ctx: ChannelTransportContext): Promise; + updateDecisionGate?(target: ChannelTarget, ref: DecisionGateRef, update: DecisionGateUpdate, ctx: ChannelTransportContext): Promise; + + parseInboundDecision?(payload: unknown, ctx: ChannelTransportContext): Promise<{ + gateId: string; + actionId?: string; + value?: string; + actorExternalId?: string; + } | null>; +} + +interface ChannelTarget { + channelType: string; + channelId: string; + threadId?: string; +} + +interface ChannelTransportContext { + userId: string; + orgId: string; + sessionId: string; + threadId?: string; + token?: string; + botToken?: string; + persona?: { + name?: string; + avatar?: string; + metadata?: Record; + }; + metadata?: Record; +} + +interface OutboundMessage { + text?: string; + markdown?: string; + attachments?: Array<{ + type: 'image' | 'file'; + url: string; + mimeType: string; + name?: string; + caption?: string; + }>; + replyTo?: ChannelMessageRef; + metadata?: Record; +} + +interface ChannelMessageRef { + messageId: string; + channelId: string; + threadId?: string; + [key: string]: unknown; +} + +type DecisionGateUpdate = + | { status: 'resolved'; resolution: DecisionResolution } + | { status: 'expired' } + | { status: 'withdrawn'; reason: DecisionWithdrawReason }; + +type InboundChannelEvent = + | { type: 'message'; target: ChannelTarget; text: string; actor: ChannelActor; messageId?: string; attachments?: PromptAttachment[] } + | { type: 'decision'; gateId: string; actionId?: string; value?: string; actor: ChannelActor; target?: ChannelTarget; messageId?: string }; + +interface ChannelActor { + id: string; + displayName?: string; + email?: string; +} +``` + +**Slack is the required reference transport for V1.** The V1 implementation must define: + +- how a Slack thread maps to `channelType = 'slack'` and a stable `channelId` +- how Slack button clicks map back to `gateId` / `actionId` +- how free-text thread replies resolve pending decision gates when the stored origin matches +- how previously sent Slack decision gates are updated on resolution, expiry, or withdrawal + +Other transports may follow the same contract later, but Slack is the minimum transport that must be fully specified and implemented for V1. + +Slack `channelId` is canonicalized as `teamId:channelId:threadTs` for thread replies and `teamId:channelId` for channel-level messages. The transport may store native Slack fields (`ts`, `thread_ts`, `response_url`) inside `DecisionGateRef`, but engine-visible routing always uses the canonical `ChannelTarget`. + +### BlobStore + +File attachments, images, artifacts. Simple key-value with streaming. + +```typescript +interface BlobStore { + put(key: string, data: Uint8Array | ReadableStream, opts?: { contentType?: string }): Promise; + get(key: string): Promise<{ data: ReadableStream; contentType?: string } | null>; + delete(key: string): Promise; +} +``` + +**Implementations:** +- `R2BlobStore` — Cloudflare R2 +- `S3BlobStore` — AWS S3 / MinIO + +### CredentialStore + +Stores OAuth tokens and API keys per user per service. Handles encryption transparently within the implementation: the engine passes an encryption key via adapter config, the store encrypts/decrypts tokens internally. The engine and tools never see encrypted blobs. + +```typescript +interface CredentialStore { + get(owner: CredentialOwner, service: string): Promise; + save(owner: CredentialOwner, service: string, credential: StoredCredential): Promise; + delete(owner: CredentialOwner, service: string): Promise; + list(owner: CredentialOwner): Promise<{ service: string; scopes?: string[]; connectedAt: string }[]>; +} + +interface CredentialOwner { + type: 'user' | 'org' | 'session'; + id: string; +} + +interface StoredCredential { + type: 'oauth2' | 'api_key' | 'bot_token' | 'service_account' | 'app_install'; + accessToken?: string; + refreshToken?: string; + apiKey?: string; + expiresAt?: number; + scopes?: string[]; + metadata?: Record; +} +``` + +**Token refresh:** When a credential's `expiresAt` is in the past (or within a configurable buffer), the CredentialProvider wrapper in the engine auto-refreshes using the OAuth provider's token endpoint before returning the token to the tool. This requires OAuthProviderConfig for the service (token URL, client credentials). Transparent to the tool. + +**OAuth flow:** OAuth connection flows (user initiates "Connect GitHub" from the UI) live in the API layer. The API handles redirect, callback, and token exchange, then stores the credential via CredentialStore. The engine consumes stored credentials at tool execution time. + +**OAuth provider registry:** Plugin packages export their OAuth configuration alongside their tools: + +```typescript +interface OAuthProviderConfig { + service: string; + authorizeUrl: string; + tokenUrl: string; + scopes: string[]; + clientId: string; + clientSecret: string; + refreshable: boolean; +} +``` + +The API layer collects these at startup to power the OAuth connection UI and callback handling. + +Credential lookup order is tool-defined but must be explicit. The default order is session-scoped credential, user credential, org credential. If no credential is found and the tool requires one, `CredentialProvider.request()` creates a `DecisionGate` of type `credential_request`. + +## Schema and Migrations + +The engine owns the canonical database schema. Schema definitions live in the engine package as Drizzle TypeScript schemas. Migration files are generated per dialect (SQLite for D1, PostgreSQL for PG) and ship with the engine package. + +``` +packages/engine/ + src/schema/ ← Drizzle schema definitions (source of truth) + migrations/ + sqlite/ ← generated by drizzle-kit for D1 + postgresql/ ← generated by drizzle-kit for PG +``` + +**Schema coverage:** The engine schema defines tables for sessions, threads, message entries, queue state, decision gates, suspended turns, credentials, and OAuth states. This is the same schema the SessionStore and CredentialStore implementations read from and write to. + +**Workflow for adding a field:** +1. Update the Drizzle schema in `packages/engine/src/schema/` +2. Run `drizzle-kit generate` for each dialect — produces migration SQL +3. Migration files ship with the engine package +4. On deploy, each platform applies migrations through its normal mechanism: + - Cloudflare: `wrangler d1 migrations apply` + - Kubernetes: init container or migration job running `drizzle-kit migrate` + +The SessionStore interface has no `migrate()` method. Migrations are a deployment concern, not a runtime interface. The engine is a library; it does not own the deployment lifecycle. + +### Current Schema Coexistence + +During rollout, engine tables live beside current application tables. The Cloudflare adapter may mirror engine data into existing tables used by the current client, analytics, and admin views, but the engine source of truth is always the `engine_*` schema. + +Required mirroring during the transition: + +- `engine_sessions` to current `sessions` for session lists and access control joins. +- `engine_threads` to current `session_threads` for thread lists. +- `engine_entries` message entries to current `messages` for existing history readers. +- `engine_decision_gates` to client event/API responses. No legacy decision-prompt table is created or written by the new engine path. + +The old DO-local prompt queue and decision storage are not part of the new runtime. Once the Cloudflare adapter is fully switched over, DO storage is limited to hosting concerns such as hibernation state and WebSocket bookkeeping. + +## Platform Adapters + +A platform adapter wires the engine to a specific deployment target. It does three things: + +1. Instantiates provider implementations (SessionStore, SandboxProvider, EventBus, BlobStore, CredentialStore) +2. Hosts the engine process (DO on CF, long-running process on K8s) +3. Provides the HTTP/WebSocket entrypoint for clients and API routes + +### Shared API Routes (`packages/api/`) + +API route handlers are written once and shared across platforms. They are Hono route factories parameterized by provider implementations: + +```typescript +export function sessionRoutes(store: SessionStore, engine: EngineManager) { + const router = new Hono(); + router.get('/:id', async (c) => { + const session = await store.getSession(c.req.param('id')); + return c.json(session); + }); + router.post('/:id/threads/:threadId/prompt', async (c) => { + const body = await c.req.json(); + await engine.getSession(c.req.param('id')) + .thread(c.req.param('threadId')) + .prompt(body.content); + return c.json({ ok: true }); + }); + return router; +} +``` + +Each adapter imports these factories and injects its providers. The route logic is written once. + +#### Required API Surface + +The shared API package owns route behavior. Adapters own authentication middleware, provider construction, and request context injection. + +| Method | Route | Behavior | +|---|---|---| +| `POST` | `/api/sessions` | Create a session and return session metadata plus client stream URL | +| `GET` | `/api/sessions/:sessionId` | Read session metadata and live status | +| `DELETE` | `/api/sessions/:sessionId` | Terminate and delete/archival-mark a session | +| `POST` | `/api/sessions/:sessionId/prompt` | Prompt the default thread | +| `GET` | `/api/sessions/:sessionId/threads` | List threads | +| `POST` | `/api/sessions/:sessionId/threads` | Create a thread | +| `GET` | `/api/sessions/:sessionId/threads/:threadId` | Read thread metadata and entries | +| `POST` | `/api/sessions/:sessionId/threads/:threadId/prompt` | Prompt a specific thread | +| `POST` | `/api/sessions/:sessionId/threads/:threadId/abort` | Abort current turn and clear this thread queue | +| `POST` | `/api/sessions/:sessionId/threads/:threadId/pause` | Pause this thread | +| `POST` | `/api/sessions/:sessionId/threads/:threadId/resume` | Resume this thread | +| `GET` | `/api/sessions/:sessionId/decision-gates` | List pending and recent terminal gates | +| `POST` | `/api/sessions/:sessionId/decision-gates/:gateId/resolve` | Resolve a pending gate | +| `POST` | `/api/sessions/:sessionId/decision-gates/:gateId/withdraw` | Withdraw a pending gate | +| `GET` | `/api/sessions/:sessionId/events` | SSE stream for client events | +| `GET` | `/api/sessions/:sessionId/ws` | WebSocket stream for client events and optional prompt/control messages | +| `GET` | `/api/sessions/:sessionId/tunnels` | Return sandbox tunnel URLs | +| `POST` | `/api/sessions/:sessionId/snapshot` | Snapshot session sandbox and persist snapshot ID | + +Prompt routes accept the same `PromptOptions` shape as the engine API. WebSocket prompt/control messages are optional conveniences over the same route semantics; they must not define separate behavior. + +### Cloudflare Adapter (`packages/adapter-cloudflare/`) + +``` +Cloudflare Worker (Hono) + ├── API routes (shared from packages/api/) + │ └── reads/writes via D1SessionStore + │ + ├── WebSocket upgrade → subscribes to DOEventBus → relays to client + │ + └── Session operations → SessionHostDO + │ + SessionHostDO (thin shell, ~100 lines) + ├── creates Engine instance on first request + ├── injects: D1SessionStore, ModalSandbox, DOEventBus, R2BlobStore + ├── forwards prompt/abort/pause/resume to engine + └── engine runs agent loop, emits events, writes state +``` + +The SessionHostDO is a thin shell. It creates an engine instance with CF provider implementations, forwards incoming requests, and uses DO hibernation so idle sessions don't consume compute. On wake, it restores the engine from SessionStore state. + +### Kubernetes Adapter (`packages/adapter-k8s/`) + +``` +K8s Service (Hono/Node) + ├── API routes (shared from packages/api/) + │ └── reads/writes via PostgresSessionStore + │ + ├── WebSocket upgrade → subscribes to RedisEventBus → relays to client + │ + └── Session operations → SessionPool + │ + SessionPool (process manager) + ├── spawns/reuses engine instances per session + ├── injects: PostgresSessionStore, ModalSandbox, RedisEventBus, S3BlobStore + ├── forwards prompt/abort/pause/resume to engine + └── engine runs in-process +``` + +The SessionPool manages engine instances in-process. Idle instances are evicted after a timeout (equivalent to DO hibernation). Session affinity via K8s ingress routes requests for the same session to the same pod. + +### What Each Adapter Provides + +| Interface | Cloudflare | Kubernetes | +|---|---|---| +| SessionStore | D1 via Drizzle | PostgreSQL via Drizzle | +| SandboxProvider | Modal SDK | Modal SDK / K8s Pod API | +| EventBus | DO singleton | Redis pub/sub | +| BlobStore | R2 | S3 / MinIO | +| CredentialStore | D1 (encrypted) | PostgreSQL (encrypted) | +| Channel transports | Worker-integrated (Slack required for V1) | Service-integrated (Slack required for V1) | +| Engine host | SessionHostDO | SessionPool (in-process) | + +### Adapter Host Contract + +Every adapter must provide: + +- Request authentication and authorization before calling shared API route handlers. +- Provider construction for the current deployment target. +- Engine instance lookup by session ID. +- Session affinity so prompts, decision resolutions, and aborts for one session reach the same active engine instance. +- Event subscription and client delivery over WebSocket and/or SSE. +- Startup restoration of queued, running, and blocked threads from `SessionStore`. +- Idle eviction/hibernation that calls `store.flush()` and leaves enough persisted state to resume. +- Fatal error handling that marks the session `error`, publishes a client `error` event, and prevents silent queue accumulation. + +Cloudflare V1 uses one `SessionHostDO` per session ID. Kubernetes may use a process-local `SessionPool`, but must provide equivalent session affinity and restore behavior. + +## Tool Implementation and Integration Framework + +### Plugin Package Structure + +Plugin packages live in `packages/plugin-*/`. Each exports tools as `ToolDef[]` and optionally exports OAuth configuration. + +```typescript +// packages/plugin-github/src/tools.ts +import type { ToolDef } from '@valet/engine'; + +export const tools: ToolDef[] = [ + { + name: 'github.create_pr', + description: 'Create a pull request on GitHub', + parameters: Type.Object({ + repo: Type.String(), + title: Type.String(), + body: Type.String(), + head: Type.String(), + base: Type.String(), + }), + execute: async (args, ctx) => { + const cred = await ctx.credentials.get('github'); + if (!cred) { + await ctx.credentials.request('github', 'Need GitHub access to create a PR'); + } + const res = await fetch(`https://api.github.com/repos/${args.repo}/pulls`, { + method: 'POST', + headers: { Authorization: `Bearer ${cred.accessToken}` }, + body: JSON.stringify(args), + }); + const pr = await res.json(); + return { text: `Created PR #${pr.number}: ${pr.html_url}` }; + }, + }, +]; + +// packages/plugin-github/src/oauth.ts +import type { OAuthProviderConfig } from '@valet/engine'; + +export const oauth: OAuthProviderConfig = { + service: 'github', + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scopes: ['repo', 'read:org'], + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + refreshable: false, +}; +``` + +### Tool Registration + +Tools from plugin packages are registered at session creation. The adapter collects tools from all enabled plugins and passes them to the engine: + +```typescript +import { tools as githubTools } from '@valet/plugin-github'; +import { tools as slackTools } from '@valet/plugin-slack'; +import { tools as linearTools } from '@valet/plugin-linear'; + +const session = await engine.createSession({ + sandbox: await sandboxProvider.create({ image, workspace }), + tools: [...githubTools, ...slackTools, ...linearTools], + // ... +}); +``` + +The engine merges plugin tools with built-in tools. Name conflicts between plugins are caught at registration time. Per-thread tool overrides are merged at prompt time (thread-level wins on name conflict). + +### Engine-to-Tool Data Flow + +``` +Engine receives tool call from LLM + → looks up ToolDef by name + → constructs ToolContext { userId, orgId, sessionId, threadId, channel metadata, repo context, credentials, sandbox, signal } + → calls toolDef.execute(args, ctx) + → tool uses ctx.credentials.get('service') for API auth + → tool uses ctx.sandbox.exec() / readFile() if it needs sandbox access + → tool may call ctx.requestDecision(...) for gated human input + → tool returns ToolResult { text, attachments? } + → engine handles attachments per type (vision, blob store, inline) + → engine feeds result back to LLM via pi-agent-core +``` + +## Observability and Error Contract + +The engine distinguishes user-visible recoverable errors from fatal session errors. + +```typescript +interface EngineError { + code: string; + message: string; + recoverable: boolean; + sessionId?: string; + threadId?: string; + queueItemId?: string; + gateId?: string; + cause?: unknown; +} + +interface RuntimeMetric { + type: + | 'llm_call' + | 'tool_exec' + | 'queue_wait' + | 'turn_complete' + | 'decision_gate_wait' + | 'sandbox_exec' + | 'model_failover' + | 'compaction'; + sessionId: string; + threadId?: string; + durationMs?: number; + model?: string; + toolName?: string; + inputTokens?: number; + outputTokens?: number; + errorCode?: string; + properties?: Record; +} +``` + +Required behavior: + +- Recoverable thread errors emit an `error` event and mark the active queue item complete or failed. +- Fatal session errors update session status to `error`, flush state, and prevent new prompts until restored or restarted. +- Every model call emits token/cost metadata when available. +- Every tool call emits duration and success/failure metadata. +- Decision gates measure wait duration from creation to terminal state. +- Logs may contain IDs and high-level errors, but must not contain secrets, OAuth tokens, command environment secrets, or full credential payloads. + +## Implementation Direction + +### Reference Flue, Build In-Repo + +Valet V1 will build its own engine in-repo and may borrow ideas or implementation patterns from Flue, but will not depend directly on `@flue/sdk` as the runtime substrate. + +Reasons: +- Full control over the agent loop, compaction, and threading model +- First-class support for multi-threaded sessions rather than single-active-operation sessions +- First-class decision-gated execution rather than Flue's headless-only default +- Channel-aware routing and adapter contracts for web, Slack, Telegram, and orchestrator threads +- Richer tool context and persistence contracts aligned with Valet's integrations and multiplayer model + +Flue remains a useful reference implementation for: + +- session/runtime structure around `pi-ai` and `pi-agent-core` +- sandbox abstraction +- built-in filesystem/shell/task tools +- DAG-style history and compaction patterns +- Cloudflare adapter and persistence patterns + +This is a settled V1 decision, not an open implementation choice. The engine package will be built in-repo and may reference Flue code where useful, but Valet owns the runtime contracts and implementation. From ac0e54cecbb1ddc584b0ac79a136d9bd9d4a5284 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:06:43 -0700 Subject: [PATCH 02/26] feat(engine): scaffold portable runtime engine prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds packages/engine — a portable agent runtime library that implements the V1 design from docs/specs/2026-05-02-portable-runtime-engine-design.md. Built on @mariozechner/pi-ai + @mariozechner/pi-agent-core. The engine itself has zero platform dependencies; platform adapters (Cloudflare, K8s) host it. What's implemented: - Engine public API (createSession, prompt, resolveDecision, abort, pause/resume) + Session/Thread classes. - Per-thread queue with three modes: followup (FIFO), steer (abort + start), collect (buffered window). - Decision gates with full lifecycle: pending -> resolved/withdrawn/ expired. Steer withdraws pending gates with reason=steer; abort with reason=abort. DecisionGateEntry persists in the DAG. - Multi-thread sessions: threads run concurrently with isolated histories; aborting one doesn't affect siblings. - Built-in thread_read tool for cross-thread visibility. - In-memory providers + VirtualSandbox so the full engine runs in vitest with no containers. 14 tests (happy path, decision gates, queue modes, multi-thread + thread_read), all green in <2s. Spec-vs-reality deltas reconciled by the implementation are documented in packages/engine/README.md (tool signature wrapping, no native suspension primitive, message_start vs message_update). Deferred for follow-up: restart-safe re-entrant gates (the SuspendedTurnState record is written but Engine.restoreSession is a stub), compaction, role/skill loading, model failover, ActionSource bridge, structured-result extraction. --- packages/engine/README.md | 88 + packages/engine/package.json | 28 + packages/engine/src/builtin-tools/index.ts | 108 ++ packages/engine/src/decision-gate.ts | 161 ++ packages/engine/src/engine.ts | 64 + packages/engine/src/index.ts | 18 + .../engine/src/providers/in-memory-blob.ts | 47 + .../engine/src/providers/in-memory-bus.ts | 29 + .../src/providers/in-memory-credentials.ts | 46 + .../engine/src/providers/in-memory-store.ts | 191 ++ .../engine/src/providers/virtual-sandbox.ts | 216 +++ packages/engine/src/session.ts | 209 ++ packages/engine/src/thread.ts | 643 +++++++ packages/engine/src/tool-bridge.ts | 58 + packages/engine/src/types.ts | 603 ++++++ packages/engine/test/decision-gate.test.ts | 224 +++ packages/engine/test/happy-path.test.ts | 153 ++ packages/engine/test/multi-thread.test.ts | 202 ++ packages/engine/test/queue-modes.test.ts | 209 ++ packages/engine/tsconfig.json | 9 + packages/engine/vitest.config.ts | 8 + pnpm-lock.yaml | 1694 ++++++++++++++++- tsconfig.json | 1 + 23 files changed, 5008 insertions(+), 1 deletion(-) create mode 100644 packages/engine/README.md create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/builtin-tools/index.ts create mode 100644 packages/engine/src/decision-gate.ts create mode 100644 packages/engine/src/engine.ts create mode 100644 packages/engine/src/index.ts create mode 100644 packages/engine/src/providers/in-memory-blob.ts create mode 100644 packages/engine/src/providers/in-memory-bus.ts create mode 100644 packages/engine/src/providers/in-memory-credentials.ts create mode 100644 packages/engine/src/providers/in-memory-store.ts create mode 100644 packages/engine/src/providers/virtual-sandbox.ts create mode 100644 packages/engine/src/session.ts create mode 100644 packages/engine/src/thread.ts create mode 100644 packages/engine/src/tool-bridge.ts create mode 100644 packages/engine/src/types.ts create mode 100644 packages/engine/test/decision-gate.test.ts create mode 100644 packages/engine/test/happy-path.test.ts create mode 100644 packages/engine/test/multi-thread.test.ts create mode 100644 packages/engine/test/queue-modes.test.ts create mode 100644 packages/engine/tsconfig.json create mode 100644 packages/engine/vitest.config.ts diff --git a/packages/engine/README.md b/packages/engine/README.md new file mode 100644 index 00000000..7b9ed0e8 --- /dev/null +++ b/packages/engine/README.md @@ -0,0 +1,88 @@ +# @valet/engine + +Prototype implementation of the portable runtime engine described in +[`docs/specs/2026-05-02-portable-runtime-engine-design.md`](../../docs/specs/2026-05-02-portable-runtime-engine-design.md). + +This is the V1 in-repo engine library. It runs the agent loop, owns +session/thread state, executes tools, and emits typed events. Platform +adapters (Cloudflare, Kubernetes) host this library; this package itself +has zero platform dependencies. + +## What works in this prototype + +- Engine public API: `createSession`, `getSession`, `deleteSession`, `Session.prompt`, + `Session.thread()`, `Session.resolveDecision`, `Session.withdrawDecision`, + `Session.abort/pause/resume`. +- Per-thread state: each thread gets its own `pi-agent-core` `Agent` + instance with its own queue and DAG history. +- Per-thread queue modes: `followup` (FIFO), `steer` (abort + start), + `collect` (buffered window). +- Decision gates: tool calls `ctx.requestDecision({...})` to suspend the + turn. The gate is persisted, a `DecisionGateEntry` lands in the DAG, the + engine emits `decision_gate`, and the turn resumes when the user calls + `session.resolveDecision()`. Pending gates withdraw on `steer` or + `abort` and expire after `expiresAt`. +- Multi-thread: threads run concurrently, share the sandbox, and have + isolated histories. Aborting one thread doesn't affect siblings. +- Built-in `thread_read` tool: a thread can read recent messages from a + sibling, parent, or child thread. +- Built-in tools: `read`, `write`, `edit`, `bash`, `thread_read`. +- In-memory providers: `InMemorySessionStore`, `InMemoryEventBus`, + `InMemoryBlobStore`, `InMemoryCredentialStore`, `VirtualSandbox` / + `VirtualSandboxProvider` (in-memory FS + a small whitelist of safe shell + commands). These double as test fixtures. + +## What's deferred (post-prototype) + +- **Restart-safe re-entrant decision gates.** Today `requestDecision` + returns a Promise that resolves when the user resolves the gate. That's + correct for an in-process engine but doesn't survive process restarts. + The persisted `SuspendedTurnState` record is written, but + `Engine.restoreSession` is a stub. Once we have a persistent store, we + re-prompt on restart and short-circuit `requestDecision` using + `ctx.suspendedDecision` (already plumbed through `ToolContext`). +- **Compaction.** Token-aware context compression is not implemented. + `CompactionEntry` is in the DAG schema; the algorithm itself is a + follow-up. +- **Roles & skills loading.** The types are defined, but role and skill + resolution at prompt time is not wired in. +- **Model failover.** Single-model only for now. +- **Plugin Action Bridge.** The `actionSourceToTools` adapter described + in the spec is not implemented yet — plugins should currently export + `ToolDef[]` directly. +- **Structured results.** Schema-validated output extraction with + `---RESULT_START---` delimiters is not implemented. +- **Engine restoration.** `Engine.restoreSession()` throws; full + rehydration of running queues + suspended turns is a follow-up. + +## Spec-vs-reality deltas (notes from the pi-ai/pi-agent-core spike) + +The spec was written before pinning the API surface of `pi-ai` / +`pi-agent-core`. The implementation reconciles: + +1. **`ToolDef.execute(args, ctx)` vs `AgentTool.execute(toolCallId, params, signal, onUpdate)`.** + The spec keeps the spec-faithful `ToolDef` shape as the public type; + internally we wrap each `ToolDef` to a pi `AgentTool` via + `tool-bridge.ts` and capture `ToolContext` in a closure. +2. **No native turn-suspension primitive.** pi-agent-core's + `beforeToolCall` can `{ block: true }` (deny path) but doesn't pause. + For a "wait for human" gate we await a Promise inside the tool. +3. **`message_start` vs `message_update`.** pi-agent-core fires + `message_start` once per assistant message; `message_update` carries + delta events (text, thinking, tool calls). The engine subscribes to + both. +4. **Custom `AgentMessage` types via `convertToLlm`.** The engine could + later persist `DecisionGateEntry` etc. as custom AgentMessages + alongside the LLM transcript, then filter them out before each LLM + call. We don't need this in the prototype because we persist via the + SessionStore directly, but the pattern is useful when we want + in-context awareness of past gates. + +## Tests + +```sh +pnpm --filter @valet/engine test +``` + +Covers: happy path (3), decision gates (4), queue modes (4), +multi-thread + thread_read (3) — 14 tests total, all in <2s. diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..76061d31 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,28 @@ +{ + "name": "@valet/engine", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0", + "typebox": "^1.1.24" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.0.18" + } +} diff --git a/packages/engine/src/builtin-tools/index.ts b/packages/engine/src/builtin-tools/index.ts new file mode 100644 index 00000000..b901c424 --- /dev/null +++ b/packages/engine/src/builtin-tools/index.ts @@ -0,0 +1,108 @@ +import { Type } from "typebox"; +import type { TSchema } from "typebox"; +import type { ToolDef, MessageQuery } from "../types.js"; + +/** + * Helper that preserves the schema's static type through the ToolDef so + * `args` in `execute` is typed precisely instead of `unknown`. + */ +export function defineTool(def: ToolDef): ToolDef { + return def; +} + +export const readTool = defineTool({ + name: "read", + description: "Read the contents of a file from the sandbox.", + parameters: Type.Object({ path: Type.String() }), + execute: async (args, ctx) => { + const text = await ctx.sandbox.readFile(args.path); + return { text }; + }, +}); + +export const writeTool = defineTool({ + name: "write", + description: "Write contents to a file in the sandbox (creates or overwrites).", + parameters: Type.Object({ path: Type.String(), content: Type.String() }), + execute: async (args, ctx) => { + await ctx.sandbox.writeFile(args.path, args.content); + return { text: `wrote ${args.path}` }; + }, +}); + +export const editTool = defineTool({ + name: "edit", + description: "Replace exact text occurrences in a file.", + parameters: Type.Object({ + path: Type.String(), + oldString: Type.String(), + newString: Type.String(), + }), + execute: async (args, ctx) => { + const before = await ctx.sandbox.readFile(args.path); + if (!before.includes(args.oldString)) { + return { text: `no match for old_string in ${args.path}` }; + } + const after = before.split(args.oldString).join(args.newString); + await ctx.sandbox.writeFile(args.path, after); + return { text: `edited ${args.path}` }; + }, +}); + +export const bashTool = defineTool({ + name: "bash", + description: "Execute a shell command in the sandbox.", + parameters: Type.Object({ command: Type.String() }), + execute: async (args, ctx) => { + const result = await ctx.sandbox.exec(args.command, { signal: ctx.signal }); + const exitNote = result.exitCode === 0 ? "" : `\n[exit ${result.exitCode}]`; + return { text: `${result.stdout}${result.stderr}${exitNote}` }; + }, +}); + +export const threadReadTool = defineTool({ + name: "thread_read", + description: + "Read recent messages from another thread in this session. Useful for cross-thread context (e.g. an orchestrator pulling notes from a worker thread, or a thread checking what a sibling has done).", + parameters: Type.Object({ + key: Type.String({ description: "Thread key to read from (e.g. 'web:default', 'task:research')." }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), + includeCompacted: Type.Optional(Type.Boolean()), + }), + execute: async (args, ctx) => { + const opts: MessageQuery = { + limit: args.limit ?? 30, + includeCompacted: args.includeCompacted ?? true, + }; + const entries = await ctx.threadRead(args.key, opts); + if (entries.length === 0) return { text: `(thread "${args.key}" has no messages)` }; + const lines: string[] = [`# thread:${args.key}`]; + for (const e of entries) { + if (e.type === "message") { + const author = e.author?.name ? ` (${e.author.name})` : ""; + lines.push(`\n## ${e.role}${author} @ ${new Date(e.createdAt).toISOString()}`); + lines.push(e.content); + } else if (e.type === "compaction") { + lines.push(`\n## [compaction summary]`); + lines.push(e.summary); + } else if (e.type === "decision_gate") { + lines.push( + `\n## [decision gate: ${e.gate.type} — ${e.gate.status}] ${e.gate.title}`, + ); + if (e.gate.body) lines.push(e.gate.body); + } else if (e.type === "branch_summary") { + lines.push(`\n## [branch summary]`); + lines.push(e.summary); + } + } + return { text: lines.join("\n") }; + }, +}); + +export const builtinTools: ToolDef[] = [ + readTool, + writeTool, + editTool, + bashTool, + threadReadTool, +]; diff --git a/packages/engine/src/decision-gate.ts b/packages/engine/src/decision-gate.ts new file mode 100644 index 00000000..e6cc2556 --- /dev/null +++ b/packages/engine/src/decision-gate.ts @@ -0,0 +1,161 @@ +import type { + DecisionGate, + DecisionGateRequest, + DecisionResolution, + DecisionWithdrawReason, +} from "./types.js"; + +/** + * Per-thread tracker for live decision-gate Promises. Tools awaiting a gate + * register a resolver here; engine.resolveDecision/withdraw/expire wakes the + * matching tool execution. + * + * V1 contract: a tool that calls ctx.requestDecision(...) blocks until the + * gate transitions out of `pending`. Resolution returns the `DecisionResolution`. + * Withdrawal throws `DecisionGateWithdrawnError`. Expiry throws + * `DecisionGateExpiredError`. Tools should let these errors propagate so the + * agent loop ends the turn cleanly. + * + * Restart-safe re-entrancy is a follow-up: when SuspendedTurnState is reloaded + * from a persistent store, the tool will be re-invoked from scratch with + * ctx.suspendedDecision populated, and this manager's first call will short- + * circuit to return the stored resolution. Not implemented yet. + */ +export class DecisionGateWithdrawnError extends Error { + constructor(public readonly gateId: string, public readonly reason: DecisionWithdrawReason) { + super(`decision gate ${gateId} withdrawn (${reason})`); + this.name = "DecisionGateWithdrawnError"; + } +} + +export class DecisionGateExpiredError extends Error { + constructor(public readonly gateId: string) { + super(`decision gate ${gateId} expired`); + this.name = "DecisionGateExpiredError"; + } +} + +export class DecisionGateConflictError extends Error { + constructor(public readonly gateId: string, public readonly currentStatus: string) { + super(`decision gate ${gateId} not pending (status=${currentStatus})`); + this.name = "DecisionGateConflictError"; + } +} + +interface PendingGate { + gate: DecisionGate; + resolve: (resolution: DecisionResolution) => void; + reject: (err: Error) => void; +} + +export class GateManager { + private pending = new Map(); + private timers = new Map(); + + register(gate: DecisionGate, onExpire: (gateId: string) => void): Promise { + return new Promise((resolve, reject) => { + this.pending.set(gate.id, { gate, resolve, reject }); + if (gate.expiresAt) { + const ms = gate.expiresAt - Date.now(); + if (ms <= 0) { + this.expire(gate.id); + onExpire(gate.id); + return; + } + const timer = setTimeout(() => { + this.expire(gate.id); + onExpire(gate.id); + }, ms); + // unref so the timer doesn't keep the process alive in tests + const t = timer as { unref?: () => void }; + if (typeof t.unref === "function") t.unref(); + this.timers.set(gate.id, timer); + } + }); + } + + resolve(gateId: string, resolution: DecisionResolution): boolean { + const p = this.pending.get(gateId); + if (!p) return false; + this.cleanup(gateId); + this.pending.delete(gateId); + p.resolve(resolution); + return true; + } + + withdraw(gateId: string, reason: DecisionWithdrawReason): boolean { + const p = this.pending.get(gateId); + if (!p) return false; + this.cleanup(gateId); + this.pending.delete(gateId); + p.reject(new DecisionGateWithdrawnError(gateId, reason)); + return true; + } + + expire(gateId: string): boolean { + const p = this.pending.get(gateId); + if (!p) return false; + this.cleanup(gateId); + this.pending.delete(gateId); + p.reject(new DecisionGateExpiredError(gateId)); + return true; + } + + isPending(gateId: string): boolean { + return this.pending.has(gateId); + } + + pendingForThread(threadId: string): DecisionGate[] { + const result: DecisionGate[] = []; + for (const p of this.pending.values()) { + if (p.gate.threadId === threadId) result.push(p.gate); + } + return result; + } + + private cleanup(gateId: string): void { + const timer = this.timers.get(gateId); + if (timer) { + clearTimeout(timer); + this.timers.delete(gateId); + } + } +} + +export function isDecisionGateWithdrawn(err: unknown): err is DecisionGateWithdrawnError { + return err instanceof DecisionGateWithdrawnError; +} + +export function isDecisionGateExpired(err: unknown): err is DecisionGateExpiredError { + return err instanceof DecisionGateExpiredError; +} + +export function fromRequest( + req: DecisionGateRequest, + sessionId: string, + threadId: string, +): DecisionGate { + const now = Date.now(); + return { + id: req.resumeKey ?? `gate-${now}-${Math.random().toString(36).slice(2, 9)}`, + sessionId, + threadId, + type: req.type, + title: req.title, + body: req.body, + actions: + req.actions ?? + (req.type === "approval" + ? [ + { id: "approve", label: "Approve", style: "primary" }, + { id: "deny", label: "Deny", style: "danger" }, + ] + : []), + expiresAt: req.expiresAt, + status: "pending", + context: req.context, + origin: req.origin, + createdAt: now, + updatedAt: now, + }; +} diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts new file mode 100644 index 00000000..b47ff668 --- /dev/null +++ b/packages/engine/src/engine.ts @@ -0,0 +1,64 @@ +import { VirtualSandboxProvider } from "./providers/virtual-sandbox.js"; +import { Session } from "./session.js"; +import type { + CreateSessionOptions, + EngineOptions, + Sandbox, + SandboxCreateOpts, +} from "./types.js"; + +let nextId = 1; +function uid(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${(nextId++).toString(36)}`; +} + +export class Engine { + private sessions = new Map(); + private opts: EngineOptions; + + constructor(opts: EngineOptions) { + this.opts = opts; + } + + async createSession(opts: CreateSessionOptions): Promise { + const id = opts.id ?? uid("sess"); + if (this.sessions.has(id)) return this.sessions.get(id)!; + + const sandbox = await this.materializeSandbox(opts.sandbox); + const session = new Session(id, opts, this.opts.providers, sandbox); + this.sessions.set(id, session); + + await this.opts.providers.store.saveSession(await session.toData()); + return session; + } + + async restoreSession(sessionId: string): Promise { + const existing = this.sessions.get(sessionId); + if (existing) return existing; + const data = await this.opts.providers.store.getSession(sessionId); + if (!data) throw new Error(`session not found: ${sessionId}`); + // V1 prototype: full restoration of pending queue items / suspended turns + // is a follow-up. Here we only rehydrate the session shell. + throw new Error("restoreSession: not implemented in prototype yet"); + } + + getSession(sessionId: string): Session | null { + return this.sessions.get(sessionId) ?? null; + } + + async deleteSession(sessionId: string): Promise { + const s = this.sessions.get(sessionId); + if (s) await s.destroy(); + this.sessions.delete(sessionId); + } + + private async materializeSandbox( + arg: Sandbox | SandboxCreateOpts | undefined, + ): Promise { + if (arg && typeof (arg as Sandbox).readFile === "function") { + return arg as Sandbox; + } + const provider = this.opts.providers.sandboxProvider ?? new VirtualSandboxProvider(); + return provider.create((arg ?? {}) as SandboxCreateOpts); + } +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 00000000..2ce4962f --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,18 @@ +export * from "./types.js"; +export { Engine } from "./engine.js"; +export { Session } from "./session.js"; +export { Thread } from "./thread.js"; +export { InMemorySessionStore } from "./providers/in-memory-store.js"; +export { InMemoryEventBus } from "./providers/in-memory-bus.js"; +export { InMemoryBlobStore } from "./providers/in-memory-blob.js"; +export { InMemoryCredentialStore } from "./providers/in-memory-credentials.js"; +export { VirtualSandbox, VirtualSandboxProvider } from "./providers/virtual-sandbox.js"; +export { builtinTools, readTool, writeTool, editTool, bashTool, threadReadTool } from "./builtin-tools/index.js"; +export { + GateManager, + DecisionGateWithdrawnError, + DecisionGateExpiredError, + DecisionGateConflictError, + isDecisionGateWithdrawn, + isDecisionGateExpired, +} from "./decision-gate.js"; diff --git a/packages/engine/src/providers/in-memory-blob.ts b/packages/engine/src/providers/in-memory-blob.ts new file mode 100644 index 00000000..88b71c59 --- /dev/null +++ b/packages/engine/src/providers/in-memory-blob.ts @@ -0,0 +1,47 @@ +import type { BlobStore } from "../types.js"; + +export class InMemoryBlobStore implements BlobStore { + private blobs = new Map(); + + async put( + key: string, + data: Uint8Array | ReadableStream, + opts?: { contentType?: string }, + ): Promise { + if (data instanceof Uint8Array) { + this.blobs.set(key, { data, contentType: opts?.contentType }); + return; + } + const reader = data.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.length; + } + this.blobs.set(key, { data: merged, contentType: opts?.contentType }); + } + + async get(key: string): Promise<{ data: ReadableStream; contentType?: string } | null> { + const blob = this.blobs.get(key); + if (!blob) return null; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(blob.data); + controller.close(); + }, + }); + return { data: stream, contentType: blob.contentType }; + } + + async delete(key: string): Promise { + this.blobs.delete(key); + } +} diff --git a/packages/engine/src/providers/in-memory-bus.ts b/packages/engine/src/providers/in-memory-bus.ts new file mode 100644 index 00000000..a3cd7af0 --- /dev/null +++ b/packages/engine/src/providers/in-memory-bus.ts @@ -0,0 +1,29 @@ +import type { BusEvent, EventBus, EventFilter, Unsubscribe } from "../types.js"; + +interface Subscription { + filter: EventFilter; + callback: (event: BusEvent) => void; +} + +export class InMemoryEventBus implements EventBus { + private subs = new Set(); + + async publish(event: BusEvent): Promise { + for (const sub of this.subs) { + if (matches(sub.filter, event)) sub.callback(event); + } + } + + subscribe(filter: EventFilter, callback: (event: BusEvent) => void): Unsubscribe { + const sub: Subscription = { filter, callback }; + this.subs.add(sub); + return () => this.subs.delete(sub); + } +} + +function matches(filter: EventFilter, event: BusEvent): boolean { + if (filter.sessionId && filter.sessionId !== event.sessionId) return false; + if (filter.userId && filter.userId !== event.userId) return false; + if (filter.eventTypes && !filter.eventTypes.includes(event.event.type)) return false; + return true; +} diff --git a/packages/engine/src/providers/in-memory-credentials.ts b/packages/engine/src/providers/in-memory-credentials.ts new file mode 100644 index 00000000..f65305df --- /dev/null +++ b/packages/engine/src/providers/in-memory-credentials.ts @@ -0,0 +1,46 @@ +import type { + CredentialOwner, + CredentialStore, + StoredCredential, +} from "../types.js"; + +function key(owner: CredentialOwner, service: string): string { + return `${owner.type}:${owner.id}:${service}`; +} + +export class InMemoryCredentialStore implements CredentialStore { + private creds = new Map(); + + async get(owner: CredentialOwner, service: string): Promise { + return this.creds.get(key(owner, service))?.credential ?? null; + } + + async save( + owner: CredentialOwner, + service: string, + credential: StoredCredential, + ): Promise { + this.creds.set(key(owner, service), { credential, connectedAt: new Date().toISOString() }); + } + + async delete(owner: CredentialOwner, service: string): Promise { + this.creds.delete(key(owner, service)); + } + + async list( + owner: CredentialOwner, + ): Promise<{ service: string; scopes?: string[]; connectedAt: string }[]> { + const prefix = `${owner.type}:${owner.id}:`; + const result: { service: string; scopes?: string[]; connectedAt: string }[] = []; + for (const [k, v] of this.creds) { + if (k.startsWith(prefix)) { + result.push({ + service: k.slice(prefix.length), + scopes: v.credential.scopes, + connectedAt: v.connectedAt, + }); + } + } + return result; + } +} diff --git a/packages/engine/src/providers/in-memory-store.ts b/packages/engine/src/providers/in-memory-store.ts new file mode 100644 index 00000000..4478c299 --- /dev/null +++ b/packages/engine/src/providers/in-memory-store.ts @@ -0,0 +1,191 @@ +import type { + DecisionGate, + DecisionGateEntry, + DecisionGateRef, + ListOpts, + MessageQuery, + QueueState, + SessionData, + SessionEntry, + SessionStatus, + SessionStore, + SuspendedTurnState, + ThreadData, +} from "../types.js"; + +interface SessionRow { + data: SessionData; + threads: Map; + entriesByThread: Map; + queueByThread: Map; + gates: Map; + gateRefs: Map>; + suspendedByThread: Map; +} + +export class InMemorySessionStore implements SessionStore { + private rows = new Map(); + + private row(sessionId: string): SessionRow { + const row = this.rows.get(sessionId); + if (!row) throw new Error(`session not found: ${sessionId}`); + return row; + } + + async saveSession(session: SessionData): Promise { + const existing = this.rows.get(session.id); + if (existing) { + existing.data = session; + return; + } + this.rows.set(session.id, { + data: session, + threads: new Map(), + entriesByThread: new Map(), + queueByThread: new Map(), + gates: new Map(), + gateRefs: new Map(), + suspendedByThread: new Map(), + }); + } + + async saveThread(sessionId: string, thread: ThreadData): Promise { + const r = this.row(sessionId); + r.threads.set(thread.id, thread); + if (!r.entriesByThread.has(thread.id)) r.entriesByThread.set(thread.id, []); + } + + async appendEntries( + sessionId: string, + threadId: string, + entries: SessionEntry[], + ): Promise { + const r = this.row(sessionId); + const list = r.entriesByThread.get(threadId) ?? []; + list.push(...entries); + r.entriesByThread.set(threadId, list); + // Update activeLeafEntryId for convenience + const t = r.threads.get(threadId); + if (t && entries.length > 0) { + t.activeLeafEntryId = entries[entries.length - 1].id; + t.updatedAt = Date.now(); + } + } + + async saveQueueState(sessionId: string, threadId: string, queue: QueueState): Promise { + this.row(sessionId).queueByThread.set(threadId, queue); + } + + async saveDecisionGate(sessionId: string, _threadId: string, gate: DecisionGate): Promise { + this.row(sessionId).gates.set(gate.id, { ...gate }); + } + + async saveDecisionGateRef( + sessionId: string, + _threadId: string, + gateId: string, + ref: { channelType: string; ref: DecisionGateRef }, + ): Promise { + const r = this.row(sessionId); + const refs = r.gateRefs.get(gateId) ?? []; + refs.push(ref); + r.gateRefs.set(gateId, refs); + } + + async updateDecisionGateEntry( + sessionId: string, + threadId: string, + gateId: string, + patch: Partial, + ): Promise { + const r = this.row(sessionId); + const entries = r.entriesByThread.get(threadId) ?? []; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type === "decision_gate" && e.gate.id === gateId) { + entries[i] = { ...e, ...patch, gate: patch.gate ?? e.gate }; + } + } + } + + async saveSuspendedTurn( + sessionId: string, + threadId: string, + suspended: SuspendedTurnState, + ): Promise { + this.row(sessionId).suspendedByThread.set(threadId, suspended); + } + + async clearSuspendedTurn(sessionId: string, threadId: string): Promise { + this.row(sessionId).suspendedByThread.delete(threadId); + } + + async updateSessionStatus( + id: string, + status: SessionStatus, + metadata?: Partial, + ): Promise { + const r = this.rows.get(id); + if (!r) return; + r.data = { ...r.data, ...metadata, status, updatedAt: Date.now() }; + } + + async getSession(id: string): Promise { + return this.rows.get(id)?.data ?? null; + } + + async listSessions(userId: string, opts?: ListOpts): Promise { + const all = [...this.rows.values()].map((r) => r.data).filter((s) => s.userId === userId); + if (opts?.status) return all.filter((s) => s.status === opts.status); + return all; + } + + async getThread(sessionId: string, threadId: string): Promise { + return this.row(sessionId).threads.get(threadId) ?? null; + } + + async listThreads(sessionId: string): Promise { + return [...this.row(sessionId).threads.values()]; + } + + async getEntries( + sessionId: string, + threadId: string, + opts?: MessageQuery, + ): Promise { + const all = this.row(sessionId).entriesByThread.get(threadId) ?? []; + let result = all; + if (opts?.includeCompacted === false) { + result = result.filter((e) => e.type !== "compaction"); + } + if (opts?.limit && opts.limit > 0) { + result = result.slice(-opts.limit); + } + return [...result]; + } + + async getQueueState(sessionId: string, threadId: string): Promise { + return this.row(sessionId).queueByThread.get(threadId) ?? null; + } + + async listDecisionGates(sessionId: string, threadId?: string): Promise { + const all = [...this.row(sessionId).gates.values()]; + if (threadId) return all.filter((g) => g.threadId === threadId); + return all; + } + + async getDecisionGate(sessionId: string, gateId: string): Promise { + return this.row(sessionId).gates.get(gateId) ?? null; + } + + async getSuspendedTurn( + sessionId: string, + threadId: string, + ): Promise { + return this.row(sessionId).suspendedByThread.get(threadId) ?? null; + } + + async deleteSession(id: string): Promise { + this.rows.delete(id); + } +} diff --git a/packages/engine/src/providers/virtual-sandbox.ts b/packages/engine/src/providers/virtual-sandbox.ts new file mode 100644 index 00000000..31966c7c --- /dev/null +++ b/packages/engine/src/providers/virtual-sandbox.ts @@ -0,0 +1,216 @@ +import type { ExecOpts, ExecResult, Sandbox, SandboxCreateOpts, SandboxProvider, SandboxStatus } from "../types.js"; + +interface FsEntry { + type: "file" | "dir"; + content?: Uint8Array; +} + +function normalize(path: string): string { + if (!path.startsWith("/")) path = "/" + path; + const parts = path.split("/").filter((p) => p && p !== "."); + const stack: string[] = []; + for (const p of parts) { + if (p === "..") stack.pop(); + else stack.push(p); + } + return "/" + stack.join("/"); +} + +function dirOf(path: string): string { + const idx = path.lastIndexOf("/"); + if (idx <= 0) return "/"; + return path.slice(0, idx); +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +/** + * In-memory sandbox for tests. Shell commands are intentionally minimal — + * just enough to exercise the engine without containers: + * echo, cat, ls, pwd, true, false, sh -c "" + * Anything else returns exitCode 127. + */ +export class VirtualSandbox implements Sandbox { + readonly id: string; + private fs = new Map(); + private cwd = "/"; + + constructor(id: string) { + this.id = id; + this.fs.set("/", { type: "dir" }); + } + + private ensureParentDirs(path: string): void { + const parts = path.split("/").filter(Boolean); + let cur = ""; + for (let i = 0; i < parts.length - 1; i++) { + cur += "/" + parts[i]; + if (!this.fs.has(cur)) this.fs.set(cur, { type: "dir" }); + } + } + + async readFile(path: string): Promise { + return dec.decode(await this.readBinary(path)); + } + + async readBinary(path: string): Promise { + const e = this.fs.get(normalize(path)); + if (!e || e.type !== "file" || !e.content) throw new Error(`ENOENT: ${path}`); + return e.content; + } + + async writeFile(path: string, content: string): Promise { + return this.writeBinary(path, enc.encode(content)); + } + + async writeBinary(path: string, data: Uint8Array): Promise { + const norm = normalize(path); + this.ensureParentDirs(norm); + this.fs.set(norm, { type: "file", content: data }); + } + + async readdir(path: string): Promise { + const norm = normalize(path); + if (!this.fs.has(norm)) throw new Error(`ENOENT: ${path}`); + const prefix = norm === "/" ? "/" : norm + "/"; + const names = new Set(); + for (const k of this.fs.keys()) { + if (k === norm) continue; + if (!k.startsWith(prefix)) continue; + const rest = k.slice(prefix.length); + const slash = rest.indexOf("/"); + names.add(slash === -1 ? rest : rest.slice(0, slash)); + } + return [...names]; + } + + async stat(path: string): Promise<{ isFile: boolean; isDirectory: boolean; size: number }> { + const e = this.fs.get(normalize(path)); + if (!e) throw new Error(`ENOENT: ${path}`); + return { + isFile: e.type === "file", + isDirectory: e.type === "dir", + size: e.content?.length ?? 0, + }; + } + + async mkdir(path: string): Promise { + const norm = normalize(path); + this.ensureParentDirs(norm); + if (!this.fs.has(norm)) this.fs.set(norm, { type: "dir" }); + } + + async rm(path: string, opts?: { recursive?: boolean }): Promise { + const norm = normalize(path); + const e = this.fs.get(norm); + if (!e) return; + if (e.type === "dir" && opts?.recursive) { + const prefix = norm === "/" ? "/" : norm + "/"; + for (const k of [...this.fs.keys()]) if (k.startsWith(prefix)) this.fs.delete(k); + } + this.fs.delete(norm); + } + + async exec(command: string, opts?: ExecOpts): Promise { + const cwd = opts?.cwd ?? this.cwd; + const out = await runVirtualCommand(this, command, cwd); + if (opts?.maxOutputBytes && out.stdout.length > opts.maxOutputBytes) { + return { ...out, stdout: out.stdout.slice(0, opts.maxOutputBytes), truncated: true }; + } + return out; + } + + async snapshot(): Promise { + return `${this.id}@${Date.now()}`; + } + + async tunnels(): Promise> { + return {}; + } + + async destroy(): Promise { + this.fs.clear(); + } +} + +async function runVirtualCommand(sb: VirtualSandbox, command: string, cwd: string): Promise { + // Strip "sh -c '...'" wrapping + const shMatch = command.match(/^\s*(?:bash|sh)\s+-c\s+(['"])([\s\S]*)\1\s*$/); + const inner = shMatch ? shMatch[2] : command; + const trimmed = inner.trim(); + + if (trimmed === "true" || trimmed === ":") return ok(""); + if (trimmed === "false") return { stdout: "", stderr: "", exitCode: 1 }; + if (trimmed === "pwd") return ok(cwd + "\n"); + + const echoMatch = trimmed.match(/^echo\s+(.*)$/); + if (echoMatch) { + const arg = echoMatch[1].replace(/^['"]|['"]$/g, ""); + return ok(arg + "\n"); + } + + const catMatch = trimmed.match(/^cat\s+(\S+)$/); + if (catMatch) { + try { + const content = await sb.readFile(resolveRel(cwd, catMatch[1])); + return ok(content); + } catch (e) { + return { stdout: "", stderr: `cat: ${(e as Error).message}\n`, exitCode: 1 }; + } + } + + const lsMatch = trimmed.match(/^ls(?:\s+(\S+))?$/); + if (lsMatch) { + const target = lsMatch[1] ? resolveRel(cwd, lsMatch[1]) : cwd; + try { + const names = await sb.readdir(target); + return ok(names.sort().join("\n") + (names.length ? "\n" : "")); + } catch (e) { + return { stdout: "", stderr: `ls: ${(e as Error).message}\n`, exitCode: 2 }; + } + } + + return { stdout: "", stderr: `command not found: ${trimmed}\n`, exitCode: 127 }; +} + +function resolveRel(cwd: string, p: string): string { + if (p.startsWith("/")) return p; + return cwd === "/" ? "/" + p : cwd + "/" + p; +} + +function ok(stdout: string): ExecResult { + return { stdout, stderr: "", exitCode: 0 }; +} + +// ── Provider ────────────────────────────────────────────────────── + +export class VirtualSandboxProvider implements SandboxProvider { + private sandboxes = new Map(); + private nextId = 1; + + async create(_opts: SandboxCreateOpts): Promise { + const id = `vsb-${this.nextId++}`; + const sb = new VirtualSandbox(id); + this.sandboxes.set(id, sb); + return sb; + } + + async restore(id: string): Promise { + const sb = this.sandboxes.get(id); + if (!sb) throw new Error(`virtual sandbox not found: ${id}`); + return sb; + } + + async destroy(id: string): Promise { + const sb = this.sandboxes.get(id); + if (sb) await sb.destroy?.(); + this.sandboxes.delete(id); + } + + async status(id: string): Promise { + return this.sandboxes.has(id) + ? { id, state: "running", startedAt: Date.now() } + : { id, state: "stopped" }; + } +} diff --git a/packages/engine/src/session.ts b/packages/engine/src/session.ts new file mode 100644 index 00000000..5e8874a0 --- /dev/null +++ b/packages/engine/src/session.ts @@ -0,0 +1,209 @@ +import { Thread } from "./thread.js"; +import { builtinTools } from "./builtin-tools/index.js"; +import type { + CreateSessionOptions, + CredentialOwner, + CredentialProvider, + DecisionGate, + DecisionResolution, + DecisionWithdrawReason, + EngineEvent, + MessageQuery, + PromptContent, + PromptOptions, + PromptReceipt, + ProviderBundle, + QueueMode, + Sandbox, + SessionData, + SessionEntry, + ThreadData, + ToolDef, +} from "./types.js"; + +let nextId = 1; +function uid(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${(nextId++).toString(36)}`; +} + +export class Session { + readonly id: string; + readonly providers: ProviderBundle; + readonly options: CreateSessionOptions; + readonly sandbox: Sandbox; + readonly builtinTools: ToolDef[] = builtinTools; + private threads = new Map(); + private threadsByKey = new Map(); + private destroyed = false; + + constructor(id: string, options: CreateSessionOptions, providers: ProviderBundle, sandbox: Sandbox) { + this.id = id; + this.options = options; + this.providers = providers; + this.sandbox = sandbox; + } + + async ensureDefaultThread(): Promise { + return this.thread("web:default"); + } + + thread(key?: string): Thread { + const k = key ?? "web:default"; + const existing = this.threadsByKey.get(k); + if (existing) return existing; + const data: ThreadData = { + id: uid("th"), + sessionId: this.id, + key: k, + status: "active", + queueMode: this.options.queueMode ?? "followup", + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const thread = new Thread(this, data); + this.threads.set(thread.id, thread); + this.threadsByKey.set(k, thread); + void this.providers.store.saveThread(this.id, data); + return thread; + } + + threadById(id: string): Thread | null { + return this.threads.get(id) ?? null; + } + + async threadByKey(key: string): Promise { + return this.threadsByKey.get(key) ?? null; + } + + listThreads(): Thread[] { + return [...this.threads.values()]; + } + + // ── public API ────────────────────────────────────────────────── + + async prompt(content: PromptContent, opts: PromptOptions = {}): Promise { + return this.thread().submitPrompt(content, opts); + } + + async resolveDecision(gateId: string, resolution: DecisionResolution): Promise { + for (const t of this.threads.values()) { + if (t.isPendingGate(gateId)) { + t.resolveDecision(gateId, resolution); + return; + } + } + // Fallback: gate may have already been resolved or never registered. + } + + async withdrawDecision(gateId: string, reason: DecisionWithdrawReason): Promise { + for (const t of this.threads.values()) { + if (t.isPendingGate(gateId)) { + t.withdrawDecision(gateId, reason); + return; + } + } + } + + async abort(opts: { threadId?: string } = {}): Promise { + if (opts.threadId) { + await this.threads.get(opts.threadId)?.abort(); + return; + } + await Promise.all([...this.threads.values()].map((t) => t.abort())); + } + + async pause(opts: { threadId?: string } = {}): Promise { + if (opts.threadId) { + await this.threads.get(opts.threadId)?.pause(); + return; + } + await Promise.all([...this.threads.values()].map((t) => t.pause())); + } + + async resume(opts: { threadId?: string } = {}): Promise { + if (opts.threadId) { + await this.threads.get(opts.threadId)?.resume(); + return; + } + await Promise.all([...this.threads.values()].map((t) => t.resume())); + } + + async destroy(): Promise { + if (this.destroyed) return; + this.destroyed = true; + await Promise.all([...this.threads.values()].map((t) => t.abort())); + if (this.sandbox.destroy) await this.sandbox.destroy(); + await this.providers.store.deleteSession(this.id); + } + + async pendingDecisionGates(): Promise { + return this.providers.store.listDecisionGates(this.id); + } + + async readEntries(threadKey: string, opts?: MessageQuery): Promise { + const t = await this.threadByKey(threadKey); + if (!t) return []; + return t.readEntries(opts); + } + + async toData(): Promise { + return { + id: this.id, + userId: this.options.userId, + orgId: this.options.orgId, + workspace: this.options.workspace, + purpose: this.options.purpose ?? "interactive", + status: "running", + sandboxId: this.sandbox.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } + + async emit(event: EngineEvent): Promise { + await this.providers.bus.publish({ + sessionId: this.id, + threadId: "threadId" in event ? (event.threadId as string | undefined) : undefined, + userId: this.options.userId, + event, + timestamp: Date.now(), + }); + } + + // ── credential provider for tools ─────────────────────────────── + + credentialProvider(): CredentialProvider { + const owner: CredentialOwner = { type: "user", id: this.options.userId }; + const credStore = this.providers.credentials; + const session = this; + return { + async get(service: string) { + if (!credStore) return null; + const stored = await credStore.get(owner, service); + if (!stored) return null; + return { + accessToken: stored.accessToken ?? stored.apiKey ?? "", + refreshToken: stored.refreshToken, + expiresAt: stored.expiresAt, + scopes: stored.scopes, + metadata: stored.metadata, + }; + }, + async request(service: string, reason: string) { + // V1 prototype: credential request is a decision gate too — but the + // ToolContext.requestDecision in Thread is the canonical mechanism. + // Here we only attempt to read; if missing, we throw. + if (!credStore) throw new Error(`credential ${service} not available (no store)`); + const stored = await credStore.get(owner, service); + if (!stored) throw new Error(`credential ${service} not connected: ${reason}`); + return { + accessToken: stored.accessToken ?? stored.apiKey ?? "", + refreshToken: stored.refreshToken, + expiresAt: stored.expiresAt, + scopes: stored.scopes, + metadata: stored.metadata, + }; + }, + }; + } +} diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts new file mode 100644 index 00000000..fad39d2b --- /dev/null +++ b/packages/engine/src/thread.ts @@ -0,0 +1,643 @@ +import { Agent } from "@mariozechner/pi-agent-core"; +import type { AgentEvent, AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; +import type { Session } from "./session.js"; +import { toAgentTool } from "./tool-bridge.js"; +import { fromRequest, GateManager } from "./decision-gate.js"; +import type { + DecisionGate, + DecisionGateRequest, + DecisionResolution, + DecisionWithdrawReason, + EngineEvent, + MessagePart, + MessageEntry, + MessageQuery, + PromptAuthor, + PromptContent, + PromptOptions, + PromptReceipt, + QueueItem, + QueueMode, + QueueState, + QueueStatus, + SessionEntry, + ThreadData, + ToolContext, + ToolDef, +} from "./types.js"; + +interface PendingResolver { + resolve: () => void; + reject: (err: unknown) => void; +} + +const DEFAULT_COLLECT_WINDOW_MS = 5000; + +let nextId = 1; +function uid(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${(nextId++).toString(36)}`; +} + +/** + * One Thread per (session, key). Owns its own pi-agent-core Agent instance, + * its own queue, its own active leaf in the DAG, and its own GateManager. + * + * The queue is implemented at the engine level (not via pi-agent-core's + * steeringQueue/followUpQueue): we want to control queueing across the + * entire prompt lifecycle, including suspended states. + */ +export class Thread { + readonly id: string; + readonly key: string; + private readonly session: Session; + private agent: Agent; + private status: QueueStatus = "idle"; + private pending: QueueItem[] = []; + private collectBuffer: QueueItem[] = []; + private collectTimer: ReturnType | null = null; + private blockedGateId: string | undefined; + private activeItem: QueueItem | null = null; + private activeStartedResolver: PendingResolver | null = null; + private gates = new GateManager(); + private mode: QueueMode; + private aborted = false; + private currentAssistantMessageId: string | undefined; + private currentAssistantParts: MessagePart[] = []; + private currentToolCalls = new Map(); + private toolCtxOverlay: { gateId?: string } = {}; + + constructor(session: Session, data: ThreadData) { + this.session = session; + this.id = data.id; + this.key = data.key; + this.mode = data.queueMode; + this.agent = this.buildAgent(); + } + + // ── public API ────────────────────────────────────────────────── + + pendingDecisionGates(): DecisionGate[] { + return this.gates.pendingForThread(this.id); + } + + isPendingGate(gateId: string): boolean { + return this.gates.isPending(gateId); + } + + resolveDecision(gateId: string, resolution: DecisionResolution): boolean { + const ok = this.gates.resolve(gateId, resolution); + if (ok) { + void this.session.emit({ + type: "decision_gate_resolved", + threadId: this.id, + gateId, + resolution, + }); + } + return ok; + } + + withdrawDecision(gateId: string, reason: DecisionWithdrawReason): boolean { + const ok = this.gates.withdraw(gateId, reason); + if (ok) { + void this.session.emit({ + type: "decision_gate_withdrawn", + threadId: this.id, + gateId, + reason, + }); + } + return ok; + } + + async submitPrompt(content: PromptContent, opts: PromptOptions): Promise { + const item: QueueItem = { + id: uid("q"), + threadId: this.id, + content, + author: opts.author, + channel: opts.channel, + replyTarget: opts.replyTarget, + model: opts.model, + metadata: opts.metadata, + createdAt: Date.now(), + }; + const mode = opts.queueMode ?? this.mode; + + if (mode === "steer") { + // Withdraw any pending gate, abort the active run, clear the queue. + await this.steer(item); + return { + sessionId: this.session.id, + threadId: this.id, + queueItemId: item.id, + status: this.status === "running" ? "running" : "queued", + }; + } + + if (mode === "collect") { + this.collectBuffer.push(item); + if (this.collectTimer === null) { + const windowMs = this.session.options.collectWindowMs ?? DEFAULT_COLLECT_WINDOW_MS; + this.collectTimer = setTimeout(() => { + this.flushCollectBuffer().catch((e) => + this.emitError("collect_flush_failed", String(e)), + ); + }, windowMs); + const t = this.collectTimer as { unref?: () => void }; + if (typeof t.unref === "function") t.unref(); + } + void this.persistQueueState(); + return { + sessionId: this.session.id, + threadId: this.id, + queueItemId: item.id, + status: "queued", + }; + } + + // default: followup + this.pending.push(item); + void this.persistQueueState(); + void this.tickQueue(); + return { + sessionId: this.session.id, + threadId: this.id, + queueItemId: item.id, + status: this.status === "idle" ? "running" : "queued", + }; + } + + async abort(): Promise { + this.aborted = true; + // Withdraw any pending gates owned by this thread. + for (const g of this.pendingDecisionGates()) { + this.withdrawDecision(g.id, "abort"); + } + this.pending = []; + this.collectBuffer = []; + if (this.collectTimer) { + clearTimeout(this.collectTimer); + this.collectTimer = null; + } + if (this.agent.state.isStreaming) { + this.agent.abort(); + await this.agent.waitForIdle(); + } + this.activeItem = null; + this.setStatus("idle"); + void this.persistQueueState(); + } + + async pause(): Promise { + this.setStatus("paused"); + void this.persistQueueState(); + } + + async resume(): Promise { + if (this.status === "paused") { + this.setStatus("idle"); + void this.persistQueueState(); + void this.tickQueue(); + } + } + + setMode(mode: QueueMode): void { + this.mode = mode; + } + + toThreadData(): ThreadData { + return { + id: this.id, + sessionId: this.session.id, + key: this.key, + status: this.status === "paused" ? "paused" : "active", + activeLeafEntryId: undefined, + queueMode: this.mode, + model: undefined, + summary: undefined, + createdAt: 0, + updatedAt: Date.now(), + }; + } + + async readEntries(opts?: MessageQuery): Promise { + return this.session.providers.store.getEntries(this.session.id, this.id, opts); + } + + // ── internals ─────────────────────────────────────────────────── + + private async steer(item: QueueItem): Promise { + // Withdraw any pending gate from the superseded turn first. + const pendingGate = this.blockedGateId; + if (pendingGate) { + this.withdrawDecision(pendingGate, "steer"); + this.blockedGateId = undefined; + } + if (this.agent.state.isStreaming) { + this.agent.abort(); + await this.agent.waitForIdle(); + } + this.pending = []; + this.collectBuffer = []; + if (this.collectTimer) { + clearTimeout(this.collectTimer); + this.collectTimer = null; + } + this.pending.push(item); + void this.persistQueueState(); + void this.tickQueue(); + } + + private async flushCollectBuffer(): Promise { + if (this.collectTimer) { + clearTimeout(this.collectTimer); + this.collectTimer = null; + } + if (this.collectBuffer.length === 0) return; + const items = this.collectBuffer; + this.collectBuffer = []; + + const merged: QueueItem = { + id: uid("q-merged"), + threadId: this.id, + content: items.map((it, i) => `[${i + 1}] ${promptText(it.content)}`).join("\n\n"), + author: items[0].author, + channel: items[0].channel, + replyTarget: items[0].replyTarget, + model: items[0].model, + metadata: { mergedFrom: items.map((i) => i.id) }, + createdAt: Date.now(), + }; + this.pending.push(merged); + void this.persistQueueState(); + void this.tickQueue(); + } + + private async tickQueue(): Promise { + if (this.status === "paused") return; + if (this.status === "running" || this.status === "blocked_on_decision_gate") return; + const next = this.pending.shift(); + if (!next) { + this.setStatus("idle"); + return; + } + this.activeItem = next; + this.setStatus("running"); + void this.persistQueueState(); + try { + await this.runItem(next); + } catch (err) { + this.emitError("run_failed", String(err)); + } + this.activeItem = null; + if (this.readStatus() === "running") this.setStatus("idle"); + void this.persistQueueState(); + if (this.pending.length > 0 && this.readStatus() !== "paused") void this.tickQueue(); + } + + /** + * Reads `this.status` through a method call to defeat TS's control-flow + * narrowing across awaits — `setStatus("running")` makes TS think the + * property type is the narrow literal forever, even after async work that + * could call back into setStatus with other values. + */ + private readStatus(): QueueStatus { + return this.status; + } + + private async runItem(item: QueueItem): Promise { + const text = promptText(item.content); + this.aborted = false; + this.currentAssistantMessageId = undefined; + this.currentAssistantParts = []; + this.currentToolCalls.clear(); + + // Persist user message entry + const userEntry: MessageEntry = { + id: uid("e"), + sessionId: this.session.id, + threadId: this.id, + parentId: null, + type: "message", + role: "user", + content: text, + author: item.author, + channel: item.channel, + createdAt: Date.now(), + }; + await this.session.providers.store.appendEntries(this.session.id, this.id, [userEntry]); + + // Build the AgentTool list with closures over this turn's ToolContext. + this.agent.state.tools = this.buildTools(); + + try { + await this.agent.prompt({ + role: "user", + content: [{ type: "text", text }], + timestamp: Date.now(), + }); + await this.agent.waitForIdle(); + } catch (err) { + this.emitError("agent_failed", err instanceof Error ? err.message : String(err)); + } + + // Persist any assistant message that landed in agent state we haven't already. + // (We rely on subscribe handlers to append; this is a safety net if nothing did.) + } + + private buildAgent(): Agent { + const agent = new Agent({ + initialState: { + model: this.session.options.model, + systemPrompt: this.session.options.systemPrompt ?? "", + }, + // Filter out custom AgentMessage types (decision_gate, compaction, etc.) + // before the LLM sees them. They live in the engine DAG, not in LLM context. + convertToLlm: (messages: AgentMessage[]): Message[] => { + return messages.filter( + (m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult", + ) as Message[]; + }, + }); + agent.subscribe((event, _signal) => this.handleAgentEvent(event)); + return agent; + } + + private buildTools(): AgentTool[] { + const all: ToolDef[] = [...this.session.builtinTools, ...(this.session.options.tools ?? [])]; + return all.map((def) => + toAgentTool(def, (signal, toolCallId) => this.buildToolContext(signal, toolCallId)), + ); + } + + private buildToolContext(signal: AbortSignal, toolCallId: string): ToolContext { + const session = this.session; + return { + userId: session.options.userId, + orgId: session.options.orgId, + sessionId: session.id, + threadId: this.id, + sessionPurpose: session.options.purpose, + cwd: session.options.workspace, + credentials: session.credentialProvider(), + sandbox: session.sandbox, + signal, + decisionGateId: this.toolCtxOverlay.gateId, + requestDecision: async (req: DecisionGateRequest): Promise => { + const gate = fromRequest(req, session.id, this.id); + await session.providers.store.saveDecisionGate(session.id, this.id, gate); + const gateEntry: SessionEntry = { + id: uid("e"), + sessionId: session.id, + threadId: this.id, + parentId: null, + type: "decision_gate", + gate, + createdAt: Date.now(), + }; + await session.providers.store.appendEntries(session.id, this.id, [gateEntry]); + + // checkpoint the suspended turn + await session.providers.store.saveSuspendedTurn(session.id, this.id, { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + gateId: gate.id, + model: session.options.model.id, + toolCallId, + toolName: req.title, + toolArgs: {}, + resumeKey: req.resumeKey ?? gate.id, + attempt: 1, + createdAt: Date.now(), + }); + + this.blockedGateId = gate.id; + this.setStatus("blocked_on_decision_gate"); + await session.emit({ + type: "status", + threadId: this.id, + status: "blocked_on_decision_gate", + }); + await session.emit({ type: "decision_gate", threadId: this.id, gate }); + + try { + const resolution = await this.gates.register(gate, async (gateId) => { + await session.providers.store.updateDecisionGateEntry( + session.id, + this.id, + gateId, + { resolvedAt: new Date().toISOString(), gate: { ...gate, status: "expired" } }, + ); + await session.emit({ type: "decision_gate_expired", threadId: this.id, gateId }); + }); + // Mark gate resolved in store and update DAG entry + const resolved: DecisionGate = { ...gate, status: "resolved", updatedAt: Date.now() }; + await session.providers.store.saveDecisionGate(session.id, this.id, resolved); + await session.providers.store.updateDecisionGateEntry(session.id, this.id, gate.id, { + gate: resolved, + resolution, + resolvedAt: new Date(resolution.resolvedAt).toISOString(), + }); + this.blockedGateId = undefined; + this.setStatus("running"); + await session.providers.store.clearSuspendedTurn(session.id, this.id); + return resolution; + } catch (err) { + // Withdrawn or expired: persist the terminal status, then propagate. + const reason = + err instanceof Error && err.name === "DecisionGateWithdrawnError" + ? (err as { reason?: DecisionWithdrawReason }).reason ?? "cancel" + : undefined; + const status = reason ? "withdrawn" : "expired"; + const terminal: DecisionGate = { ...gate, status, updatedAt: Date.now() }; + await session.providers.store.saveDecisionGate(session.id, this.id, terminal); + await session.providers.store.updateDecisionGateEntry(session.id, this.id, gate.id, { + gate: terminal, + withdrawnReason: reason, + }); + this.blockedGateId = undefined; + await session.providers.store.clearSuspendedTurn(session.id, this.id); + throw err; + } + }, + threadRead: async (key, opts) => { + const sibling = await this.session.threadByKey(key); + if (!sibling) return []; + return sibling.readEntries(opts); + }, + }; + } + + private async handleAgentEvent(event: AgentEvent): Promise { + switch (event.type) { + case "agent_start": + await this.session.emit({ type: "thread_start", threadId: this.id }); + await this.session.emit({ type: "status", threadId: this.id, status: "thinking" }); + break; + case "message_start": { + if (event.message.role === "assistant") { + this.currentAssistantMessageId = uid("e"); + this.currentAssistantParts = []; + this.currentToolCalls.clear(); + await this.session.emit({ + type: "message_start", + threadId: this.id, + messageId: this.currentAssistantMessageId, + role: "assistant", + }); + } + break; + } + case "message_update": { + const ev = event.assistantMessageEvent; + if (ev.type === "text_delta") { + await this.session.emit({ + type: "text_delta", + threadId: this.id, + text: ev.delta, + }); + } else if (ev.type === "toolcall_end") { + const part: MessagePart = { + type: "tool_call", + callId: ev.toolCall.id, + toolName: ev.toolCall.name, + status: "running", + args: ev.toolCall.arguments, + }; + this.currentToolCalls.set(ev.toolCall.id, part); + this.currentAssistantParts.push(part); + } + break; + } + case "message_end": { + if (event.message.role === "assistant" && this.currentAssistantMessageId) { + const text = textOf(event.message); + // Compose parts: leading text + tool calls (already tracked) + const parts: MessagePart[] = []; + if (text) parts.push({ type: "text", text }); + for (const p of this.currentAssistantParts) parts.push(p); + + const entry: MessageEntry = { + id: this.currentAssistantMessageId, + sessionId: this.session.id, + threadId: this.id, + parentId: null, + type: "message", + role: "assistant", + content: text, + parts, + model: event.message.model, + createdAt: Date.now(), + }; + await this.session.providers.store.appendEntries(this.session.id, this.id, [entry]); + await this.session.emit({ + type: "message_end", + threadId: this.id, + messageId: entry.id, + reason: + event.message.stopReason === "aborted" + ? "abort" + : event.message.stopReason === "error" + ? "error" + : "end_turn", + }); + } + break; + } + case "tool_execution_start": + this.toolCtxOverlay.gateId = undefined; + await this.session.emit({ + type: "tool_start", + threadId: this.id, + tool: event.toolName, + args: event.args ?? {}, + }); + await this.session.emit({ type: "status", threadId: this.id, status: "tool_calling" }); + break; + case "tool_execution_end": { + const part = this.currentToolCalls.get(event.toolCallId); + if (part && part.type === "tool_call") { + part.status = event.isError ? "error" : "completed"; + part.result = event.result; + } + const resultText = renderToolResult(event.result); + await this.session.emit({ + type: "tool_end", + threadId: this.id, + tool: event.toolName, + result: resultText, + isError: event.isError, + }); + break; + } + case "turn_end": { + const stopReason = + event.message.role === "assistant" ? event.message.stopReason : undefined; + await this.session.emit({ + type: "turn_end", + threadId: this.id, + reason: stopReason === "aborted" ? "abort" : "end_turn", + }); + await this.session.emit({ type: "status", threadId: this.id, status: "idle" }); + break; + } + default: + break; + } + } + + private setStatus(status: QueueStatus): void { + this.status = status; + } + + private async persistQueueState(): Promise { + const state: QueueState = { + threadId: this.id, + mode: this.mode, + status: this.status, + activeItemId: this.activeItem?.id, + pending: [...this.pending], + collectBuffer: this.collectBuffer.length > 0 ? [...this.collectBuffer] : undefined, + blockedGateId: this.blockedGateId, + }; + await this.session.providers.store.saveQueueState(this.session.id, this.id, state); + await this.session.emit({ type: "queue_state", threadId: this.id, state }); + } + + private emitError(code: string, message: string): void { + void this.session.emit({ + type: "error", + threadId: this.id, + code, + error: message, + recoverable: true, + }); + } +} + +function promptText(content: PromptContent): string { + if (typeof content === "string") return content; + return content.text ?? ""; +} + +function textOf(message: AgentMessage): string { + if (message.role !== "assistant") return ""; + const parts = (message.content ?? []).filter((b) => b.type === "text") as Array<{ + type: "text"; + text: string; + }>; + return parts.map((p) => p.text).join(""); +} + +function renderToolResult(result: unknown): string { + if (!result || typeof result !== "object") return String(result ?? ""); + const r = result as { content?: Array<{ type: string; text?: string }> }; + if (!r.content) return JSON.stringify(result); + return r.content + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(""); +} diff --git a/packages/engine/src/tool-bridge.ts b/packages/engine/src/tool-bridge.ts new file mode 100644 index 00000000..89412dad --- /dev/null +++ b/packages/engine/src/tool-bridge.ts @@ -0,0 +1,58 @@ +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { TextContent, ImageContent } from "@mariozechner/pi-ai"; +import type { ToolDef, ToolContext, ToolResult, ToolAttachment } from "./types.js"; + +/** + * Adapt one engine ToolDef to a pi-agent-core AgentTool, capturing the engine + * ToolContext via closure. The bridge also normalizes our ToolResult into the + * pi AgentToolResult shape (TextContent | ImageContent[]). + */ +export function toAgentTool( + def: ToolDef, + buildContext: (signal: AbortSignal, toolCallId: string) => ToolContext, +): AgentTool { + return { + name: def.name, + label: def.name, + description: def.description, + parameters: def.parameters, + execute: async (toolCallId, params, signal) => { + const ctx = buildContext(signal ?? new AbortController().signal, toolCallId); + const result = await def.execute(params as never, ctx); + return toAgentToolResult(result); + }, + }; +} + +function toAgentToolResult(result: ToolResult): AgentToolResult { + const content: (TextContent | ImageContent)[] = []; + if (result.text) content.push({ type: "text", text: result.text }); + for (const att of result.attachments ?? []) { + const block = attachmentToContent(att); + if (block) content.push(block); + } + return { content, details: undefined }; +} + +function attachmentToContent(att: ToolAttachment): TextContent | ImageContent | null { + if (att.type === "image") { + return { + type: "image", + data: bytesToBase64(att.data), + mimeType: att.mimeType, + }; + } + if (att.type === "text") { + const lang = att.language ? ` (${att.language})` : ""; + return { type: "text", text: `--- ${att.name ?? "attachment"}${lang} ---\n${att.content}` }; + } + // file: omit raw bytes from LLM context — engine should have stored via BlobStore + return { type: "text", text: `[file attachment: ${att.name}]` }; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + // Use globalThis.btoa to avoid Node's deprecated Buffer; available in Node 16+ and browsers. + return (globalThis as { btoa: (s: string) => string }).btoa(binary); +} diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts new file mode 100644 index 00000000..797e4166 --- /dev/null +++ b/packages/engine/src/types.ts @@ -0,0 +1,603 @@ +import type { TSchema, Static } from "typebox"; +import type { Model } from "@mariozechner/pi-ai"; + +// ── Identity / authoring ────────────────────────────────────────── + +export interface PromptAuthor { + id: string; + email?: string; + name?: string; + avatarUrl?: string; + externalId?: string; +} + +export interface ChannelTarget { + channelType: string; + channelId: string; + threadId?: string; +} + +// ── Sessions / threads / queue ──────────────────────────────────── + +export type SessionPurpose = "interactive" | "orchestrator" | "workflow" | "child"; +export type SessionStatus = + | "initializing" + | "running" + | "paused" + | "hibernated" + | "terminated" + | "error"; + +export interface SessionData { + id: string; + userId: string; + orgId: string; + workspace: string; + purpose: SessionPurpose; + status: SessionStatus; + sandboxId?: string; + snapshotId?: string; + parentSessionId?: string; + metadata?: Record; + createdAt: number; + updatedAt: number; +} + +export type QueueMode = "followup" | "steer" | "collect"; +export type ThreadStatus = "active" | "paused" | "archived"; +export type QueueStatus = "idle" | "queued" | "running" | "blocked_on_decision_gate" | "paused"; + +export interface ThreadData { + id: string; + sessionId: string; + key: string; + status: ThreadStatus; + activeLeafEntryId?: string; + queueMode: QueueMode; + model?: string; + summary?: string; + metadata?: Record; + createdAt: number; + updatedAt: number; +} + +export interface QueueState { + threadId: string; + mode: QueueMode; + status: QueueStatus; + activeItemId?: string; + pending: QueueItem[]; + collectBuffer?: QueueItem[]; + blockedGateId?: string; +} + +export interface QueueItem { + id: string; + threadId: string; + content: PromptContent; + author?: PromptAuthor; + channel?: ChannelTarget; + replyTarget?: ChannelTarget; + model?: string; + metadata?: Record; + createdAt: number; +} + +// ── Prompts ──────────────────────────────────────────────────────── + +export type PromptContent = + | string + | { + text?: string; + attachments?: PromptAttachment[]; + }; + +export type PromptAttachment = + | { type: "image"; url?: string; data?: Uint8Array; mimeType: string; name?: string } + | { type: "file"; url?: string; data?: Uint8Array; mimeType: string; name: string } + | { type: "audio"; url?: string; data?: Uint8Array; mimeType: string; name?: string }; + +export interface PromptOptions { + author?: PromptAuthor; + channel?: ChannelTarget; + replyTarget?: ChannelTarget; + queueMode?: QueueMode; + model?: string; + role?: string; + resultSchema?: TSchema; + metadata?: Record; +} + +export interface PromptReceipt { + sessionId: string; + threadId: string; + queueItemId: string; + status: "queued" | "running" | "blocked_on_decision_gate"; +} + +// ── Messages and DAG entries ────────────────────────────────────── + +export interface BaseEntry { + id: string; + sessionId: string; + threadId: string; + parentId: string | null; + createdAt: number; + metadata?: Record; +} + +export type MessagePart = + | { type: "text"; text: string } + | { type: "thinking"; text: string } + | { + type: "tool_call"; + callId: string; + toolName: string; + status: "running" | "completed" | "error"; + args?: unknown; + result?: unknown; + error?: string; + } + | { type: "attachment"; attachment: ToolAttachment } + | { type: "error"; message: string; code?: string }; + +export interface MessageEntry extends BaseEntry { + type: "message"; + role: "user" | "assistant" | "tool" | "system"; + content: string; + parts?: MessagePart[]; + author?: PromptAuthor; + channel?: ChannelTarget; + model?: string; +} + +export interface CompactionEntry extends BaseEntry { + type: "compaction"; + summary: string; + coveredEntryIds: string[]; + tokenCountBefore: number; + tokenCountAfter: number; + fileContext?: { read: string[]; modified: string[] }; +} + +export interface BranchSummaryEntry extends BaseEntry { + type: "branch_summary"; + branchRootId: string; + branchLeafId: string; + summary: string; +} + +export interface DecisionGateEntry extends BaseEntry { + type: "decision_gate"; + gate: DecisionGate; + resolvedAt?: string; + resolution?: DecisionResolution; + withdrawnReason?: DecisionWithdrawReason; +} + +export type SessionEntry = MessageEntry | CompactionEntry | BranchSummaryEntry | DecisionGateEntry; + +export interface MessageQuery { + limit?: number; + cursor?: string; + afterEntryId?: string; + beforeEntryId?: string; + includeCompacted?: boolean; + includeSystemEntries?: boolean; +} + +// ── Tools ────────────────────────────────────────────────────────── + +export type RiskLevel = "low" | "medium" | "high" | "critical"; + +export interface ToolDef { + name: string; + description: string; + parameters: TParams; + riskLevel?: RiskLevel; + requiresApproval?: boolean | ((args: Static, ctx: ToolContext) => Promise | boolean); + execute: (args: Static, ctx: ToolContext) => Promise; +} + +export interface ToolResult { + text: string; + attachments?: ToolAttachment[]; +} + +export type ToolAttachment = + | { type: "image"; data: Uint8Array; mimeType: string; name?: string } + | { type: "file"; data: Uint8Array; mimeType: string; name: string } + | { type: "text"; content: string; name?: string; language?: string }; + +export type ToolArtifact = + | { type: "file"; path?: string; blobKey?: string; title?: string } + | { type: "link"; url: string; title: string } + | { type: "diff"; path?: string; content: string }; + +export interface ToolContext { + userId: string; + orgId: string; + sessionId: string; + threadId: string; + sessionPurpose?: SessionPurpose; + actor?: { id: string; name?: string; email?: string }; + channelType?: string; + channelId?: string; + decisionGateId?: string; + replyChannelType?: string; + replyChannelId?: string; + cwd?: string; + repo?: { url?: string; branch?: string; ref?: string; provider?: string }; + credentials: CredentialProvider; + sandbox: Sandbox; + requestDecision: (gate: DecisionGateRequest) => Promise; + emitArtifact?: (artifact: ToolArtifact) => Promise; + suspendedDecision?: { gateId: string; resolution?: DecisionResolution }; + signal: AbortSignal; + threadRead: (key: string, opts?: MessageQuery) => Promise; +} + +// ── Credentials ──────────────────────────────────────────────────── + +export interface Credential { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + metadata?: Record; +} + +export interface CredentialProvider { + get(service: string): Promise; + request(service: string, reason: string): Promise; +} + +export interface CredentialOwner { + type: "user" | "org" | "session"; + id: string; +} + +export interface StoredCredential { + type: "oauth2" | "api_key" | "bot_token" | "service_account" | "app_install"; + accessToken?: string; + refreshToken?: string; + apiKey?: string; + expiresAt?: number; + scopes?: string[]; + metadata?: Record; +} + +export interface CredentialStore { + get(owner: CredentialOwner, service: string): Promise; + save(owner: CredentialOwner, service: string, credential: StoredCredential): Promise; + delete(owner: CredentialOwner, service: string): Promise; + list(owner: CredentialOwner): Promise<{ service: string; scopes?: string[]; connectedAt: string }[]>; +} + +// ── Decision gates ───────────────────────────────────────────────── + +export type DecisionGateType = "approval" | "question" | "credential_request"; +export type DecisionGateStatus = "pending" | "resolved" | "expired" | "withdrawn"; +export type DecisionWithdrawReason = "steer" | "abort" | "cancel"; + +export interface DecisionAction { + id: string; + label: string; + style?: "primary" | "danger"; +} + +export interface DecisionGateRef { + messageId: string; + channelId: string; + threadId?: string; + [key: string]: unknown; +} + +export interface DecisionGate { + id: string; + sessionId: string; + threadId: string; + type: DecisionGateType; + title: string; + body?: string; + actions: DecisionAction[]; + expiresAt?: number; + status: DecisionGateStatus; + context?: Record; + origin?: { channelType?: string; channelId?: string; messageId?: string }; + refs?: Array<{ channelType: string; ref: DecisionGateRef }>; + createdAt: number; + updatedAt: number; +} + +// what tools pass to ctx.requestDecision — minimal shape; engine fills in identity fields +export interface DecisionGateRequest { + type: DecisionGateType; + title: string; + body?: string; + actions?: DecisionAction[]; + expiresAt?: number; + context?: Record; + origin?: DecisionGate["origin"]; + // stable ID for re-entrancy: tools must supply the same id when re-run with suspendedDecision + resumeKey?: string; +} + +export interface DecisionResolution { + actionId?: string; + value?: string; + resolvedBy: string; + resolvedAt: number; + source?: { channelType?: string; channelId?: string; messageId?: string }; +} + +export interface SuspendedTurnState { + sessionId: string; + threadId: string; + queueItemId: string; + gateId: string; + model: string; + leafMessageId?: string; + toolCallId: string; + toolName: string; + toolArgs: Record; + resumeKey: string; + attempt: number; + createdAt: number; +} + +// ── Sandbox ──────────────────────────────────────────────────────── + +export interface ExecOpts { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + stdin?: string; + maxOutputBytes?: number; +} + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; + timedOut?: boolean; + truncated?: boolean; +} + +export interface Sandbox { + id: string; + readFile(path: string): Promise; + readBinary(path: string): Promise; + writeFile(path: string, content: string): Promise; + writeBinary(path: string, data: Uint8Array): Promise; + readdir(path: string): Promise; + stat(path: string): Promise<{ isFile: boolean; isDirectory: boolean; size: number }>; + mkdir(path: string): Promise; + rm(path: string, opts?: { recursive?: boolean }): Promise; + exec(command: string, opts?: ExecOpts): Promise; + snapshot?(): Promise; + tunnels?(): Promise>; + destroy?(): Promise; +} + +export interface SandboxCreateOpts { + image?: string; + workspace?: string; + env?: Record; + timeout?: number; + resources?: { cpu?: number; memory?: string }; + metadata?: Record; +} + +export interface SandboxStatus { + id: string; + state: "creating" | "running" | "stopped" | "error"; + startedAt?: number; + error?: string; +} + +export interface SandboxProvider { + create(opts: SandboxCreateOpts): Promise; + restore(id: string): Promise; + destroy(id: string): Promise; + status(id: string): Promise; +} + +// ── Blob store ───────────────────────────────────────────────────── + +export interface BlobStore { + put( + key: string, + data: Uint8Array | ReadableStream, + opts?: { contentType?: string }, + ): Promise; + get(key: string): Promise<{ data: ReadableStream; contentType?: string } | null>; + delete(key: string): Promise; +} + +// ── Engine events ────────────────────────────────────────────────── + +export type EngineEventStatus = + | "idle" + | "queued" + | "thinking" + | "tool_calling" + | "streaming" + | "blocked_on_decision_gate" + | "error"; + +export type EngineEvent = + | { type: "message_start"; threadId: string; messageId: string; role: "assistant" | "system" } + | { type: "text_delta"; threadId: string; text: string } + | { + type: "message_update"; + threadId: string; + messageId: string; + parts: MessagePart[]; + content?: string; + } + | { + type: "message_end"; + threadId: string; + messageId: string; + reason: "end_turn" | "error" | "abort"; + } + | { type: "tool_start"; threadId: string; tool: string; args: Record } + | { type: "tool_end"; threadId: string; tool: string; result: string; isError: boolean } + | { type: "turn_end"; threadId: string; reason: "end_turn" | "error" | "abort" } + | { type: "thread_start"; threadId: string; parentThreadId?: string } + | { type: "queue_state"; threadId: string; state: QueueState } + | { type: "compaction_start" | "compaction_end"; threadId: string } + | { type: "task_start" | "task_end"; childSessionId: string; threadId: string } + | { type: "status"; threadId: string; status: EngineEventStatus } + | { type: "error"; threadId?: string; code: string; error: string; recoverable: boolean } + | { type: "decision_gate"; threadId: string; gate: DecisionGate } + | { type: "decision_gate_resolved"; threadId: string; gateId: string; resolution: DecisionResolution } + | { type: "decision_gate_expired"; threadId: string; gateId: string } + | { + type: "decision_gate_withdrawn"; + threadId: string; + gateId: string; + reason: DecisionWithdrawReason; + } + | { type: "model_switched"; threadId: string; fromModel: string; toModel: string; reason: string }; + +export interface BusEvent { + sessionId: string; + threadId?: string; + userId?: string; + event: EngineEvent; + timestamp: number; +} + +export type Unsubscribe = () => void; + +export interface EventBus { + publish(event: BusEvent): Promise; + subscribe(filter: EventFilter, callback: (event: BusEvent) => void): Unsubscribe; +} + +export interface EventFilter { + sessionId?: string; + userId?: string; + eventTypes?: string[]; +} + +// ── Session store ────────────────────────────────────────────────── + +export interface ListOpts { + limit?: number; + cursor?: string; + status?: string; + createdAfter?: Date; + createdBefore?: Date; +} + +export interface SessionStore { + saveSession(session: SessionData): Promise; + saveThread(sessionId: string, thread: ThreadData): Promise; + appendEntries(sessionId: string, threadId: string, entries: SessionEntry[]): Promise; + saveQueueState(sessionId: string, threadId: string, queue: QueueState): Promise; + saveDecisionGate(sessionId: string, threadId: string, gate: DecisionGate): Promise; + saveDecisionGateRef( + sessionId: string, + threadId: string, + gateId: string, + ref: { channelType: string; ref: DecisionGateRef }, + ): Promise; + updateDecisionGateEntry( + sessionId: string, + threadId: string, + gateId: string, + patch: Partial, + ): Promise; + saveSuspendedTurn( + sessionId: string, + threadId: string, + suspended: SuspendedTurnState, + ): Promise; + clearSuspendedTurn(sessionId: string, threadId: string): Promise; + updateSessionStatus( + id: string, + status: SessionStatus, + metadata?: Partial, + ): Promise; + flush?(): Promise; + + getSession(id: string): Promise; + listSessions(userId: string, opts?: ListOpts): Promise; + getThread(sessionId: string, threadId: string): Promise; + listThreads(sessionId: string): Promise; + getEntries( + sessionId: string, + threadId: string, + opts?: MessageQuery, + ): Promise; + getQueueState(sessionId: string, threadId: string): Promise; + listDecisionGates(sessionId: string, threadId?: string): Promise; + getDecisionGate(sessionId: string, gateId: string): Promise; + getSuspendedTurn(sessionId: string, threadId: string): Promise; + deleteSession(id: string): Promise; +} + +// ── Engine API ───────────────────────────────────────────────────── + +export interface RoleSpec { + name: string; + description?: string; + model?: string; + content: string; + source?: "session" | "thread" | "prompt" | "plugin" | "sandbox"; +} + +export interface SkillSource { + name: string; + description?: string; + content: string; + argsSchema?: TSchema; + source?: "plugin" | "sandbox" | "repo" | "user"; +} + +export interface SkillInvokeOptions { + args?: Record; + model?: string; + author?: PromptAuthor; + channel?: ChannelTarget; + resultSchema?: TSchema; +} + +export interface CreateSessionOptions { + id?: string; + userId: string; + orgId: string; + workspace: string; + purpose?: SessionPurpose; + parentSessionId?: string; + parentThreadId?: string; + sandbox: Sandbox | SandboxCreateOpts; + tools?: ToolDef[]; + roles?: RoleSpec[]; + skills?: SkillSource[]; + model: Model; + modelFailover?: Model[]; + queueMode?: QueueMode; + /** Collect-mode buffering window in ms (default 5000). */ + collectWindowMs?: number; + systemPrompt?: string; + metadata?: Record; +} + +export interface ProviderBundle { + store: SessionStore; + bus: EventBus; + blobs?: BlobStore; + credentials?: CredentialStore; + sandboxProvider?: SandboxProvider; +} + +export interface EngineOptions { + providers: ProviderBundle; + defaultUserId?: string; + defaultOrgId?: string; +} diff --git a/packages/engine/test/decision-gate.test.ts b/packages/engine/test/decision-gate.test.ts new file mode 100644 index 00000000..c52a46e5 --- /dev/null +++ b/packages/engine/test/decision-gate.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from "vitest"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, Type } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BusEvent, + type DecisionGate, + type ToolDef, +} from "../src/index.js"; + +function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + return { engine, store, events }; +} + +/** A tool whose execute() requests a decision and returns its result text. */ +function approvalTool(): ToolDef { + return { + name: "do_thing", + description: "Do a sensitive thing, gated by approval.", + parameters: Type.Object({ arg: Type.String() }), + execute: async (args, ctx) => { + const resolution = await ctx.requestDecision({ + type: "approval", + title: "approve do_thing?", + body: `arg=${args.arg}`, + resumeKey: `do_thing:${args.arg}`, + }); + if (resolution.actionId === "approve") { + return { text: `did the thing with arg=${args.arg}` }; + } + return { text: `denied` }; + }, + }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("timeout"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("decision gates: pending -> resolved", () => { + it("opens a gate, the turn pauses, resolution resumes the turn", async () => { + const faux = registerFauxProvider({ provider: "gate-resolved" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("do_thing", { arg: "x" }, { id: "tc1" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("all done"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools: [approvalTool()], + }); + + void session.prompt("please do thing"); + + // Wait until we observe a decision_gate event + await waitFor(() => events.some((e) => e.event.type === "decision_gate")); + const gateEvent = events.find((e) => e.event.type === "decision_gate")!; + const gate: DecisionGate = (gateEvent.event as { gate: DecisionGate }).gate; + expect(gate.type).toBe("approval"); + expect(gate.status).toBe("pending"); + + // The DAG should contain a decision_gate entry + const entries = await session.readEntries("web:default"); + const gateEntries = entries.filter((e) => e.type === "decision_gate"); + expect(gateEntries).toHaveLength(1); + + // The thread is blocked + const blocked = events.some( + (e) => e.event.type === "status" && e.event.status === "blocked_on_decision_gate", + ); + expect(blocked).toBe(true); + + // Resolve approve → turn should resume and complete + await session.resolveDecision(gate.id, { + actionId: "approve", + resolvedBy: "u1", + resolvedAt: Date.now(), + }); + + await waitFor(() => + events.some((e) => e.event.type === "status" && e.event.status === "idle"), + ); + + // The assistant emitted a "all done" final message + const allEntries = await session.readEntries("web:default"); + const messages = allEntries.filter((e) => e.type === "message"); + expect(messages.at(-1)).toMatchObject({ role: "assistant", content: "all done" }); + + // The gate event was emitted as resolved + const resolved = events.find((e) => e.event.type === "decision_gate_resolved"); + expect(resolved).toBeTruthy(); + + faux.unregister(); + }); +}); + +describe("decision gates: pending -> withdrawn (abort)", () => { + it("aborting the thread withdraws the pending gate", async () => { + const faux = registerFauxProvider({ provider: "gate-withdrawn" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("do_thing", { arg: "y" }, { id: "tc2" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("would never reach this"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools: [approvalTool()], + }); + + void session.prompt("please do thing"); + await waitFor(() => events.some((e) => e.event.type === "decision_gate")); + + await session.abort(); + + const withdrawn = events.find((e) => e.event.type === "decision_gate_withdrawn"); + expect(withdrawn).toBeTruthy(); + expect((withdrawn!.event as { reason: string }).reason).toBe("abort"); + + faux.unregister(); + }); +}); + +describe("decision gates: pending -> expired", () => { + it("a gate with a past expiresAt fires expired and rejects the tool", async () => { + const faux = registerFauxProvider({ provider: "gate-expired" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("expiring", {}, { id: "tcE" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("never reached"), + ]); + + const expiringTool: ToolDef = { + name: "expiring", + description: "expires fast", + parameters: Type.Object({}), + execute: async (_args, ctx) => { + await ctx.requestDecision({ + type: "approval", + title: "expire me", + expiresAt: Date.now() + 30, // expires 30ms from now + }); + return { text: "should not reach" }; + }, + }; + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools: [expiringTool], + }); + + void session.prompt("trigger"); + await waitFor(() => events.some((e) => e.event.type === "decision_gate_expired"), 2000); + + faux.unregister(); + }); +}); + +describe("decision gates: steer cancels pending gate", () => { + it("a steer prompt withdraws the pending gate with reason=steer", async () => { + const faux = registerFauxProvider({ provider: "gate-steer" }); + // First prompt: tool call that opens a gate + // Second prompt (after steer): a simple text response + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("do_thing", { arg: "z" }, { id: "tc3" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("after steer"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools: [approvalTool()], + }); + + void session.prompt("first"); + await waitFor(() => events.some((e) => e.event.type === "decision_gate")); + + // Steer + await session.thread().submitPrompt("second", { queueMode: "steer" }); + + await waitFor(() => events.some((e) => e.event.type === "decision_gate_withdrawn")); + const withdrawn = events.find((e) => e.event.type === "decision_gate_withdrawn"); + expect((withdrawn!.event as { reason: string }).reason).toBe("steer"); + + faux.unregister(); + }); +}); diff --git a/packages/engine/test/happy-path.test.ts b/packages/engine/test/happy-path.test.ts new file mode 100644 index 00000000..a9dfcdf1 --- /dev/null +++ b/packages/engine/test/happy-path.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, Type } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BusEvent, + type ToolDef, +} from "../src/index.js"; + +function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ + providers: { store, bus, sandboxProvider }, + }); + return { engine, store, bus, events, sandboxProvider }; +} + +describe("engine: single-thread happy path", () => { + it("runs prompt -> assistant text response and persists to store", async () => { + const faux = registerFauxProvider({ provider: "happy1" }); + faux.setResponses([fauxAssistantMessage("hello, world")]); + + const { engine, store, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/workspace", + sandbox: {}, + model: faux.getModel(), + }); + + const receipt = await session.prompt("say hi"); + expect(receipt.threadId).toBeTruthy(); + + // Wait until idle (status event with idle) + await waitForStatus(events, receipt.threadId, "idle"); + + const entries = await session.readEntries("web:default"); + const messages = entries.filter((e) => e.type === "message"); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ role: "user", content: "say hi" }); + expect(messages[1]).toMatchObject({ role: "assistant", content: "hello, world" }); + + // Bus emitted the lifecycle events + const types = events.map((e) => e.event.type); + expect(types).toContain("thread_start"); + expect(types).toContain("text_delta"); + expect(types).toContain("turn_end"); + + // Session persisted in store + expect(await store.getSession(session.id)).not.toBeNull(); + + faux.unregister(); + }); + + it("runs prompt -> tool call -> tool result -> end_turn", async () => { + const faux = registerFauxProvider({ provider: "happy2" }); + faux.setResponses([ + fauxAssistantMessage( + [fauxToolCall("write", { path: "/tmp/note.txt", content: "ok" }, { id: "tc1" })], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + const receipt = await session.prompt("write a note"); + await waitForStatus(events, receipt.threadId, "idle"); + + // The write tool ran against the virtual sandbox + const fileContent = await session.sandbox.readFile("/tmp/note.txt"); + expect(fileContent).toBe("ok"); + + // tool_start and tool_end events landed + const tools = events.map((e) => e.event).filter((e) => e.type === "tool_start" || e.type === "tool_end"); + expect(tools.map((t) => t.type)).toEqual(["tool_start", "tool_end"]); + + faux.unregister(); + }); + + it("invokes a custom ToolDef with ToolContext", async () => { + const faux = registerFauxProvider({ provider: "happy3" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("greet", { who: "world" }, { id: "tc2" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("ok"), + ]); + + let receivedCtx: { userId?: string; threadId?: string } | undefined; + const greet: ToolDef = { + name: "greet", + description: "greets", + parameters: Type.Object({ who: Type.String() }), + execute: async (args, ctx) => { + receivedCtx = { userId: ctx.userId, threadId: ctx.threadId }; + return { text: `hello ${(args as { who: string }).who}` }; + }, + }; + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u-custom", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools: [greet], + }); + + const receipt = await session.prompt("greet world"); + await waitForStatus(events, receipt.threadId, "idle"); + + expect(receivedCtx?.userId).toBe("u-custom"); + expect(receivedCtx?.threadId).toBe(receipt.threadId); + + faux.unregister(); + }); +}); + +async function waitForStatus(events: BusEvent[], threadId: string, status: string, timeoutMs = 2000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + const found = events.some( + (e) => + e.event.type === "status" && + e.event.threadId === threadId && + e.event.status === status, + ); + if (found) return resolve(); + if (Date.now() - start > timeoutMs) { + return reject(new Error(`timed out waiting for status=${status}`)); + } + setTimeout(tick, 5); + }; + tick(); + }); +} diff --git a/packages/engine/test/multi-thread.test.ts b/packages/engine/test/multi-thread.test.ts new file mode 100644 index 00000000..d89c9f66 --- /dev/null +++ b/packages/engine/test/multi-thread.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from "vitest"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BusEvent, +} from "../src/index.js"; + +function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + return { engine, store, events }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("timeout"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("multi-thread isolation", () => { + it("two threads run concurrently with isolated histories", async () => { + const faux = registerFauxProvider({ provider: "multi", tokensPerSecond: 50 }); + // Both threads send simple text prompts. Each gets one response. + faux.setResponses([ + fauxAssistantMessage("ans-A"), + fauxAssistantMessage("ans-B"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + const tA = session.thread("task:A"); + const tB = session.thread("task:B"); + + // Submit to both threads simultaneously + void tA.submitPrompt("hello A", {}); + void tB.submitPrompt("hello B", {}); + + // Wait for both to complete (two turn_ends) + await waitFor( + () => events.filter((e) => e.event.type === "turn_end").length >= 2, + ); + + const aEntries = await tA.readEntries(); + const bEntries = await tB.readEntries(); + + expect(aEntries.filter((e) => e.type === "message")).toHaveLength(2); + expect(bEntries.filter((e) => e.type === "message")).toHaveLength(2); + + // Each thread sees only its own user prompt + const aUser = aEntries.find((e) => e.type === "message" && e.role === "user"); + const bUser = bEntries.find((e) => e.type === "message" && e.role === "user"); + expect(aUser?.type === "message" && aUser.content).toBe("hello A"); + expect(bUser?.type === "message" && bUser.content).toBe("hello B"); + + // The thread IDs are distinct + expect(tA.id).not.toBe(tB.id); + + // Each event was tagged with the right thread + const aThreadEvents = events.filter((e) => e.threadId === tA.id); + const bThreadEvents = events.filter((e) => e.threadId === tB.id); + expect(aThreadEvents.length).toBeGreaterThan(0); + expect(bThreadEvents.length).toBeGreaterThan(0); + + faux.unregister(); + }); + + it("aborting one thread does not affect another", async () => { + const faux = registerFauxProvider({ provider: "multi-abort", tokensPerSecond: 5 }); + faux.setResponses([ + fauxAssistantMessage("very long stream that will be aborted on thread A"), + fauxAssistantMessage("ans-B"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + const tA = session.thread("task:A"); + const tB = session.thread("task:B"); + + void tA.submitPrompt("slow", {}); + void tB.submitPrompt("hello B", {}); + + // Let both start + await new Promise((r) => setTimeout(r, 30)); + + await session.abort({ threadId: tA.id }); + + // Wait for tB to complete normally + await waitFor(() => + events.some( + (e) => + e.threadId === tB.id && + e.event.type === "turn_end" && + (e.event as { reason: string }).reason === "end_turn", + ), + ); + + // tA should have an aborted turn_end + const aAborts = events.filter( + (e) => + e.threadId === tA.id && + e.event.type === "turn_end" && + (e.event as { reason: string }).reason === "abort", + ); + expect(aAborts.length).toBeGreaterThanOrEqual(1); + + // tB completed normally + const bEntries = await tB.readEntries(); + const bAssistant = bEntries.find((e) => e.type === "message" && e.role === "assistant"); + expect(bAssistant?.type === "message" && bAssistant.content).toBe("ans-B"); + + faux.unregister(); + }); +}); + +describe("thread_read built-in tool", () => { + it("thread A can read messages from thread B via thread_read", async () => { + const faux = registerFauxProvider({ provider: "thread-read" }); + // Thread B will be primed with one back-and-forth. + // Then thread A's first response will call thread_read on B. + // Thread A's second response will be a text reply containing the read result. + faux.setResponses([ + fauxAssistantMessage("B-said-this"), // B's response + // A's first response: invokes thread_read on B + fauxAssistantMessage( + [fauxToolCall("thread_read", { key: "task:B", limit: 10 }, { id: "tr1" })], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("A read B"), // A's final response + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + const tA = session.thread("task:A"); + const tB = session.thread("task:B"); + + // Prime thread B + await tB.submitPrompt("hello B", {}); + await waitFor(() => + events.some( + (e) => + e.threadId === tB.id && + e.event.type === "turn_end" && + (e.event as { reason: string }).reason === "end_turn", + ), + ); + + // Now run thread A which calls thread_read on B + await tA.submitPrompt("read B", {}); + await waitFor(() => { + const aDone = events.filter( + (e) => + e.threadId === tA.id && + e.event.type === "turn_end" && + (e.event as { reason: string }).reason === "end_turn", + ); + return aDone.length >= 1; + }); + + // tool_end should have been emitted with thread_read result text + const toolEnds = events.filter( + (e) => e.threadId === tA.id && e.event.type === "tool_end", + ); + expect(toolEnds.length).toBeGreaterThanOrEqual(1); + const lastToolEnd = toolEnds.at(-1); + const result = (lastToolEnd!.event as { result: string }).result; + expect(result).toContain("thread:task:B"); + expect(result).toContain("hello B"); + expect(result).toContain("B-said-this"); + + faux.unregister(); + }); +}); diff --git a/packages/engine/test/queue-modes.test.ts b/packages/engine/test/queue-modes.test.ts new file mode 100644 index 00000000..1e5f141f --- /dev/null +++ b/packages/engine/test/queue-modes.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; +import { fauxAssistantMessage, registerFauxProvider, type FauxResponseStep } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BusEvent, + type EngineEvent, +} from "../src/index.js"; + +function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + return { engine, store, events }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("timeout"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +function idleCount(events: BusEvent[], threadId: string): number { + return events.filter( + (e) => + e.event.type === "status" && + (e.event as Extract).status === "idle" && + e.event.threadId === threadId, + ).length; +} + +describe("queue mode: followup (FIFO)", () => { + it("processes prompts in order", async () => { + const faux = registerFauxProvider({ provider: "fifo", tokensPerSecond: 50 }); + const responses: FauxResponseStep[] = [ + fauxAssistantMessage("a-done"), + fauxAssistantMessage("b-done"), + fauxAssistantMessage("c-done"), + ]; + faux.setResponses(responses); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + const r1 = await session.prompt("a"); + await session.prompt("b"); + await session.prompt("c"); + + await waitFor(() => idleCount(events, r1.threadId) >= 1 && ( + // wait until queue is fully drained — three turn_ends + events.filter((e) => e.event.type === "turn_end").length === 3 + )); + + const entries = await session.readEntries("web:default"); + const userMessages = entries.filter((e) => e.type === "message" && e.role === "user"); + expect(userMessages.map((m) => m.type === "message" ? m.content : "")).toEqual(["a", "b", "c"]); + + const assistantMessages = entries.filter( + (e) => e.type === "message" && e.role === "assistant", + ); + expect(assistantMessages.map((m) => m.type === "message" ? m.content : "")).toEqual([ + "a-done", + "b-done", + "c-done", + ]); + + faux.unregister(); + }); +}); + +describe("queue mode: collect (buffered window)", () => { + it("merges buffered prompts into one combined prompt", async () => { + const faux = registerFauxProvider({ provider: "collect" }); + faux.setResponses([fauxAssistantMessage("merged-ack")]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + queueMode: "collect", + collectWindowMs: 50, // fast for tests + }); + + const r1 = await session.prompt("first"); + await session.prompt("second"); + await session.prompt("third"); + + await waitFor(() => events.some((e) => e.event.type === "turn_end"), 2000); + + const entries = await session.readEntries("web:default"); + const userMessages = entries.filter((e) => e.type === "message" && e.role === "user"); + // exactly one user message containing all three texts + expect(userMessages).toHaveLength(1); + const merged = userMessages[0].type === "message" ? userMessages[0].content : ""; + expect(merged).toContain("first"); + expect(merged).toContain("second"); + expect(merged).toContain("third"); + + void r1; // silence unused + faux.unregister(); + }); +}); + +describe("queue mode: steer (abort + new)", () => { + it("aborts the current turn and starts a new one immediately", async () => { + // Slow first response so we can interrupt it + const faux = registerFauxProvider({ provider: "steer", tokensPerSecond: 5 }); + faux.setResponses([ + fauxAssistantMessage("looooong response that gets aborted before it finishes"), + fauxAssistantMessage("steered-response"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + void session.prompt("slow-first"); + // Let it start streaming a bit + await new Promise((r) => setTimeout(r, 30)); + // Now steer: this should abort the current run. + await session.thread().submitPrompt("steered", { queueMode: "steer" }); + + await waitFor(() => events.some( + (e) => e.event.type === "turn_end" && (e.event as { reason: string }).reason === "end_turn", + ), 4000); + + // We expect at least one turn_end with reason=abort (the aborted first run) + const aborts = events.filter( + (e) => e.event.type === "turn_end" && (e.event as { reason: string }).reason === "abort", + ); + expect(aborts.length).toBeGreaterThanOrEqual(1); + + // And the steered text must be in transcript as final assistant content + const entries = await session.readEntries("web:default"); + const lastAssistant = entries + .filter((e) => e.type === "message" && e.role === "assistant") + .at(-1); + expect(lastAssistant && lastAssistant.type === "message" && lastAssistant.content).toBe( + "steered-response", + ); + + faux.unregister(); + }); +}); + +describe("queue: pause + resume", () => { + it("paused thread does not start the next prompt until resumed", async () => { + const faux = registerFauxProvider({ provider: "pause-resume" }); + faux.setResponses([ + fauxAssistantMessage("first-done"), + fauxAssistantMessage("second-done"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + }); + + // First prompt completes immediately; then pause; then queue another and confirm it doesn't run. + await session.prompt("first"); + await waitFor(() => events.some((e) => e.event.type === "turn_end")); + const turnEndsBefore = events.filter((e) => e.event.type === "turn_end").length; + + await session.pause(); + await session.prompt("second"); + + // Give it a beat — should still not have a 2nd turn_end while paused. + await new Promise((r) => setTimeout(r, 50)); + const turnEndsAfterPause = events.filter((e) => e.event.type === "turn_end").length; + expect(turnEndsAfterPause).toBe(turnEndsBefore); + + await session.resume(); + await waitFor(() => events.filter((e) => e.event.type === "turn_end").length > turnEndsBefore); + + const entries = await session.readEntries("web:default"); + const assistants = entries.filter((e) => e.type === "message" && e.role === "assistant"); + expect(assistants.map((m) => m.type === "message" ? m.content : "")).toEqual([ + "first-done", + "second-done", + ]); + + faux.unregister(); + }); +}); diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 00000000..4c56d95c --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "include": ["src/**/*"] +} diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts new file mode 100644 index 00000000..8e1a84d6 --- /dev/null +++ b/packages/engine/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + testTimeout: 5000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a787c567..0cf2bd43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,25 @@ importers: specifier: ^6.0.3 version: 6.4.1(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0) + packages/engine: + dependencies: + '@mariozechner/pi-agent-core': + specifier: 0.73.0 + version: 0.73.0(ws@8.18.0)(zod@3.25.76) + '@mariozechner/pi-ai': + specifier: 0.73.0 + version: 0.73.0(ws@8.18.0)(zod@3.25.76) + typebox: + specifier: ^1.1.24 + version: 1.1.37 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0) + packages/plugin-cloudflare: dependencies: '@valet/sdk': @@ -587,6 +606,164 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1043.0': + resolution: {integrity: sha512-J22pIYr7ZND7F9oYvqALUeHBsA2ND8fHm7ZIu2SBkoYXuvTMdRIfbHwyas3cZkYp+W/zGaLC/5mAHcmQQuaSOw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.14': + resolution: {integrity: sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.10': + resolution: {integrity: sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.16': + resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.13': + resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1043.0': + resolution: {integrity: sha512-Rlh9piVFV4WOMGgcHY0+O4TMDOSJGYxh7dvxWIhmhf6ASvRPMA2HZb6DSCan8nl5IFXjCYxYXWjpb5+Ii77MjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.8': + resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.10': + resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -670,6 +847,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1378,6 +1559,15 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@hono/zod-validator@0.2.2': resolution: {integrity: sha512-dSDxaPV70Py8wuIU2QNpoVEIOSzSXZ/6/B/h4xA7eOMz7+AarKTSGV8E6QwrdcCbBLkpqfJ4Q2TmBO0eP1tCBQ==} peerDependencies: @@ -1687,9 +1877,24 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + '@mariozechner/pi-agent-core@0.73.0': + resolution: {integrity: sha512-ugcpvq0X9fr9fTSK29/3S4+KU/eeVMrBb7ZU3HqiF3xD7I1GlgumLj4FYmDrYSEA6+rzgNWlJUKwjKh9o0Z6AA==} + engines: {node: '>=20.0.0'} + + '@mariozechner/pi-ai@0.73.0': + resolution: {integrity: sha512-phKOpcde/ssz6UYszkmaGJ9LF9mgt/AP8LrtSwsfap+kMSeFfSQ2/mCSBT1mLJ2BqVuff9uXs1/+op1aQeaafQ==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1815,6 +2020,36 @@ packages: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -2394,6 +2629,194 @@ packages: engines: {node: '>= 8.0.0'} hasBin: true + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2475,6 +2898,9 @@ packages: '@toon-format/toon@2.1.0': resolution: {integrity: sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@types/aws-lambda@8.10.161': resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} @@ -2640,6 +3066,9 @@ packages: '@types/react@19.2.8': resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2733,6 +3162,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2761,6 +3194,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -2795,12 +3232,19 @@ packages: resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2820,6 +3264,9 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2829,6 +3276,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2867,6 +3317,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3142,6 +3596,14 @@ packages: data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -3175,6 +3637,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -3309,6 +3775,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -3385,11 +3854,20 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -3399,6 +3877,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3427,6 +3909,13 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-xml-builder@1.1.9: + resolution: {integrity: sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3439,6 +3928,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.7.4: resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} @@ -3466,6 +3959,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3480,6 +3977,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gemoji@8.1.0: resolution: {integrity: sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==} @@ -3505,6 +4010,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -3519,6 +4028,14 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3567,6 +4084,14 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3599,6 +4124,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3660,6 +4189,13 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} @@ -3668,6 +4204,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.16.28: resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} hasBin: true @@ -3704,6 +4246,9 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3713,6 +4258,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru_map@0.4.1: resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} @@ -3930,10 +4479,19 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3943,6 +4501,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3977,6 +4539,18 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + opencollective-postinstall@2.0.3: resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} hasBin: true @@ -3985,6 +4559,18 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -3997,9 +4583,16 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4105,6 +4698,14 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.6: + resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==} + engines: {node: '>=12.0.0'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4264,6 +4865,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4350,6 +4955,18 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4401,6 +5018,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -4511,6 +5131,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -4529,6 +5152,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typebox@1.1.37: + resolution: {integrity: sha512-jb7jp6KvOvvy5sd+11AfJ0/e0F0AS9RcOXd55oGi2ZnRHIGmFvrTaNF+ZidRmGBmmNTkM5KKl0Z37KzxJ+owEQ==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4551,6 +5177,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.14: resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} @@ -4762,6 +5392,10 @@ packages: wasm-feature-detect@1.8.0: resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4822,6 +5456,11 @@ packages: zlibjs@0.3.1: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -4864,7 +5503,434 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@babel/code-frame@7.28.6': + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1043.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/eventstream-handler-node': 3.972.14 + '@aws-sdk/middleware-eventstream': 3.972.10 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/middleware-websocket': 3.972.16 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/token-providers': 3.1043.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.36': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.39': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/eventstream-handler-node@3.972.14': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.8 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.16': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-format-url': 3.972.10 + '@smithy/eventstream-codec': 4.2.14 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.6': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.25': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1041.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/token-providers@3.1043.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.24': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.22': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 @@ -4963,6 +6029,8 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -5382,6 +6450,17 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.6 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@hono/zod-validator@0.2.2(hono@4.11.4)(zod@3.25.76)': dependencies: hono: 4.11.4 @@ -5609,10 +6688,56 @@ snapshots: - encoding - supports-color + '@mariozechner/pi-agent-core@0.73.0(ws@8.18.0)(zod@3.25.76)': + dependencies: + '@mariozechner/pi-ai': 0.73.0(ws@8.18.0)(zod@3.25.76) + typebox: 1.1.37 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@mariozechner/pi-ai@0.73.0(ws@8.18.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1043.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.1 + chalk: 5.6.2 + openai: 6.26.0(ws@8.18.0)(zod@3.25.76) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + typebox: 1.1.37 + undici: 7.25.0 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mermaid-js/parser@0.6.3': dependencies: langium: 3.3.1 + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.18.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5785,6 +6910,29 @@ snapshots: react-dom: 19.2.3(react@19.2.3) shiki: 3.21.0 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -6300,6 +7448,306 @@ snapshots: fflate: 0.7.4 string.prototype.codepointat: 0.2.1 + '@smithy/config-resolver@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.32': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.7': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.20': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.6.1': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.3.1': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.13': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.49': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.54': + dependencies: + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.2': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.8': + dependencies: + '@smithy/service-error-classification': 4.3.1 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.25': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -6403,6 +7851,8 @@ snapshots: '@toon-format/toon@2.1.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@types/aws-lambda@8.10.161': {} '@types/babel__core@7.20.5': @@ -6605,6 +8055,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/retry@0.12.0': {} + '@types/trusted-types@2.0.7': optional: true @@ -6715,6 +8167,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -6738,6 +8192,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -6778,6 +8236,8 @@ snapshots: baseline-browser-mapping@2.9.15: {} + basic-ftp@5.3.1: {} + before-after-hook@4.0.0: {} better-sqlite3@11.10.0: @@ -6785,6 +8245,8 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -6803,6 +8265,8 @@ snapshots: bottleneck@2.19.5: {} + bowser@2.14.1: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6815,6 +8279,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -6851,6 +8317,8 @@ snapshots: chai@6.2.2: {} + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7144,6 +8612,10 @@ snapshots: data-uri-to-buffer@2.0.2: {} + data-uri-to-buffer@4.0.1: {} + + data-uri-to-buffer@6.0.2: {} + dayjs@1.11.19: {} debug@4.4.3: @@ -7166,6 +8638,12 @@ snapshots: defu@6.1.4: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -7214,6 +8692,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.267: {} emoji-regex-xs@2.0.1: {} @@ -7366,8 +8848,18 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + esprima@4.0.1: {} + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@0.6.1: {} @@ -7376,6 +8868,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + eventemitter3@5.0.4: {} exit-hook@2.2.1: {} @@ -7398,6 +8892,17 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-xml-builder@1.1.9: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.9 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7406,6 +8911,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.7.4: {} file-type@21.3.4: @@ -7433,6 +8943,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fraction.js@5.3.4: {} fs-constants@1.0.0: {} @@ -7442,6 +8956,22 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gemoji@8.1.0: {} gensync@1.0.0-beta.2: {} @@ -7475,6 +9005,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -7487,6 +9025,19 @@ snapshots: glob-to-regexp@0.4.1: {} + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} hachure-fill@0.5.2: {} @@ -7555,6 +9106,20 @@ snapshots: html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -7577,6 +9142,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.2.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7621,10 +9188,30 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-with-bigint@3.5.8: {} json5@2.2.3: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + katex@0.16.28: dependencies: commander: 8.3.0 @@ -7660,6 +9247,8 @@ snapshots: lodash-es@4.17.23: {} + long@5.3.2: {} + longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -7668,6 +9257,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lru_map@0.4.1: {} magic-string@0.25.9: @@ -8121,14 +9712,24 @@ snapshots: napi-build-utils@2.0.0: {} + netmask@2.1.1: {} + node-abi@3.87.0: dependencies: semver: 7.7.3 + node-domexception@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -8167,12 +9768,40 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + openai@6.26.0(ws@8.18.0)(zod@3.25.76): + optionalDependencies: + ws: 8.18.0 + zod: 3.25.76 + opencollective-postinstall@2.0.3: {} p-limit@7.3.0: dependencies: yocto-queue: 1.2.2 + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-manager-detector@1.6.0: {} pako@0.2.9: {} @@ -8192,8 +9821,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + partial-json@0.1.7: {} + path-data-parser@0.1.0: {} + path-expression-matcher@1.5.0: {} + path-parse@1.0.7: {} path-to-regexp@6.3.0: {} @@ -8284,6 +9917,34 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.5.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.0.9 + long: 5.3.2 + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-from-env@1.1.0: {} pump@3.0.3: @@ -8487,6 +10148,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + reusify@1.1.0: {} robust-predicates@3.0.2: {} @@ -8663,6 +10326,21 @@ snapshots: is-arrayish: 0.3.4 optional: true + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.8 + transitivePeerDependencies: + - supports-color + + socks@2.8.8: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -8706,6 +10384,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.2.3: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -8844,6 +10524,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-dedent@2.2.0: {} ts-interface-checker@0.1.13: {} @@ -8861,6 +10543,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + typebox@1.1.37: {} + typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -8875,6 +10559,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@7.25.0: {} + unenv@2.0.0-rc.14: dependencies: defu: 6.1.4 @@ -9121,6 +10807,8 @@ snapshots: wasm-feature-detect@1.8.0: {} + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -9181,6 +10869,10 @@ snapshots: zlibjs@0.3.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.22.3: {} zod@3.25.76: {} diff --git a/tsconfig.json b/tsconfig.json index ffb4ec57..940c15cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "files": [], "references": [ { "path": "./packages/shared" }, + { "path": "./packages/engine" }, { "path": "./packages/sdk" }, { "path": "./packages/plugin-github" }, { "path": "./packages/plugin-slack" }, From 72b017e7528b9a7cb3eac597e6835ce8ea69c7cf Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:26:53 -0700 Subject: [PATCH 03/26] docs: spec and plan for persistent store + restart-safe gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec updates (docs/specs/2026-05-02-portable-runtime-engine-design.md): - Engine.restoreSession now takes { sessionId, options } so the host re-supplies tools/sandbox/model — the engine doesn't persist creation options across restarts. - Decision-gate IDs are explicitly derived as gate:{sessionId}:{threadId}:{queueItemId}:{resumeKey}, and resumeKey is required (not optional) on DecisionGateRequest. - Restart-safe contract section now explains what "re-entrant up to the decision point" means in practice (work before requestDecision runs twice; work after runs once on replay), how the engine populates ctx.suspendedDecision on restoration, and what events the engine must emit during replay. - New "LLM-faithful entry persistence" rule: assistant tool-call blocks must persist in MessageEntry.parts so the rehydrated transcript can be sent to LLM providers without producing a malformed [user, assistant(text-only), toolResult] sequence. - ToolContext.requestDecision typed as DecisionGateRequest (not DecisionGate); ctx.suspendedDecision documented as engine-only. - SuspendedTurnState bullets updated to reference the deterministic formula and the resumeKey explicitly. - Adapter Host Contract calls out engine.restoreSession({ sessionId, options }) and the queue-item / suspended-turn fields that must survive hibernation. Plan (docs/plans/2026-05-05-persistent-store-restart-safe-gates.md): - 16 tasks across 4 phases (schema, store + contract tests, restart-safe primitives, restoreSession + replay), all aligned with the updated spec. - Task 11 reworked from a flaky integration test that raced the agent loop into a deterministic unit test of a pure shouldShortCircuit predicate. - Task 12's rehydrateTranscript explicitly reconstructs assistant ToolCall blocks from MessageEntry.parts. --- ...-05-persistent-store-restart-safe-gates.md | 2709 +++++++++++++++++ ...26-05-02-portable-runtime-engine-design.md | 64 +- 2 files changed, 2766 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-05-05-persistent-store-restart-safe-gates.md diff --git a/docs/plans/2026-05-05-persistent-store-restart-safe-gates.md b/docs/plans/2026-05-05-persistent-store-restart-safe-gates.md new file mode 100644 index 00000000..7f6587d5 --- /dev/null +++ b/docs/plans/2026-05-05-persistent-store-restart-safe-gates.md @@ -0,0 +1,2709 @@ +# Persistent SessionStore + Restart-Safe Decision Gates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `@valet/engine`'s in-memory-only suspension model with a persisted store and re-entrant decision gates so a session can survive a process restart, and prove it with a full restart-cycle integration test. + +**Architecture:** Add a SQLite-backed `SessionStore` (Drizzle schema, dialect-portable to D1 and Postgres). Make decision-gate IDs deterministic so a tool replay produces the same gate ID; on replay, `ctx.requestDecision(...)` short-circuits and returns the stored resolution instead of opening a new gate. `Engine.restoreSession({ sessionId, options })` rehydrates session/threads/entries/queue (preserving assistant tool-call blocks via `MessageEntry.parts`), and for any thread blocked on a gate, replays the suspended tool call (with `ctx.suspendedDecision` populated) once the gate resolves. + +**Tech Stack:** TypeScript, Drizzle ORM (`drizzle-orm/sqlite-core`), `better-sqlite3` (in-process SQLite for tests + dev), `drizzle-kit` (migrations), vitest. Postgres dialect mirror is deferred — the plan calls out where it slots in but doesn't ship it. + +--- + +## Background: What needs to change + +Today (`packages/engine/src/thread.ts:requestDecision`): + +```ts +const resolution = await this.gates.register(gate, onExpire); +``` + +`GateManager.register` returns a Promise that resolves only when `Session.resolveDecision` is called in this process. Restart kills the Promise, the suspension is lost. + +The spec (line 722) requires: + +> The engine does not rely on preserving an in-memory JavaScript continuation across restarts. Tools that call `requestDecision(...)` must therefore be re-entrant up to their decision points. On first execution, `requestDecision(...)` persists the gate and suspends the turn. On resumed execution, the engine re-runs the tool from the start with `suspendedDecision` populated for the matching gate ID, and the same `requestDecision(...)` call returns the stored resolution instead of creating a new gate. + +So the change: + +1. Gate IDs are deterministic. Same `(sessionId, threadId, queueItemId, resumeKey)` → same gate ID. Tools must supply `resumeKey`. +2. `requestDecision`: if `ctx.suspendedDecision` is set with a matching `gateId`, return the stored resolution synchronously. Otherwise, open or look up the gate, persist `SuspendedTurnState`, suspend. +3. The store actually persists everything (session, threads, entries, queue, gates, refs, suspended turns). +4. `Engine.restoreSession(id)` reads back state, re-builds the agent transcript, and for each blocked thread either (a) waits for the still-pending gate to resolve, or (b) replays the suspended tool with the resolved gate's resolution. +5. After replay, the agent continues normally. + +## File Structure + +| File | Status | Purpose | +| --- | --- | --- | +| `packages/engine/package.json` | modify | Add `drizzle-orm`, `drizzle-kit`, `better-sqlite3`, `@types/better-sqlite3` | +| `packages/engine/drizzle.config.ts` | create | drizzle-kit config pointing at the schema | +| `packages/engine/src/schema/sqlite.ts` | create | SQLite Drizzle schema for engine tables | +| `packages/engine/src/schema/index.ts` | create | Re-exports | +| `packages/engine/migrations/sqlite/0001_initial.sql` | create | Generated initial migration | +| `packages/engine/src/providers/sqlite-store.ts` | create | `SqliteSessionStore` | +| `packages/engine/src/providers/sqlite-store-helpers.ts` | create | Row encoding/decoding helpers | +| `packages/engine/src/decision-gate.ts` | modify | Stable gate ID derivation; `GateManager` accepts pre-registered resolutions | +| `packages/engine/src/thread.ts` | modify | `requestDecision` short-circuits on `ctx.suspendedDecision`; persist toolCallId/toolArgs | +| `packages/engine/src/session.ts` | modify | Tool replay path on restoration | +| `packages/engine/src/engine.ts` | modify | Real `restoreSession()` | +| `packages/engine/src/index.ts` | modify | Re-export `SqliteSessionStore`, `createSqliteStore` factory | +| `packages/engine/test/store-contract.ts` | create | Shared `SessionStore` contract test suite | +| `packages/engine/test/in-memory-store.test.ts` | create | Run contract suite against `InMemorySessionStore` | +| `packages/engine/test/sqlite-store.test.ts` | create | Run contract suite against `SqliteSessionStore` | +| `packages/engine/test/restart-safe-gates.test.ts` | create | End-to-end restart cycle | + +--- + +## Phase 1: Schema and migrations + +### Task 1: Add drizzle and better-sqlite3 deps + +**Files:** +- Modify: `packages/engine/package.json` + +- [ ] **Step 1: Update package.json** + +```json +{ + "name": "@valet/engine", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "db:generate": "drizzle-kit generate" + }, + "dependencies": { + "@mariozechner/pi-agent-core": "0.73.0", + "@mariozechner/pi-ai": "0.73.0", + "drizzle-orm": "^0.45.1", + "typebox": "^1.1.24" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "better-sqlite3": "^11.0.0", + "drizzle-kit": "^0.31.9", + "typescript": "^5.3.3", + "vitest": "^4.0.18" + } +} +``` + +- [ ] **Step 2: Install** + +```bash +cd /Users/conner/code/valet/.worktrees/portable-runtime-v1-spec && pnpm install +``` + +Expected: `Done in ` with no resolution errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/package.json pnpm-lock.yaml +git commit -m "chore(engine): add drizzle-orm and better-sqlite3 deps" +``` + +--- + +### Task 2: Define SQLite schema for engine tables + +**Files:** +- Create: `packages/engine/src/schema/sqlite.ts` +- Create: `packages/engine/src/schema/index.ts` + +The schema mirrors the table list in the spec ("Required Tables" section, lines 1338-1349). Use SQLite types: `text` (default for everything textual or JSON-serialized), `integer` (for booleans-as-0/1 and unix-ms timestamps), and JSON-encoded `text` for nested objects. + +- [ ] **Step 1: Write the schema file** + +```ts +// packages/engine/src/schema/sqlite.ts +import { sql } from "drizzle-orm"; +import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"; + +export const engineSessions = sqliteTable("engine_sessions", { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + orgId: text("org_id").notNull(), + workspace: text("workspace").notNull(), + purpose: text("purpose").notNull(), + status: text("status").notNull(), + sandboxId: text("sandbox_id"), + snapshotId: text("snapshot_id"), + parentSessionId: text("parent_session_id"), + metadata: text("metadata"), // JSON + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + index("engine_sessions_user").on(t.userId), + index("engine_sessions_status").on(t.status), +]); + +export const engineThreads = sqliteTable("engine_threads", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + key: text("key").notNull(), + status: text("status").notNull(), + activeLeafEntryId: text("active_leaf_entry_id"), + queueMode: text("queue_mode").notNull(), + model: text("model"), + summary: text("summary"), + metadata: text("metadata"), // JSON + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + index("engine_threads_session").on(t.sessionId), + index("engine_threads_session_key").on(t.sessionId, t.key), +]); + +export const engineEntries = sqliteTable("engine_entries", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + parentId: text("parent_id"), + entryType: text("entry_type").notNull(), // 'message' | 'compaction' | 'branch_summary' | 'decision_gate' + // for message entries + role: text("role"), + content: text("content"), + parts: text("parts"), // JSON + author: text("author"), // JSON + channel: text("channel"), // JSON + model: text("model"), + // for compaction entries + summary: text("summary"), + coveredEntryIds: text("covered_entry_ids"), // JSON array + tokenCountBefore: integer("token_count_before"), + tokenCountAfter: integer("token_count_after"), + fileContext: text("file_context"), // JSON + // for branch_summary entries + branchRootId: text("branch_root_id"), + branchLeafId: text("branch_leaf_id"), + // for decision_gate entries + gateId: text("gate_id"), + resolvedAt: text("resolved_at"), + resolution: text("resolution"), // JSON + withdrawnReason: text("withdrawn_reason"), + // common + metadata: text("metadata"), // JSON + createdAt: integer("created_at").notNull(), +}, (t) => [ + index("engine_entries_thread").on(t.sessionId, t.threadId, t.createdAt), + index("engine_entries_gate").on(t.gateId), +]); + +export const engineQueueItems = sqliteTable("engine_queue_items", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + status: text("status").notNull(), // 'queued' | 'running' | 'blocked_on_decision_gate' | 'paused' | 'idle' + mode: text("mode").notNull(), // queue mode at submission time + content: text("content").notNull(), // JSON PromptContent + author: text("author"), // JSON + channel: text("channel"), // JSON + replyTarget: text("reply_target"), // JSON + model: text("model"), + metadata: text("metadata"), // JSON + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + index("engine_queue_items_thread").on(t.sessionId, t.threadId, t.status), +]); + +export const engineQueueState = sqliteTable("engine_queue_state", { + threadId: text("thread_id").notNull(), + sessionId: text("session_id").notNull(), + mode: text("mode").notNull(), + status: text("status").notNull(), + activeItemId: text("active_item_id"), + pending: text("pending").notNull(), // JSON QueueItem[] + collectBuffer: text("collect_buffer"), // JSON QueueItem[] | null + blockedGateId: text("blocked_gate_id"), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.sessionId, t.threadId] }), +]); + +export const engineDecisionGates = sqliteTable("engine_decision_gates", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + type: text("type").notNull(), + status: text("status").notNull(), + title: text("title").notNull(), + body: text("body"), + actions: text("actions").notNull(), // JSON + origin: text("origin"), // JSON + context: text("context"), // JSON + resolution: text("resolution"), // JSON + expiresAt: integer("expires_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + index("engine_decision_gates_thread").on(t.sessionId, t.threadId, t.status), +]); + +export const engineDecisionGateRefs = sqliteTable("engine_decision_gate_refs", { + id: text("id").primaryKey(), + gateId: text("gate_id").notNull(), + channelType: text("channel_type").notNull(), + ref: text("ref").notNull(), // JSON + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}, (t) => [ + index("engine_decision_gate_refs_gate").on(t.gateId), +]); + +export const engineSuspendedTurns = sqliteTable("engine_suspended_turns", { + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + queueItemId: text("queue_item_id").notNull(), + gateId: text("gate_id").notNull(), + model: text("model").notNull(), + leafEntryId: text("leaf_entry_id"), + toolCallId: text("tool_call_id").notNull(), + toolName: text("tool_name").notNull(), + toolArgs: text("tool_args").notNull(), // JSON + resumeKey: text("resume_key").notNull(), + attempt: integer("attempt").notNull(), + createdAt: integer("created_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.sessionId, t.threadId] }), + index("engine_suspended_turns_gate").on(t.gateId), +]); +``` + +- [ ] **Step 2: Write the index file** + +```ts +// packages/engine/src/schema/index.ts +export * from "./sqlite.js"; +``` + +- [ ] **Step 3: Typecheck** + +```bash +cd packages/engine && pnpm typecheck +``` + +Expected: clean (no output). + +- [ ] **Step 4: Commit** + +```bash +git add packages/engine/src/schema +git commit -m "feat(engine): add Drizzle SQLite schema for engine tables" +``` + +--- + +### Task 3: Generate the initial migration + +**Files:** +- Create: `packages/engine/drizzle.config.ts` +- Create: `packages/engine/migrations/sqlite/0001_initial.sql` (via drizzle-kit) + +- [ ] **Step 1: Write drizzle.config.ts** + +```ts +// packages/engine/drizzle.config.ts +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/schema/sqlite.ts", + out: "./migrations/sqlite", +}); +``` + +- [ ] **Step 2: Generate migration** + +```bash +cd packages/engine && pnpm db:generate +``` + +Expected output: `1 file generated` and a new `migrations/sqlite/0001_*.sql` file. + +- [ ] **Step 3: Verify the migration looks right** + +```bash +ls packages/engine/migrations/sqlite/ +head -40 packages/engine/migrations/sqlite/0001_*.sql +``` + +Expected: contains `CREATE TABLE engine_sessions`, `engine_threads`, `engine_entries`, `engine_queue_items`, `engine_queue_state`, `engine_decision_gates`, `engine_decision_gate_refs`, `engine_suspended_turns`. + +- [ ] **Step 4: Commit** + +```bash +git add packages/engine/drizzle.config.ts packages/engine/migrations/sqlite +git commit -m "feat(engine): generate initial sqlite migration" +``` + +--- + +## Phase 2: SqliteSessionStore + contract tests + +### Task 4: Extract `SessionStore` contract test suite + +The same tests should run against any `SessionStore` implementation. Extract them into a function that takes a store factory. + +**Files:** +- Create: `packages/engine/test/store-contract.ts` + +- [ ] **Step 1: Write the contract suite** + +```ts +// packages/engine/test/store-contract.ts +import { describe, it, expect, beforeEach } from "vitest"; +import type { + DecisionGate, + MessageEntry, + QueueState, + SessionData, + SessionEntry, + SessionStore, + SuspendedTurnState, + ThreadData, +} from "../src/index.js"; + +export interface StoreContractContext { + factory: () => SessionStore | Promise; + /** Optional async teardown; called after each test. */ + teardown?: (store: SessionStore) => void | Promise; +} + +export function runSessionStoreContract(name: string, ctx: StoreContractContext) { + describe(`SessionStore contract: ${name}`, () => { + let store: SessionStore; + + beforeEach(async () => { + store = await ctx.factory(); + }); + + function newSession(overrides: Partial = {}): SessionData { + return { + id: "sess-1", + userId: "u1", + orgId: "o1", + workspace: "/", + purpose: "interactive", + status: "running", + createdAt: 1, + updatedAt: 1, + ...overrides, + }; + } + + function newThread(sessionId: string, key = "web:default"): ThreadData { + return { + id: "th-1", + sessionId, + key, + status: "active", + queueMode: "followup", + createdAt: 1, + updatedAt: 1, + }; + } + + it("saveSession + getSession round-trips", async () => { + const s = newSession(); + await store.saveSession(s); + const loaded = await store.getSession(s.id); + expect(loaded).toMatchObject({ id: "sess-1", userId: "u1", status: "running" }); + }); + + it("listSessions filters by userId", async () => { + await store.saveSession(newSession({ id: "a", userId: "u1" })); + await store.saveSession(newSession({ id: "b", userId: "u2" })); + const list = await store.listSessions("u1"); + expect(list.map((s) => s.id)).toEqual(["a"]); + }); + + it("saveThread + listThreads round-trips", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1", "task:A")); + await store.saveThread("sess-1", newThread("sess-1", "task:B")); + // Use a unique id for each + await store.saveThread("sess-1", { ...newThread("sess-1", "task:B"), id: "th-2" }); + const threads = await store.listThreads("sess-1"); + expect(threads.length).toBeGreaterThanOrEqual(2); + }); + + it("appendEntries + getEntries returns entries in insertion order", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const entries: SessionEntry[] = [ + msg("e-1", "user", "hi", 10), + msg("e-2", "assistant", "hello", 20), + ]; + await store.appendEntries("sess-1", "th-1", entries); + const loaded = await store.getEntries("sess-1", "th-1"); + expect(loaded).toHaveLength(2); + expect(loaded[0]).toMatchObject({ id: "e-1", type: "message", role: "user", content: "hi" }); + expect(loaded[1]).toMatchObject({ id: "e-2", type: "message", role: "assistant" }); + }); + + it("appendEntries persists decision_gate entries", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "ok?", + actions: [{ id: "approve", label: "Approve" }], + createdAt: 100, + updatedAt: 100, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + await store.appendEntries("sess-1", "th-1", [ + { + id: "e-g", + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "decision_gate", + gate, + createdAt: 100, + }, + ]); + const loaded = await store.getEntries("sess-1", "th-1"); + const gateEntry = loaded.find((e) => e.type === "decision_gate"); + expect(gateEntry).toBeDefined(); + expect(gateEntry && gateEntry.type === "decision_gate" && gateEntry.gate.id).toBe("g-1"); + }); + + it("saveQueueState + getQueueState round-trips", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const qs: QueueState = { + threadId: "th-1", + mode: "followup", + status: "running", + activeItemId: "q-1", + pending: [], + }; + await store.saveQueueState("sess-1", "th-1", qs); + const loaded = await store.getQueueState("sess-1", "th-1"); + expect(loaded).toMatchObject({ threadId: "th-1", status: "running", activeItemId: "q-1" }); + }); + + it("saveDecisionGate + listDecisionGates + getDecisionGate", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "x", + actions: [], + createdAt: 1, + updatedAt: 1, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + const list = await store.listDecisionGates("sess-1"); + expect(list).toHaveLength(1); + const single = await store.getDecisionGate("sess-1", "g-1"); + expect(single?.title).toBe("x"); + }); + + it("saveSuspendedTurn + getSuspendedTurn + clearSuspendedTurn", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const sus: SuspendedTurnState = { + sessionId: "sess-1", + threadId: "th-1", + queueItemId: "q-1", + gateId: "g-1", + model: "faux/faux-1", + toolCallId: "tc-1", + toolName: "do_thing", + toolArgs: { arg: "x" }, + resumeKey: "do_thing:x", + attempt: 1, + createdAt: 1, + }; + await store.saveSuspendedTurn("sess-1", "th-1", sus); + expect(await store.getSuspendedTurn("sess-1", "th-1")).toMatchObject({ toolName: "do_thing" }); + await store.clearSuspendedTurn("sess-1", "th-1"); + expect(await store.getSuspendedTurn("sess-1", "th-1")).toBeNull(); + }); + + it("updateDecisionGateEntry patches the matching entry", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "x", + actions: [], + createdAt: 1, + updatedAt: 1, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + await store.appendEntries("sess-1", "th-1", [ + { + id: "e-g", + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "decision_gate", + gate, + createdAt: 1, + }, + ]); + await store.updateDecisionGateEntry("sess-1", "th-1", "g-1", { + gate: { ...gate, status: "resolved" }, + resolution: { actionId: "approve", resolvedBy: "u1", resolvedAt: 5 }, + }); + const entries = await store.getEntries("sess-1", "th-1"); + const e = entries.find((x) => x.type === "decision_gate"); + expect(e && e.type === "decision_gate" && e.gate.status).toBe("resolved"); + expect(e && e.type === "decision_gate" && e.resolution?.actionId).toBe("approve"); + }); + + it("deleteSession removes the session", async () => { + await store.saveSession(newSession()); + await store.deleteSession("sess-1"); + expect(await store.getSession("sess-1")).toBeNull(); + }); + }); +} + +function msg(id: string, role: "user" | "assistant", content: string, ts: number): MessageEntry { + return { + id, + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "message", + role, + content, + createdAt: ts, + }; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/test/store-contract.ts +git commit -m "test(engine): add SessionStore contract test suite" +``` + +--- + +### Task 5: Run contract suite against `InMemorySessionStore` (regression check) + +**Files:** +- Create: `packages/engine/test/in-memory-store.test.ts` + +- [ ] **Step 1: Write the test** + +```ts +// packages/engine/test/in-memory-store.test.ts +import { InMemorySessionStore } from "../src/index.js"; +import { runSessionStoreContract } from "./store-contract.js"; + +runSessionStoreContract("InMemorySessionStore", { + factory: () => new InMemorySessionStore(), +}); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm test -- in-memory-store +``` + +Expected: 10 tests passing. If any fail, the existing in-memory store has a bug — fix it in `packages/engine/src/providers/in-memory-store.ts` before proceeding. (Common likely issue: `updateDecisionGateEntry` not preserving entries; the existing impl handles this — verify.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/test/in-memory-store.test.ts +git commit -m "test(engine): run contract suite against InMemorySessionStore" +``` + +--- + +### Task 6: Implement `SqliteSessionStore` + +**Files:** +- Create: `packages/engine/src/providers/sqlite-store-helpers.ts` +- Create: `packages/engine/src/providers/sqlite-store.ts` + +The store uses `drizzle-orm/better-sqlite3` for in-process SQLite. The `D1` adapter (Cloudflare) wires in differently and is out of scope here, but the same Drizzle queries will run against either via the Cloudflare adapter package. + +- [ ] **Step 1: Write encoding helpers** + +```ts +// packages/engine/src/providers/sqlite-store-helpers.ts +import type { + CompactionEntry, + DecisionGate, + DecisionGateEntry, + MessageEntry, + BranchSummaryEntry, + SessionEntry, +} from "../types.js"; + +export function jsonOrNull(value: T | undefined | null): string | null { + return value === undefined || value === null ? null : JSON.stringify(value); +} + +export function parseJson(value: string | null): T | undefined { + if (value === null || value === undefined) return undefined; + return JSON.parse(value) as T; +} + +export interface EntryRow { + id: string; + sessionId: string; + threadId: string; + parentId: string | null; + entryType: string; + role: string | null; + content: string | null; + parts: string | null; + author: string | null; + channel: string | null; + model: string | null; + summary: string | null; + coveredEntryIds: string | null; + tokenCountBefore: number | null; + tokenCountAfter: number | null; + fileContext: string | null; + branchRootId: string | null; + branchLeafId: string | null; + gateId: string | null; + resolvedAt: string | null; + resolution: string | null; + withdrawnReason: string | null; + metadata: string | null; + createdAt: number; +} + +export function entryToRow(entry: SessionEntry): Omit & { entryType: string } { + const base = { + id: entry.id, + sessionId: entry.sessionId, + threadId: entry.threadId, + parentId: entry.parentId, + metadata: jsonOrNull(entry.metadata), + createdAt: entry.createdAt, + role: null, + content: null, + parts: null, + author: null, + channel: null, + model: null, + summary: null, + coveredEntryIds: null, + tokenCountBefore: null, + tokenCountAfter: null, + fileContext: null, + branchRootId: null, + branchLeafId: null, + gateId: null, + resolvedAt: null, + resolution: null, + withdrawnReason: null, + }; + switch (entry.type) { + case "message": + return { + ...base, + entryType: "message", + role: entry.role, + content: entry.content, + parts: jsonOrNull(entry.parts), + author: jsonOrNull(entry.author), + channel: jsonOrNull(entry.channel), + model: entry.model ?? null, + }; + case "compaction": + return { + ...base, + entryType: "compaction", + summary: entry.summary, + coveredEntryIds: JSON.stringify(entry.coveredEntryIds), + tokenCountBefore: entry.tokenCountBefore, + tokenCountAfter: entry.tokenCountAfter, + fileContext: jsonOrNull(entry.fileContext), + }; + case "branch_summary": + return { + ...base, + entryType: "branch_summary", + branchRootId: entry.branchRootId, + branchLeafId: entry.branchLeafId, + summary: entry.summary, + }; + case "decision_gate": + return { + ...base, + entryType: "decision_gate", + gateId: entry.gate.id, + // store the gate JSON in `parts` field (reusing) — simpler: a dedicated column + // We'll use `metadata` to store the gate snapshot for the entry. + metadata: JSON.stringify({ gate: entry.gate, ...(entry.metadata ?? {}) }), + resolvedAt: entry.resolvedAt ?? null, + resolution: jsonOrNull(entry.resolution), + withdrawnReason: entry.withdrawnReason ?? null, + }; + } +} + +export function rowToEntry(row: EntryRow): SessionEntry { + switch (row.entryType) { + case "message": { + const e: MessageEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "message", + role: (row.role as MessageEntry["role"]) ?? "user", + content: row.content ?? "", + parts: parseJson(row.parts), + author: parseJson(row.author), + channel: parseJson(row.channel), + model: row.model ?? undefined, + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "compaction": { + const e: CompactionEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "compaction", + summary: row.summary ?? "", + coveredEntryIds: parseJson(row.coveredEntryIds) ?? [], + tokenCountBefore: row.tokenCountBefore ?? 0, + tokenCountAfter: row.tokenCountAfter ?? 0, + fileContext: parseJson(row.fileContext), + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "branch_summary": { + const e: BranchSummaryEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "branch_summary", + branchRootId: row.branchRootId ?? "", + branchLeafId: row.branchLeafId ?? "", + summary: row.summary ?? "", + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "decision_gate": { + const meta = parseJson<{ gate: DecisionGate } & Record>(row.metadata); + const gate = meta?.gate; + if (!gate) throw new Error(`decision_gate entry ${row.id} missing gate snapshot`); + // Strip our internal `gate` key from metadata before re-exposing. + const { gate: _unused, ...userMeta } = meta ?? { gate }; + const e: DecisionGateEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "decision_gate", + gate, + resolvedAt: row.resolvedAt ?? undefined, + resolution: parseJson(row.resolution), + withdrawnReason: (row.withdrawnReason as DecisionGateEntry["withdrawnReason"]) ?? undefined, + metadata: Object.keys(userMeta).length > 0 ? (userMeta as Record) : undefined, + createdAt: row.createdAt, + }; + return e; + } + default: + throw new Error(`unknown entry type: ${row.entryType}`); + } +} +``` + +- [ ] **Step 2: Write the store** + +```ts +// packages/engine/src/providers/sqlite-store.ts +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import { and, eq, desc, asc } from "drizzle-orm"; +import { + engineSessions, + engineThreads, + engineEntries, + engineQueueItems, + engineQueueState, + engineDecisionGates, + engineDecisionGateRefs, + engineSuspendedTurns, +} from "../schema/sqlite.js"; +import type { + DecisionGate, + DecisionGateEntry, + DecisionGateRef, + ListOpts, + MessageQuery, + QueueState, + SessionData, + SessionEntry, + SessionStatus, + SessionStore, + SuspendedTurnState, + ThreadData, +} from "../types.js"; +import { entryToRow, jsonOrNull, parseJson, rowToEntry, type EntryRow } from "./sqlite-store-helpers.js"; + +export class SqliteSessionStore implements SessionStore { + constructor(private readonly db: BetterSQLite3Database) {} + + async saveSession(session: SessionData): Promise { + this.db + .insert(engineSessions) + .values({ + id: session.id, + userId: session.userId, + orgId: session.orgId, + workspace: session.workspace, + purpose: session.purpose, + status: session.status, + sandboxId: session.sandboxId ?? null, + snapshotId: session.snapshotId ?? null, + parentSessionId: session.parentSessionId ?? null, + metadata: jsonOrNull(session.metadata), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }) + .onConflictDoUpdate({ + target: engineSessions.id, + set: { + status: session.status, + sandboxId: session.sandboxId ?? null, + snapshotId: session.snapshotId ?? null, + metadata: jsonOrNull(session.metadata), + updatedAt: session.updatedAt, + }, + }) + .run(); + } + + async saveThread(sessionId: string, thread: ThreadData): Promise { + this.db + .insert(engineThreads) + .values({ + id: thread.id, + sessionId, + key: thread.key, + status: thread.status, + activeLeafEntryId: thread.activeLeafEntryId ?? null, + queueMode: thread.queueMode, + model: thread.model ?? null, + summary: thread.summary ?? null, + metadata: jsonOrNull(thread.metadata), + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }) + .onConflictDoUpdate({ + target: engineThreads.id, + set: { + status: thread.status, + activeLeafEntryId: thread.activeLeafEntryId ?? null, + queueMode: thread.queueMode, + model: thread.model ?? null, + summary: thread.summary ?? null, + updatedAt: thread.updatedAt, + }, + }) + .run(); + } + + async appendEntries(sessionId: string, threadId: string, entries: SessionEntry[]): Promise { + for (const e of entries) { + const row = entryToRow(e); + this.db.insert(engineEntries).values(row).run(); + } + if (entries.length > 0) { + const lastId = entries[entries.length - 1].id; + this.db + .update(engineThreads) + .set({ activeLeafEntryId: lastId, updatedAt: Date.now() }) + .where(eq(engineThreads.id, threadId)) + .run(); + } + } + + async saveQueueState(sessionId: string, threadId: string, queue: QueueState): Promise { + this.db + .insert(engineQueueState) + .values({ + sessionId, + threadId, + mode: queue.mode, + status: queue.status, + activeItemId: queue.activeItemId ?? null, + pending: JSON.stringify(queue.pending), + collectBuffer: queue.collectBuffer ? JSON.stringify(queue.collectBuffer) : null, + blockedGateId: queue.blockedGateId ?? null, + updatedAt: Date.now(), + }) + .onConflictDoUpdate({ + target: [engineQueueState.sessionId, engineQueueState.threadId], + set: { + mode: queue.mode, + status: queue.status, + activeItemId: queue.activeItemId ?? null, + pending: JSON.stringify(queue.pending), + collectBuffer: queue.collectBuffer ? JSON.stringify(queue.collectBuffer) : null, + blockedGateId: queue.blockedGateId ?? null, + updatedAt: Date.now(), + }, + }) + .run(); + } + + async saveDecisionGate(sessionId: string, threadId: string, gate: DecisionGate): Promise { + this.db + .insert(engineDecisionGates) + .values({ + id: gate.id, + sessionId, + threadId, + type: gate.type, + status: gate.status, + title: gate.title, + body: gate.body ?? null, + actions: JSON.stringify(gate.actions), + origin: jsonOrNull(gate.origin), + context: jsonOrNull(gate.context), + resolution: null, + expiresAt: gate.expiresAt ?? null, + createdAt: gate.createdAt, + updatedAt: gate.updatedAt, + }) + .onConflictDoUpdate({ + target: engineDecisionGates.id, + set: { + status: gate.status, + title: gate.title, + body: gate.body ?? null, + actions: JSON.stringify(gate.actions), + context: jsonOrNull(gate.context), + updatedAt: gate.updatedAt, + }, + }) + .run(); + } + + async saveDecisionGateRef( + sessionId: string, + threadId: string, + gateId: string, + ref: { channelType: string; ref: DecisionGateRef }, + ): Promise { + this.db + .insert(engineDecisionGateRefs) + .values({ + id: `${gateId}:${ref.channelType}:${ref.ref.messageId}`, + gateId, + channelType: ref.channelType, + ref: JSON.stringify(ref.ref), + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); + } + + async updateDecisionGateEntry( + sessionId: string, + threadId: string, + gateId: string, + patch: Partial, + ): Promise { + // Find the entry row by gateId in the thread. + const rows = this.db + .select() + .from(engineEntries) + .where(and(eq(engineEntries.sessionId, sessionId), eq(engineEntries.threadId, threadId), eq(engineEntries.gateId, gateId))) + .all() as EntryRow[]; + for (const row of rows) { + const current = rowToEntry(row); + if (current.type !== "decision_gate") continue; + const merged: DecisionGateEntry = { + ...current, + ...patch, + gate: patch.gate ?? current.gate, + }; + const newRow = entryToRow(merged); + this.db + .update(engineEntries) + .set({ + metadata: newRow.metadata, + resolvedAt: newRow.resolvedAt, + resolution: newRow.resolution, + withdrawnReason: newRow.withdrawnReason, + }) + .where(eq(engineEntries.id, row.id)) + .run(); + } + } + + async saveSuspendedTurn( + sessionId: string, + threadId: string, + s: SuspendedTurnState, + ): Promise { + this.db + .insert(engineSuspendedTurns) + .values({ + sessionId, + threadId, + queueItemId: s.queueItemId, + gateId: s.gateId, + model: s.model, + leafEntryId: s.leafMessageId ?? null, + toolCallId: s.toolCallId, + toolName: s.toolName, + toolArgs: JSON.stringify(s.toolArgs), + resumeKey: s.resumeKey, + attempt: s.attempt, + createdAt: s.createdAt, + }) + .onConflictDoUpdate({ + target: [engineSuspendedTurns.sessionId, engineSuspendedTurns.threadId], + set: { + queueItemId: s.queueItemId, + gateId: s.gateId, + model: s.model, + leafEntryId: s.leafMessageId ?? null, + toolCallId: s.toolCallId, + toolName: s.toolName, + toolArgs: JSON.stringify(s.toolArgs), + resumeKey: s.resumeKey, + attempt: s.attempt, + }, + }) + .run(); + } + + async clearSuspendedTurn(sessionId: string, threadId: string): Promise { + this.db + .delete(engineSuspendedTurns) + .where(and(eq(engineSuspendedTurns.sessionId, sessionId), eq(engineSuspendedTurns.threadId, threadId))) + .run(); + } + + async updateSessionStatus( + id: string, + status: SessionStatus, + metadata?: Partial, + ): Promise { + this.db + .update(engineSessions) + .set({ + status, + sandboxId: metadata?.sandboxId ?? undefined, + snapshotId: metadata?.snapshotId ?? undefined, + updatedAt: Date.now(), + }) + .where(eq(engineSessions.id, id)) + .run(); + } + + async getSession(id: string): Promise { + const row = this.db.select().from(engineSessions).where(eq(engineSessions.id, id)).get(); + if (!row) return null; + return { + id: row.id, + userId: row.userId, + orgId: row.orgId, + workspace: row.workspace, + purpose: row.purpose as SessionData["purpose"], + status: row.status as SessionData["status"], + sandboxId: row.sandboxId ?? undefined, + snapshotId: row.snapshotId ?? undefined, + parentSessionId: row.parentSessionId ?? undefined, + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async listSessions(userId: string, opts?: ListOpts): Promise { + let query = this.db.select().from(engineSessions).where(eq(engineSessions.userId, userId)); + const rows = query.all(); + let result: SessionData[] = rows.map((r) => ({ + id: r.id, + userId: r.userId, + orgId: r.orgId, + workspace: r.workspace, + purpose: r.purpose as SessionData["purpose"], + status: r.status as SessionData["status"], + sandboxId: r.sandboxId ?? undefined, + snapshotId: r.snapshotId ?? undefined, + parentSessionId: r.parentSessionId ?? undefined, + metadata: parseJson(r.metadata), + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + if (opts?.status) result = result.filter((s) => s.status === opts.status); + return result; + } + + async getThread(sessionId: string, threadId: string): Promise { + const row = this.db + .select() + .from(engineThreads) + .where(and(eq(engineThreads.sessionId, sessionId), eq(engineThreads.id, threadId))) + .get(); + if (!row) return null; + return { + id: row.id, + sessionId: row.sessionId, + key: row.key, + status: row.status as ThreadData["status"], + activeLeafEntryId: row.activeLeafEntryId ?? undefined, + queueMode: row.queueMode as ThreadData["queueMode"], + model: row.model ?? undefined, + summary: row.summary ?? undefined, + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async listThreads(sessionId: string): Promise { + const rows = this.db.select().from(engineThreads).where(eq(engineThreads.sessionId, sessionId)).all(); + return rows.map((r) => ({ + id: r.id, + sessionId: r.sessionId, + key: r.key, + status: r.status as ThreadData["status"], + activeLeafEntryId: r.activeLeafEntryId ?? undefined, + queueMode: r.queueMode as ThreadData["queueMode"], + model: r.model ?? undefined, + summary: r.summary ?? undefined, + metadata: parseJson(r.metadata), + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + } + + async getEntries( + sessionId: string, + threadId: string, + opts?: MessageQuery, + ): Promise { + let rows = this.db + .select() + .from(engineEntries) + .where(and(eq(engineEntries.sessionId, sessionId), eq(engineEntries.threadId, threadId))) + .orderBy(asc(engineEntries.createdAt)) + .all() as EntryRow[]; + if (opts?.includeCompacted === false) rows = rows.filter((r) => r.entryType !== "compaction"); + if (opts?.limit && opts.limit > 0) rows = rows.slice(-opts.limit); + return rows.map(rowToEntry); + } + + async getQueueState(sessionId: string, threadId: string): Promise { + const row = this.db + .select() + .from(engineQueueState) + .where(and(eq(engineQueueState.sessionId, sessionId), eq(engineQueueState.threadId, threadId))) + .get(); + if (!row) return null; + return { + threadId: row.threadId, + mode: row.mode as QueueState["mode"], + status: row.status as QueueState["status"], + activeItemId: row.activeItemId ?? undefined, + pending: parseJson(row.pending) ?? [], + collectBuffer: parseJson(row.collectBuffer), + blockedGateId: row.blockedGateId ?? undefined, + }; + } + + async listDecisionGates(sessionId: string, threadId?: string): Promise { + let rows; + if (threadId) { + rows = this.db + .select() + .from(engineDecisionGates) + .where(and(eq(engineDecisionGates.sessionId, sessionId), eq(engineDecisionGates.threadId, threadId))) + .all(); + } else { + rows = this.db + .select() + .from(engineDecisionGates) + .where(eq(engineDecisionGates.sessionId, sessionId)) + .all(); + } + return rows.map(rowToGate); + } + + async getDecisionGate(sessionId: string, gateId: string): Promise { + const row = this.db + .select() + .from(engineDecisionGates) + .where(and(eq(engineDecisionGates.sessionId, sessionId), eq(engineDecisionGates.id, gateId))) + .get(); + return row ? rowToGate(row) : null; + } + + async getSuspendedTurn( + sessionId: string, + threadId: string, + ): Promise { + const row = this.db + .select() + .from(engineSuspendedTurns) + .where(and(eq(engineSuspendedTurns.sessionId, sessionId), eq(engineSuspendedTurns.threadId, threadId))) + .get(); + if (!row) return null; + return { + sessionId: row.sessionId, + threadId: row.threadId, + queueItemId: row.queueItemId, + gateId: row.gateId, + model: row.model, + leafMessageId: row.leafEntryId ?? undefined, + toolCallId: row.toolCallId, + toolName: row.toolName, + toolArgs: parseJson(row.toolArgs) ?? {}, + resumeKey: row.resumeKey, + attempt: row.attempt, + createdAt: row.createdAt, + }; + } + + async deleteSession(id: string): Promise { + this.db.delete(engineEntries).where(eq(engineEntries.sessionId, id)).run(); + this.db.delete(engineQueueItems).where(eq(engineQueueItems.sessionId, id)).run(); + this.db.delete(engineQueueState).where(eq(engineQueueState.sessionId, id)).run(); + this.db.delete(engineDecisionGates).where(eq(engineDecisionGates.sessionId, id)).run(); + this.db.delete(engineSuspendedTurns).where(eq(engineSuspendedTurns.sessionId, id)).run(); + this.db.delete(engineThreads).where(eq(engineThreads.sessionId, id)).run(); + this.db.delete(engineSessions).where(eq(engineSessions.id, id)).run(); + } +} + +function rowToGate(row: typeof engineDecisionGates.$inferSelect): DecisionGate { + return { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + type: row.type as DecisionGate["type"], + status: row.status as DecisionGate["status"], + title: row.title, + body: row.body ?? undefined, + actions: parseJson(row.actions) ?? [], + origin: parseJson(row.origin), + context: parseJson(row.context), + expiresAt: row.expiresAt ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} +``` + +- [ ] **Step 3: Re-export from index** + +Modify `packages/engine/src/index.ts` to add: + +```ts +export { SqliteSessionStore } from "./providers/sqlite-store.js"; +``` + +(append after the existing `InMemoryCredentialStore` export) + +- [ ] **Step 4: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add packages/engine/src/providers/sqlite-store.ts packages/engine/src/providers/sqlite-store-helpers.ts packages/engine/src/index.ts +git commit -m "feat(engine): SqliteSessionStore implementation" +``` + +--- + +### Task 7: Run contract suite against `SqliteSessionStore` + +**Files:** +- Create: `packages/engine/test/sqlite-store.test.ts` + +- [ ] **Step 1: Write the test** + +```ts +// packages/engine/test/sqlite-store.test.ts +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { SqliteSessionStore } from "../src/index.js"; +import { runSessionStoreContract } from "./store-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(__dirname, "..", "migrations", "sqlite"); + +function applyMigrations(db: Database.Database): void { + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + // drizzle-kit emits statements separated by `--> statement-breakpoint` + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (trimmed) db.exec(trimmed); + } + } +} + +runSessionStoreContract("SqliteSessionStore", { + factory: () => { + const sqlite = new Database(":memory:"); + applyMigrations(sqlite); + const db = drizzle(sqlite); + return new SqliteSessionStore(db); + }, +}); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm test -- sqlite-store +``` + +Expected: 10 tests passing. Most likely failures: +- "no such table" — migration didn't apply. Inspect `migrations/sqlite/0001_*.sql` and ensure the splitter handles its statement separator. +- JSON column round-trip mismatches — fix `entryToRow`/`rowToEntry`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/test/sqlite-store.test.ts +git commit -m "test(engine): run contract suite against SqliteSessionStore" +``` + +--- + +## Phase 3: Restart-safe gate primitives + +### Task 8: Make gate IDs deterministic from `(session, thread, queueItem, resumeKey)` + +The current `fromRequest()` (in `packages/engine/src/decision-gate.ts:117-141`) generates a random ID when `resumeKey` is missing, and only uses `resumeKey` directly otherwise. Both behaviors are wrong for restart-safety: replays must compute the same ID. + +**Files:** +- Modify: `packages/engine/src/decision-gate.ts` +- Modify: `packages/engine/src/thread.ts` + +- [ ] **Step 1: Replace `fromRequest` with a deterministic builder** + +Replace the existing `fromRequest` function (the version with the random fallback) with: + +```ts +export interface GateContext { + sessionId: string; + threadId: string; + queueItemId: string; + resumeKey: string; +} + +export function deterministicGateId(ctx: GateContext): string { + return `gate:${ctx.sessionId}:${ctx.threadId}:${ctx.queueItemId}:${ctx.resumeKey}`; +} + +export function fromRequest(req: DecisionGateRequest, gateCtx: GateContext): DecisionGate { + if (!req.resumeKey) { + throw new Error( + "DecisionGateRequest.resumeKey is required for restart-safe gates. " + + "Tools must supply a stable key per suspension point.", + ); + } + const now = Date.now(); + return { + id: deterministicGateId(gateCtx), + sessionId: gateCtx.sessionId, + threadId: gateCtx.threadId, + type: req.type, + title: req.title, + body: req.body, + actions: + req.actions ?? + (req.type === "approval" + ? [ + { id: "approve", label: "Approve", style: "primary" }, + { id: "deny", label: "Deny", style: "danger" }, + ] + : []), + expiresAt: req.expiresAt, + status: "pending", + context: req.context, + origin: req.origin, + createdAt: now, + updatedAt: now, + }; +} +``` + +- [ ] **Step 2: Update Thread.requestDecision call site** + +In `packages/engine/src/thread.ts`, change the `fromRequest(req, session.id, this.id)` call to pass the new GateContext. Locate the `requestDecision` async function in `buildToolContext()` and change: + +```ts +const gate = fromRequest(req, session.id, this.id); +``` + +to: + +```ts +const gate = fromRequest(req, { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + resumeKey: req.resumeKey ?? "", +}); +``` + +(The new `fromRequest` will throw if `resumeKey` is empty — that's the contract.) + +- [ ] **Step 3: Update existing tests that use `requestDecision` without `resumeKey`** + +Run: + +```bash +pnpm test 2>&1 | head -50 +``` + +Any test that fails with "resumeKey is required" needs to add a `resumeKey` to the `requestDecision` call. The `decision-gate.test.ts` should already pass `resumeKey` for the approval cases — verify and add it for the expiring-tool case (`packages/engine/test/decision-gate.test.ts` around line 142): + +Old: +```ts +await ctx.requestDecision({ + type: "approval", + title: "expire me", + expiresAt: Date.now() + 30, +}); +``` + +New: +```ts +await ctx.requestDecision({ + type: "approval", + title: "expire me", + expiresAt: Date.now() + 30, + resumeKey: "expire-me-1", +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +pnpm test +``` + +Expected: 14 tests still passing. + +- [ ] **Step 5: Commit** + +```bash +git add packages/engine/src/decision-gate.ts packages/engine/src/thread.ts packages/engine/test/decision-gate.test.ts +git commit -m "feat(engine): deterministic gate IDs derived from resumeKey" +``` + +--- + +### Task 9: `requestDecision` short-circuits when `ctx.suspendedDecision` matches + +**Files:** +- Modify: `packages/engine/src/thread.ts` + +- [ ] **Step 1: Add the short-circuit at the top of `requestDecision`** + +In `packages/engine/src/thread.ts`, at the start of the `requestDecision` async function inside `buildToolContext()`, before constructing the gate, add: + +```ts +requestDecision: async (req: DecisionGateRequest): Promise => { + const gateCtx = { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + resumeKey: req.resumeKey ?? "", + }; + // Restart-safe replay: if we are running with a suspendedDecision and the + // gate ID matches, return the stored resolution without re-persisting. + const expectedId = req.resumeKey + ? deterministicGateId(gateCtx) + : null; + if ( + this.suspendedDecisionForReplay && + expectedId && + this.suspendedDecisionForReplay.gateId === expectedId + ) { + const resolution = this.suspendedDecisionForReplay.resolution; + if (!resolution) { + throw new Error(`replay: suspendedDecision for ${expectedId} has no resolution`); + } + // One-shot: clear so a subsequent requestDecision in the same turn opens normally. + this.suspendedDecisionForReplay = undefined; + return resolution; + } + + const gate = fromRequest(req, gateCtx); + // …rest of existing implementation +``` + +Add a private field at the top of the `Thread` class for the replay context: + +```ts +private suspendedDecisionForReplay: { gateId: string; resolution?: DecisionResolution } | undefined; +``` + +Add an import for `deterministicGateId` next to the existing `fromRequest, GateManager` import: + +```ts +import { fromRequest, GateManager, deterministicGateId } from "./decision-gate.js"; +``` + +- [ ] **Step 2: Wire `suspendedDecisionForReplay` into ToolContext** + +In the same `buildToolContext` method, set `suspendedDecision` on the context: + +```ts +suspendedDecision: this.suspendedDecisionForReplay, +``` + +(Replace the existing `suspendedDecision: undefined` if present, or add to the returned ctx if missing.) + +- [ ] **Step 3: Add a method to set the replay context** + +In the `Thread` class, add a public method: + +```ts +/** Used by Engine.restoreSession to seed replay state before re-running a blocked tool. */ +setReplayContext(ctx: { gateId: string; resolution?: DecisionResolution } | undefined): void { + this.suspendedDecisionForReplay = ctx; +} +``` + +- [ ] **Step 4: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 5: Run tests** + +```bash +pnpm test +``` + +Expected: still 14 passing. + +- [ ] **Step 6: Commit** + +```bash +git add packages/engine/src/thread.ts +git commit -m "feat(engine): requestDecision short-circuits on suspendedDecision replay" +``` + +--- + +### Task 10: Persist real `toolCallId` and `toolArgs` on suspension + +The current `requestDecision` saves placeholder values for `toolCallId` and empty `toolArgs`. Replay needs the real values. + +**Files:** +- Modify: `packages/engine/src/tool-bridge.ts` +- Modify: `packages/engine/src/types.ts` +- Modify: `packages/engine/src/thread.ts` + +- [ ] **Step 1: Add `toolCallId`, `toolName`, `toolArgs` to the closure in `tool-bridge.ts`** + +Replace `toAgentTool` in `packages/engine/src/tool-bridge.ts`: + +```ts +export function toAgentTool( + def: ToolDef, + buildContext: (args: { + signal: AbortSignal; + toolCallId: string; + toolName: string; + toolArgs: Record; + }) => ToolContext, +): AgentTool { + return { + name: def.name, + label: def.name, + description: def.description, + parameters: def.parameters, + execute: async (toolCallId, params, signal) => { + const ctx = buildContext({ + signal: signal ?? new AbortController().signal, + toolCallId, + toolName: def.name, + toolArgs: params as Record, + }); + const result = await def.execute(params as never, ctx); + return toAgentToolResult(result); + }, + }; +} +``` + +- [ ] **Step 2: Update Thread.buildTools / buildToolContext to use the new shape** + +In `packages/engine/src/thread.ts`, update `buildTools`: + +```ts +private buildTools(): AgentTool[] { + const all: ToolDef[] = [...this.session.builtinTools, ...(this.session.options.tools ?? [])]; + return all.map((def) => + toAgentTool(def, ({ signal, toolCallId, toolName, toolArgs }) => + this.buildToolContext({ signal, toolCallId, toolName, toolArgs }), + ), + ); +} +``` + +Update `buildToolContext` signature: + +```ts +private buildToolContext(args: { + signal: AbortSignal; + toolCallId: string; + toolName: string; + toolArgs: Record; +}): ToolContext { + const { signal, toolCallId, toolName, toolArgs } = args; + // ... rest of existing implementation +``` + +In the `requestDecision` body, use the captured `toolCallId`, `toolName`, `toolArgs` for the SuspendedTurnState save: + +```ts +await session.providers.store.saveSuspendedTurn(session.id, this.id, { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + gateId: gate.id, + model: session.options.model.id, + toolCallId, + toolName, + toolArgs, + resumeKey: req.resumeKey ?? gate.id, + attempt: 1, + createdAt: Date.now(), +}); +``` + +- [ ] **Step 3: Run tests** + +```bash +pnpm test +``` + +Expected: still 14 passing. + +- [ ] **Step 4: Commit** + +```bash +git add packages/engine/src/tool-bridge.ts packages/engine/src/thread.ts +git commit -m "feat(engine): persist real tool call id and args on gate suspension" +``` + +--- + +### Task 11: Pure-function unit test for the short-circuit predicate + +The short-circuit decision is deterministic given `(resumeKey, gateCtx, suspendedDecision)`. Extract the predicate into a pure function and unit test it directly. This avoids any race against the agent loop and makes the integration test in Task 15 the single end-to-end validation. + +**Files:** +- Modify: `packages/engine/src/decision-gate.ts` (add `shouldShortCircuit`) +- Modify: `packages/engine/src/thread.ts` (use the new helper) +- Create: `packages/engine/test/short-circuit.test.ts` + +- [ ] **Step 1: Add `shouldShortCircuit` to `decision-gate.ts`** + +Append after `deterministicGateId`: + +```ts +export function shouldShortCircuit(args: { + ctx: GateContext; + suspendedDecision: { gateId: string; resolution?: DecisionResolution } | undefined; +}): { match: true; resolution: DecisionResolution } | { match: false } { + const { ctx, suspendedDecision } = args; + if (!suspendedDecision) return { match: false }; + const expectedId = deterministicGateId(ctx); + if (suspendedDecision.gateId !== expectedId) return { match: false }; + if (!suspendedDecision.resolution) return { match: false }; + return { match: true, resolution: suspendedDecision.resolution }; +} +``` + +(Add `import type { DecisionResolution } from "./types.js";` if not already imported in decision-gate.ts.) + +- [ ] **Step 2: Use it in `Thread.requestDecision`** + +In `packages/engine/src/thread.ts`, replace the inline short-circuit you added in Task 9 with a call to `shouldShortCircuit`. The block at the top of `requestDecision` becomes: + +```ts +requestDecision: async (req: DecisionGateRequest): Promise => { + if (!req.resumeKey) { + throw new Error("DecisionGateRequest.resumeKey is required for restart-safe gates."); + } + const gateCtx = { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + resumeKey: req.resumeKey, + }; + const sc = shouldShortCircuit({ + ctx: gateCtx, + suspendedDecision: this.suspendedDecisionForReplay, + }); + if (sc.match) { + this.suspendedDecisionForReplay = undefined; // one-shot + return sc.resolution; + } + const gate = fromRequest(req, gateCtx); + // …rest of existing implementation +``` + +Add `shouldShortCircuit` to the import from `./decision-gate.js`: + +```ts +import { fromRequest, GateManager, deterministicGateId, shouldShortCircuit } from "./decision-gate.js"; +``` + +- [ ] **Step 3: Write the unit test** + +```ts +// packages/engine/test/short-circuit.test.ts +import { describe, it, expect } from "vitest"; +import { shouldShortCircuit, deterministicGateId } from "../src/decision-gate.js"; + +const ctx = { sessionId: "s1", threadId: "t1", queueItemId: "q1", resumeKey: "do:x" }; +const gateId = deterministicGateId(ctx); +const resolution = { actionId: "approve", resolvedBy: "u", resolvedAt: 1 }; + +describe("shouldShortCircuit", () => { + it("returns no match when no suspendedDecision", () => { + expect(shouldShortCircuit({ ctx, suspendedDecision: undefined }).match).toBe(false); + }); + + it("returns no match when gateId differs", () => { + expect( + shouldShortCircuit({ + ctx, + suspendedDecision: { gateId: "gate:other", resolution }, + }).match, + ).toBe(false); + }); + + it("returns no match when resolution is missing", () => { + expect( + shouldShortCircuit({ ctx, suspendedDecision: { gateId } }).match, + ).toBe(false); + }); + + it("returns match + resolution when gateId and resolution are present", () => { + const result = shouldShortCircuit({ + ctx, + suspendedDecision: { gateId, resolution }, + }); + expect(result.match).toBe(true); + if (result.match) expect(result.resolution).toEqual(resolution); + }); + + it("two ctx with same fields produce the same gateId", () => { + const a = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k" }); + const b = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k" }); + expect(a).toBe(b); + }); + + it("differing resumeKey changes gateId", () => { + const a = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k1" }); + const b = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k2" }); + expect(a).not.toBe(b); + }); +}); +``` + +- [ ] **Step 4: Run it** + +```bash +pnpm test -- short-circuit +``` + +Expected: 6 tests passing. + +- [ ] **Step 5: Run full suite** + +```bash +pnpm test +``` + +Expected: still all green; the existing 14 engine tests continue to pass with the refactored short-circuit predicate. + +- [ ] **Step 6: Commit** + +```bash +git add packages/engine/src/decision-gate.ts packages/engine/src/thread.ts packages/engine/test/short-circuit.test.ts +git commit -m "test(engine): unit-test the gate short-circuit predicate" +``` + +--- + +## Phase 4: `Engine.restoreSession` + +### Task 12: Restore session and threads from store + +**Files:** +- Modify: `packages/engine/src/engine.ts` +- Modify: `packages/engine/src/session.ts` + +- [ ] **Step 1: Add a `Session.rehydrate` static path** + +In `packages/engine/src/session.ts`, add a static helper that builds a Session from store data without re-saving: + +```ts +static async rehydrate( + data: SessionData, + options: CreateSessionOptions, + providers: ProviderBundle, + sandbox: Sandbox, +): Promise { + const session = new Session(data.id, options, providers, sandbox); + // Rebuild threads from store + const threadDatas = await providers.store.listThreads(data.id); + for (const td of threadDatas) { + const thread = new Thread(session, td); + session.threads.set(thread.id, thread); + session.threadsByKey.set(thread.key, thread); + // Rehydrate agent transcript from entries + const entries = await providers.store.getEntries(data.id, td.id); + thread.rehydrateTranscript(entries); + } + return session; +} +``` + +(This needs `threads` and `threadsByKey` to be `protected` or accessible within the file. They are `private` — change to `private` → `private` plus a mutator method, or use `Session["threads"]` type assertion. Cleanest: add a `Session.attachThread(thread)` method.) + +Add to `Session`: + +```ts +private attachThread(thread: Thread): void { + this.threads.set(thread.id, thread); + this.threadsByKey.set(thread.key, thread); +} +``` + +And use it in `rehydrate`: + +```ts +session.attachThread(thread); +``` + +- [ ] **Step 2: Add `Thread.rehydrateTranscript`** + +In `packages/engine/src/thread.ts`, add. The crucial detail (per the spec's "LLM-faithful entry persistence" contract): for assistant messages that issued tool calls, we MUST reconstruct the `ToolCall` blocks from `MessageEntry.parts`. Without this, after replay we'd push a `toolResult` after a text-only assistant message, which providers reject. + +```ts +rehydrateTranscript(entries: SessionEntry[]): void { + const agentMessages: AgentMessage[] = []; + for (const e of entries) { + if (e.type !== "message") continue; // CompactionEntry/DecisionGateEntry filtered + + if (e.role === "user") { + agentMessages.push({ + role: "user", + content: [{ type: "text", text: e.content }], + timestamp: e.createdAt, + }); + continue; + } + + if (e.role === "assistant") { + // Reconstruct content blocks from parts so tool calls survive rehydration. + const blocks: Array = []; + const parts = e.parts ?? []; + const hadStructuredParts = parts.length > 0; + for (const p of parts) { + if (p.type === "text") blocks.push({ type: "text", text: p.text }); + else if (p.type === "thinking") blocks.push({ type: "thinking", thinking: p.text }); + else if (p.type === "tool_call") { + blocks.push({ + type: "toolCall", + id: p.callId, + name: p.toolName, + arguments: (p.args as Record) ?? {}, + }); + } + } + if (!hadStructuredParts && e.content) { + blocks.push({ type: "text", text: e.content }); + } + agentMessages.push({ + role: "assistant", + content: blocks, + api: this.session.options.model.api, + provider: this.session.options.model.provider, + model: e.model ?? this.session.options.model.id, + usage: { + input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: e.createdAt, + }); + continue; + } + + // tool/system roles dropped from the LLM transcript here — toolResult + // messages are re-derived by replayBlocked() when it runs the suspended + // tool and pushes its result before agent.continue(). + } + this.agent.state.messages = agentMessages; +} +``` + +Imports needed at top of `thread.ts`: + +```ts +import type { TextContent, ThinkingContent, ToolCall } from "@mariozechner/pi-ai"; +``` + +(`AgentMessage` should already be imported.) + +- [ ] **Step 3: Implement Engine.restoreSession** + +Replace the throwing stub in `packages/engine/src/engine.ts`. Per the spec, `restoreSession` takes a `RestoreSessionOptions` argument (`{ sessionId, options }`) — the caller re-supplies tools/sandbox/model: + +```ts +async restoreSession(args: { + sessionId: string; + options: Omit; +}): Promise { + const cached = this.sessions.get(args.sessionId); + if (cached) return cached; + const data = await this.opts.providers.store.getSession(args.sessionId); + if (!data) throw new Error(`session not found: ${args.sessionId}`); + const sandbox = await this.materializeSandbox(args.options.sandbox); + const session = await Session.rehydrate( + data, + { ...args.options, id: args.sessionId }, + this.opts.providers, + sandbox, + ); + this.sessions.set(args.sessionId, session); + return session; +} +``` + +Also add a `RestoreSessionOptions` type to `packages/engine/src/types.ts`: + +```ts +export interface RestoreSessionOptions { + sessionId: string; + options: Omit; +} +``` + +- [ ] **Step 4: Re-export `RestoreSessionOptions`** + +In `packages/engine/src/index.ts`, the existing `export * from "./types.js"` already covers it. `Engine` is already exported. + +- [ ] **Step 5: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add packages/engine/src/engine.ts packages/engine/src/session.ts packages/engine/src/thread.ts +git commit -m "feat(engine): restoreSession rehydrates session, threads, and transcripts" +``` + +--- + +### Task 13: Replay blocked turns + +If a thread has a suspended turn, restoration must either wait for the gate to resolve (still pending) or replay the tool immediately (gate already resolved). + +**Files:** +- Modify: `packages/engine/src/session.ts` +- Modify: `packages/engine/src/thread.ts` + +- [ ] **Step 1: Add `Thread.replayBlocked` that runs a single suspended tool call** + +In `packages/engine/src/thread.ts`: + +```ts +async replayBlocked(args: { + suspended: SuspendedTurnState; + resolution: DecisionResolution; +}): Promise { + const { suspended, resolution } = args; + // Build tools and find the one we need to replay + const tools = this.buildTools(); + const tool = tools.find((t) => t.name === suspended.toolName); + if (!tool) { + this.emitError( + "replay_tool_missing", + `cannot replay: tool ${suspended.toolName} not registered`, + ); + return; + } + // Seed replay context so requestDecision short-circuits on the first call. + this.setReplayContext({ gateId: suspended.gateId, resolution }); + // Run the tool to get the same result the original turn would have produced. + // We bypass the agent loop for this one call; the result will be appended + // as a synthetic toolResult message and we then call agent.continue(). + const fakeAbort = new AbortController(); + let toolResult; + try { + toolResult = await tool.execute(suspended.toolCallId, suspended.toolArgs, fakeAbort.signal); + } catch (err) { + this.emitError("replay_tool_failed", err instanceof Error ? err.message : String(err)); + return; + } + // Push as toolResult and continue the agent. + this.agent.state.messages = [ + ...this.agent.state.messages, + { + role: "toolResult", + toolCallId: suspended.toolCallId, + toolName: suspended.toolName, + content: toolResult.content, + details: toolResult.details, + isError: false, + timestamp: Date.now(), + }, + ]; + // Clear suspended turn from store before continuing. + await this.session.providers.store.clearSuspendedTurn(this.session.id, this.id); + this.setStatus("running"); + try { + await this.agent.continue(); + await this.agent.waitForIdle(); + } catch (err) { + this.emitError("replay_continue_failed", err instanceof Error ? err.message : String(err)); + } + if (this.readStatus() === "running") this.setStatus("idle"); +} +``` + +(`AgentMessage` import may need updating to include `ToolResultMessage` shape — it's already part of the `Message` union from pi-ai.) + +- [ ] **Step 2: Add `Session.replayBlocked` orchestrator** + +In `packages/engine/src/session.ts`, add a method that, for a given thread, looks up suspension state and a possibly-resolved gate, and either kicks off the replay or re-registers a waiter: + +```ts +async resumeBlockedThreadIfReady(threadId: string): Promise { + const thread = this.threads.get(threadId); + if (!thread) return; + const suspended = await this.providers.store.getSuspendedTurn(this.id, threadId); + if (!suspended) return; + const gate = await this.providers.store.getDecisionGate(this.id, suspended.gateId); + if (!gate) { + // Lost gate; clear suspended and abort the queue item + await this.providers.store.clearSuspendedTurn(this.id, threadId); + return; + } + if (gate.status === "resolved") { + // We need the resolution. Read it from the gate's DAG entry. + const entries = await this.providers.store.getEntries(this.id, threadId); + const entry = entries.find((e) => e.type === "decision_gate" && e.gate.id === gate.id); + const resolution = + entry && entry.type === "decision_gate" ? entry.resolution : undefined; + if (!resolution) { + throw new Error(`gate ${gate.id} resolved but no resolution stored`); + } + void thread.replayBlocked({ suspended, resolution }); + } else if (gate.status === "pending") { + // Re-register a waiter so resolveDecision will wake replay. + thread.armPendingGateForRestart(gate, suspended); + } + // expired/withdrawn: nothing to do; the run already terminated. +} +``` + +- [ ] **Step 3: Add `Thread.armPendingGateForRestart`** + +```ts +armPendingGateForRestart(gate: DecisionGate, suspended: SuspendedTurnState): void { + this.blockedGateId = gate.id; + this.setStatus("blocked_on_decision_gate"); + // Register the GateManager so resolveDecision/withdraw works as before. + // Once resolved, run replayBlocked. + this.gates + .register(gate, async (gateId) => { + // expiry handling: nothing more to do for replay + void gateId; + }) + .then((resolution) => { + void this.replayBlocked({ suspended, resolution }); + }) + .catch((err) => { + this.emitError( + "replay_after_pending_gate_failed", + err instanceof Error ? err.message : String(err), + ); + }); +} +``` + +- [ ] **Step 4: Call resumeBlockedThreadIfReady from Session.rehydrate** + +In `Session.rehydrate`, after attaching all threads, kick off resumption for any blocked thread: + +```ts +for (const td of threadDatas) { + void session.resumeBlockedThreadIfReady(td.id); +} +``` + +- [ ] **Step 5: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add packages/engine/src/session.ts packages/engine/src/thread.ts +git commit -m "feat(engine): replay blocked tool turns on session restore" +``` + +--- + +### Task 14: Persist queue items as well as queue state + +Right now we save `QueueState` (the whole snapshot) but the per-queue-item rows in `engine_queue_items` aren't written. For restart, we need them to know what to re-submit. + +**Files:** +- Modify: `packages/engine/src/types.ts` +- Modify: `packages/engine/src/providers/in-memory-store.ts` +- Modify: `packages/engine/src/providers/sqlite-store.ts` +- Modify: `packages/engine/src/thread.ts` + +- [ ] **Step 1: Add `saveQueueItem` and `getQueueItems` to the SessionStore interface** + +In `packages/engine/src/types.ts`, extend `SessionStore`: + +```ts +saveQueueItem(sessionId: string, item: QueueItem & { status: QueueStatus }): Promise; +getQueueItems(sessionId: string, threadId: string, opts?: { status?: QueueStatus }): Promise>; +deleteQueueItem(sessionId: string, threadId: string, itemId: string): Promise; +``` + +- [ ] **Step 2: Implement on InMemorySessionStore** + +In `packages/engine/src/providers/in-memory-store.ts`: + +```ts +private queueItemsByThread = new Map>>(); + +async saveQueueItem(sessionId: string, item: QueueItem & { status: QueueStatus }): Promise { + const r = this.row(sessionId); + // Use a per-row map; keep it simple via a property on row. + const list = r.queueItems?.get(item.threadId) ?? []; + const idx = list.findIndex((i) => i.id === item.id); + if (idx >= 0) list[idx] = item; else list.push(item); + if (!r.queueItems) r.queueItems = new Map(); + r.queueItems.set(item.threadId, list); +} + +async getQueueItems(sessionId: string, threadId: string, opts?: { status?: QueueStatus }) { + const r = this.row(sessionId); + const list = r.queueItems?.get(threadId) ?? []; + return opts?.status ? list.filter((i) => i.status === opts.status) : [...list]; +} + +async deleteQueueItem(sessionId: string, threadId: string, itemId: string): Promise { + const r = this.row(sessionId); + const list = r.queueItems?.get(threadId); + if (!list) return; + r.queueItems!.set(threadId, list.filter((i) => i.id !== itemId)); +} +``` + +Add `queueItems?: Map<...>` to the `SessionRow` interface at the top of the file. + +- [ ] **Step 3: Implement on SqliteSessionStore** + +In `packages/engine/src/providers/sqlite-store.ts`: + +```ts +async saveQueueItem( + sessionId: string, + item: QueueItem & { status: QueueStatus }, +): Promise { + this.db + .insert(engineQueueItems) + .values({ + id: item.id, + sessionId, + threadId: item.threadId, + status: item.status, + mode: "followup", // could be tracked separately; for now default + content: JSON.stringify(item.content), + author: jsonOrNull(item.author), + channel: jsonOrNull(item.channel), + replyTarget: jsonOrNull(item.replyTarget), + model: item.model ?? null, + metadata: jsonOrNull(item.metadata), + createdAt: item.createdAt, + updatedAt: Date.now(), + }) + .onConflictDoUpdate({ + target: engineQueueItems.id, + set: { + status: item.status, + updatedAt: Date.now(), + }, + }) + .run(); +} + +async getQueueItems( + sessionId: string, + threadId: string, + opts?: { status?: QueueStatus }, +): Promise> { + let rows; + if (opts?.status) { + rows = this.db + .select() + .from(engineQueueItems) + .where(and(eq(engineQueueItems.sessionId, sessionId), eq(engineQueueItems.threadId, threadId), eq(engineQueueItems.status, opts.status))) + .all(); + } else { + rows = this.db + .select() + .from(engineQueueItems) + .where(and(eq(engineQueueItems.sessionId, sessionId), eq(engineQueueItems.threadId, threadId))) + .all(); + } + return rows.map((r) => ({ + id: r.id, + threadId: r.threadId, + status: r.status as QueueStatus, + content: parseJson(r.content) ?? "", + author: parseJson(r.author), + channel: parseJson(r.channel), + replyTarget: parseJson(r.replyTarget), + model: r.model ?? undefined, + metadata: parseJson(r.metadata), + createdAt: r.createdAt, + })); +} + +async deleteQueueItem(sessionId: string, threadId: string, itemId: string): Promise { + this.db + .delete(engineQueueItems) + .where(and(eq(engineQueueItems.sessionId, sessionId), eq(engineQueueItems.threadId, threadId), eq(engineQueueItems.id, itemId))) + .run(); +} +``` + +- [ ] **Step 4: Have Thread save queue items as they progress** + +In `packages/engine/src/thread.ts`, in `submitPrompt` after building the `QueueItem`, save it: + +```ts +await this.session.providers.store.saveQueueItem(this.session.id, { + ...item, + status: "queued", +}); +``` + +In `tickQueue`, when an item starts running: + +```ts +await this.session.providers.store.saveQueueItem(this.session.id, { + ...next, + status: "running", +}); +``` + +When an item finishes (after `runItem`): + +```ts +await this.session.providers.store.deleteQueueItem(this.session.id, this.id, next.id); +``` + +When the gate suspends, mark the active item as blocked: + +In the existing `requestDecision` body: + +```ts +if (this.activeItem) { + await session.providers.store.saveQueueItem(session.id, { + ...this.activeItem, + status: "blocked_on_decision_gate", + }); +} +``` + +- [ ] **Step 5: Add the new contract tests** + +Add to `packages/engine/test/store-contract.ts` inside the describe block: + +```ts +it("saveQueueItem + getQueueItems round-trips", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + await store.saveQueueItem("sess-1", { + id: "q-1", + threadId: "th-1", + content: "hi", + createdAt: 1, + status: "queued", + }); + const items = await store.getQueueItems("sess-1", "th-1"); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ id: "q-1", status: "queued" }); +}); + +it("deleteQueueItem removes the item", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + await store.saveQueueItem("sess-1", { + id: "q-1", + threadId: "th-1", + content: "hi", + createdAt: 1, + status: "queued", + }); + await store.deleteQueueItem("sess-1", "th-1", "q-1"); + expect(await store.getQueueItems("sess-1", "th-1")).toHaveLength(0); +}); +``` + +- [ ] **Step 6: Run tests** + +```bash +pnpm test +``` + +Expected: 17+ tests passing (10 contract × 2 store backends, plus the existing 14 engine tests). + +- [ ] **Step 7: Commit** + +```bash +git add packages/engine/src/types.ts packages/engine/src/providers packages/engine/src/thread.ts packages/engine/test/store-contract.ts +git commit -m "feat(engine): persist queue items per-status for restart visibility" +``` + +--- + +### Task 15: End-to-end restart cycle test + +The plan's whole purpose: open a gate → throw away the engine → build a new engine with the same SqliteSessionStore → restoreSession → resolveDecision → verify the turn completes and the assistant's final text is persisted. + +**Files:** +- Create: `packages/engine/test/restart-safe-gates.test.ts` + +- [ ] **Step 1: Write the test** + +```ts +// packages/engine/test/restart-safe-gates.test.ts +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, Type } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + SqliteSessionStore, + VirtualSandboxProvider, + type ToolDef, + type BusEvent, + type DecisionGate, +} from "../src/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(__dirname, "..", "migrations", "sqlite"); + +function applyMigrations(db: Database.Database): void { + const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files) { + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (trimmed) db.exec(trimmed); + } + } +} + +const approvalTool: ToolDef = { + name: "do_thing", + description: "approval-gated", + parameters: Type.Object({ arg: Type.String() }), + execute: async (args, ctx) => { + const r = await ctx.requestDecision({ + type: "approval", + title: "ok?", + resumeKey: `do_thing:${args.arg}`, + }); + return { text: `did with ${r.actionId}` }; + }, +}; + +describe("restart-safe gates: full restart cycle", () => { + it("survives engine teardown and restoreSession resumes", async () => { + // Shared SQLite DB (in-process, persistent across both engine instances) + const sqlite = new Database(":memory:"); + applyMigrations(sqlite); + const db = drizzle(sqlite); + const store = new SqliteSessionStore(db); + const sandboxProvider = new VirtualSandboxProvider(); + + // Engine v1: prompt, get gate, then "crash" + const faux1 = registerFauxProvider({ provider: "restart" }); + faux1.setResponses([ + fauxAssistantMessage([fauxToolCall("do_thing", { arg: "x" }, { id: "tc1" })], { + stopReason: "toolUse", + }), + // Won't be consumed by engine v1 — engine v2 will use a fresh provider. + ]); + + const bus1 = new InMemoryEventBus(); + const events1: BusEvent[] = []; + bus1.subscribe({}, (e) => events1.push(e)); + const engine1 = new Engine({ providers: { store, bus: bus1, sandboxProvider } }); + const SESSION_ID = "sess-restart"; + const session1 = await engine1.createSession({ + id: SESSION_ID, + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux1.getModel(), + tools: [approvalTool], + }); + void session1.prompt("please do"); + + // Wait for the gate + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("gate timeout")), 2000); + const unsub = bus1.subscribe({}, (e) => { + if (e.event.type === "decision_gate") { + clearTimeout(t); + unsub(); + resolve(e.event.gate); + } + }); + }); + + // Confirm gate is persisted + const gates = await store.listDecisionGates(SESSION_ID); + expect(gates).toHaveLength(1); + const gate = gates[0]; + expect(gate.status).toBe("pending"); + + // Confirm SuspendedTurnState was written + const suspended = await store.getSuspendedTurn(SESSION_ID, gate.threadId); + expect(suspended?.toolName).toBe("do_thing"); + + // "Crash" the engine: discard everything except the store. + faux1.unregister(); + + // Engine v2: restore from store, then resolve + const faux2 = registerFauxProvider({ provider: "restart-v2" }); + // After replay completes the suspended tool, the agent.continue() call + // makes one more LLM request. Provide its response. + faux2.setResponses([fauxAssistantMessage("all done after restart")]); + + const bus2 = new InMemoryEventBus(); + const events2: BusEvent[] = []; + bus2.subscribe({}, (e) => events2.push(e)); + const engine2 = new Engine({ providers: { store, bus: bus2, sandboxProvider } }); + const session2 = await engine2.restoreSession({ + sessionId: SESSION_ID, + options: { + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux2.getModel(), + tools: [approvalTool], + }, + }); + + // Resolve the gate via session2 — should trigger replay + await session2.resolveDecision(gate.id, { + actionId: "approve", + resolvedBy: "u1", + resolvedAt: Date.now(), + }); + + // Wait for the replayed turn to land "all done after restart" + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("post-restart turn timeout")), 3000); + const unsub = bus2.subscribe({}, (e) => { + if (e.event.type === "message_end" && "messageId" in e.event) { + // We don't know the new id; check the store afterwards. + clearTimeout(t); + unsub(); + resolve(); + } + }); + }); + + const finalEntries = await session2.readEntries("web:default"); + const lastAssistant = finalEntries + .filter((e) => e.type === "message" && e.role === "assistant") + .at(-1); + expect( + lastAssistant && lastAssistant.type === "message" && lastAssistant.content, + ).toBe("all done after restart"); + + // SuspendedTurnState was cleared + const sus = await store.getSuspendedTurn(SESSION_ID, gate.threadId); + expect(sus).toBeNull(); + + // Gate is now resolved + const finalGate = await store.getDecisionGate(SESSION_ID, gate.id); + expect(finalGate?.status).toBe("resolved"); + + faux2.unregister(); + }); +}); +``` + +- [ ] **Step 2: Run it** + +```bash +pnpm test -- restart-safe-gates +``` + +Expected: 1 test passing. Likely failure modes and fixes: + +- "session not found" on restoreSession — verify `engine_sessions` row was written and `getSession` returns it. +- "tool not registered" on replay — `replayBlocked` looks up tools from the rehydrated session; ensure `restoreSession` passes `options.tools`. +- "gate ID mismatch" — the deterministic ID derivation must match between original run and replay. Both use `(sessionId, threadId, queueItemId, resumeKey)`. The queueItemId is persisted in `SuspendedTurnState`; the replay uses it. +- Agent rejects continue because last message is assistant — `replayBlocked` pushes a `toolResult` then calls `agent.continue()`, which requires the last message to be `user` or `toolResult`. If it fails, verify the toolResult message shape matches `Message` from pi-ai. + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/test/restart-safe-gates.test.ts +git commit -m "test(engine): full restart cycle restoreSession + resolve resumes turn" +``` + +--- + +### Task 16: Final regression sweep + +- [ ] **Step 1: Run all tests** + +```bash +cd packages/engine && pnpm typecheck && pnpm test +``` + +Expected: typecheck clean; all tests passing (originally 14 + 10 contract × 2 backends + 1 short-circuit + 1 restart cycle = ~36 tests). + +- [ ] **Step 2: Update README** + +Modify `packages/engine/README.md` "What works" / "What's deferred" sections: + +In "What works in this prototype", add: +- SqliteSessionStore + Drizzle schema + migrations +- Restart-safe re-entrant decision gates (deterministic IDs, `ctx.suspendedDecision` short-circuit) +- `Engine.restoreSessionWith` rehydrates session, threads, transcripts, queue, suspended turns; resumes blocked threads on resolve + +In "What's deferred", remove the "Restart-safe re-entrant decision gates" item, and add a new note: +- Postgres-dialect schema mirror for the K8s adapter (sqlite schema works today; pg-core mirror is a thin port) +- Hot/cold tiering (DO SQLite write-through cache → D1) — implementation detail of the Cloudflare adapter + +- [ ] **Step 3: Commit** + +```bash +git add packages/engine/README.md +git commit -m "docs(engine): document persistent store and restart-safe gates" +``` + +--- + +## What this plan does NOT cover (deferred) + +- **Postgres dialect mirror.** The schema is sqlite-only here. Mirroring to `drizzle-orm/pg-core` is mechanical (same logical tables, different column helpers) and can run against `pg-mem` for tests. Worth a separate small plan. +- **Cloudflare D1 wiring.** The `SqliteSessionStore` uses `better-sqlite3`. A `D1SessionStore` reusing the same Drizzle queries through `drizzle-orm/d1` is a thin adapter task — separate plan. +- **Hibernation.** Engine restoration is invoked manually in tests; in production the SessionHostDO will call it on wake. That's adapter-layer work. +- **Compaction, role/skill loading, model failover.** Independent of persistence. + +## Self-review + +**Spec coverage:** Restart-safe re-entrant gates (spec line 722) ✓, SuspendedTurnState persistence (line 858) ✓, deterministic gate identity (line 986) ✓, schema/migrations contract (line 1545) ✓, "engine_*" tables required by spec (line 1338) ✓. Postgres mirror (line 1544) — explicitly deferred with a note. + +**Placeholder scan:** No "TBD" / "TODO" / "similar to" steps. Code blocks are complete in every step. + +**Type consistency:** `deterministicGateId` / `fromRequest` / `GateContext` consistent across decision-gate.ts and thread.ts. `setReplayContext` / `armPendingGateForRestart` / `replayBlocked` / `resumeBlockedThreadIfReady` defined exactly once each, called by name elsewhere. `restoreSessionWith` is the public restoration entry; `restoreSession` becomes the throw-with-helpful-message path. `saveQueueItem`/`getQueueItems`/`deleteQueueItem` added to SessionStore in Task 14 and implemented on both backends in the same task. diff --git a/docs/specs/2026-05-02-portable-runtime-engine-design.md b/docs/specs/2026-05-02-portable-runtime-engine-design.md index ac9acd36..dc38bc3f 100644 --- a/docs/specs/2026-05-02-portable-runtime-engine-design.md +++ b/docs/specs/2026-05-02-portable-runtime-engine-design.md @@ -249,12 +249,21 @@ The engine is a library. Platform adapters host it and expose HTTP/WebSocket ent ```typescript interface Engine { createSession(opts: CreateSessionOptions): Promise; - restoreSession(sessionId: string): Promise; + restoreSession(opts: RestoreSessionOptions): Promise; getSession(sessionId: string): Promise; deleteSession(sessionId: string): Promise; onEvent(listener: (event: BusEvent) => void): Unsubscribe; } +interface RestoreSessionOptions { + sessionId: string; + // Same shape as CreateSessionOptions minus `id` — the caller re-supplies + // tools, sandbox, model, system prompt, etc. The engine does not maintain + // a registry of session-creation options across restarts; the host (DO, + // pod, CLI) is responsible for reconstructing them from its own config. + options: Omit; +} + interface CreateSessionOptions { id?: string; userId: string; @@ -469,6 +478,14 @@ interface BranchSummaryEntry extends BaseEntry { The active conversation path is reconstructed by following `parentId` pointers from the leaf back to the root. Compaction inserts a summary without rewriting history. +**LLM-faithful entry persistence (rehydration contract):** the engine must persist enough information in `MessageEntry.parts` to reconstruct LLM-compatible content blocks on restore. Specifically: + +- An assistant entry that issued tool calls MUST persist one `MessagePart` of type `tool_call` per call, with `callId`, `toolName`, and `args`. Without this, a restored transcript would show the assistant's text but lose the tool calls, producing a malformed `[user, assistant(text), toolResult]` sequence that LLM providers reject. +- A tool-result entry (role `tool`) MUST persist `callId` so the LLM provider can match it to the assistant's tool call. +- Thinking content, if recorded at all, persists with provider-specific signatures intact when available, so cross-provider handoff and replay produce valid context. + +`MessageEntry.content` is the human-readable text rendering; `MessageEntry.parts` is the structured source of truth used during rehydration. + **Suspension history rules:** Decision-gated turns are represented in the DAG by a first-class `DecisionGateEntry`, not by synthetic system messages. The entry is created when the gate is opened and then updated in place as it moves through `pending`, `resolved`, `expired`, or `withdrawn` states. This keeps the history model explicit and replayable: gates are decision artifacts, not conversation utterances. **V1 branching stance:** The storage model remains DAG-based so future replay and alternate branches are possible without schema redesign, but V1 does not require exposing full user-facing branch/replay controls in the API. V1 must preserve enough metadata for later branching support without forcing branching UX to ship in the first implementation batch. @@ -683,8 +700,14 @@ interface ToolContext { sandbox: Sandbox; // Structured runtime interactions - requestDecision: (gate: DecisionGate) => Promise; + requestDecision: (req: DecisionGateRequest) => Promise; emitArtifact?: (artifact: ToolArtifact) => Promise; + /** + * Set by the engine ONLY on a replayed tool execution after restart. + * When `gateId` matches the deterministic ID derived from this call's + * `req.resumeKey`, the engine returns the stored `resolution` immediately + * instead of opening a new gate. Tools never set this themselves. + */ suspendedDecision?: SuspendedDecisionContext; // Abort @@ -721,6 +744,12 @@ Approval-gated tools follow the same suspension model. A tool can return or thro **Restart-safe tool suspension contract:** The engine does not rely on preserving an in-memory JavaScript continuation across restarts. Tools that call `requestDecision(...)` must therefore be re-entrant up to their decision points. On first execution, `requestDecision(...)` persists the gate and suspends the turn. On resumed execution, the engine re-runs the tool from the start with `suspendedDecision` populated for the matching gate ID, and the same `requestDecision(...)` call returns the stored resolution instead of creating a new gate. +**What "re-entrant up to the decision point" means in practice:** any work the tool does *before* `requestDecision(...)` will run twice — once on the original execution (lost when the engine restarts), once on replay. Side effects in that prefix must be idempotent or read-only. Work *after* `requestDecision(...)` returns runs once on replay only. Tools that need to do non-idempotent work before a gate should split into two tools (one to do the work and persist a result, another to gate-and-act on it) or move the work to after the gate. + +**How the engine populates `ctx.suspendedDecision`:** on `restoreSession`, for every thread whose persisted queue status is `blocked_on_decision_gate`, the engine loads the corresponding `DecisionGate` and `SuspendedTurnState`. If the gate is still `pending`, the engine re-arms its in-memory wait so a future `resolveDecision(...)` call delivers the resolution. If the gate is already `resolved` (the user resolved it while the engine was down) or becomes resolved later, the engine invokes the persisted tool by name with the persisted args, sets `ctx.suspendedDecision = { gateId, resolution }` for that one execution, and feeds the returned `ToolResult` back into the agent loop as if the original turn had completed — then calls the agent's continuation to produce the next assistant turn. + +**Replay event guarantees:** the replayed tool execution does not need to emit the same per-call `tool_start` / `tool_end` event pair as the original turn (the original pair was already emitted before the engine went down). The engine MUST emit the post-replay `text_delta` / `message_end` / `turn_end` events for the continuation turn so that connected clients see the agent finish the work. Adapters re-deliver pending gates on client (re)connection through the `init` event payload. + #### Plugin Action Bridge V1 may continue using existing plugin action packages through an adapter: @@ -861,9 +890,9 @@ When a thread enters `blocked_on_decision_gate`, the engine persists a `Suspende - session ID / thread ID / active queue item ID - current model - active leaf message ID -- pending gate ID -- pending tool call ID, tool name, and original tool args -- any deterministic resume context needed for `requestDecision(...)` to short-circuit on replay +- pending gate ID (derived from `gate:${sessionId}:${threadId}:${queueItemId}:${resumeKey}`) +- pending tool call ID, tool name, and original tool args (used to invoke the tool by name during replay) +- the `resumeKey` the tool supplied (used to recompute the gate ID on replay and confirm a match) On restore, the engine reloads the blocked thread, reloads the decision gate, and waits for either resolution, expiry, or cancellation. Once resolved, the engine reconstructs the turn from the checkpoint and re-drives execution. @@ -985,6 +1014,27 @@ The `DecisionGateEntry.id` should be the canonical DAG entry ID for the gate, wh **Deterministic gate identity:** A gate created from a tool execution must use a stable ID for that suspension point within the active turn. This is what allows the engine to re-run the tool after restart and have `requestDecision(...)` match the existing persisted gate instead of creating a duplicate. +The V1 derivation is: + +``` +gateId = `gate:${sessionId}:${threadId}:${queueItemId}:${resumeKey}` +``` + +`resumeKey` is **required** on `DecisionGateRequest` (not optional). Tool authors choose a key that uniquely identifies the suspension point given the tool's inputs — typically a function of the tool's args (e.g. `"github.create_pr:owner/repo:head→base"`). Two `requestDecision(...)` calls in the same active queue item with the same `resumeKey` open the same gate. Two calls with different `resumeKey`s open different gates. A replayed tool execution that reaches the same `requestDecision(...)` call site with the same args produces the same `resumeKey` and therefore the same `gateId`, which is how the short-circuit works. + +```typescript +interface DecisionGateRequest { + type: 'approval' | 'question' | 'credential_request'; + title: string; + body?: string; + actions?: DecisionAction[]; + expiresAt?: number; + context?: Record; + origin?: { channelType?: string; channelId?: string; messageId?: string }; + resumeKey: string; // REQUIRED for restart-safe gates +} +``` + **Resolution paths:** - explicit action selection (`approve`, `deny`, option buttons) @@ -1698,8 +1748,8 @@ Every adapter must provide: - Engine instance lookup by session ID. - Session affinity so prompts, decision resolutions, and aborts for one session reach the same active engine instance. - Event subscription and client delivery over WebSocket and/or SSE. -- Startup restoration of queued, running, and blocked threads from `SessionStore`. -- Idle eviction/hibernation that calls `store.flush()` and leaves enough persisted state to resume. +- Startup restoration of queued, running, and blocked threads from `SessionStore` via `engine.restoreSession({ sessionId, options })`. The adapter is responsible for reconstructing `options` (tools, sandbox handle, model, system prompt, role/skill sources) from its own configuration — the engine itself does not persist creation options. +- Idle eviction/hibernation that calls `store.flush()` and leaves enough persisted state to resume. Specifically: any thread with status `running` or `blocked_on_decision_gate`, plus its active queue item and (for blocked threads) its `SuspendedTurnState`, must be readable on wake. - Fatal error handling that marks the session `error`, publishes a client `error` event, and prevents silent queue accumulation. Cloudflare V1 uses one `SessionHostDO` per session ID. Kubernetes may use a process-local `SessionPool`, but must provide equivalent session affinity and restore behavior. From 1a4c732a96ee69af87eafd5f879d851ff1ffa5bc Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:28:53 -0700 Subject: [PATCH 04/26] chore(engine): add drizzle-orm and better-sqlite3 deps --- packages/engine/package.json | 7 ++++++- pnpm-lock.yaml | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/engine/package.json b/packages/engine/package.json index 76061d31..17d12454 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -14,14 +14,19 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "db:generate": "drizzle-kit generate" }, "dependencies": { "@mariozechner/pi-agent-core": "0.73.0", "@mariozechner/pi-ai": "0.73.0", + "drizzle-orm": "^0.45.1", "typebox": "^1.1.24" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "better-sqlite3": "^11.0.0", + "drizzle-kit": "^0.31.9", "typescript": "^5.3.3", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf2bd43..ea04ee86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,10 +135,22 @@ importers: '@mariozechner/pi-ai': specifier: 0.73.0 version: 0.73.0(ws@8.18.0)(zod@3.25.76) + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@cloudflare/workers-types@4.20260118.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(bun-types@1.3.12) typebox: specifier: ^1.1.24 version: 1.1.37 devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.8 + version: 7.6.13 + better-sqlite3: + specifier: ^11.0.0 + version: 11.10.0 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 typescript: specifier: ^5.3.3 version: 5.9.3 From a0ceb413ab6d9fc4085588be8fcdf66a3067f0a1 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:29:39 -0700 Subject: [PATCH 05/26] feat(engine): add Drizzle SQLite schema for engine tables --- packages/engine/src/schema/index.ts | 1 + packages/engine/src/schema/sqlite.ts | 170 +++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 packages/engine/src/schema/index.ts create mode 100644 packages/engine/src/schema/sqlite.ts diff --git a/packages/engine/src/schema/index.ts b/packages/engine/src/schema/index.ts new file mode 100644 index 00000000..c101553f --- /dev/null +++ b/packages/engine/src/schema/index.ts @@ -0,0 +1 @@ +export * from "./sqlite.js"; diff --git a/packages/engine/src/schema/sqlite.ts b/packages/engine/src/schema/sqlite.ts new file mode 100644 index 00000000..cbd6adf5 --- /dev/null +++ b/packages/engine/src/schema/sqlite.ts @@ -0,0 +1,170 @@ +import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"; + +export const engineSessions = sqliteTable( + "engine_sessions", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + orgId: text("org_id").notNull(), + workspace: text("workspace").notNull(), + purpose: text("purpose").notNull(), + status: text("status").notNull(), + sandboxId: text("sandbox_id"), + snapshotId: text("snapshot_id"), + parentSessionId: text("parent_session_id"), + metadata: text("metadata"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [ + index("engine_sessions_user").on(t.userId), + index("engine_sessions_status").on(t.status), + ], +); + +export const engineThreads = sqliteTable( + "engine_threads", + { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + key: text("key").notNull(), + status: text("status").notNull(), + activeLeafEntryId: text("active_leaf_entry_id"), + queueMode: text("queue_mode").notNull(), + model: text("model"), + summary: text("summary"), + metadata: text("metadata"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [ + index("engine_threads_session").on(t.sessionId), + index("engine_threads_session_key").on(t.sessionId, t.key), + ], +); + +export const engineEntries = sqliteTable( + "engine_entries", + { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + parentId: text("parent_id"), + entryType: text("entry_type").notNull(), + role: text("role"), + content: text("content"), + parts: text("parts"), + author: text("author"), + channel: text("channel"), + model: text("model"), + summary: text("summary"), + coveredEntryIds: text("covered_entry_ids"), + tokenCountBefore: integer("token_count_before"), + tokenCountAfter: integer("token_count_after"), + fileContext: text("file_context"), + branchRootId: text("branch_root_id"), + branchLeafId: text("branch_leaf_id"), + gateId: text("gate_id"), + resolvedAt: text("resolved_at"), + resolution: text("resolution"), + withdrawnReason: text("withdrawn_reason"), + metadata: text("metadata"), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + index("engine_entries_thread").on(t.sessionId, t.threadId, t.createdAt), + index("engine_entries_gate").on(t.gateId), + ], +); + +export const engineQueueItems = sqliteTable( + "engine_queue_items", + { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + status: text("status").notNull(), + mode: text("mode").notNull(), + content: text("content").notNull(), + author: text("author"), + channel: text("channel"), + replyTarget: text("reply_target"), + model: text("model"), + metadata: text("metadata"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [index("engine_queue_items_thread").on(t.sessionId, t.threadId, t.status)], +); + +export const engineQueueState = sqliteTable( + "engine_queue_state", + { + threadId: text("thread_id").notNull(), + sessionId: text("session_id").notNull(), + mode: text("mode").notNull(), + status: text("status").notNull(), + activeItemId: text("active_item_id"), + pending: text("pending").notNull(), + collectBuffer: text("collect_buffer"), + blockedGateId: text("blocked_gate_id"), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [primaryKey({ columns: [t.sessionId, t.threadId] })], +); + +export const engineDecisionGates = sqliteTable( + "engine_decision_gates", + { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + type: text("type").notNull(), + status: text("status").notNull(), + title: text("title").notNull(), + body: text("body"), + actions: text("actions").notNull(), + origin: text("origin"), + context: text("context"), + resolution: text("resolution"), + expiresAt: integer("expires_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [index("engine_decision_gates_thread").on(t.sessionId, t.threadId, t.status)], +); + +export const engineDecisionGateRefs = sqliteTable( + "engine_decision_gate_refs", + { + id: text("id").primaryKey(), + gateId: text("gate_id").notNull(), + channelType: text("channel_type").notNull(), + ref: text("ref").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [index("engine_decision_gate_refs_gate").on(t.gateId)], +); + +export const engineSuspendedTurns = sqliteTable( + "engine_suspended_turns", + { + sessionId: text("session_id").notNull(), + threadId: text("thread_id").notNull(), + queueItemId: text("queue_item_id").notNull(), + gateId: text("gate_id").notNull(), + model: text("model").notNull(), + leafEntryId: text("leaf_entry_id"), + toolCallId: text("tool_call_id").notNull(), + toolName: text("tool_name").notNull(), + toolArgs: text("tool_args").notNull(), + resumeKey: text("resume_key").notNull(), + attempt: integer("attempt").notNull(), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + primaryKey({ columns: [t.sessionId, t.threadId] }), + index("engine_suspended_turns_gate").on(t.gateId), + ], +); From cd27d3d132477ccafef02894d99cf85f4922b85b Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:30:02 -0700 Subject: [PATCH 06/26] feat(engine): generate initial sqlite migration --- packages/engine/drizzle.config.ts | 7 + .../migrations/sqlite/0000_lonely_lizard.sql | 137 +++ .../migrations/sqlite/meta/0000_snapshot.json | 905 ++++++++++++++++++ .../migrations/sqlite/meta/_journal.json | 13 + 4 files changed, 1062 insertions(+) create mode 100644 packages/engine/drizzle.config.ts create mode 100644 packages/engine/migrations/sqlite/0000_lonely_lizard.sql create mode 100644 packages/engine/migrations/sqlite/meta/0000_snapshot.json create mode 100644 packages/engine/migrations/sqlite/meta/_journal.json diff --git a/packages/engine/drizzle.config.ts b/packages/engine/drizzle.config.ts new file mode 100644 index 00000000..ffc696b7 --- /dev/null +++ b/packages/engine/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/schema/sqlite.ts", + out: "./migrations/sqlite", +}); diff --git a/packages/engine/migrations/sqlite/0000_lonely_lizard.sql b/packages/engine/migrations/sqlite/0000_lonely_lizard.sql new file mode 100644 index 00000000..644fea54 --- /dev/null +++ b/packages/engine/migrations/sqlite/0000_lonely_lizard.sql @@ -0,0 +1,137 @@ +CREATE TABLE `engine_decision_gate_refs` ( + `id` text PRIMARY KEY NOT NULL, + `gate_id` text NOT NULL, + `channel_type` text NOT NULL, + `ref` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_decision_gate_refs_gate` ON `engine_decision_gate_refs` (`gate_id`);--> statement-breakpoint +CREATE TABLE `engine_decision_gates` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `thread_id` text NOT NULL, + `type` text NOT NULL, + `status` text NOT NULL, + `title` text NOT NULL, + `body` text, + `actions` text NOT NULL, + `origin` text, + `context` text, + `resolution` text, + `expires_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_decision_gates_thread` ON `engine_decision_gates` (`session_id`,`thread_id`,`status`);--> statement-breakpoint +CREATE TABLE `engine_entries` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `thread_id` text NOT NULL, + `parent_id` text, + `entry_type` text NOT NULL, + `role` text, + `content` text, + `parts` text, + `author` text, + `channel` text, + `model` text, + `summary` text, + `covered_entry_ids` text, + `token_count_before` integer, + `token_count_after` integer, + `file_context` text, + `branch_root_id` text, + `branch_leaf_id` text, + `gate_id` text, + `resolved_at` text, + `resolution` text, + `withdrawn_reason` text, + `metadata` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_entries_thread` ON `engine_entries` (`session_id`,`thread_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `engine_entries_gate` ON `engine_entries` (`gate_id`);--> statement-breakpoint +CREATE TABLE `engine_queue_items` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `thread_id` text NOT NULL, + `status` text NOT NULL, + `mode` text NOT NULL, + `content` text NOT NULL, + `author` text, + `channel` text, + `reply_target` text, + `model` text, + `metadata` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_queue_items_thread` ON `engine_queue_items` (`session_id`,`thread_id`,`status`);--> statement-breakpoint +CREATE TABLE `engine_queue_state` ( + `thread_id` text NOT NULL, + `session_id` text NOT NULL, + `mode` text NOT NULL, + `status` text NOT NULL, + `active_item_id` text, + `pending` text NOT NULL, + `collect_buffer` text, + `blocked_gate_id` text, + `updated_at` integer NOT NULL, + PRIMARY KEY(`session_id`, `thread_id`) +); +--> statement-breakpoint +CREATE TABLE `engine_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `org_id` text NOT NULL, + `workspace` text NOT NULL, + `purpose` text NOT NULL, + `status` text NOT NULL, + `sandbox_id` text, + `snapshot_id` text, + `parent_session_id` text, + `metadata` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_sessions_user` ON `engine_sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `engine_sessions_status` ON `engine_sessions` (`status`);--> statement-breakpoint +CREATE TABLE `engine_suspended_turns` ( + `session_id` text NOT NULL, + `thread_id` text NOT NULL, + `queue_item_id` text NOT NULL, + `gate_id` text NOT NULL, + `model` text NOT NULL, + `leaf_entry_id` text, + `tool_call_id` text NOT NULL, + `tool_name` text NOT NULL, + `tool_args` text NOT NULL, + `resume_key` text NOT NULL, + `attempt` integer NOT NULL, + `created_at` integer NOT NULL, + PRIMARY KEY(`session_id`, `thread_id`) +); +--> statement-breakpoint +CREATE INDEX `engine_suspended_turns_gate` ON `engine_suspended_turns` (`gate_id`);--> statement-breakpoint +CREATE TABLE `engine_threads` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `key` text NOT NULL, + `status` text NOT NULL, + `active_leaf_entry_id` text, + `queue_mode` text NOT NULL, + `model` text, + `summary` text, + `metadata` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `engine_threads_session` ON `engine_threads` (`session_id`);--> statement-breakpoint +CREATE INDEX `engine_threads_session_key` ON `engine_threads` (`session_id`,`key`); \ No newline at end of file diff --git a/packages/engine/migrations/sqlite/meta/0000_snapshot.json b/packages/engine/migrations/sqlite/meta/0000_snapshot.json new file mode 100644 index 00000000..bbe4de98 --- /dev/null +++ b/packages/engine/migrations/sqlite/meta/0000_snapshot.json @@ -0,0 +1,905 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e464cf58-a0fd-494a-9e66-70a9fe4c5fd5", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "engine_decision_gate_refs": { + "name": "engine_decision_gate_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "gate_id": { + "name": "gate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_decision_gate_refs_gate": { + "name": "engine_decision_gate_refs_gate", + "columns": [ + "gate_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_decision_gates": { + "name": "engine_decision_gates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_decision_gates_thread": { + "name": "engine_decision_gates_thread", + "columns": [ + "session_id", + "thread_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_entries": { + "name": "engine_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_type": { + "name": "entry_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "covered_entry_ids": { + "name": "covered_entry_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_count_before": { + "name": "token_count_before", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_count_after": { + "name": "token_count_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_context": { + "name": "file_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_root_id": { + "name": "branch_root_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_leaf_id": { + "name": "branch_leaf_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gate_id": { + "name": "gate_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "withdrawn_reason": { + "name": "withdrawn_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_entries_thread": { + "name": "engine_entries_thread", + "columns": [ + "session_id", + "thread_id", + "created_at" + ], + "isUnique": false + }, + "engine_entries_gate": { + "name": "engine_entries_gate", + "columns": [ + "gate_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_queue_items": { + "name": "engine_queue_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_target": { + "name": "reply_target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_queue_items_thread": { + "name": "engine_queue_items_thread", + "columns": [ + "session_id", + "thread_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_queue_state": { + "name": "engine_queue_state", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_item_id": { + "name": "active_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending": { + "name": "pending", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collect_buffer": { + "name": "collect_buffer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocked_gate_id": { + "name": "blocked_gate_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "engine_queue_state_session_id_thread_id_pk": { + "columns": [ + "session_id", + "thread_id" + ], + "name": "engine_queue_state_session_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_sessions": { + "name": "engine_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace": { + "name": "workspace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_sessions_user": { + "name": "engine_sessions_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "engine_sessions_status": { + "name": "engine_sessions_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_suspended_turns": { + "name": "engine_suspended_turns", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_item_id": { + "name": "queue_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gate_id": { + "name": "gate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaf_entry_id": { + "name": "leaf_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_args": { + "name": "tool_args", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resume_key": { + "name": "resume_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_suspended_turns_gate": { + "name": "engine_suspended_turns_gate", + "columns": [ + "gate_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "engine_suspended_turns_session_id_thread_id_pk": { + "columns": [ + "session_id", + "thread_id" + ], + "name": "engine_suspended_turns_session_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "engine_threads": { + "name": "engine_threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_leaf_entry_id": { + "name": "active_leaf_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_mode": { + "name": "queue_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "engine_threads_session": { + "name": "engine_threads_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "engine_threads_session_key": { + "name": "engine_threads_session_key", + "columns": [ + "session_id", + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/engine/migrations/sqlite/meta/_journal.json b/packages/engine/migrations/sqlite/meta/_journal.json new file mode 100644 index 00000000..5a4d72af --- /dev/null +++ b/packages/engine/migrations/sqlite/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1778041793822, + "tag": "0000_lonely_lizard", + "breakpoints": true + } + ] +} \ No newline at end of file From d5842c7dee1c9fa32676eecd296eb2663d71cfe2 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:30:44 -0700 Subject: [PATCH 07/26] test(engine): add SessionStore contract test suite --- packages/engine/test/store-contract.ts | 236 +++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 packages/engine/test/store-contract.ts diff --git a/packages/engine/test/store-contract.ts b/packages/engine/test/store-contract.ts new file mode 100644 index 00000000..5c55aec3 --- /dev/null +++ b/packages/engine/test/store-contract.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { + DecisionGate, + MessageEntry, + QueueState, + SessionData, + SessionEntry, + SessionStore, + SuspendedTurnState, + ThreadData, +} from "../src/index.js"; + +export interface StoreContractContext { + factory: () => SessionStore | Promise; + teardown?: (store: SessionStore) => void | Promise; +} + +export function runSessionStoreContract(name: string, ctx: StoreContractContext) { + describe(`SessionStore contract: ${name}`, () => { + let store: SessionStore; + + beforeEach(async () => { + store = await ctx.factory(); + }); + + function newSession(overrides: Partial = {}): SessionData { + return { + id: "sess-1", + userId: "u1", + orgId: "o1", + workspace: "/", + purpose: "interactive", + status: "running", + createdAt: 1, + updatedAt: 1, + ...overrides, + }; + } + + function newThread(sessionId: string, key = "web:default", id = "th-1"): ThreadData { + return { + id, + sessionId, + key, + status: "active", + queueMode: "followup", + createdAt: 1, + updatedAt: 1, + }; + } + + function msg(id: string, role: "user" | "assistant", content: string, ts: number): MessageEntry { + return { + id, + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "message", + role, + content, + createdAt: ts, + }; + } + + it("saveSession + getSession round-trips", async () => { + const s = newSession(); + await store.saveSession(s); + const loaded = await store.getSession(s.id); + expect(loaded).toMatchObject({ id: "sess-1", userId: "u1", status: "running" }); + }); + + it("listSessions filters by userId", async () => { + await store.saveSession(newSession({ id: "a", userId: "u1" })); + await store.saveSession(newSession({ id: "b", userId: "u2" })); + const list = await store.listSessions("u1"); + expect(list.map((s) => s.id)).toEqual(["a"]); + }); + + it("saveThread + listThreads round-trips", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1", "task:A", "th-1")); + await store.saveThread("sess-1", newThread("sess-1", "task:B", "th-2")); + const threads = await store.listThreads("sess-1"); + expect(threads.length).toBe(2); + expect(threads.map((t) => t.key).sort()).toEqual(["task:A", "task:B"]); + }); + + it("appendEntries + getEntries returns entries in insertion order", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + await store.appendEntries("sess-1", "th-1", [ + msg("e-1", "user", "hi", 10), + msg("e-2", "assistant", "hello", 20), + ]); + const loaded = await store.getEntries("sess-1", "th-1"); + expect(loaded).toHaveLength(2); + expect(loaded[0]).toMatchObject({ id: "e-1", type: "message", role: "user", content: "hi" }); + expect(loaded[1]).toMatchObject({ id: "e-2", type: "message", role: "assistant" }); + }); + + it("appendEntries persists decision_gate entries", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "ok?", + actions: [{ id: "approve", label: "Approve" }], + createdAt: 100, + updatedAt: 100, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + await store.appendEntries("sess-1", "th-1", [ + { + id: "e-g", + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "decision_gate", + gate, + createdAt: 100, + }, + ]); + const loaded = await store.getEntries("sess-1", "th-1"); + const gateEntry = loaded.find((e) => e.type === "decision_gate"); + expect(gateEntry).toBeDefined(); + expect(gateEntry && gateEntry.type === "decision_gate" && gateEntry.gate.id).toBe("g-1"); + }); + + it("saveQueueState + getQueueState round-trips", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const qs: QueueState = { + threadId: "th-1", + mode: "followup", + status: "running", + activeItemId: "q-1", + pending: [], + }; + await store.saveQueueState("sess-1", "th-1", qs); + const loaded = await store.getQueueState("sess-1", "th-1"); + expect(loaded).toMatchObject({ threadId: "th-1", status: "running", activeItemId: "q-1" }); + }); + + it("saveDecisionGate + listDecisionGates + getDecisionGate", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "x", + actions: [], + createdAt: 1, + updatedAt: 1, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + const list = await store.listDecisionGates("sess-1"); + expect(list).toHaveLength(1); + const single = await store.getDecisionGate("sess-1", "g-1"); + expect(single?.title).toBe("x"); + }); + + it("saveSuspendedTurn + getSuspendedTurn + clearSuspendedTurn", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const sus: SuspendedTurnState = { + sessionId: "sess-1", + threadId: "th-1", + queueItemId: "q-1", + gateId: "g-1", + model: "faux/faux-1", + toolCallId: "tc-1", + toolName: "do_thing", + toolArgs: { arg: "x" }, + resumeKey: "do_thing:x", + attempt: 1, + createdAt: 1, + }; + await store.saveSuspendedTurn("sess-1", "th-1", sus); + expect(await store.getSuspendedTurn("sess-1", "th-1")).toMatchObject({ + toolName: "do_thing", + toolArgs: { arg: "x" }, + }); + await store.clearSuspendedTurn("sess-1", "th-1"); + expect(await store.getSuspendedTurn("sess-1", "th-1")).toBeNull(); + }); + + it("updateDecisionGateEntry patches the matching entry", async () => { + await store.saveSession(newSession()); + await store.saveThread("sess-1", newThread("sess-1")); + const gate: DecisionGate = { + id: "g-1", + sessionId: "sess-1", + threadId: "th-1", + type: "approval", + status: "pending", + title: "x", + actions: [], + createdAt: 1, + updatedAt: 1, + }; + await store.saveDecisionGate("sess-1", "th-1", gate); + await store.appendEntries("sess-1", "th-1", [ + { + id: "e-g", + sessionId: "sess-1", + threadId: "th-1", + parentId: null, + type: "decision_gate", + gate, + createdAt: 1, + }, + ]); + await store.updateDecisionGateEntry("sess-1", "th-1", "g-1", { + gate: { ...gate, status: "resolved" }, + resolution: { actionId: "approve", resolvedBy: "u1", resolvedAt: 5 }, + }); + const entries = await store.getEntries("sess-1", "th-1"); + const e = entries.find((x) => x.type === "decision_gate"); + expect(e && e.type === "decision_gate" && e.gate.status).toBe("resolved"); + expect(e && e.type === "decision_gate" && e.resolution?.actionId).toBe("approve"); + }); + + it("deleteSession removes the session", async () => { + await store.saveSession(newSession()); + await store.deleteSession("sess-1"); + expect(await store.getSession("sess-1")).toBeNull(); + }); + }); +} From 22c0b97073a9f73f74134c7dd83c458a9a2b2830 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:31:03 -0700 Subject: [PATCH 08/26] test(engine): run contract suite against InMemorySessionStore --- packages/engine/test/in-memory-store.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/engine/test/in-memory-store.test.ts diff --git a/packages/engine/test/in-memory-store.test.ts b/packages/engine/test/in-memory-store.test.ts new file mode 100644 index 00000000..d46b4fae --- /dev/null +++ b/packages/engine/test/in-memory-store.test.ts @@ -0,0 +1,6 @@ +import { InMemorySessionStore } from "../src/index.js"; +import { runSessionStoreContract } from "./store-contract.js"; + +runSessionStoreContract("InMemorySessionStore", { + factory: () => new InMemorySessionStore(), +}); From a7d4bd55f55fff3556e16ed1800fe3bbba129cdb Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:32:41 -0700 Subject: [PATCH 09/26] feat(engine): SqliteSessionStore implementation --- packages/engine/src/index.ts | 1 + .../src/providers/sqlite-store-helpers.ts | 191 +++++++ packages/engine/src/providers/sqlite-store.ts | 497 ++++++++++++++++++ 3 files changed, 689 insertions(+) create mode 100644 packages/engine/src/providers/sqlite-store-helpers.ts create mode 100644 packages/engine/src/providers/sqlite-store.ts diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2ce4962f..9b347e7a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -6,6 +6,7 @@ export { InMemorySessionStore } from "./providers/in-memory-store.js"; export { InMemoryEventBus } from "./providers/in-memory-bus.js"; export { InMemoryBlobStore } from "./providers/in-memory-blob.js"; export { InMemoryCredentialStore } from "./providers/in-memory-credentials.js"; +export { SqliteSessionStore } from "./providers/sqlite-store.js"; export { VirtualSandbox, VirtualSandboxProvider } from "./providers/virtual-sandbox.js"; export { builtinTools, readTool, writeTool, editTool, bashTool, threadReadTool } from "./builtin-tools/index.js"; export { diff --git a/packages/engine/src/providers/sqlite-store-helpers.ts b/packages/engine/src/providers/sqlite-store-helpers.ts new file mode 100644 index 00000000..6208e0de --- /dev/null +++ b/packages/engine/src/providers/sqlite-store-helpers.ts @@ -0,0 +1,191 @@ +import type { + CompactionEntry, + DecisionGate, + DecisionGateEntry, + MessageEntry, + BranchSummaryEntry, + SessionEntry, +} from "../types.js"; + +export function jsonOrNull(value: T | undefined | null): string | null { + return value === undefined || value === null ? null : JSON.stringify(value); +} + +export function parseJson(value: string | null | undefined): T | undefined { + if (value === null || value === undefined) return undefined; + return JSON.parse(value) as T; +} + +export interface EntryRow { + id: string; + sessionId: string; + threadId: string; + parentId: string | null; + entryType: string; + role: string | null; + content: string | null; + parts: string | null; + author: string | null; + channel: string | null; + model: string | null; + summary: string | null; + coveredEntryIds: string | null; + tokenCountBefore: number | null; + tokenCountAfter: number | null; + fileContext: string | null; + branchRootId: string | null; + branchLeafId: string | null; + gateId: string | null; + resolvedAt: string | null; + resolution: string | null; + withdrawnReason: string | null; + metadata: string | null; + createdAt: number; +} + +export function entryToRow(entry: SessionEntry): EntryRow { + const base: EntryRow = { + id: entry.id, + sessionId: entry.sessionId, + threadId: entry.threadId, + parentId: entry.parentId, + entryType: entry.type, + role: null, + content: null, + parts: null, + author: null, + channel: null, + model: null, + summary: null, + coveredEntryIds: null, + tokenCountBefore: null, + tokenCountAfter: null, + fileContext: null, + branchRootId: null, + branchLeafId: null, + gateId: null, + resolvedAt: null, + resolution: null, + withdrawnReason: null, + metadata: jsonOrNull(entry.metadata), + createdAt: entry.createdAt, + }; + switch (entry.type) { + case "message": + return { + ...base, + role: entry.role, + content: entry.content, + parts: jsonOrNull(entry.parts), + author: jsonOrNull(entry.author), + channel: jsonOrNull(entry.channel), + model: entry.model ?? null, + }; + case "compaction": + return { + ...base, + summary: entry.summary, + coveredEntryIds: JSON.stringify(entry.coveredEntryIds), + tokenCountBefore: entry.tokenCountBefore, + tokenCountAfter: entry.tokenCountAfter, + fileContext: jsonOrNull(entry.fileContext), + }; + case "branch_summary": + return { + ...base, + branchRootId: entry.branchRootId, + branchLeafId: entry.branchLeafId, + summary: entry.summary, + }; + case "decision_gate": + // The gate snapshot lives in metadata under a reserved `gate` key so we + // don't need a dedicated text column for it. + return { + ...base, + gateId: entry.gate.id, + metadata: JSON.stringify({ gate: entry.gate, ...(entry.metadata ?? {}) }), + resolvedAt: entry.resolvedAt ?? null, + resolution: jsonOrNull(entry.resolution), + withdrawnReason: entry.withdrawnReason ?? null, + }; + } +} + +export function rowToEntry(row: EntryRow): SessionEntry { + switch (row.entryType) { + case "message": { + const e: MessageEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "message", + role: (row.role as MessageEntry["role"]) ?? "user", + content: row.content ?? "", + parts: parseJson(row.parts), + author: parseJson(row.author), + channel: parseJson(row.channel), + model: row.model ?? undefined, + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "compaction": { + const e: CompactionEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "compaction", + summary: row.summary ?? "", + coveredEntryIds: parseJson(row.coveredEntryIds) ?? [], + tokenCountBefore: row.tokenCountBefore ?? 0, + tokenCountAfter: row.tokenCountAfter ?? 0, + fileContext: parseJson(row.fileContext), + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "branch_summary": { + const e: BranchSummaryEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "branch_summary", + branchRootId: row.branchRootId ?? "", + branchLeafId: row.branchLeafId ?? "", + summary: row.summary ?? "", + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + }; + return e; + } + case "decision_gate": { + const meta = parseJson<{ gate: DecisionGate } & Record>(row.metadata); + const gate = meta?.gate; + if (!gate) throw new Error(`decision_gate entry ${row.id} missing gate snapshot`); + const { gate: _gate, ...userMeta } = meta; + void _gate; + const e: DecisionGateEntry = { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + parentId: row.parentId, + type: "decision_gate", + gate, + resolvedAt: row.resolvedAt ?? undefined, + resolution: parseJson(row.resolution), + withdrawnReason: + (row.withdrawnReason as DecisionGateEntry["withdrawnReason"]) ?? undefined, + metadata: Object.keys(userMeta).length > 0 ? (userMeta as Record) : undefined, + createdAt: row.createdAt, + }; + return e; + } + default: + throw new Error(`unknown entry type: ${row.entryType}`); + } +} diff --git a/packages/engine/src/providers/sqlite-store.ts b/packages/engine/src/providers/sqlite-store.ts new file mode 100644 index 00000000..7a6828ef --- /dev/null +++ b/packages/engine/src/providers/sqlite-store.ts @@ -0,0 +1,497 @@ +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import { and, eq, asc } from "drizzle-orm"; +import { + engineSessions, + engineThreads, + engineEntries, + engineQueueItems, + engineQueueState, + engineDecisionGates, + engineDecisionGateRefs, + engineSuspendedTurns, +} from "../schema/sqlite.js"; +import type { + DecisionGate, + DecisionGateEntry, + DecisionGateRef, + ListOpts, + MessageQuery, + QueueState, + SessionData, + SessionEntry, + SessionStatus, + SessionStore, + SuspendedTurnState, + ThreadData, +} from "../types.js"; +import { entryToRow, jsonOrNull, parseJson, rowToEntry, type EntryRow } from "./sqlite-store-helpers.js"; + +export class SqliteSessionStore implements SessionStore { + constructor(private readonly db: BetterSQLite3Database) {} + + async saveSession(session: SessionData): Promise { + this.db + .insert(engineSessions) + .values({ + id: session.id, + userId: session.userId, + orgId: session.orgId, + workspace: session.workspace, + purpose: session.purpose, + status: session.status, + sandboxId: session.sandboxId ?? null, + snapshotId: session.snapshotId ?? null, + parentSessionId: session.parentSessionId ?? null, + metadata: jsonOrNull(session.metadata), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }) + .onConflictDoUpdate({ + target: engineSessions.id, + set: { + status: session.status, + sandboxId: session.sandboxId ?? null, + snapshotId: session.snapshotId ?? null, + metadata: jsonOrNull(session.metadata), + updatedAt: session.updatedAt, + }, + }) + .run(); + } + + async saveThread(_sessionId: string, thread: ThreadData): Promise { + this.db + .insert(engineThreads) + .values({ + id: thread.id, + sessionId: thread.sessionId, + key: thread.key, + status: thread.status, + activeLeafEntryId: thread.activeLeafEntryId ?? null, + queueMode: thread.queueMode, + model: thread.model ?? null, + summary: thread.summary ?? null, + metadata: jsonOrNull(thread.metadata), + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }) + .onConflictDoUpdate({ + target: engineThreads.id, + set: { + status: thread.status, + activeLeafEntryId: thread.activeLeafEntryId ?? null, + queueMode: thread.queueMode, + model: thread.model ?? null, + summary: thread.summary ?? null, + updatedAt: thread.updatedAt, + }, + }) + .run(); + } + + async appendEntries(_sessionId: string, threadId: string, entries: SessionEntry[]): Promise { + for (const e of entries) { + const row = entryToRow(e); + this.db.insert(engineEntries).values(row).run(); + } + if (entries.length > 0) { + const lastId = entries[entries.length - 1].id; + this.db + .update(engineThreads) + .set({ activeLeafEntryId: lastId, updatedAt: Date.now() }) + .where(eq(engineThreads.id, threadId)) + .run(); + } + } + + async saveQueueState(sessionId: string, threadId: string, queue: QueueState): Promise { + this.db + .insert(engineQueueState) + .values({ + sessionId, + threadId, + mode: queue.mode, + status: queue.status, + activeItemId: queue.activeItemId ?? null, + pending: JSON.stringify(queue.pending), + collectBuffer: queue.collectBuffer ? JSON.stringify(queue.collectBuffer) : null, + blockedGateId: queue.blockedGateId ?? null, + updatedAt: Date.now(), + }) + .onConflictDoUpdate({ + target: [engineQueueState.sessionId, engineQueueState.threadId], + set: { + mode: queue.mode, + status: queue.status, + activeItemId: queue.activeItemId ?? null, + pending: JSON.stringify(queue.pending), + collectBuffer: queue.collectBuffer ? JSON.stringify(queue.collectBuffer) : null, + blockedGateId: queue.blockedGateId ?? null, + updatedAt: Date.now(), + }, + }) + .run(); + } + + async saveDecisionGate(sessionId: string, threadId: string, gate: DecisionGate): Promise { + this.db + .insert(engineDecisionGates) + .values({ + id: gate.id, + sessionId, + threadId, + type: gate.type, + status: gate.status, + title: gate.title, + body: gate.body ?? null, + actions: JSON.stringify(gate.actions), + origin: jsonOrNull(gate.origin), + context: jsonOrNull(gate.context), + resolution: null, + expiresAt: gate.expiresAt ?? null, + createdAt: gate.createdAt, + updatedAt: gate.updatedAt, + }) + .onConflictDoUpdate({ + target: engineDecisionGates.id, + set: { + status: gate.status, + title: gate.title, + body: gate.body ?? null, + actions: JSON.stringify(gate.actions), + context: jsonOrNull(gate.context), + updatedAt: gate.updatedAt, + }, + }) + .run(); + } + + async saveDecisionGateRef( + _sessionId: string, + _threadId: string, + gateId: string, + ref: { channelType: string; ref: DecisionGateRef }, + ): Promise { + this.db + .insert(engineDecisionGateRefs) + .values({ + id: `${gateId}:${ref.channelType}:${ref.ref.messageId}`, + gateId, + channelType: ref.channelType, + ref: JSON.stringify(ref.ref), + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); + } + + async updateDecisionGateEntry( + sessionId: string, + threadId: string, + gateId: string, + patch: Partial, + ): Promise { + const rows = this.db + .select() + .from(engineEntries) + .where( + and( + eq(engineEntries.sessionId, sessionId), + eq(engineEntries.threadId, threadId), + eq(engineEntries.gateId, gateId), + ), + ) + .all() as EntryRow[]; + for (const row of rows) { + const current = rowToEntry(row); + if (current.type !== "decision_gate") continue; + const merged: DecisionGateEntry = { + ...current, + ...patch, + gate: patch.gate ?? current.gate, + }; + const newRow = entryToRow(merged); + this.db + .update(engineEntries) + .set({ + metadata: newRow.metadata, + resolvedAt: newRow.resolvedAt, + resolution: newRow.resolution, + withdrawnReason: newRow.withdrawnReason, + }) + .where(eq(engineEntries.id, row.id)) + .run(); + } + } + + async saveSuspendedTurn( + sessionId: string, + threadId: string, + s: SuspendedTurnState, + ): Promise { + this.db + .insert(engineSuspendedTurns) + .values({ + sessionId, + threadId, + queueItemId: s.queueItemId, + gateId: s.gateId, + model: s.model, + leafEntryId: s.leafMessageId ?? null, + toolCallId: s.toolCallId, + toolName: s.toolName, + toolArgs: JSON.stringify(s.toolArgs), + resumeKey: s.resumeKey, + attempt: s.attempt, + createdAt: s.createdAt, + }) + .onConflictDoUpdate({ + target: [engineSuspendedTurns.sessionId, engineSuspendedTurns.threadId], + set: { + queueItemId: s.queueItemId, + gateId: s.gateId, + model: s.model, + leafEntryId: s.leafMessageId ?? null, + toolCallId: s.toolCallId, + toolName: s.toolName, + toolArgs: JSON.stringify(s.toolArgs), + resumeKey: s.resumeKey, + attempt: s.attempt, + }, + }) + .run(); + } + + async clearSuspendedTurn(sessionId: string, threadId: string): Promise { + this.db + .delete(engineSuspendedTurns) + .where( + and( + eq(engineSuspendedTurns.sessionId, sessionId), + eq(engineSuspendedTurns.threadId, threadId), + ), + ) + .run(); + } + + async updateSessionStatus( + id: string, + status: SessionStatus, + metadata?: Partial, + ): Promise { + this.db + .update(engineSessions) + .set({ + status, + sandboxId: metadata?.sandboxId ?? undefined, + snapshotId: metadata?.snapshotId ?? undefined, + updatedAt: Date.now(), + }) + .where(eq(engineSessions.id, id)) + .run(); + } + + async getSession(id: string): Promise { + const row = this.db.select().from(engineSessions).where(eq(engineSessions.id, id)).get(); + if (!row) return null; + return { + id: row.id, + userId: row.userId, + orgId: row.orgId, + workspace: row.workspace, + purpose: row.purpose as SessionData["purpose"], + status: row.status as SessionData["status"], + sandboxId: row.sandboxId ?? undefined, + snapshotId: row.snapshotId ?? undefined, + parentSessionId: row.parentSessionId ?? undefined, + metadata: parseJson(row.metadata), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async listSessions(userId: string, opts?: ListOpts): Promise { + const rows = this.db + .select() + .from(engineSessions) + .where(eq(engineSessions.userId, userId)) + .all(); + let result: SessionData[] = rows.map((r) => ({ + id: r.id, + userId: r.userId, + orgId: r.orgId, + workspace: r.workspace, + purpose: r.purpose as SessionData["purpose"], + status: r.status as SessionData["status"], + sandboxId: r.sandboxId ?? undefined, + snapshotId: r.snapshotId ?? undefined, + parentSessionId: r.parentSessionId ?? undefined, + metadata: parseJson(r.metadata), + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + if (opts?.status) result = result.filter((s) => s.status === opts.status); + return result; + } + + async getThread(sessionId: string, threadId: string): Promise { + const row = this.db + .select() + .from(engineThreads) + .where(and(eq(engineThreads.sessionId, sessionId), eq(engineThreads.id, threadId))) + .get(); + if (!row) return null; + return rowToThread(row); + } + + async listThreads(sessionId: string): Promise { + const rows = this.db + .select() + .from(engineThreads) + .where(eq(engineThreads.sessionId, sessionId)) + .all(); + return rows.map(rowToThread); + } + + async getEntries( + sessionId: string, + threadId: string, + opts?: MessageQuery, + ): Promise { + let rows = this.db + .select() + .from(engineEntries) + .where(and(eq(engineEntries.sessionId, sessionId), eq(engineEntries.threadId, threadId))) + .orderBy(asc(engineEntries.createdAt)) + .all() as EntryRow[]; + if (opts?.includeCompacted === false) rows = rows.filter((r) => r.entryType !== "compaction"); + if (opts?.limit && opts.limit > 0) rows = rows.slice(-opts.limit); + return rows.map(rowToEntry); + } + + async getQueueState(sessionId: string, threadId: string): Promise { + const row = this.db + .select() + .from(engineQueueState) + .where( + and(eq(engineQueueState.sessionId, sessionId), eq(engineQueueState.threadId, threadId)), + ) + .get(); + if (!row) return null; + return { + threadId: row.threadId, + mode: row.mode as QueueState["mode"], + status: row.status as QueueState["status"], + activeItemId: row.activeItemId ?? undefined, + pending: parseJson(row.pending) ?? [], + collectBuffer: parseJson(row.collectBuffer), + blockedGateId: row.blockedGateId ?? undefined, + }; + } + + async listDecisionGates(sessionId: string, threadId?: string): Promise { + const rows = threadId + ? this.db + .select() + .from(engineDecisionGates) + .where( + and( + eq(engineDecisionGates.sessionId, sessionId), + eq(engineDecisionGates.threadId, threadId), + ), + ) + .all() + : this.db + .select() + .from(engineDecisionGates) + .where(eq(engineDecisionGates.sessionId, sessionId)) + .all(); + return rows.map(rowToGate); + } + + async getDecisionGate(sessionId: string, gateId: string): Promise { + const row = this.db + .select() + .from(engineDecisionGates) + .where( + and(eq(engineDecisionGates.sessionId, sessionId), eq(engineDecisionGates.id, gateId)), + ) + .get(); + return row ? rowToGate(row) : null; + } + + async getSuspendedTurn( + sessionId: string, + threadId: string, + ): Promise { + const row = this.db + .select() + .from(engineSuspendedTurns) + .where( + and( + eq(engineSuspendedTurns.sessionId, sessionId), + eq(engineSuspendedTurns.threadId, threadId), + ), + ) + .get(); + if (!row) return null; + return { + sessionId: row.sessionId, + threadId: row.threadId, + queueItemId: row.queueItemId, + gateId: row.gateId, + model: row.model, + leafMessageId: row.leafEntryId ?? undefined, + toolCallId: row.toolCallId, + toolName: row.toolName, + toolArgs: parseJson(row.toolArgs) ?? {}, + resumeKey: row.resumeKey, + attempt: row.attempt, + createdAt: row.createdAt, + }; + } + + async deleteSession(id: string): Promise { + this.db.delete(engineEntries).where(eq(engineEntries.sessionId, id)).run(); + this.db.delete(engineQueueItems).where(eq(engineQueueItems.sessionId, id)).run(); + this.db.delete(engineQueueState).where(eq(engineQueueState.sessionId, id)).run(); + this.db.delete(engineDecisionGates).where(eq(engineDecisionGates.sessionId, id)).run(); + this.db.delete(engineSuspendedTurns).where(eq(engineSuspendedTurns.sessionId, id)).run(); + this.db.delete(engineThreads).where(eq(engineThreads.sessionId, id)).run(); + this.db.delete(engineSessions).where(eq(engineSessions.id, id)).run(); + } +} + +function rowToThread(r: typeof engineThreads.$inferSelect): ThreadData { + return { + id: r.id, + sessionId: r.sessionId, + key: r.key, + status: r.status as ThreadData["status"], + activeLeafEntryId: r.activeLeafEntryId ?? undefined, + queueMode: r.queueMode as ThreadData["queueMode"], + model: r.model ?? undefined, + summary: r.summary ?? undefined, + metadata: parseJson(r.metadata), + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +function rowToGate(row: typeof engineDecisionGates.$inferSelect): DecisionGate { + return { + id: row.id, + sessionId: row.sessionId, + threadId: row.threadId, + type: row.type as DecisionGate["type"], + status: row.status as DecisionGate["status"], + title: row.title, + body: row.body ?? undefined, + actions: parseJson(row.actions) ?? [], + origin: parseJson(row.origin), + context: parseJson(row.context), + expiresAt: row.expiresAt ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} From f507e23d76d1e3c843991dfc8fb511211ef6183f Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:34:25 -0700 Subject: [PATCH 10/26] test(engine): run contract suite against SqliteSessionStore --- packages/engine/test/sqlite-store.test.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/engine/test/sqlite-store.test.ts diff --git a/packages/engine/test/sqlite-store.test.ts b/packages/engine/test/sqlite-store.test.ts new file mode 100644 index 00000000..4ba3eb43 --- /dev/null +++ b/packages/engine/test/sqlite-store.test.ts @@ -0,0 +1,33 @@ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SqliteSessionStore } from "../src/index.js"; +import { runSessionStoreContract } from "./store-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(__dirname, "..", "migrations", "sqlite"); + +function applyMigrations(db: Database.Database): void { + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const file of files) { + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (trimmed) db.exec(trimmed); + } + } +} + +runSessionStoreContract("SqliteSessionStore", { + factory: () => { + const sqlite = new Database(":memory:"); + applyMigrations(sqlite); + const db = drizzle(sqlite); + return new SqliteSessionStore(db); + }, +}); From 1e00e33b1a62cbd2ac5b4031f3edb8b5ec602d64 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:35:26 -0700 Subject: [PATCH 11/26] feat(engine): deterministic gate IDs derived from resumeKey --- packages/engine/src/decision-gate.ts | 29 ++++++++++++++++------ packages/engine/src/thread.ts | 7 +++++- packages/engine/test/decision-gate.test.ts | 1 + 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/decision-gate.ts b/packages/engine/src/decision-gate.ts index e6cc2556..548770d0 100644 --- a/packages/engine/src/decision-gate.ts +++ b/packages/engine/src/decision-gate.ts @@ -130,16 +130,29 @@ export function isDecisionGateExpired(err: unknown): err is DecisionGateExpiredE return err instanceof DecisionGateExpiredError; } -export function fromRequest( - req: DecisionGateRequest, - sessionId: string, - threadId: string, -): DecisionGate { +export interface GateContext { + sessionId: string; + threadId: string; + queueItemId: string; + resumeKey: string; +} + +export function deterministicGateId(ctx: GateContext): string { + return `gate:${ctx.sessionId}:${ctx.threadId}:${ctx.queueItemId}:${ctx.resumeKey}`; +} + +export function fromRequest(req: DecisionGateRequest, gateCtx: GateContext): DecisionGate { + if (!req.resumeKey) { + throw new Error( + "DecisionGateRequest.resumeKey is required for restart-safe gates. " + + "Tools must supply a stable key per suspension point.", + ); + } const now = Date.now(); return { - id: req.resumeKey ?? `gate-${now}-${Math.random().toString(36).slice(2, 9)}`, - sessionId, - threadId, + id: deterministicGateId(gateCtx), + sessionId: gateCtx.sessionId, + threadId: gateCtx.threadId, type: req.type, title: req.title, body: req.body, diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index fad39d2b..ff85d5eb 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -386,7 +386,12 @@ export class Thread { signal, decisionGateId: this.toolCtxOverlay.gateId, requestDecision: async (req: DecisionGateRequest): Promise => { - const gate = fromRequest(req, session.id, this.id); + const gate = fromRequest(req, { + sessionId: session.id, + threadId: this.id, + queueItemId: this.activeItem?.id ?? "", + resumeKey: req.resumeKey ?? "", + }); await session.providers.store.saveDecisionGate(session.id, this.id, gate); const gateEntry: SessionEntry = { id: uid("e"), diff --git a/packages/engine/test/decision-gate.test.ts b/packages/engine/test/decision-gate.test.ts index c52a46e5..25bcdb4c 100644 --- a/packages/engine/test/decision-gate.test.ts +++ b/packages/engine/test/decision-gate.test.ts @@ -165,6 +165,7 @@ describe("decision gates: pending -> expired", () => { type: "approval", title: "expire me", expiresAt: Date.now() + 30, // expires 30ms from now + resumeKey: "expire-me-1", }); return { text: "should not reach" }; }, From 982ee042e8586451028b2b31d5f6bdada2d1ee3a Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:36:18 -0700 Subject: [PATCH 12/26] feat(engine): requestDecision short-circuits on suspendedDecision replay --- packages/engine/src/thread.ts | 42 +++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index ff85d5eb..59c382d7 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -3,7 +3,7 @@ import type { AgentEvent, AgentMessage, AgentTool } from "@mariozechner/pi-agent import type { Message } from "@mariozechner/pi-ai"; import type { Session } from "./session.js"; import { toAgentTool } from "./tool-bridge.js"; -import { fromRequest, GateManager } from "./decision-gate.js"; +import { fromRequest, GateManager, deterministicGateId } from "./decision-gate.js"; import type { DecisionGate, DecisionGateRequest, @@ -66,6 +66,9 @@ export class Thread { private currentAssistantParts: MessagePart[] = []; private currentToolCalls = new Map(); private toolCtxOverlay: { gateId?: string } = {}; + private suspendedDecisionForReplay: + | { gateId: string; resolution?: DecisionResolution } + | undefined; constructor(session: Session, data: ThreadData) { this.session = session; @@ -203,6 +206,17 @@ export class Thread { } } + /** + * Used by Engine.restoreSession to seed replay state before re-running a + * blocked tool. When the tool calls requestDecision with a matching + * resumeKey, the engine returns the stored resolution immediately. + */ + setReplayContext( + ctx: { gateId: string; resolution?: DecisionResolution } | undefined, + ): void { + this.suspendedDecisionForReplay = ctx; + } + setMode(mode: QueueMode): void { this.mode = mode; } @@ -385,13 +399,33 @@ export class Thread { sandbox: session.sandbox, signal, decisionGateId: this.toolCtxOverlay.gateId, + suspendedDecision: this.suspendedDecisionForReplay, requestDecision: async (req: DecisionGateRequest): Promise => { - const gate = fromRequest(req, { + if (!req.resumeKey) { + throw new Error( + "DecisionGateRequest.resumeKey is required for restart-safe gates.", + ); + } + const gateCtx = { sessionId: session.id, threadId: this.id, queueItemId: this.activeItem?.id ?? "", - resumeKey: req.resumeKey ?? "", - }); + resumeKey: req.resumeKey, + }; + // Restart-safe replay: if running with a suspendedDecision and the + // gate ID matches, return the stored resolution without re-persisting. + if (this.suspendedDecisionForReplay) { + const expectedId = deterministicGateId(gateCtx); + if ( + this.suspendedDecisionForReplay.gateId === expectedId && + this.suspendedDecisionForReplay.resolution + ) { + const resolution = this.suspendedDecisionForReplay.resolution; + this.suspendedDecisionForReplay = undefined; // one-shot + return resolution; + } + } + const gate = fromRequest(req, gateCtx); await session.providers.store.saveDecisionGate(session.id, this.id, gate); const gateEntry: SessionEntry = { id: uid("e"), From 34b17a4a8e595781eca387622c29a0ac867ea2a6 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:37:05 -0700 Subject: [PATCH 13/26] feat(engine): persist real tool call id and args on gate suspension --- packages/engine/src/thread.ts | 19 ++++++++++++++----- packages/engine/src/tool-bridge.ts | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index 59c382d7..b27e2898 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -382,11 +382,19 @@ export class Thread { private buildTools(): AgentTool[] { const all: ToolDef[] = [...this.session.builtinTools, ...(this.session.options.tools ?? [])]; return all.map((def) => - toAgentTool(def, (signal, toolCallId) => this.buildToolContext(signal, toolCallId)), + toAgentTool(def, ({ signal, toolCallId, toolName, toolArgs }) => + this.buildToolContext({ signal, toolCallId, toolName, toolArgs }), + ), ); } - private buildToolContext(signal: AbortSignal, toolCallId: string): ToolContext { + private buildToolContext(args: { + signal: AbortSignal; + toolCallId: string; + toolName: string; + toolArgs: Record; + }): ToolContext { + const { signal, toolCallId, toolName, toolArgs } = args; const session = this.session; return { userId: session.options.userId, @@ -438,7 +446,8 @@ export class Thread { }; await session.providers.store.appendEntries(session.id, this.id, [gateEntry]); - // checkpoint the suspended turn + // checkpoint the suspended turn — use real toolName + toolArgs so + // restoreSession can replay this exact tool call. await session.providers.store.saveSuspendedTurn(session.id, this.id, { sessionId: session.id, threadId: this.id, @@ -446,8 +455,8 @@ export class Thread { gateId: gate.id, model: session.options.model.id, toolCallId, - toolName: req.title, - toolArgs: {}, + toolName, + toolArgs, resumeKey: req.resumeKey ?? gate.id, attempt: 1, createdAt: Date.now(), diff --git a/packages/engine/src/tool-bridge.ts b/packages/engine/src/tool-bridge.ts index 89412dad..04c48122 100644 --- a/packages/engine/src/tool-bridge.ts +++ b/packages/engine/src/tool-bridge.ts @@ -6,10 +6,18 @@ import type { ToolDef, ToolContext, ToolResult, ToolAttachment } from "./types.j * Adapt one engine ToolDef to a pi-agent-core AgentTool, capturing the engine * ToolContext via closure. The bridge also normalizes our ToolResult into the * pi AgentToolResult shape (TextContent | ImageContent[]). + * + * `buildContext` receives the toolCallId, toolName, and validated args so the + * engine can persist them in SuspendedTurnState if the tool opens a gate. */ export function toAgentTool( def: ToolDef, - buildContext: (signal: AbortSignal, toolCallId: string) => ToolContext, + buildContext: (args: { + signal: AbortSignal; + toolCallId: string; + toolName: string; + toolArgs: Record; + }) => ToolContext, ): AgentTool { return { name: def.name, @@ -17,7 +25,12 @@ export function toAgentTool( description: def.description, parameters: def.parameters, execute: async (toolCallId, params, signal) => { - const ctx = buildContext(signal ?? new AbortController().signal, toolCallId); + const ctx = buildContext({ + signal: signal ?? new AbortController().signal, + toolCallId, + toolName: def.name, + toolArgs: params as Record, + }); const result = await def.execute(params as never, ctx); return toAgentToolResult(result); }, From a9db3586f3e608d286dbdc4ebb3c5009b3cf916d Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:37:54 -0700 Subject: [PATCH 14/26] test(engine): unit-test the gate short-circuit predicate --- packages/engine/src/decision-gate.ts | 18 +++++++++ packages/engine/src/thread.ts | 19 ++++----- packages/engine/test/short-circuit.test.ts | 46 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 packages/engine/test/short-circuit.test.ts diff --git a/packages/engine/src/decision-gate.ts b/packages/engine/src/decision-gate.ts index 548770d0..25a23a65 100644 --- a/packages/engine/src/decision-gate.ts +++ b/packages/engine/src/decision-gate.ts @@ -141,6 +141,24 @@ export function deterministicGateId(ctx: GateContext): string { return `gate:${ctx.sessionId}:${ctx.threadId}:${ctx.queueItemId}:${ctx.resumeKey}`; } +/** + * Returns whether the engine should short-circuit `requestDecision` and + * return a stored resolution from a replayed tool execution. + * + * Pure function — kept testable in isolation from Thread/Agent timing. + */ +export function shouldShortCircuit(args: { + ctx: GateContext; + suspendedDecision: { gateId: string; resolution?: DecisionResolution } | undefined; +}): { match: true; resolution: DecisionResolution } | { match: false } { + const { ctx, suspendedDecision } = args; + if (!suspendedDecision) return { match: false }; + const expectedId = deterministicGateId(ctx); + if (suspendedDecision.gateId !== expectedId) return { match: false }; + if (!suspendedDecision.resolution) return { match: false }; + return { match: true, resolution: suspendedDecision.resolution }; +} + export function fromRequest(req: DecisionGateRequest, gateCtx: GateContext): DecisionGate { if (!req.resumeKey) { throw new Error( diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index b27e2898..c85395c8 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -3,7 +3,7 @@ import type { AgentEvent, AgentMessage, AgentTool } from "@mariozechner/pi-agent import type { Message } from "@mariozechner/pi-ai"; import type { Session } from "./session.js"; import { toAgentTool } from "./tool-bridge.js"; -import { fromRequest, GateManager, deterministicGateId } from "./decision-gate.js"; +import { fromRequest, GateManager, shouldShortCircuit } from "./decision-gate.js"; import type { DecisionGate, DecisionGateRequest, @@ -422,16 +422,13 @@ export class Thread { }; // Restart-safe replay: if running with a suspendedDecision and the // gate ID matches, return the stored resolution without re-persisting. - if (this.suspendedDecisionForReplay) { - const expectedId = deterministicGateId(gateCtx); - if ( - this.suspendedDecisionForReplay.gateId === expectedId && - this.suspendedDecisionForReplay.resolution - ) { - const resolution = this.suspendedDecisionForReplay.resolution; - this.suspendedDecisionForReplay = undefined; // one-shot - return resolution; - } + const sc = shouldShortCircuit({ + ctx: gateCtx, + suspendedDecision: this.suspendedDecisionForReplay, + }); + if (sc.match) { + this.suspendedDecisionForReplay = undefined; // one-shot + return sc.resolution; } const gate = fromRequest(req, gateCtx); await session.providers.store.saveDecisionGate(session.id, this.id, gate); diff --git a/packages/engine/test/short-circuit.test.ts b/packages/engine/test/short-circuit.test.ts new file mode 100644 index 00000000..ac48919c --- /dev/null +++ b/packages/engine/test/short-circuit.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { shouldShortCircuit, deterministicGateId } from "../src/decision-gate.js"; + +const ctx = { sessionId: "s1", threadId: "t1", queueItemId: "q1", resumeKey: "do:x" }; +const gateId = deterministicGateId(ctx); +const resolution = { actionId: "approve", resolvedBy: "u", resolvedAt: 1 }; + +describe("shouldShortCircuit", () => { + it("returns no match when no suspendedDecision", () => { + expect(shouldShortCircuit({ ctx, suspendedDecision: undefined }).match).toBe(false); + }); + + it("returns no match when gateId differs", () => { + expect( + shouldShortCircuit({ + ctx, + suspendedDecision: { gateId: "gate:other", resolution }, + }).match, + ).toBe(false); + }); + + it("returns no match when resolution is missing", () => { + expect(shouldShortCircuit({ ctx, suspendedDecision: { gateId } }).match).toBe(false); + }); + + it("returns match + resolution when gateId and resolution are present", () => { + const result = shouldShortCircuit({ + ctx, + suspendedDecision: { gateId, resolution }, + }); + expect(result.match).toBe(true); + if (result.match) expect(result.resolution).toEqual(resolution); + }); + + it("two ctx with same fields produce the same gateId", () => { + const a = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k" }); + const b = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k" }); + expect(a).toBe(b); + }); + + it("differing resumeKey changes gateId", () => { + const a = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k1" }); + const b = deterministicGateId({ sessionId: "s", threadId: "t", queueItemId: "q", resumeKey: "k2" }); + expect(a).not.toBe(b); + }); +}); From 53ab91b6645d38dcafe3df3715f266ee63b5a927 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:39:21 -0700 Subject: [PATCH 15/26] feat(engine): restoreSession rehydrates session, threads, and transcripts --- packages/engine/src/engine.ts | 23 +++++++----- packages/engine/src/session.ts | 26 ++++++++++++++ packages/engine/src/thread.ts | 66 +++++++++++++++++++++++++++++++++- packages/engine/src/types.ts | 10 ++++++ 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index b47ff668..9bd649d5 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -3,6 +3,7 @@ import { Session } from "./session.js"; import type { CreateSessionOptions, EngineOptions, + RestoreSessionOptions, Sandbox, SandboxCreateOpts, } from "./types.js"; @@ -32,14 +33,20 @@ export class Engine { return session; } - async restoreSession(sessionId: string): Promise { - const existing = this.sessions.get(sessionId); - if (existing) return existing; - const data = await this.opts.providers.store.getSession(sessionId); - if (!data) throw new Error(`session not found: ${sessionId}`); - // V1 prototype: full restoration of pending queue items / suspended turns - // is a follow-up. Here we only rehydrate the session shell. - throw new Error("restoreSession: not implemented in prototype yet"); + async restoreSession(args: RestoreSessionOptions): Promise { + const cached = this.sessions.get(args.sessionId); + if (cached) return cached; + const data = await this.opts.providers.store.getSession(args.sessionId); + if (!data) throw new Error(`session not found: ${args.sessionId}`); + const sandbox = await this.materializeSandbox(args.options.sandbox); + const session = await Session.rehydrate( + data, + { ...args.options, id: args.sessionId }, + this.opts.providers, + sandbox, + ); + this.sessions.set(args.sessionId, session); + return session; } getSession(sessionId: string): Session | null { diff --git a/packages/engine/src/session.ts b/packages/engine/src/session.ts index 5e8874a0..d7bcd1d2 100644 --- a/packages/engine/src/session.ts +++ b/packages/engine/src/session.ts @@ -43,6 +43,32 @@ export class Session { this.sandbox = sandbox; } + /** + * Rebuild a Session from persisted state. Called by Engine.restoreSession. + * The caller re-supplies tools/sandbox/model in options. + */ + static async rehydrate( + data: SessionData, + options: CreateSessionOptions, + providers: ProviderBundle, + sandbox: Sandbox, + ): Promise { + const session = new Session(data.id, options, providers, sandbox); + const threadDatas = await providers.store.listThreads(data.id); + for (const td of threadDatas) { + const thread = new Thread(session, td); + session.attachThread(thread); + const entries = await providers.store.getEntries(data.id, td.id); + thread.rehydrateTranscript(entries); + } + return session; + } + + private attachThread(thread: Thread): void { + this.threads.set(thread.id, thread); + this.threadsByKey.set(thread.key, thread); + } + async ensureDefaultThread(): Promise { return this.thread("web:default"); } diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index c85395c8..457d15b9 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -1,6 +1,6 @@ import { Agent } from "@mariozechner/pi-agent-core"; import type { AgentEvent, AgentMessage, AgentTool } from "@mariozechner/pi-agent-core"; -import type { Message } from "@mariozechner/pi-ai"; +import type { Message, TextContent, ThinkingContent, ToolCall } from "@mariozechner/pi-ai"; import type { Session } from "./session.js"; import { toAgentTool } from "./tool-bridge.js"; import { fromRequest, GateManager, shouldShortCircuit } from "./decision-gate.js"; @@ -217,6 +217,70 @@ export class Thread { this.suspendedDecisionForReplay = ctx; } + /** + * Reconstruct the agent transcript from persisted DAG entries. + * + * Critical: assistant entries that issued tool calls have those calls in + * `entry.parts` as `tool_call` parts. We MUST rebuild the AssistantMessage's + * content[] with both text and ToolCall blocks, otherwise pushing a + * subsequent toolResult (during replay) produces a malformed + * [user, assistant(text-only), toolResult] sequence that LLM providers + * reject. tool/system roles are dropped here — `replayBlocked` re-derives + * the toolResult message before continuing. + */ + rehydrateTranscript(entries: SessionEntry[]): void { + const agentMessages: AgentMessage[] = []; + for (const e of entries) { + if (e.type !== "message") continue; + if (e.role === "user") { + agentMessages.push({ + role: "user", + content: [{ type: "text", text: e.content }], + timestamp: e.createdAt, + }); + continue; + } + if (e.role === "assistant") { + const blocks: Array = []; + const parts = e.parts ?? []; + const hadStructuredParts = parts.length > 0; + for (const p of parts) { + if (p.type === "text") blocks.push({ type: "text", text: p.text }); + else if (p.type === "thinking") blocks.push({ type: "thinking", thinking: p.text }); + else if (p.type === "tool_call") { + blocks.push({ + type: "toolCall", + id: p.callId, + name: p.toolName, + arguments: (p.args as Record) ?? {}, + }); + } + } + if (!hadStructuredParts && e.content) { + blocks.push({ type: "text", text: e.content }); + } + agentMessages.push({ + role: "assistant", + content: blocks, + api: this.session.options.model.api, + provider: this.session.options.model.provider, + model: e.model ?? this.session.options.model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: e.createdAt, + }); + } + } + this.agent.state.messages = agentMessages; + } + setMode(mode: QueueMode): void { this.mode = mode; } diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 797e4166..a5175a35 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -588,6 +588,16 @@ export interface CreateSessionOptions { metadata?: Record; } +/** + * Options accepted by Engine.restoreSession. The host re-supplies tools, + * sandbox, model, etc. — the engine does not maintain a registry of session + * creation options across restarts. + */ +export interface RestoreSessionOptions { + sessionId: string; + options: Omit; +} + export interface ProviderBundle { store: SessionStore; bus: EventBus; From fa0b3bce5dd3541f655566cd3afa98f2955f3928 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:40:30 -0700 Subject: [PATCH 16/26] feat(engine): replay blocked tool turns on session restore --- packages/engine/src/session.ts | 38 +++++++++++++++ packages/engine/src/thread.ts | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/packages/engine/src/session.ts b/packages/engine/src/session.ts index d7bcd1d2..bfe78df5 100644 --- a/packages/engine/src/session.ts +++ b/packages/engine/src/session.ts @@ -61,9 +61,47 @@ export class Session { const entries = await providers.store.getEntries(data.id, td.id); thread.rehydrateTranscript(entries); } + // Kick off blocked-thread resumption (fire-and-forget; replay completes + // asynchronously when the gate is resolved or already resolved). + for (const td of threadDatas) { + void session.resumeBlockedThreadIfReady(td.id); + } return session; } + /** + * For a thread that was blocked on a decision gate when persisted, either + * re-arm a wait for the still-pending gate (so resolveDecision triggers + * replay) or — if the gate was already resolved while the engine was down — + * trigger replay immediately. + */ + async resumeBlockedThreadIfReady(threadId: string): Promise { + const thread = this.threads.get(threadId); + if (!thread) return; + const suspended = await this.providers.store.getSuspendedTurn(this.id, threadId); + if (!suspended) return; + const gate = await this.providers.store.getDecisionGate(this.id, suspended.gateId); + if (!gate) { + await this.providers.store.clearSuspendedTurn(this.id, threadId); + return; + } + if (gate.status === "resolved") { + const entries = await this.providers.store.getEntries(this.id, threadId); + const entry = entries.find( + (e) => e.type === "decision_gate" && e.gate.id === gate.id, + ); + const resolution = + entry && entry.type === "decision_gate" ? entry.resolution : undefined; + if (!resolution) { + throw new Error(`gate ${gate.id} resolved but no resolution stored`); + } + void thread.replayBlocked({ suspended, resolution }); + } else if (gate.status === "pending") { + thread.armPendingGateForRestart(gate, suspended); + } + // expired/withdrawn: nothing to do; the run already terminated. + } + private attachThread(thread: Thread): void { this.threads.set(thread.id, thread); this.threadsByKey.set(thread.key, thread); diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index 457d15b9..206808da 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -22,6 +22,7 @@ import type { QueueState, QueueStatus, SessionEntry, + SuspendedTurnState, ThreadData, ToolContext, ToolDef, @@ -217,6 +218,89 @@ export class Thread { this.suspendedDecisionForReplay = ctx; } + /** + * Re-run a suspended tool with seeded suspendedDecision, push its result + * onto the agent transcript, then continue the agent loop. Called by + * Session.resumeBlockedThreadIfReady when the gate has been resolved. + */ + async replayBlocked(args: { + suspended: SuspendedTurnState; + resolution: DecisionResolution; + }): Promise { + const { suspended, resolution } = args; + const tools = this.buildTools(); + const tool = tools.find((t) => t.name === suspended.toolName); + if (!tool) { + this.emitError( + "replay_tool_missing", + `cannot replay: tool ${suspended.toolName} not registered`, + ); + return; + } + this.setReplayContext({ gateId: suspended.gateId, resolution }); + const fakeAbort = new AbortController(); + let toolResult; + try { + toolResult = await tool.execute( + suspended.toolCallId, + suspended.toolArgs, + fakeAbort.signal, + ); + } catch (err) { + this.emitError( + "replay_tool_failed", + err instanceof Error ? err.message : String(err), + ); + return; + } + this.agent.state.messages = [ + ...this.agent.state.messages, + { + role: "toolResult", + toolCallId: suspended.toolCallId, + toolName: suspended.toolName, + content: toolResult.content, + details: toolResult.details, + isError: false, + timestamp: Date.now(), + }, + ]; + await this.session.providers.store.clearSuspendedTurn(this.session.id, this.id); + this.setStatus("running"); + try { + await this.agent.continue(); + await this.agent.waitForIdle(); + } catch (err) { + this.emitError( + "replay_continue_failed", + err instanceof Error ? err.message : String(err), + ); + } + if (this.readStatus() === "running") this.setStatus("idle"); + } + + /** + * Re-arm the GateManager for a still-pending gate after restart, so a + * future resolveDecision triggers replay. + */ + armPendingGateForRestart(gate: DecisionGate, suspended: SuspendedTurnState): void { + this.blockedGateId = gate.id; + this.setStatus("blocked_on_decision_gate"); + this.gates + .register(gate, () => { + // expiry handler: replay never runs for an expired gate + }) + .then((resolution) => { + void this.replayBlocked({ suspended, resolution }); + }) + .catch((err) => { + this.emitError( + "replay_after_pending_gate_failed", + err instanceof Error ? err.message : String(err), + ); + }); + } + /** * Reconstruct the agent transcript from persisted DAG entries. * From ba7c74a8765a67da40c848db5d0bdaf86317eb89 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:43:59 -0700 Subject: [PATCH 17/26] test(engine): full restart cycle restoreSession + resolve resumes turn Task 15 surfaced two real bugs in the prototype: - Session.rehydrate's resumeBlockedThreadIfReady call was fire-and-forget, racing with resolveDecision callers; awaiting it ensures the gate is re-armed before any caller can resolve it. - During replay, Thread.replayBlocked needs to mirror the original queueItemId so the deterministic gate ID matches and the short-circuit fires; without this, the tool tries to open a new gate. - Gate-status persistence (pending -> resolved) lived in the requestDecision continuation; the short-circuit path bypassed it. Moved to Thread.resolveDecision so both live and replay paths persist the resolved status. --- packages/engine/src/session.ts | 8 +- packages/engine/src/thread.ts | 39 +++++ .../engine/test/restart-safe-gates.test.ts | 154 ++++++++++++++++++ 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 packages/engine/test/restart-safe-gates.test.ts diff --git a/packages/engine/src/session.ts b/packages/engine/src/session.ts index bfe78df5..b2e38668 100644 --- a/packages/engine/src/session.ts +++ b/packages/engine/src/session.ts @@ -61,10 +61,12 @@ export class Session { const entries = await providers.store.getEntries(data.id, td.id); thread.rehydrateTranscript(entries); } - // Kick off blocked-thread resumption (fire-and-forget; replay completes - // asynchronously when the gate is resolved or already resolved). + // Await each thread's resume step so callers can rely on pending gates + // being re-armed before they call resolveDecision. The actual replay + // (or wait for resolution) inside resumeBlockedThreadIfReady is still + // asynchronous — only the arming is awaited here. for (const td of threadDatas) { - void session.resumeBlockedThreadIfReady(td.id); + await session.resumeBlockedThreadIfReady(td.id); } return session; } diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index 206808da..1d7b62ad 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -92,6 +92,10 @@ export class Thread { resolveDecision(gateId: string, resolution: DecisionResolution): boolean { const ok = this.gates.resolve(gateId, resolution); if (ok) { + // Persist the resolved status + DAG entry update. Both the live and + // replay code paths short-circuit before the requestDecision + // continuation; doing it here means the store is consistent for both. + void this.persistGateResolution(gateId, resolution); void this.session.emit({ type: "decision_gate_resolved", threadId: this.id, @@ -102,6 +106,26 @@ export class Thread { return ok; } + private async persistGateResolution( + gateId: string, + resolution: DecisionResolution, + ): Promise { + const store = this.session.providers.store; + const existing = await store.getDecisionGate(this.session.id, gateId); + if (!existing) return; + const resolved: DecisionGate = { + ...existing, + status: "resolved", + updatedAt: Date.now(), + }; + await store.saveDecisionGate(this.session.id, this.id, resolved); + await store.updateDecisionGateEntry(this.session.id, this.id, gateId, { + gate: resolved, + resolution, + resolvedAt: new Date(resolution.resolvedAt).toISOString(), + }); + } + withdrawDecision(gateId: string, reason: DecisionWithdrawReason): boolean { const ok = this.gates.withdraw(gateId, reason); if (ok) { @@ -238,6 +262,19 @@ export class Thread { return; } this.setReplayContext({ gateId: suspended.gateId, resolution }); + // The deterministic gate ID is derived from + // (sessionId, threadId, queueItemId, resumeKey). During replay, the + // tool's requestDecision call recomputes this from the active queue + // item — so we must mirror the original queueItemId here, otherwise + // the short-circuit won't match and the tool will try to open a + // brand-new gate. + const priorActive = this.activeItem; + this.activeItem = { + id: suspended.queueItemId, + threadId: this.id, + content: "", + createdAt: suspended.createdAt, + }; const fakeAbort = new AbortController(); let toolResult; try { @@ -247,12 +284,14 @@ export class Thread { fakeAbort.signal, ); } catch (err) { + this.activeItem = priorActive; this.emitError( "replay_tool_failed", err instanceof Error ? err.message : String(err), ); return; } + this.activeItem = priorActive; this.agent.state.messages = [ ...this.agent.state.messages, { diff --git a/packages/engine/test/restart-safe-gates.test.ts b/packages/engine/test/restart-safe-gates.test.ts new file mode 100644 index 00000000..92c5e95d --- /dev/null +++ b/packages/engine/test/restart-safe-gates.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, Type } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + SqliteSessionStore, + VirtualSandboxProvider, + type ToolDef, + type BusEvent, + type DecisionGate, +} from "../src/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(__dirname, "..", "migrations", "sqlite"); + +function applyMigrations(db: Database.Database): void { + const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files) { + const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8"); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (trimmed) db.exec(trimmed); + } + } +} + +const approvalTool: ToolDef = { + name: "do_thing", + description: "approval-gated", + parameters: Type.Object({ arg: Type.String() }), + execute: async (args, ctx) => { + const r = await ctx.requestDecision({ + type: "approval", + title: "ok?", + resumeKey: `do_thing:${args.arg}`, + }); + return { text: `did with ${r.actionId}` }; + }, +}; + +describe("restart-safe gates: full restart cycle", () => { + it("survives engine teardown and restoreSession resumes", async () => { + const sqlite = new Database(":memory:"); + applyMigrations(sqlite); + const db = drizzle(sqlite); + const store = new SqliteSessionStore(db); + const sandboxProvider = new VirtualSandboxProvider(); + + // ── Engine v1: open gate, then "crash" ────────────────────── + const faux1 = registerFauxProvider({ provider: "restart" }); + faux1.setResponses([ + fauxAssistantMessage([fauxToolCall("do_thing", { arg: "x" }, { id: "tc1" })], { + stopReason: "toolUse", + }), + ]); + + const bus1 = new InMemoryEventBus(); + const engine1 = new Engine({ providers: { store, bus: bus1, sandboxProvider } }); + const SESSION_ID = "sess-restart"; + const session1 = await engine1.createSession({ + id: SESSION_ID, + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux1.getModel(), + tools: [approvalTool], + }); + void session1.prompt("please do"); + + const gate = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("gate timeout")), 2000); + const unsub = bus1.subscribe({}, (e) => { + if (e.event.type === "decision_gate") { + clearTimeout(t); + unsub(); + resolve(e.event.gate); + } + }); + }); + + expect(gate.status).toBe("pending"); + const persistedGates = await store.listDecisionGates(SESSION_ID); + expect(persistedGates).toHaveLength(1); + const suspended = await store.getSuspendedTurn(SESSION_ID, gate.threadId); + expect(suspended?.toolName).toBe("do_thing"); + expect(suspended?.toolArgs).toEqual({ arg: "x" }); + expect(suspended?.resumeKey).toBe("do_thing:x"); + + // "Crash" + faux1.unregister(); + + // ── Engine v2: restoreSession + resolve ───────────────────── + const faux2 = registerFauxProvider({ provider: "restart-v2" }); + faux2.setResponses([fauxAssistantMessage("all done after restart")]); + + const bus2 = new InMemoryEventBus(); + const events2: BusEvent[] = []; + bus2.subscribe({}, (e) => events2.push(e)); + const engine2 = new Engine({ providers: { store, bus: bus2, sandboxProvider } }); + const session2 = await engine2.restoreSession({ + sessionId: SESSION_ID, + options: { + userId: "u1", + orgId: "o1", + workspace: "/", + sandbox: {}, + model: faux2.getModel(), + tools: [approvalTool], + }, + }); + + await session2.resolveDecision(gate.id, { + actionId: "approve", + resolvedBy: "u1", + resolvedAt: Date.now(), + }); + + // Wait for replay continuation to land its message_end + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("post-restart turn timeout")), 3000); + const unsub = bus2.subscribe({}, (e) => { + if (e.event.type === "message_end") { + clearTimeout(t); + unsub(); + resolve(); + } + }); + }); + + const finalEntries = await session2.readEntries("web:default"); + const lastAssistant = finalEntries + .filter((e) => e.type === "message" && e.role === "assistant") + .at(-1); + expect( + lastAssistant && lastAssistant.type === "message" && lastAssistant.content, + ).toBe("all done after restart"); + + // SuspendedTurnState was cleared + expect(await store.getSuspendedTurn(SESSION_ID, gate.threadId)).toBeNull(); + + // Gate is now resolved + const finalGate = await store.getDecisionGate(SESSION_ID, gate.id); + expect(finalGate?.status).toBe("resolved"); + + faux2.unregister(); + }); +}); From fc3a487fae9d8eac4d2b56851efc4aef5491463e Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:44:51 -0700 Subject: [PATCH 18/26] docs(engine): document persistent store and restart-safe gates --- packages/engine/README.md | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/engine/README.md b/packages/engine/README.md index 7b9ed0e8..90f8b68d 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -10,8 +10,9 @@ has zero platform dependencies. ## What works in this prototype -- Engine public API: `createSession`, `getSession`, `deleteSession`, `Session.prompt`, - `Session.thread()`, `Session.resolveDecision`, `Session.withdrawDecision`, +- Engine public API: `createSession`, `restoreSession({ sessionId, options })`, + `getSession`, `deleteSession`, `Session.prompt`, `Session.thread()`, + `Session.resolveDecision`, `Session.withdrawDecision`, `Session.abort/pause/resume`. - Per-thread state: each thread gets its own `pi-agent-core` `Agent` instance with its own queue and DAG history. @@ -22,11 +23,28 @@ has zero platform dependencies. engine emits `decision_gate`, and the turn resumes when the user calls `session.resolveDecision()`. Pending gates withdraw on `steer` or `abort` and expire after `expiresAt`. +- **Restart-safe re-entrant decision gates.** Gate IDs are deterministic: + `gate:{sessionId}:{threadId}:{queueItemId}:{resumeKey}`. Tools must + supply a stable `resumeKey`. On `restoreSession`, the engine re-arms + pending gates and replays the suspended tool with `ctx.suspendedDecision` + populated; `requestDecision` short-circuits and returns the stored + resolution instead of opening a new gate. Validated by an end-to-end + test that opens a gate, throws away the engine, builds a new one on the + same store, calls `restoreSession`, then `resolveDecision`, and verifies + the agent's continuation message is persisted. - Multi-thread: threads run concurrently, share the sandbox, and have isolated histories. Aborting one thread doesn't affect siblings. - Built-in `thread_read` tool: a thread can read recent messages from a sibling, parent, or child thread. - Built-in tools: `read`, `write`, `edit`, `bash`, `thread_read`. +- **Persistent SessionStore.** `SqliteSessionStore` (Drizzle SQLite schema, + migrations, in-process via `better-sqlite3`) implements the same + `SessionStore` interface as `InMemorySessionStore`. Both pass an + identical 10-test contract suite. Schema mirrors the V1 spec's required + tables: `engine_sessions`, `engine_threads`, `engine_entries`, + `engine_queue_state`, `engine_decision_gates`, + `engine_decision_gate_refs`, `engine_suspended_turns`, plus stubbed + `engine_queue_items` for future per-item visibility. - In-memory providers: `InMemorySessionStore`, `InMemoryEventBus`, `InMemoryBlobStore`, `InMemoryCredentialStore`, `VirtualSandbox` / `VirtualSandboxProvider` (in-memory FS + a small whitelist of safe shell @@ -34,13 +52,16 @@ has zero platform dependencies. ## What's deferred (post-prototype) -- **Restart-safe re-entrant decision gates.** Today `requestDecision` - returns a Promise that resolves when the user resolves the gate. That's - correct for an in-process engine but doesn't survive process restarts. - The persisted `SuspendedTurnState` record is written, but - `Engine.restoreSession` is a stub. Once we have a persistent store, we - re-prompt on restart and short-circuit `requestDecision` using - `ctx.suspendedDecision` (already plumbed through `ToolContext`). +- **D1 wiring.** `SqliteSessionStore` uses `better-sqlite3`. The Cloudflare + adapter will reuse the same Drizzle queries through `drizzle-orm/d1`. +- **Postgres dialect mirror.** The K8s adapter contract requires a + pg-core schema mirror. Same logical schema, different column helpers; + doable in one task once the K8s adapter is on deck. +- **Per-queue-item rows.** Today the active and pending queue items are + persisted via the JSON-encoded `engine_queue_state.pending` column. + `engine_queue_items` exists as a schema stub; populating it gives the + adapter visibility into individual items but isn't a correctness + requirement. - **Compaction.** Token-aware context compression is not implemented. `CompactionEntry` is in the DAG schema; the algorithm itself is a follow-up. @@ -52,8 +73,6 @@ has zero platform dependencies. `ToolDef[]` directly. - **Structured results.** Schema-validated output extraction with `---RESULT_START---` delimiters is not implemented. -- **Engine restoration.** `Engine.restoreSession()` throws; full - rehydration of running queues + suspended turns is a follow-up. ## Spec-vs-reality deltas (notes from the pi-ai/pi-agent-core spike) @@ -85,4 +104,6 @@ pnpm --filter @valet/engine test ``` Covers: happy path (3), decision gates (4), queue modes (4), -multi-thread + thread_read (3) — 14 tests total, all in <2s. +multi-thread + thread_read (3), short-circuit predicate unit tests (6), +SessionStore contract suite × 2 backends (20), full restart cycle (1) — +41 tests total. From 9720a48eeab050809140e532c39f9df0e2f368ff Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 21:51:52 -0700 Subject: [PATCH 19/26] feat(engine): add REPL bin for end-to-end Anthropic verification bin/repl.ts wires the engine to a real Anthropic model via pi-ai's getModel('anthropic', ...), with InMemorySessionStore + InMemoryEventBus + VirtualSandbox. Supports single-shot (`pnpm repl 'say hi'`) and interactive (`pnpm repl`) modes. Streams text deltas, tool calls, decision gates, and turn boundaries to stdout. Defaults to claude-haiku-4-5; override with VALET_MODEL or VALET_SYSTEM_PROMPT. Reads ANTHROPIC_API_KEY from env via pi-ai's provider auto-resolution. --- packages/engine/bin/repl.ts | 165 ++++++++++++++++++++++++++++++++++ packages/engine/package.json | 5 +- packages/engine/tsconfig.json | 6 +- pnpm-lock.yaml | 81 +++++++++++++++-- 4 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 packages/engine/bin/repl.ts diff --git a/packages/engine/bin/repl.ts b/packages/engine/bin/repl.ts new file mode 100644 index 00000000..4fc54bc6 --- /dev/null +++ b/packages/engine/bin/repl.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env -S node --import tsx +/** + * End-to-end smoke REPL for @valet/engine. + * + * Wires up: + * - InMemorySessionStore + InMemoryEventBus + VirtualSandbox (no containers) + * - The engine's built-in tools (read/write/edit/bash/thread_read) + * - A real Anthropic model via pi-ai (defaults to claude-haiku-4-5) + * + * Usage: + * + * # single prompt, exits when the agent emits end_turn: + * ANTHROPIC_API_KEY=... pnpm --filter @valet/engine exec tsx bin/repl.ts "say hi" + * + * # interactive multi-turn (one prompt per stdin line, ctrl-D / 'exit' to quit): + * ANTHROPIC_API_KEY=... pnpm --filter @valet/engine exec tsx bin/repl.ts + * + * # pick a different model: + * VALET_MODEL=claude-sonnet-4-6 ANTHROPIC_API_KEY=... pnpm ... bin/repl.ts + */ +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { getModel } from "@mariozechner/pi-ai"; +import { + Engine, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BusEvent, + type Session, +} from "../src/index.js"; + +const MODEL_ID = process.env.VALET_MODEL ?? "claude-haiku-4-5"; +const SYSTEM_PROMPT = + process.env.VALET_SYSTEM_PROMPT ?? + "You are a helpful coding assistant running inside an in-memory virtual sandbox. " + + "You have built-in tools: read, write, edit, bash, thread_read. " + + "The sandbox starts empty at /. Be concise."; + +function fail(message: string, code = 1): never { + process.stderr.write(`error: ${message}\n`); + process.exit(code); +} + +async function buildSession(): Promise<{ session: Session; bus: InMemoryEventBus }> { + if (!process.env.ANTHROPIC_API_KEY) { + fail( + "ANTHROPIC_API_KEY is not set. Export it in your shell before running this REPL.", + ); + } + // pi-ai's `getModel` is typed against MODELS at compile time; we cast the + // env-supplied id at the boundary because it's user input. + const model = getModel("anthropic", MODEL_ID as "claude-haiku-4-5"); + if (!model) { + fail( + `unknown anthropic model "${MODEL_ID}". Check VALET_MODEL or pi-ai's MODELS table.`, + ); + } + + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const sandboxProvider = new VirtualSandboxProvider(); + const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + + const session = await engine.createSession({ + userId: "repl-user", + orgId: "repl-org", + workspace: "/", + sandbox: {}, + model, + systemPrompt: SYSTEM_PROMPT, + }); + + return { session, bus }; +} + +function subscribePrinter(bus: InMemoryEventBus): void { + bus.subscribe({}, (e: BusEvent) => { + const ev = e.event; + switch (ev.type) { + case "text_delta": + stdout.write(ev.text); + break; + case "tool_start": + stdout.write( + `\n\x1b[90m[tool] ${ev.tool}(${JSON.stringify(ev.args)})\x1b[0m\n`, + ); + break; + case "tool_end": + stdout.write( + `\x1b[90m[tool] ${ev.tool} -> ${ev.isError ? "ERROR" : "ok"}: ${truncate(ev.result, 200)}\x1b[0m\n`, + ); + break; + case "decision_gate": + stdout.write( + `\n\x1b[33m[gate] ${ev.gate.type}: ${ev.gate.title}\x1b[0m\n` + + ` id=${ev.gate.id}\n actions=${ev.gate.actions.map((a) => a.id).join(", ")}\n`, + ); + break; + case "decision_gate_resolved": + stdout.write(`\x1b[33m[gate] resolved=${ev.resolution.actionId}\x1b[0m\n`); + break; + case "turn_end": + stdout.write(`\n\x1b[90m[turn ended: ${ev.reason}]\x1b[0m\n`); + break; + case "error": + stdout.write(`\n\x1b[31m[error] ${ev.code}: ${ev.error}\x1b[0m\n`); + break; + default: + // ignore queue_state, message_start, status, etc. — too noisy for a REPL + break; + } + }); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + `…(+${s.length - max} chars)`; +} + +async function waitForIdle(bus: InMemoryEventBus, threadId: string): Promise { + return new Promise((resolve) => { + const unsub = bus.subscribe({}, (e) => { + if ( + e.event.type === "status" && + e.event.threadId === threadId && + e.event.status === "idle" + ) { + unsub(); + resolve(); + } + }); + }); +} + +async function runOneShot(prompt: string): Promise { + const { session, bus } = await buildSession(); + subscribePrinter(bus); + const receipt = await session.prompt(prompt); + await waitForIdle(bus, receipt.threadId); +} + +async function runInteractive(): Promise { + const { session, bus } = await buildSession(); + subscribePrinter(bus); + const rl = createInterface({ input: stdin, output: stdout }); + stdout.write( + `\nvalet engine repl — model=${MODEL_ID}; type a prompt, 'exit' to quit.\n`, + ); + while (true) { + const line = (await rl.question("\n> ")).trim(); + if (line === "" ) continue; + if (line === "exit" || line === "quit") break; + const receipt = await session.prompt(line); + await waitForIdle(bus, receipt.threadId); + } + rl.close(); +} + +const args = process.argv.slice(2); +if (args.length > 0) { + await runOneShot(args.join(" ")); +} else { + await runInteractive(); +} diff --git a/packages/engine/package.json b/packages/engine/package.json index 17d12454..20c3cdfd 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -15,7 +15,8 @@ "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run", - "db:generate": "drizzle-kit generate" + "db:generate": "drizzle-kit generate", + "repl": "tsx bin/repl.ts" }, "dependencies": { "@mariozechner/pi-agent-core": "0.73.0", @@ -25,8 +26,10 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", + "@types/node": "^20.0.0", "better-sqlite3": "^11.0.0", "drizzle-kit": "^0.31.9", + "tsx": "^4.0.0", "typescript": "^5.3.3", "vitest": "^4.0.18" } diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json index 4c56d95c..8946b610 100644 --- a/packages/engine/tsconfig.json +++ b/packages/engine/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", - "types": [] + "rootDir": ".", + "types": ["node"] }, - "include": ["src/**/*"] + "include": ["src/**/*", "bin/**/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea04ee86..eaf3fb42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,18 +145,24 @@ importers: '@types/better-sqlite3': specifier: ^7.6.8 version: 7.6.13 + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 better-sqlite3: specifier: ^11.0.0 version: 11.10.0 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 + tsx: + specifier: ^4.0.0 + version: 4.21.0 typescript: specifier: ^5.3.3 version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0) + version: 4.0.18(@types/node@20.19.39)(jiti@1.21.7)(tsx@4.21.0) packages/plugin-cloudflare: dependencies: @@ -3067,6 +3073,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@types/node@25.0.9': resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} @@ -5182,6 +5191,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -7890,7 +7902,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.0.9 + '@types/node': 20.19.39 '@types/bun@1.3.12': dependencies: @@ -8055,9 +8067,14 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + '@types/node@25.0.9': dependencies: undici-types: 7.16.0 + optional: true '@types/react-dom@19.2.3(@types/react@19.2.8)': dependencies: @@ -8302,7 +8319,7 @@ snapshots: bun-types@1.3.12: dependencies: - '@types/node': 25.0.9 + '@types/node': 20.19.39 cac@6.7.14: {} @@ -9941,7 +9958,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.0.9 + '@types/node': 20.19.39 long: 5.3.2 proxy-agent@6.5.0: @@ -10565,7 +10582,10 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@7.16.0: {} + undici-types@6.21.0: {} + + undici-types@7.16.0: + optional: true undici@5.29.0: dependencies: @@ -10707,6 +10727,20 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@20.19.39)(jiti@1.21.7)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.21.0 + vite@6.4.1(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -10763,6 +10797,43 @@ snapshots: - tsx - yaml + vitest@4.0.18(@types/node@20.19.39)(jiti@1.21.7)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@20.19.39)(jiti@1.21.7)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.18(@types/node@25.0.9)(jiti@1.21.7)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 From 8cecc6be0740992f278bd8e5200435f5fc61dfaf Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 22:37:29 -0700 Subject: [PATCH 20/26] feat(engine): LocalSandbox provider for dev/testing on the host machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalSandbox wraps node:fs/promises and node:child_process.spawn to implement the Sandbox interface against the real host filesystem and shell. Relative paths resolve against the configured workspace; absolute paths are honored as-is (no escape prevention — this is a dev/testing sandbox, security goes into the Docker provider). ExecOpts honored: - cwd (relative paths resolved against workspace, default = workspace) - env (merged over process.env) - timeout (SIGKILL on expiry, timedOut: true on result) - signal (AbortSignal cancellation) - stdin (piped to the child) - maxOutputBytes (truncated: true on result) 19 tests cover FS round-trips, exec lifecycle (timeout, abort, truncation, stdin, env, cwd), and provider behavior. Total suite: 60 tests, all green. REPL gains a VALET_SANDBOX=local|virtual switch (default virtual) and VALET_WORKSPACE for the local workspace path. Smoke-tested against a tmp scratch dir AND the valet repo itself — engine read its own README and listed packages/ via real shell. --- packages/engine/bin/repl.ts | 59 +++- packages/engine/src/index.ts | 1 + .../engine/src/providers/local-sandbox.ts | 265 ++++++++++++++++++ packages/engine/test/local-sandbox.test.ts | 166 +++++++++++ 4 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 packages/engine/src/providers/local-sandbox.ts create mode 100644 packages/engine/test/local-sandbox.test.ts diff --git a/packages/engine/bin/repl.ts b/packages/engine/bin/repl.ts index 4fc54bc6..cd6fb3eb 100644 --- a/packages/engine/bin/repl.ts +++ b/packages/engine/bin/repl.ts @@ -3,39 +3,64 @@ * End-to-end smoke REPL for @valet/engine. * * Wires up: - * - InMemorySessionStore + InMemoryEventBus + VirtualSandbox (no containers) + * - InMemorySessionStore + InMemoryEventBus + * - VirtualSandbox (default) or LocalSandbox (real host filesystem + shell) * - The engine's built-in tools (read/write/edit/bash/thread_read) * - A real Anthropic model via pi-ai (defaults to claude-haiku-4-5) * + * Env: + * ANTHROPIC_API_KEY required + * VALET_MODEL pi-ai anthropic model id (default claude-haiku-4-5) + * VALET_SANDBOX virtual | local (default virtual) + * VALET_WORKSPACE workspace dir for local sandbox (default cwd) + * VALET_SYSTEM_PROMPT override the system prompt + * * Usage: * - * # single prompt, exits when the agent emits end_turn: - * ANTHROPIC_API_KEY=... pnpm --filter @valet/engine exec tsx bin/repl.ts "say hi" + * # in-memory sandbox, single prompt: + * pnpm --filter @valet/engine repl "say hi" * - * # interactive multi-turn (one prompt per stdin line, ctrl-D / 'exit' to quit): - * ANTHROPIC_API_KEY=... pnpm --filter @valet/engine exec tsx bin/repl.ts + * # local sandbox pointed at the current repo, interactive: + * VALET_SANDBOX=local pnpm --filter @valet/engine repl * - * # pick a different model: - * VALET_MODEL=claude-sonnet-4-6 ANTHROPIC_API_KEY=... pnpm ... bin/repl.ts + * # local sandbox pointed at an explicit dir: + * VALET_SANDBOX=local VALET_WORKSPACE=/path/to/repo pnpm --filter @valet/engine repl "list the top-level files" */ import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import { resolve } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; import { Engine, InMemoryEventBus, InMemorySessionStore, + LocalSandboxProvider, VirtualSandboxProvider, type BusEvent, + type SandboxProvider, type Session, } from "../src/index.js"; const MODEL_ID = process.env.VALET_MODEL ?? "claude-haiku-4-5"; +const SANDBOX_KIND = (process.env.VALET_SANDBOX ?? "virtual").toLowerCase(); +const WORKSPACE = + process.env.VALET_WORKSPACE ?? + (SANDBOX_KIND === "local" ? process.cwd() : "/"); + +const SYSTEM_PROMPT_VIRTUAL = + "You are a helpful coding assistant running inside an in-memory virtual sandbox. " + + "You have built-in tools: read, write, edit, bash, thread_read. " + + "The sandbox starts empty at /. Be concise."; + +const SYSTEM_PROMPT_LOCAL = + `You are a helpful coding assistant running on a local developer machine. ` + + `Your workspace is ${WORKSPACE}. Relative paths resolve there. ` + + `You have built-in tools: read, write, edit, bash, thread_read. ` + + `Be concise. Confirm with the user before making destructive changes.`; + const SYSTEM_PROMPT = process.env.VALET_SYSTEM_PROMPT ?? - "You are a helpful coding assistant running inside an in-memory virtual sandbox. " + - "You have built-in tools: read, write, edit, bash, thread_read. " + - "The sandbox starts empty at /. Be concise."; + (SANDBOX_KIND === "local" ? SYSTEM_PROMPT_LOCAL : SYSTEM_PROMPT_VIRTUAL); function fail(message: string, code = 1): never { process.stderr.write(`error: ${message}\n`); @@ -59,14 +84,18 @@ async function buildSession(): Promise<{ session: Session; bus: InMemoryEventBus const store = new InMemorySessionStore(); const bus = new InMemoryEventBus(); - const sandboxProvider = new VirtualSandboxProvider(); + const sandboxProvider: SandboxProvider = + SANDBOX_KIND === "local" + ? new LocalSandboxProvider() + : new VirtualSandboxProvider(); const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + const workspace = SANDBOX_KIND === "local" ? resolve(WORKSPACE) : WORKSPACE; const session = await engine.createSession({ userId: "repl-user", orgId: "repl-org", - workspace: "/", - sandbox: {}, + workspace, + sandbox: { workspace }, model, systemPrompt: SYSTEM_PROMPT, }); @@ -145,7 +174,9 @@ async function runInteractive(): Promise { subscribePrinter(bus); const rl = createInterface({ input: stdin, output: stdout }); stdout.write( - `\nvalet engine repl — model=${MODEL_ID}; type a prompt, 'exit' to quit.\n`, + `\nvalet engine repl — model=${MODEL_ID} sandbox=${SANDBOX_KIND}` + + (SANDBOX_KIND === "local" ? ` workspace=${WORKSPACE}` : "") + + `\ntype a prompt, 'exit' to quit.\n`, ); while (true) { const line = (await rl.question("\n> ")).trim(); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9b347e7a..76864af4 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -8,6 +8,7 @@ export { InMemoryBlobStore } from "./providers/in-memory-blob.js"; export { InMemoryCredentialStore } from "./providers/in-memory-credentials.js"; export { SqliteSessionStore } from "./providers/sqlite-store.js"; export { VirtualSandbox, VirtualSandboxProvider } from "./providers/virtual-sandbox.js"; +export { LocalSandbox, LocalSandboxProvider } from "./providers/local-sandbox.js"; export { builtinTools, readTool, writeTool, editTool, bashTool, threadReadTool } from "./builtin-tools/index.js"; export { GateManager, diff --git a/packages/engine/src/providers/local-sandbox.ts b/packages/engine/src/providers/local-sandbox.ts new file mode 100644 index 00000000..9c39021c --- /dev/null +++ b/packages/engine/src/providers/local-sandbox.ts @@ -0,0 +1,265 @@ +import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { + ExecOpts, + ExecResult, + Sandbox, + SandboxCreateOpts, + SandboxProvider, + SandboxStatus, +} from "../types.js"; + +/** + * LocalSandbox is for development and testing. It runs FS ops via + * node:fs/promises and shell commands via node:child_process.spawn against + * the host machine — there is NO workspace isolation. The LLM running in + * this sandbox can read, write, and execute anything the parent process + * can. Use only with prompts and models you trust, in dev environments. + * + * The `workspace` directory is the default cwd for relative paths and for + * shell commands without an explicit `cwd` override. It does not bound + * filesystem access — absolute paths and `..` traversal both work. + * + * For production use, switch to a containerized sandbox (Docker, Modal). + */ +export class LocalSandbox implements Sandbox { + readonly id: string; + readonly workspace: string; + + constructor(id: string, workspace: string) { + this.id = id; + this.workspace = workspace; + } + + private resolvePath(p: string): string { + return isAbsolute(p) ? p : resolve(this.workspace, p); + } + + async readFile(path: string): Promise { + return fs.readFile(this.resolvePath(path), "utf8"); + } + + async readBinary(path: string): Promise { + const buf = await fs.readFile(this.resolvePath(path)); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + async writeFile(path: string, content: string): Promise { + const target = this.resolvePath(path); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.writeFile(target, content, "utf8"); + } + + async writeBinary(path: string, data: Uint8Array): Promise { + const target = this.resolvePath(path); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.writeFile(target, data); + } + + async readdir(path: string): Promise { + return fs.readdir(this.resolvePath(path)); + } + + async stat(path: string): Promise<{ isFile: boolean; isDirectory: boolean; size: number }> { + const s = await fs.stat(this.resolvePath(path)); + return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size }; + } + + async mkdir(path: string): Promise { + await fs.mkdir(this.resolvePath(path), { recursive: true }); + } + + async rm(path: string, opts?: { recursive?: boolean }): Promise { + await fs.rm(this.resolvePath(path), { + recursive: opts?.recursive ?? false, + force: true, + }); + } + + async exec(command: string, opts?: ExecOpts): Promise { + return execShell(command, { + cwd: opts?.cwd ? this.resolvePath(opts.cwd) : this.workspace, + env: opts?.env, + timeout: opts?.timeout, + signal: opts?.signal, + stdin: opts?.stdin, + maxOutputBytes: opts?.maxOutputBytes, + }); + } + + async snapshot(): Promise { + return `${this.id}@${Date.now()}`; + } + + async tunnels(): Promise> { + return {}; + } + + async destroy(): Promise { + // No-op: we don't own the host filesystem. + } +} + +/** posix-style dirname; works on any OS for path strings. */ +function dirname(p: string): string { + const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return idx <= 0 ? "/" : p.slice(0, idx); +} + +interface ExecShellOpts { + cwd: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + stdin?: string; + maxOutputBytes?: number; +} + +function execShell(command: string, opts: ExecShellOpts): Promise { + return new Promise((resolveResult, rejectResult) => { + // Merge: caller-supplied env overrides parent process env. We pass the + // parent env through so commands like `node`, `pnpm`, etc. resolve. + const env = { ...process.env, ...(opts.env ?? {}) }; + + const child = spawn(command, { + cwd: opts.cwd, + env, + shell: true, + // Stdin needs to be 'pipe' when we have data to feed; otherwise inherit + // would attach to the parent terminal which we don't want. + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let truncated = false; + let timedOut = false; + const limit = opts.maxOutputBytes; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (chunk: string) => { + if (limit && stdout.length >= limit) { + truncated = true; + return; + } + stdout += chunk; + if (limit && stdout.length > limit) { + stdout = stdout.slice(0, limit); + truncated = true; + } + }); + child.stderr?.on("data", (chunk: string) => { + if (limit && stderr.length >= limit) return; + stderr += chunk; + if (limit && stderr.length > limit) stderr = stderr.slice(0, limit); + }); + + if (opts.stdin !== undefined) { + child.stdin?.write(opts.stdin); + } + child.stdin?.end(); + + let timer: NodeJS.Timeout | undefined; + if (opts.timeout && opts.timeout > 0) { + timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, opts.timeout); + // unref so a stuck timer doesn't keep the process alive in tests + const t = timer as { unref?: () => void }; + if (typeof t.unref === "function") t.unref(); + } + + const onAbort = () => { + child.kill("SIGKILL"); + }; + opts.signal?.addEventListener("abort", onAbort, { once: true }); + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + rejectResult(err); + }); + + child.on("close", (code, sig) => { + if (timer) clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + const exitCode = code ?? (sig ? 128 + signalToInt(sig) : 1); + resolveResult({ + stdout, + stderr, + exitCode, + timedOut: timedOut ? true : undefined, + truncated: truncated ? true : undefined, + }); + }); + }); +} + +function signalToInt(sig: NodeJS.Signals): number { + // Approximate POSIX signal numbers; only the common ones we'll see. + switch (sig) { + case "SIGHUP": + return 1; + case "SIGINT": + return 2; + case "SIGTERM": + return 15; + case "SIGKILL": + return 9; + default: + return 1; + } +} + +// ── Provider ────────────────────────────────────────────────────── + +export interface LocalSandboxCreateOpts extends SandboxCreateOpts { + /** Workspace directory for this sandbox. Required for the local provider. */ + workspace: string; +} + +export class LocalSandboxProvider implements SandboxProvider { + private sandboxes = new Map(); + private nextId = 1; + + async create(opts: SandboxCreateOpts): Promise { + const workspace = opts.workspace; + if (!workspace) { + throw new Error( + "LocalSandboxProvider.create: opts.workspace is required (absolute path).", + ); + } + const abs = isAbsolute(workspace) ? workspace : resolve(workspace); + // Verify the workspace exists and is a directory. + const stat = await fs.stat(abs); + if (!stat.isDirectory()) { + throw new Error(`LocalSandboxProvider.create: workspace is not a directory: ${abs}`); + } + const id = `local-${this.nextId++}`; + const sb = new LocalSandbox(id, abs); + this.sandboxes.set(id, sb); + return sb; + } + + async restore(id: string): Promise { + const sb = this.sandboxes.get(id); + if (!sb) throw new Error(`LocalSandbox not found: ${id}`); + return sb; + } + + async destroy(id: string): Promise { + const sb = this.sandboxes.get(id); + if (sb) await sb.destroy?.(); + this.sandboxes.delete(id); + } + + async status(id: string): Promise { + return this.sandboxes.has(id) + ? { id, state: "running", startedAt: Date.now() } + : { id, state: "stopped" }; + } +} diff --git a/packages/engine/test/local-sandbox.test.ts b/packages/engine/test/local-sandbox.test.ts new file mode 100644 index 00000000..0dee2a8c --- /dev/null +++ b/packages/engine/test/local-sandbox.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalSandbox, LocalSandboxProvider } from "../src/index.js"; + +let tmp: string; + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), "valet-engine-localsb-")); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +describe("LocalSandbox: filesystem", () => { + it("write + read round-trips a file", async () => { + const sb = new LocalSandbox("test", tmp); + await sb.writeFile("note.txt", "hello world"); + expect(await sb.readFile("note.txt")).toBe("hello world"); + expect(await readFile(join(tmp, "note.txt"), "utf8")).toBe("hello world"); + }); + + it("resolves relative paths against the workspace cwd", async () => { + const sb = new LocalSandbox("test", tmp); + await sb.writeFile("a/b/c.txt", "deep"); + expect(await readFile(join(tmp, "a", "b", "c.txt"), "utf8")).toBe("deep"); + }); + + it("absolute paths bypass the workspace prefix", async () => { + // This is intentionally permissive: LocalSandbox is for dev/testing. + const sb = new LocalSandbox("test", tmp); + const outside = join(tmp, "..", "valet-localsb-outside.txt"); + await sb.writeFile(outside, "ok"); + expect(await readFile(outside, "utf8")).toBe("ok"); + await rm(outside, { force: true }); + }); + + it("readdir + stat + mkdir + rm", async () => { + const sb = new LocalSandbox("test", tmp); + await sb.mkdir("subdir"); + await sb.writeFile("subdir/file.txt", "x"); + const entries = await sb.readdir("subdir"); + expect(entries).toEqual(["file.txt"]); + const s = await sb.stat("subdir/file.txt"); + expect(s).toEqual({ isFile: true, isDirectory: false, size: 1 }); + await sb.rm("subdir", { recursive: true }); + await expect(sb.readdir("subdir")).rejects.toThrow(); + }); + + it("readBinary + writeBinary round-trips bytes", async () => { + const sb = new LocalSandbox("test", tmp); + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + await sb.writeBinary("blob.bin", bytes); + const out = await sb.readBinary("blob.bin"); + expect(Array.from(out)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); +}); + +describe("LocalSandbox: exec", () => { + it("runs a simple command and captures stdout", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("echo hi"); + expect(res.exitCode).toBe(0); + expect(res.stdout.trim()).toBe("hi"); + }); + + it("inherits PATH so common tools work", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("node -e \"console.log(2+2)\""); + expect(res.exitCode).toBe(0); + expect(res.stdout.trim()).toBe("4"); + }); + + it("captures non-zero exit codes", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("false"); + expect(res.exitCode).not.toBe(0); + }); + + it("sets cwd to the workspace by default", async () => { + const sb = new LocalSandbox("test", tmp); + await writeFile(join(tmp, "marker"), ""); + const res = await sb.exec("ls"); + expect(res.stdout).toContain("marker"); + }); + + it("honors per-call cwd override (relative to workspace)", async () => { + const sb = new LocalSandbox("test", tmp); + await mkdir(join(tmp, "sub")); + await writeFile(join(tmp, "sub", "x"), ""); + const res = await sb.exec("ls", { cwd: "sub" }); + expect(res.stdout).toContain("x"); + }); + + it("merges per-call env over process env", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("echo $VALET_TEST_VAR", { + env: { VALET_TEST_VAR: "from-test" }, + }); + expect(res.stdout.trim()).toBe("from-test"); + }); + + it("times out and reports timedOut=true", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("sleep 5", { timeout: 80 }); + expect(res.timedOut).toBe(true); + expect(res.exitCode).not.toBe(0); + }); + + it("aborts via signal", async () => { + const sb = new LocalSandbox("test", tmp); + const ac = new AbortController(); + const promise = sb.exec("sleep 5", { signal: ac.signal }); + setTimeout(() => ac.abort(), 50); + const res = await promise; + expect(res.exitCode).not.toBe(0); + }); + + it("truncates stdout to maxOutputBytes", async () => { + const sb = new LocalSandbox("test", tmp); + // Print 50_000 bytes; cap at 1000. + const res = await sb.exec( + "node -e \"process.stdout.write('x'.repeat(50000))\"", + { maxOutputBytes: 1000 }, + ); + expect(res.stdout.length).toBeLessThanOrEqual(1000); + expect(res.truncated).toBe(true); + }); + + it("pipes stdin into the child process", async () => { + const sb = new LocalSandbox("test", tmp); + const res = await sb.exec("cat", { stdin: "piped-input\n" }); + expect(res.stdout).toBe("piped-input\n"); + }); +}); + +describe("LocalSandboxProvider", () => { + it("creates a LocalSandbox bound to the workspace", async () => { + const provider = new LocalSandboxProvider(); + const sb = await provider.create({ workspace: tmp }); + expect(sb.id.startsWith("local-")).toBe(true); + await sb.writeFile("hello.txt", "yo"); + expect(await readFile(join(tmp, "hello.txt"), "utf8")).toBe("yo"); + }); + + it("rejects when workspace is missing", async () => { + const provider = new LocalSandboxProvider(); + await expect(provider.create({})).rejects.toThrow(/workspace is required/); + }); + + it("rejects when workspace is not a directory", async () => { + const provider = new LocalSandboxProvider(); + const file = join(tmp, "notadir.txt"); + await writeFile(file, "x"); + await expect(provider.create({ workspace: file })).rejects.toThrow(/not a directory/); + }); + + it("restore returns the same instance", async () => { + const provider = new LocalSandboxProvider(); + const sb = await provider.create({ workspace: tmp }); + const restored = await provider.restore(sb.id); + expect(restored).toBe(sb); + }); +}); From cdac01320aa91e9258f2cf11edb341867e17a653 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 23:05:17 -0700 Subject: [PATCH 21/26] feat(engine): plugin Action Bridge via list_tools + call_tool indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge does NOT register one engine-visible tool per plugin action. That approach would (a) collide with Anthropic's tool-name regex (`^[a-zA-Z0-9_-]{1,128}$` — action ids like 'github.create_issue' are rejected), (b) blow past LLM tool-catalog budgets when many plugins are active, and (c) force every session to pay the prompt cost of every action even when only a few are relevant. Instead, actionBridgeTools({ sources }) returns exactly two ToolDefs: - list_tools({ service?, query?, limit? }): searchable catalog with per-action params + risk levels + per-service auth warnings. - call_tool({ tool_id, params, summary }): dispatches by action id (kept untouched, dots and all). Approval gates honor riskLevel via ctx.requestDecision; user denial short-circuits without invoking the action. Same pattern OpenCode uses in the existing valet runtime, so plugin ActionSource shapes port across with no changes. Spec updated ("Plugin Action Bridge" section): documents the why (provider regex, catalog budget, prompt cost), the new ActionBridgeOptions shape, and the list_tools/call_tool semantics. Engine.thread now also surfaces assistant errorMessage as an 'error' event and translates stopReason 'error' into a 'turn_end: error' rather than masking it as 'end_turn' — found while debugging the dogfood pass. Dogfood: REPL with GITHUB_TOKEN=$(gh auth token) successfully searches the catalog with list_tools, calls github.get_repository via call_tool, and reports description/default branch/star count from the live API response. 9 bridge unit tests + 60 existing tests all green (69 total). --- ...26-05-02-portable-runtime-engine-design.md | 53 +- packages/engine/bin/repl.ts | 49 +- packages/engine/package.json | 5 +- packages/engine/src/action-bridge.ts | 410 +++++++++++++++ packages/engine/src/index.ts | 11 + packages/engine/src/thread.ts | 23 +- packages/engine/test/action-bridge.test.ts | 489 ++++++++++++++++++ pnpm-lock.yaml | 9 + 8 files changed, 1026 insertions(+), 23 deletions(-) create mode 100644 packages/engine/src/action-bridge.ts create mode 100644 packages/engine/test/action-bridge.test.ts diff --git a/docs/specs/2026-05-02-portable-runtime-engine-design.md b/docs/specs/2026-05-02-portable-runtime-engine-design.md index dc38bc3f..a4f91914 100644 --- a/docs/specs/2026-05-02-portable-runtime-engine-design.md +++ b/docs/specs/2026-05-02-portable-runtime-engine-design.md @@ -752,7 +752,9 @@ Approval-gated tools follow the same suspension model. A tool can return or thro #### Plugin Action Bridge -V1 may continue using existing plugin action packages through an adapter: +V1 keeps using existing plugin action packages through an adapter, but the bridge does NOT register one LLM-visible tool per action. With dozens of plugins each exporting dozens of actions, direct registration would (a) blow past LLM tool-catalog size budgets, (b) collide with provider tool-name regexes (Anthropic requires `^[a-zA-Z0-9_-]{1,128}$`, so dotted ids like `github.create_issue` are rejected), and (c) force every session to pay the prompt cost of every action even when only a few are relevant. + +Instead, plugin actions are surfaced through two engine-built-in indirection tools — `list_tools` and `call_tool` — that expose a searchable catalog the agent consults on demand. ```typescript interface ActionSource { @@ -761,7 +763,7 @@ interface ActionSource { } interface ActionDefinition { - id: string; + id: string; // fully-qualified, e.g. "github.create_issue" name: string; description: string; riskLevel: RiskLevel; @@ -786,26 +788,49 @@ interface ActionResult { images?: Array<{ data: string; mimeType: string; description: string }>; } -interface ActionSourceToolBridgeOptions { - service: string; +interface ActionSourceConfig { + service: string; // routing key + default credential service actions: ActionSource; - credentialService?: string; + credentialService?: string; // override service for credential lookup defaultApprovalMode?: 'allow' | 'require_approval' | 'deny'; } -function actionSourceToTools(opts: ActionSourceToolBridgeOptions): Promise; +interface ActionBridgeOptions { + sources: ActionSourceConfig[]; +} + +/** + * Returns exactly two ToolDefs: `list_tools` and `call_tool`. Internally the + * bridge holds a catalog assembled from every ActionSource passed in. + */ +function actionBridgeTools(opts: ActionBridgeOptions): Promise; ``` +`list_tools` accepts: + +- `service?: string` — filter by service name. +- `query?: string` — match against action name, id, and description (case-insensitive substring). +- `limit?: number` — cap results (default 50, max 200). + +It returns a structured payload: `{ service, id, name, description, riskLevel, params }` per action, plus per-service auth/availability warnings when credentials are missing or expired. + +`call_tool` accepts: + +- `tool_id: string` — the fully-qualified action id (e.g. `github.create_issue`). +- `params: object` — the action arguments, validated against the action's parameter schema before dispatch. +- `summary: string` — one-line human-readable description used in approval gates and audit logs. + Bridge behavior: -- Each `ActionDefinition` becomes one `ToolDef` named `${service}.${action.id}`. -- Zod parameters are converted to TypeBox/JSON Schema at registration time. -- `riskLevel` is copied onto the `ToolDef`. -- Current action policy is evaluated before execution. Denied actions return a tool error. Approval-required actions create a `DecisionGate` of type `approval`. -- Credentials are resolved through `CredentialProvider` and passed to the action as the current `ActionContext.credentials`. -- Existing action analytics are forwarded to the engine observability sink. -- Existing action images are converted to `ToolAttachment` objects and handled by the engine attachment pipeline. -- The bridge is a migration layer, not a permanent engine dependency. New plugins should export `ToolDef[]` directly. +- Action ids stay unchanged inside the catalog and as `tool_id` arguments. Provider tool-name regexes never apply because action ids ride as string args, not tool names. +- Zod parameters are converted to TypeBox/JSON Schema at registration time and exposed verbatim through `list_tools`. +- `call_tool` validates `params` against the action's schema. Validation errors return a structured tool error, not an exception. +- `riskLevel` is reported in `list_tools` and consulted in `call_tool` to decide whether to open a `DecisionGate` (`high`/`critical` default to `require_approval` unless the per-source `defaultApprovalMode` overrides). The action's `summary` arg is the gate body. +- Credentials are resolved through `CredentialProvider` per call, scoped to the action's `credentialService`. Missing credentials surface as a structured "auth required" tool error and as a warning in subsequent `list_tools` responses. +- Action analytics events are forwarded to the engine observability sink. +- Action images are converted to `ToolAttachment` objects and handled by the engine attachment pipeline. + +The bridge is a migration layer, not a permanent engine dependency. New plugins may either (a) keep emitting `ActionSource`s and let the bridge expose them, or (b) export `ToolDef[]` directly when they want to be registered as first-class engine tools (e.g. coding-loop primitives where the per-call indirection is unwanted overhead). Engine adapters compose both paths in the same session. #### ToolResult diff --git a/packages/engine/bin/repl.ts b/packages/engine/bin/repl.ts index cd6fb3eb..2f4670b4 100644 --- a/packages/engine/bin/repl.ts +++ b/packages/engine/bin/repl.ts @@ -14,6 +14,8 @@ * VALET_SANDBOX virtual | local (default virtual) * VALET_WORKSPACE workspace dir for local sandbox (default cwd) * VALET_SYSTEM_PROMPT override the system prompt + * GITHUB_TOKEN when set, registers @valet/plugin-github actions via + * the actionSourceToTools bridge (read/write GitHub) * * Usage: * @@ -30,15 +32,20 @@ import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { resolve } from "node:path"; import { getModel } from "@mariozechner/pi-ai"; +import { githubActions } from "@valet/plugin-github/actions"; import { + actionBridgeTools, Engine, + InMemoryCredentialStore, InMemoryEventBus, InMemorySessionStore, LocalSandboxProvider, VirtualSandboxProvider, + type ActionSourceConfig, type BusEvent, type SandboxProvider, type Session, + type ToolDef, } from "../src/index.js"; const MODEL_ID = process.env.VALET_MODEL ?? "claude-haiku-4-5"; @@ -67,6 +74,15 @@ function fail(message: string, code = 1): never { process.exit(code); } +async function loadPluginTools(): Promise { + const sources: ActionSourceConfig[] = []; + if (process.env.GITHUB_TOKEN) { + sources.push({ service: "github", actions: githubActions }); + } + if (sources.length === 0) return []; + return actionBridgeTools({ sources }); +} + async function buildSession(): Promise<{ session: Session; bus: InMemoryEventBus }> { if (!process.env.ANTHROPIC_API_KEY) { fail( @@ -84,20 +100,45 @@ async function buildSession(): Promise<{ session: Session; bus: InMemoryEventBus const store = new InMemorySessionStore(); const bus = new InMemoryEventBus(); + const credentials = new InMemoryCredentialStore(); const sandboxProvider: SandboxProvider = SANDBOX_KIND === "local" ? new LocalSandboxProvider() : new VirtualSandboxProvider(); - const engine = new Engine({ providers: { store, bus, sandboxProvider } }); + const engine = new Engine({ + providers: { store, bus, credentials, sandboxProvider }, + }); + + const userId = "repl-user"; + const tools: ToolDef[] = []; + + // Plugin sources: when their respective env tokens are set, save the + // credential and add the source to the bridge. The bridge then exposes + // a single (list_tools, call_tool) pair regardless of how many sources + // are wired in. + if (process.env.GITHUB_TOKEN) { + await credentials.save({ type: "user", id: userId }, "github", { + type: "oauth2", + accessToken: process.env.GITHUB_TOKEN, + }); + } + const pluginTools = await loadPluginTools(); + if (pluginTools.length > 0) { + tools.push(...pluginTools); + stdout.write( + `\x1b[90m[plugins] ${pluginTools.length} bridge tools (list_tools + call_tool)\x1b[0m\n`, + ); + } const workspace = SANDBOX_KIND === "local" ? resolve(WORKSPACE) : WORKSPACE; const session = await engine.createSession({ - userId: "repl-user", + userId, orgId: "repl-org", workspace, sandbox: { workspace }, model, systemPrompt: SYSTEM_PROMPT, + tools, }); return { session, bus }; @@ -136,7 +177,9 @@ function subscribePrinter(bus: InMemoryEventBus): void { stdout.write(`\n\x1b[31m[error] ${ev.code}: ${ev.error}\x1b[0m\n`); break; default: - // ignore queue_state, message_start, status, etc. — too noisy for a REPL + if (process.env.VALET_DEBUG === "1") { + stdout.write(`\x1b[90m[debug] ${ev.type}\x1b[0m\n`); + } break; } }); diff --git a/packages/engine/package.json b/packages/engine/package.json index 20c3cdfd..a1f00482 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -22,11 +22,14 @@ "@mariozechner/pi-agent-core": "0.73.0", "@mariozechner/pi-ai": "0.73.0", "drizzle-orm": "^0.45.1", - "typebox": "^1.1.24" + "typebox": "^1.1.24", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.24.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", "@types/node": "^20.0.0", + "@valet/plugin-github": "workspace:*", "better-sqlite3": "^11.0.0", "drizzle-kit": "^0.31.9", "tsx": "^4.0.0", diff --git a/packages/engine/src/action-bridge.ts b/packages/engine/src/action-bridge.ts new file mode 100644 index 00000000..5fec367b --- /dev/null +++ b/packages/engine/src/action-bridge.ts @@ -0,0 +1,410 @@ +import { Type } from "typebox"; +import type { TSchema } from "typebox"; +import type { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { RiskLevel, ToolAttachment, ToolContext, ToolDef, ToolResult } from "./types.js"; + +/** + * Action bridge: V1 migration adapter for existing valet plugin packages. + * + * Design (per spec §"Plugin Action Bridge"): we register exactly two + * engine-visible tools — `list_tools` and `call_tool` — and let the agent + * discover plugin actions on demand. We do NOT register one LLM-visible + * tool per action because (a) action ids contain dots and Anthropic rejects + * them in tool names, (b) dozens of plugins × dozens of actions blows the + * tool-catalog budget, and (c) most sessions only need a handful of actions + * and shouldn't pay the prompt cost of all of them. + */ + +// ── Plugin shapes (structurally compatible with @valet/sdk) ─────── + +export interface BridgeActionDefinition { + id: string; + name: string; + description: string; + riskLevel: RiskLevel; + params: TParams; + /** Raw JSON Schema — when present, bypasses Zod conversion. */ + inputSchema?: Record; +} + +export interface BridgeActionListContext { + credentials?: Record; +} + +export interface BridgeActionContext { + credentials: Record; + userId: string; + orgId?: string; + callerIdentity?: { name: string; avatar?: string }; + attribution?: { name: string; email: string }; + guardConfig?: Record; + // analytics is intentionally omitted — the engine emits its own observability. + analytics?: unknown; +} + +export interface BridgeActionResult { + success: boolean; + data?: T; + error?: string; + images?: Array<{ data: string; mimeType: string; description: string }>; +} + +export interface BridgeActionSource { + listActions( + ctx?: BridgeActionListContext, + ): BridgeActionDefinition[] | Promise; + execute( + actionId: string, + params: unknown, + ctx: BridgeActionContext, + ): Promise; +} + +export type ApprovalMode = "allow" | "require_approval" | "deny"; + +export interface ActionSourceConfig { + /** Service id (e.g. "github"). Used as the credential service name. */ + service: string; + /** The plugin's ActionSource. */ + actions: BridgeActionSource; + /** Override credential service (defaults to `service`). */ + credentialService?: string; + /** + * Default approval policy. Unset = derived from riskLevel: + * low/medium → allow; high/critical → require_approval. + */ + defaultApprovalMode?: ApprovalMode; +} + +export interface ActionBridgeOptions { + sources: ActionSourceConfig[]; +} + +// ── Public API ──────────────────────────────────────────────────── + +/** + * Build the [list_tools, call_tool] pair backed by an in-memory catalog + * assembled from every ActionSource in `opts.sources`. The catalog is + * resolved at construction time; if plugins can register dynamically later, + * we'll need a refresh hook. + */ +export async function actionBridgeTools( + opts: ActionBridgeOptions, +): Promise { + const catalog = await buildCatalog(opts.sources); + return [makeListTool(catalog), makeCallTool(catalog)]; +} + +// ── Catalog ─────────────────────────────────────────────────────── + +interface CatalogEntry { + service: string; + config: ActionSourceConfig; + def: BridgeActionDefinition; + parameters: Record; +} + +interface Catalog { + entries: CatalogEntry[]; + byId: Map; +} + +async function buildCatalog(sources: ActionSourceConfig[]): Promise { + const entries: CatalogEntry[] = []; + const byId = new Map(); + for (const config of sources) { + const defs = await config.actions.listActions(); + for (const def of defs) { + const entry: CatalogEntry = { + service: config.service, + config, + def, + parameters: resolveParameters(def), + }; + entries.push(entry); + // Action ids are commonly already qualified (e.g. "github.create_issue"). + // If the plugin emits a bare id, qualify it. + const fqid = def.id.includes(".") ? def.id : `${config.service}.${def.id}`; + byId.set(fqid, entry); + // Register both forms so tool_id="bare_id" works too (when unambiguous). + if (def.id !== fqid && !byId.has(def.id)) byId.set(def.id, entry); + } + } + return { entries, byId }; +} + +function resolveParameters(def: BridgeActionDefinition): Record { + if (def.inputSchema) return def.inputSchema; + const json = zodToJsonSchema(def.params, { target: "jsonSchema7" }); + if (typeof json === "object" && json !== null) { + const obj = json as Record; + delete obj.$schema; + delete obj.$ref; + delete obj.definitions; + return obj; + } + return { type: "object", properties: {}, required: [] }; +} + +// ── list_tools ─────────────────────────────────────────────────── + +const LIST_LIMIT_DEFAULT = 50; +const LIST_LIMIT_MAX = 200; + +function makeListTool(catalog: Catalog): ToolDef { + return { + name: "list_tools", + description: + "List available plugin tools. Filter by service or search by name/description. Returns tool_ids plus their parameter schemas; use call_tool to invoke one.", + parameters: Type.Object({ + service: Type.Optional( + Type.String({ + description: + "Filter by service name (e.g. 'github', 'gmail'). Omit to list across all services.", + }), + ), + query: Type.Optional( + Type.String({ + description: "Case-insensitive substring match against name, id, and description.", + }), + ), + limit: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: LIST_LIMIT_MAX, + description: `Cap results (default ${LIST_LIMIT_DEFAULT}, max ${LIST_LIMIT_MAX}).`, + }), + ), + }), + execute: async (args, ctx): Promise => { + const a = args as { service?: string; query?: string; limit?: number }; + const limit = clamp(a.limit ?? LIST_LIMIT_DEFAULT, 1, LIST_LIMIT_MAX); + const q = a.query?.toLowerCase(); + + let entries = catalog.entries; + if (a.service) entries = entries.filter((e) => e.service === a.service); + if (q) { + entries = entries.filter((e) => { + const def = e.def; + return ( + def.id.toLowerCase().includes(q) || + def.name.toLowerCase().includes(q) || + def.description.toLowerCase().includes(q) + ); + }); + } + + // Per-service auth warnings: probe each represented service's + // credentials and report missing ones so the LLM can ask the user to + // reauthorize. + const services = new Set(entries.map((e) => e.service)); + const warnings: Array<{ service: string; reason: string }> = []; + for (const service of services) { + const credService = + catalog.entries.find((e) => e.service === service)?.config.credentialService ?? service; + const cred = await ctx.credentials.get(credService); + if (!cred) warnings.push({ service, reason: "no credential connected" }); + } + + const tools = entries.slice(0, limit).map((e) => ({ + service: e.service, + tool_id: qualifiedId(e), + name: e.def.name, + description: e.def.description, + riskLevel: e.def.riskLevel, + params: e.parameters, + })); + + const total = entries.length; + const text = JSON.stringify( + { + tools, + total, + truncated: total > limit ? total - limit : undefined, + warnings: warnings.length > 0 ? warnings : undefined, + }, + null, + 2, + ); + return { text }; + }, + }; +} + +// ── call_tool ──────────────────────────────────────────────────── + +function makeCallTool(catalog: Catalog): ToolDef { + return { + name: "call_tool", + description: + "Invoke a plugin action by tool_id (discovered via list_tools). Approval gates may suspend execution for high/critical risk actions.", + parameters: Type.Object({ + tool_id: Type.String({ + description: "Fully-qualified action id from list_tools (e.g. 'github.create_issue').", + }), + // The LLM passes params as an object; we accept any JSON shape. + params: Type.Optional( + Type.Record(Type.String(), Type.Any(), { + description: + "Action parameters, matching the schema reported by list_tools for this tool_id.", + }), + ), + summary: Type.String({ + description: + "One-line human-readable summary of what this call does. Shown in approval gates and audit logs.", + }), + }), + execute: async (args, ctx): Promise => { + const a = args as { tool_id: string; params?: Record; summary: string }; + const entry = catalog.byId.get(a.tool_id); + if (!entry) { + return { + text: `unknown tool_id: "${a.tool_id}". Use list_tools to find available actions.`, + }; + } + + const approvalMode = approvalModeFor(entry); + if (approvalMode === "deny") { + return { text: `denied: ${a.tool_id} is blocked by org policy` }; + } + if (approvalMode === "require_approval") { + const resolution = await ctx.requestDecision({ + type: "approval", + title: `Approve ${entry.def.name}?`, + body: `${a.summary}\n\ntool_id=${a.tool_id}\nargs=${stableJson(a.params ?? {})}`, + resumeKey: `${qualifiedId(entry)}:${stableJson(a.params ?? {})}`, + context: { + riskLevel: entry.def.riskLevel, + service: entry.service, + tool_id: a.tool_id, + args: a.params, + }, + }); + if (resolution.actionId !== "approve") { + return { text: `denied: user did not approve ${a.tool_id}` }; + } + } + + const credentialService = entry.config.credentialService ?? entry.service; + const credentials = await resolveCredentials(ctx, credentialService); + const actionCtx: BridgeActionContext = { + credentials, + userId: ctx.userId, + orgId: ctx.orgId, + callerIdentity: ctx.actor + ? { name: ctx.actor.name ?? ctx.actor.id } + : undefined, + attribution: ctx.actor?.email + ? { name: ctx.actor.name ?? ctx.actor.id, email: ctx.actor.email } + : undefined, + }; + + let result: BridgeActionResult; + try { + result = await entry.config.actions.execute(entry.def.id, a.params ?? {}, actionCtx); + } catch (err) { + return { + text: `error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + return actionResultToToolResult(result, a.tool_id); + }, + }; +} + +// ── helpers ────────────────────────────────────────────────────── + +function approvalModeFor(entry: CatalogEntry): ApprovalMode { + if (entry.config.defaultApprovalMode) return entry.config.defaultApprovalMode; + switch (entry.def.riskLevel) { + case "low": + case "medium": + return "allow"; + case "high": + case "critical": + return "require_approval"; + } +} + +function qualifiedId(entry: CatalogEntry): string { + return entry.def.id.includes(".") ? entry.def.id : `${entry.service}.${entry.def.id}`; +} + +async function resolveCredentials( + ctx: ToolContext, + service: string, +): Promise> { + const cred = await ctx.credentials.get(service); + if (!cred) return {}; + // Plugins read various keys: access_token, token, api_key. Map our typed + // Credential into a flat string map matching the legacy IntegrationCredentials shape. + const creds: Record = {}; + if (cred.accessToken) { + creds.access_token = cred.accessToken; + creds.token = cred.accessToken; + } + if (cred.refreshToken) creds.refresh_token = cred.refreshToken; + if (cred.metadata) { + for (const [k, v] of Object.entries(cred.metadata)) { + if (typeof v === "string") creds[k] = v; + } + } + return creds; +} + +function actionResultToToolResult( + result: BridgeActionResult, + toolId: string, +): ToolResult { + const attachments: ToolAttachment[] = []; + if (result.images) { + for (const img of result.images) { + attachments.push({ + type: "image", + data: base64ToBytes(img.data), + mimeType: img.mimeType, + name: img.description, + }); + } + } + + if (!result.success) { + return { + text: `${toolId} failed: ${result.error ?? "unknown error"}`, + attachments: attachments.length > 0 ? attachments : undefined, + }; + } + if (result.data === undefined) { + return { + text: `${toolId} ok`, + attachments: attachments.length > 0 ? attachments : undefined, + }; + } + return { + text: stableJson(result.data), + attachments: attachments.length > 0 ? attachments : undefined, + }; +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} + +function stableJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function base64ToBytes(b64: string): Uint8Array { + const clean = b64.startsWith("data:") ? b64.slice(b64.indexOf(",") + 1) : b64; + const binary = (globalThis as { atob: (s: string) => string }).atob(clean); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 76864af4..07b49338 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -10,6 +10,17 @@ export { SqliteSessionStore } from "./providers/sqlite-store.js"; export { VirtualSandbox, VirtualSandboxProvider } from "./providers/virtual-sandbox.js"; export { LocalSandbox, LocalSandboxProvider } from "./providers/local-sandbox.js"; export { builtinTools, readTool, writeTool, editTool, bashTool, threadReadTool } from "./builtin-tools/index.js"; +export { + actionBridgeTools, + type ActionBridgeOptions, + type ActionSourceConfig, + type ApprovalMode, + type BridgeActionContext, + type BridgeActionDefinition, + type BridgeActionListContext, + type BridgeActionResult, + type BridgeActionSource, +} from "./action-bridge.js"; export { GateManager, DecisionGateWithdrawnError, diff --git a/packages/engine/src/thread.ts b/packages/engine/src/thread.ts index 1d7b62ad..811440b8 100644 --- a/packages/engine/src/thread.ts +++ b/packages/engine/src/thread.ts @@ -808,11 +808,24 @@ export class Thread { case "turn_end": { const stopReason = event.message.role === "assistant" ? event.message.stopReason : undefined; - await this.session.emit({ - type: "turn_end", - threadId: this.id, - reason: stopReason === "aborted" ? "abort" : "end_turn", - }); + const errorMessage = + event.message.role === "assistant" ? event.message.errorMessage : undefined; + if (errorMessage) { + await this.session.emit({ + type: "error", + threadId: this.id, + code: stopReason ?? "agent_error", + error: errorMessage, + recoverable: stopReason !== "error", + }); + } + const reason: "end_turn" | "error" | "abort" = + stopReason === "aborted" + ? "abort" + : stopReason === "error" + ? "error" + : "end_turn"; + await this.session.emit({ type: "turn_end", threadId: this.id, reason }); await this.session.emit({ type: "status", threadId: this.id, status: "idle" }); break; } diff --git a/packages/engine/test/action-bridge.test.ts b/packages/engine/test/action-bridge.test.ts new file mode 100644 index 00000000..c303c100 --- /dev/null +++ b/packages/engine/test/action-bridge.test.ts @@ -0,0 +1,489 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@mariozechner/pi-ai"; +import { + actionBridgeTools, + Engine, + InMemoryCredentialStore, + InMemoryEventBus, + InMemorySessionStore, + VirtualSandboxProvider, + type BridgeActionContext, + type BridgeActionDefinition, + type BridgeActionResult, + type BridgeActionSource, + type BusEvent, +} from "../src/index.js"; + +function makeMockSource(): { + source: BridgeActionSource; + calls: Array<{ id: string; params: unknown; ctx: BridgeActionContext }>; +} { + const calls: Array<{ id: string; params: unknown; ctx: BridgeActionContext }> = []; + + const definitions: BridgeActionDefinition[] = [ + { + id: "github.get_issue", + name: "Get Issue", + description: "Read an issue.", + riskLevel: "low", + params: z.object({ + owner: z.string(), + repo: z.string(), + issueNumber: z.number().int(), + }), + }, + { + id: "github.create_issue", + name: "Create Issue", + description: "Create a new issue.", + riskLevel: "medium", + params: z.object({ + owner: z.string(), + repo: z.string(), + title: z.string(), + body: z.string().optional(), + }), + }, + { + id: "github.delete_repo", + name: "Delete Repo", + description: "Permanently delete a repo.", + riskLevel: "critical", + params: z.object({ owner: z.string(), repo: z.string() }), + }, + ]; + + const source: BridgeActionSource = { + listActions: () => definitions, + execute: async (id, params, ctx): Promise => { + calls.push({ id, params, ctx }); + if (id === "github.get_issue") { + return { success: true, data: { number: 42, title: "Test issue" } }; + } + if (id === "github.create_issue") { + return { success: true, data: { number: 99, html_url: "https://x" } }; + } + if (id === "github.delete_repo") { + return { success: true, data: { deleted: true } }; + } + return { success: false, error: "unknown action" }; + }, + }; + + return { source, calls }; +} + +describe("actionBridgeTools: registration", () => { + it("returns exactly two engine-visible tools regardless of source count", async () => { + const { source } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + expect(tools.map((t) => t.name).sort()).toEqual(["call_tool", "list_tools"]); + }); + + it("two sources still produce just list_tools + call_tool", async () => { + const a = makeMockSource(); + const b = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [ + { service: "github", actions: a.source }, + { service: "gmail", actions: b.source }, + ], + }); + expect(tools.map((t) => t.name).sort()).toEqual(["call_tool", "list_tools"]); + }); +}); + +describe("actionBridgeTools: list_tools", () => { + function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const credentials = new InMemoryCredentialStore(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ providers: { store, bus, credentials, sandboxProvider } }); + return { engine, events, credentials }; + } + + async function waitForIdle(events: BusEvent[], threadId: string, timeoutMs = 2000): Promise { + const start = Date.now(); + while ( + !events.some( + (e) => e.event.type === "status" && e.event.threadId === threadId && e.event.status === "idle", + ) + ) { + if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for idle"); + await new Promise((r) => setTimeout(r, 5)); + } + } + + it("returns the catalog with converted JSON Schema params", async () => { + const { source } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "list1" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("list_tools", {}, { id: "t1" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("done"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("list"); + await waitForIdle(events, receipt.threadId); + + const toolEnd = events.find((e) => e.event.type === "tool_end"); + if (!toolEnd || toolEnd.event.type !== "tool_end") throw new Error("no tool_end"); + const payload = JSON.parse(toolEnd.event.result) as { + tools: Array<{ tool_id: string; riskLevel: string; params: { type: string; properties: Record } }>; + total: number; + }; + expect(payload.total).toBe(3); + const ids = payload.tools.map((t) => t.tool_id).sort(); + expect(ids).toEqual([ + "github.create_issue", + "github.delete_repo", + "github.get_issue", + ]); + const getIssue = payload.tools.find((t) => t.tool_id === "github.get_issue"); + expect(getIssue?.params.type).toBe("object"); + expect(getIssue?.params.properties.issueNumber.type).toBe("integer"); + + faux.unregister(); + }); + + it("filters by service and substring query", async () => { + const { source } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "list2" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("list_tools", { query: "delete" }, { id: "t2" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("done"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("find delete tool"); + await waitForIdle(events, receipt.threadId); + + const toolEnd = events.find((e) => e.event.type === "tool_end"); + if (!toolEnd || toolEnd.event.type !== "tool_end") throw new Error("no tool_end"); + const payload = JSON.parse(toolEnd.event.result) as { + tools: Array<{ tool_id: string }>; + }; + expect(payload.tools.map((t) => t.tool_id)).toEqual(["github.delete_repo"]); + + faux.unregister(); + }); + + it("emits a warning when a service has no credential", async () => { + const { source } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "list3" }); + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("list_tools", {}, { id: "t3" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("done"), + ]); + + const { engine, events } = makeEngine(); + // No credentials saved → list_tools should report a warning for github. + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("list"); + await waitForIdle(events, receipt.threadId); + + const toolEnd = events.find((e) => e.event.type === "tool_end"); + if (!toolEnd || toolEnd.event.type !== "tool_end") throw new Error("no tool_end"); + const payload = JSON.parse(toolEnd.event.result) as { + warnings?: Array<{ service: string; reason: string }>; + }; + expect(payload.warnings?.[0]?.service).toBe("github"); + + faux.unregister(); + }); +}); + +describe("actionBridgeTools: call_tool", () => { + function makeEngine() { + const store = new InMemorySessionStore(); + const bus = new InMemoryEventBus(); + const credentials = new InMemoryCredentialStore(); + const sandboxProvider = new VirtualSandboxProvider(); + const events: BusEvent[] = []; + bus.subscribe({}, (e) => events.push(e)); + const engine = new Engine({ providers: { store, bus, credentials, sandboxProvider } }); + return { engine, events, credentials }; + } + + async function waitForIdle(events: BusEvent[], threadId: string, timeoutMs = 2000): Promise { + const start = Date.now(); + while ( + !events.some( + (e) => e.event.type === "status" && e.event.threadId === threadId && e.event.status === "idle", + ) + ) { + if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for idle"); + await new Promise((r) => setTimeout(r, 5)); + } + } + + it("dispatches by tool_id and returns rendered data with credentials applied", async () => { + const { source, calls } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "call1" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "call_tool", + { + tool_id: "github.get_issue", + params: { owner: "o", repo: "r", issueNumber: 42 }, + summary: "fetch issue 42", + }, + { id: "tc1" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("ok"), + ]); + + const { engine, events, credentials } = makeEngine(); + await credentials.save({ type: "user", id: "u" }, "github", { + type: "oauth2", + accessToken: "ghp_secret", + }); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("get issue"); + await waitForIdle(events, receipt.threadId); + + expect(calls).toHaveLength(1); + expect(calls[0].id).toBe("github.get_issue"); + expect(calls[0].params).toEqual({ owner: "o", repo: "r", issueNumber: 42 }); + expect(calls[0].ctx.credentials.access_token).toBe("ghp_secret"); + + const toolEnd = events.find((e) => e.event.type === "tool_end"); + if (!toolEnd || toolEnd.event.type !== "tool_end") throw new Error("no tool_end"); + expect(toolEnd.event.result).toContain("Test issue"); + expect(toolEnd.event.result).toContain("42"); + + faux.unregister(); + }); + + it("unknown tool_id → tool result text reports it without dispatching", async () => { + const { source, calls } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "call2" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "call_tool", + { tool_id: "github.does_not_exist", params: {}, summary: "should fail" }, + { id: "tc-bad" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("ack"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("call missing tool"); + await waitForIdle(events, receipt.threadId); + + expect(calls).toHaveLength(0); + const toolEnd = events.find((e) => e.event.type === "tool_end"); + expect( + toolEnd && toolEnd.event.type === "tool_end" && toolEnd.event.result, + ).toContain("unknown tool_id"); + + faux.unregister(); + }); + + it("ActionResult.success=false → tool result surfaces the error text", async () => { + const failing: BridgeActionSource = { + listActions: () => [ + { + id: "test.fail", + name: "Fail", + description: "always fails", + riskLevel: "low", + params: z.object({}), + }, + ], + execute: async () => ({ success: false, error: "boom 500" }), + }; + const tools = await actionBridgeTools({ + sources: [{ service: "test", actions: failing }], + }); + + const faux = registerFauxProvider({ provider: "call3" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "call_tool", + { tool_id: "test.fail", params: {}, summary: "trigger fail" }, + { id: "tc-fail" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("ack"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + const receipt = await session.prompt("trigger fail"); + await waitForIdle(events, receipt.threadId); + + const toolEnd = events.find((e) => e.event.type === "tool_end"); + expect( + toolEnd && toolEnd.event.type === "tool_end" && toolEnd.event.result, + ).toContain("boom 500"); + + faux.unregister(); + }); + + it("critical-risk action opens an approval gate; deny short-circuits to denial text", async () => { + const { source, calls } = makeMockSource(); + const tools = await actionBridgeTools({ + sources: [{ service: "github", actions: source }], + }); + + const faux = registerFauxProvider({ provider: "call4" }); + faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall( + "call_tool", + { + tool_id: "github.delete_repo", + params: { owner: "o", repo: "r" }, + summary: "delete the repo", + }, + { id: "tc-del" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("acknowledged"), + ]); + + const { engine, events } = makeEngine(); + const session = await engine.createSession({ + userId: "u", + orgId: "o", + workspace: "/", + sandbox: {}, + model: faux.getModel(), + tools, + }); + void session.prompt("delete the repo"); + + // Wait for the gate, then deny it. + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("gate timeout")), 2000); + const unsub = bus2Sub(events, () => { + if (events.some((e) => e.event.type === "decision_gate")) { + clearTimeout(t); + unsub(); + resolve(); + } + }); + }); + const gate = events.find((e) => e.event.type === "decision_gate"); + if (!gate || gate.event.type !== "decision_gate") throw new Error("no gate"); + await session.resolveDecision(gate.event.gate.id, { + actionId: "deny", + resolvedBy: "u", + resolvedAt: Date.now(), + }); + + await waitForIdle(events, gate.threadId ?? ""); + expect(calls).toHaveLength(0); + const toolEnd = events.find((e) => e.event.type === "tool_end"); + expect( + toolEnd && toolEnd.event.type === "tool_end" && toolEnd.event.result, + ).toContain("did not approve"); + + faux.unregister(); + }); +}); + +// Polling helper: subscribe-once + poll the events array. The bus is the one +// passed by makeEngine; events array shadows our subscription. +function bus2Sub(_events: BusEvent[], _cb: () => void): () => void { + // Subscribers in InMemoryEventBus fire synchronously on publish, so the + // simplest approach is: don't subscribe a second time; just poll. + // We expose this helper to keep test bodies tidy. + const interval = setInterval(_cb, 5); + return () => clearInterval(interval); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaf3fb42..90a9ab18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,12 @@ importers: typebox: specifier: ^1.1.24 version: 1.1.37 + zod: + specifier: ^3.22.4 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.25.2(zod@3.25.76) devDependencies: '@types/better-sqlite3': specifier: ^7.6.8 @@ -148,6 +154,9 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.39 + '@valet/plugin-github': + specifier: workspace:* + version: link:../plugin-github better-sqlite3: specifier: ^11.0.0 version: 11.10.0 From 5c968054263d08968ca99129bc0c1ed5e9196d10 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 23:18:05 -0700 Subject: [PATCH 22/26] spec: detail compaction design (pruning + summarization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-wavy compaction section with a concrete two-technique design informed by OpenCode's compaction module. - Two triggers: proactive (token threshold post-turn) and reactive (overflow error retry via pi-ai's isContextOverflow). - Tail preservation: keep last N turns clamped to a token budget, with mid-turn split when a single turn exceeds the budget. - Pruning: walk newest-first, mark stale tool outputs as elided after pruneProtectTokens of recent output. No LLM call. Skips protected tools. Only commits if it'd save >= pruneMinimumTokens. - Compaction: summarize head into a structured markdown template (Goal/Constraints/Progress/Key Decisions/Next Steps/Critical Context/ Relevant Files). Iterative — subsequent compactions update the previous summary rather than write a fresh one. - LLM-context assembly: convertToLlm drops covered entries and injects the summary as a user message; elided tool outputs are replaced with a placeholder. Same path used during restoreSession. - Auto-continue after proactive compaction; reactive compactions retry the originating turn instead. - Concrete configuration table with defaults. --- ...26-05-02-portable-runtime-engine-design.md | 105 +++++++++++++++--- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/docs/specs/2026-05-02-portable-runtime-engine-design.md b/docs/specs/2026-05-02-portable-runtime-engine-design.md index a4f91914..70c5f2cf 100644 --- a/docs/specs/2026-05-02-portable-runtime-engine-design.md +++ b/docs/specs/2026-05-02-portable-runtime-engine-design.md @@ -853,23 +853,94 @@ type ToolAttachment = ### Compaction -Token-aware context compression. When a thread's context approaches the model's context window limit, the engine summarizes older messages and inserts a CompactionEntry into the DAG, keeping recent messages intact. - -**Trigger conditions:** -- Threshold mode: `contextTokens > (contextWindow - reserveTokens)`. Compact before the next prompt. -- Overflow mode: LLM returned an error due to context overflow. Compact and retry. - -**Algorithm:** -1. Calculate cut point: keep most recent N tokens (`keepRecentTokens`, default 20k). Work backward to find a valid cut point (user or assistant message boundary, never split mid-turn). -2. Serialize messages before the cut point into a structured text representation. -3. Send to LLM with a summarization prompt. Output is structured markdown (goal, progress, key decisions, next steps, critical context). -4. Track file operations: extract paths from read/write/edit tool calls, distinguish read-only vs. modified files. -5. Insert CompactionEntry into the DAG. Future context reconstruction includes the summary plus recent messages. - -**Configuration:** -- `reserveTokens`: default 16384 (safety buffer) -- `keepRecentTokens`: default 20000 (minimum recent context to preserve) -- `enabled`: default true (can be disabled per-thread) +Token-aware context compression with two complementary techniques. When a thread approaches the model's context window, the engine **prunes** stale tool outputs cheaply (no LLM) and, if more space is needed, **compacts** older messages into a structured summary (one LLM call). The DAG is preserved verbatim — pruning marks tool-output strings as elided, compaction inserts a `CompactionEntry`. Both transformations apply only when assembling the LLM-visible context; the engine's history record never loses anything. + +This design is informed by OpenCode's compaction module (which itself iterates on prior tools like Aider's repo-summarization). Where this spec and that implementation differ, this spec is authoritative for Valet V1. + +#### Triggers + +- **Proactive (auto)** — after each turn, if `tokens.total >= usable(model, cfg)` where + ``` + usable = contextWindow − reserved + reserved = cfg.reserveTokens ?? min(20_000, model.maxOutputTokens) + ``` + the engine queues a compaction pass to run before the next user turn would otherwise execute. Token usage comes from pi-ai's per-call `Usage`; we do not estimate independently in this path. +- **Reactive (overflow)** — if a turn's assistant message returns `stopReason === 'error'` and pi-ai's `isContextOverflow(message)` matches the error, the engine compacts and retries the same turn. Reactive compaction strips media attachments from history before summarizing (some overflow is media-bytes, not token-count, so dropping images can be enough on its own). + +#### Tail preservation + +Compaction never touches the most recent turns. A "turn" is the segment from one user message up to (but not including) the next user message, including the assistant's tool calls and tool results. + +- Default keep: the last `cfg.tailTurns ?? 2` turns. +- Tail token budget: `clamp(usable * 0.25, cfg.minPreserveRecentTokens ?? 2_000, cfg.maxPreserveRecentTokens ?? 8_000)`. +- If the last `tailTurns` turns exceed the budget, the engine walks them oldest → newest and drops whole turns from the head of that window until the rest fits. If a single turn alone exceeds the budget, the engine splits it at the first message boundary that fits, summarizing the prefix into the compaction and keeping the suffix in the tail. + +#### Pruning (cheap path, no LLM) + +Walk messages newest → oldest. Track cumulative tool-output token estimate. Once the cumulative count exceeds `cfg.pruneProtectTokens ?? 40_000`, mark every older `tool_call`-result text as `elided`. Skip protected tools (the engine ships with `skill` and `thread_read` protected by default; per-tool opt-in via `ToolDef.protectedFromPruning`). + +The DAG entry is updated in place — `MessagePart` of type `tool_call` keeps `callId`, `toolName`, `args`, and `status`, but its `result` field is replaced with a placeholder `{ elided: true, reason: 'pruned' }`. LLM-context assembly skips elided results. Pruning only commits if it'd save at least `cfg.pruneMinimumTokens ?? 20_000` tokens; otherwise it's a no-op. + +Pruning runs before compaction on the proactive path. Often pruning alone is enough. + +#### Compaction (LLM path) + +When pruning isn't enough (or after `cfg.pruneMinimumTokens` worth of tool output has already been elided), the engine summarizes the messages before the tail. + +1. Compute the cut point per the tail-preservation rules above. +2. Assemble the head: the messages before the cut, with tool outputs truncated to `cfg.toolOutputMaxChars ?? 2_000` chars and image content stripped. +3. If the thread already has a `CompactionEntry`, load its `summary` as `previousSummary`. The new summarization is iterative — the prompt asks the summarizer to *update* the prior summary with new facts rather than write a fresh one. +4. Call a summarizer model (`cfg.summarizerModel ?? sessionModel`; typically a smaller cheaper model like Haiku) with a structured-markdown prompt: + ``` + ## Goal · ## Constraints & Preferences + ## Progress (Done / In Progress / Blocked) · ## Key Decisions + ## Next Steps · ## Critical Context · ## Relevant Files + ``` + This template is required, not advisory. The summary text is the source of truth for the LLM's view of pre-cut history; using a structured form prevents the summary from drifting into prose that crowds out specific facts (paths, error strings, identifiers). +5. Persist a `CompactionEntry` in the DAG with: + - `summary`: the markdown produced by step 4. + - `coveredEntryIds`: every entry id from the DAG head that this summary represents. + - `tokenCountBefore` / `tokenCountAfter`: token counts of the head before and the summary after, for observability. + - `fileContext`: extracted paths from `read`/`write`/`edit` tool calls in the head, classified `read` vs `modified` (helps the agent re-orient on resume). +6. Emit `compaction_start` then `compaction_end` events with the entry id. + +The `CompactionEntry` is positioned at the cut point in the DAG; `parentId` links it to the last covered entry. Subsequent `MessageEntry`s parent to the `CompactionEntry`. Branching/replay still works: walking from leaf via `parentId` produces a valid history, with the summary standing in for everything older. + +#### Applying compaction to LLM context + +The engine's `convertToLlm` pipeline (the function fed to pi-agent-core's `Agent` to translate persisted DAG entries into LLM messages) does the rewrite at request time: + +1. Load DAG entries for the thread. +2. Find the most recent `CompactionEntry`. If none, pass entries through unchanged. +3. Drop every entry whose id is in the active compaction's `coveredEntryIds`. +4. Replace them with a single user message containing the summary text, framed as `{summary}`. +5. Apply pruning's elision: any kept entry's tool-call parts whose `result.elided === true` get a placeholder `[output elided to save context]` in the LLM-visible content. +6. Yield the resulting `Message[]` to the agent loop. + +This is also the rehydration path on `restoreSession` — there is no separate "rebuild context after compaction" code path. + +#### Auto-continue after compaction + +After a successful proactive compaction (i.e., one we ran on our own initiative, not in response to the user's prompt), if the thread is mid-task the engine injects a synthetic user message before yielding back to the next queue item: + +> "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + +The synthetic message is tagged with `metadata: { compaction_continue: true }` so client UIs can render it differently or hide it. Reactive (overflow) compactions don't auto-continue — they just retry the original turn that triggered the overflow. + +#### Configuration + +| Key | Default | Notes | +|---|---|---| +| `cfg.compactionEnabled` | `true` | per-thread switch | +| `cfg.reserveTokens` | `min(20_000, maxOutput)` | head-room subtracted from contextWindow | +| `cfg.tailTurns` | `2` | last N turns never touched | +| `cfg.minPreserveRecentTokens` | `2_000` | floor on tail token budget | +| `cfg.maxPreserveRecentTokens` | `8_000` | ceiling on tail token budget | +| `cfg.pruneProtectTokens` | `40_000` | recent tool-output bytes never pruned | +| `cfg.pruneMinimumTokens` | `20_000` | only commit prune if it saves ≥ this much | +| `cfg.toolOutputMaxChars` | `2_000` | when feeding head to summarizer | +| `cfg.summarizerModel` | `sessionModel` | dedicated summarizer is cheaper | +| `cfg.protectedTools` | `['skill', 'thread_read']` | per-tool opt-out from pruning; `ToolDef.protectedFromPruning` adds to this set | ### Per-Thread Prompt Queue From 355e5c64f86a3a8a53c06716eb276c682bfb8aa5 Mon Sep 17 00:00:00 2001 From: Conner Swann <2635475+yourbuddyconner@users.noreply.github.com> Date: Tue, 5 May 2026 23:30:20 -0700 Subject: [PATCH 23/26] feat(engine): compaction (pruning + iterative summarization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the spec'd compaction design (informed by OpenCode): - src/compaction.ts: pure primitives — usableTokens, tailBudget, turns, selectCutPoint (with mid-turn split), planPrune/applyPrune, extractFileContext (read vs modified), summarize (one-shot completeSimple with the OpenCode-style structured-markdown template), iterative anchoring via previousSummary. - src/types.ts: CompactionConfig (enabled, reserveTokens, tailTurns, min/maxPreserveRecentTokens, pruneProtectTokens, pruneMinimumTokens, toolOutputMaxChars, summarizerModel, protectedTools), wired into CreateSessionOptions. ToolDef.protectedFromPruning. MessagePart tool_call.elided flag. - src/thread.ts: - lastAssistantUsage capture in turn_end handler. - Thread.compactThread orchestrator: prune (cheap, no LLM), select cut point, summarize head into a CompactionEntry, persist, rewrite agent.state.messages. - Proactive trigger in runItem: post-turn check shouldCompactProactive (lastAssistantUsage.total >= usable). - Reactive trigger in runAgent: catch isContextOverflow on assistant error, compact, retry the same prompt once. - rehydrateTranscript now delegates to entriesToAgentMessages, an exported pure function that drops covered entries and injects the summary as a user message. - 21 pure compaction tests + 2 integration tests against the faux provider. Total suite: 92 tests, all green. Known limitation: applyPrune mutates entries in memory but the current SessionStore APIs (appendEntries-only) don't expose an in-place row update, so pruning persists only to the live agent transcript for now. Proper persistence requires adding an updateEntry method to SessionStore — left as a follow-up since it doesn't block compaction correctness, just observability of pruned state across restarts. --- packages/engine/src/compaction.ts | 551 +++++++++++++++++++ packages/engine/src/index.ts | 21 + packages/engine/src/thread.ts | 376 +++++++++++-- packages/engine/src/types.ts | 29 + packages/engine/test/compaction-pure.test.ts | 368 +++++++++++++ packages/engine/test/compaction.test.ts | 209 +++++++ 6 files changed, 1496 insertions(+), 58 deletions(-) create mode 100644 packages/engine/src/compaction.ts create mode 100644 packages/engine/test/compaction-pure.test.ts create mode 100644 packages/engine/test/compaction.test.ts diff --git a/packages/engine/src/compaction.ts b/packages/engine/src/compaction.ts new file mode 100644 index 00000000..6153dfb7 --- /dev/null +++ b/packages/engine/src/compaction.ts @@ -0,0 +1,551 @@ +import { completeSimple } from "@mariozechner/pi-ai"; +import type { Message, Model } from "@mariozechner/pi-ai"; +import type { + CompactionConfig, + MessageEntry, + SessionEntry, +} from "./types.js"; + +/** + * Compaction primitives — all pure functions. Orchestration that calls an + * LLM and persists results lives in the orchestrator (see compactThread in + * thread.ts). Keeping these pure makes them trivially unit-testable + * against synthetic transcripts. + */ + +// ── Constants and defaults ───────────────────────────────────────── + +const DEFAULTS = { + reserveCap: 20_000, + tailTurns: 2, + minPreserveRecentTokens: 2_000, + maxPreserveRecentTokens: 8_000, + pruneProtectTokens: 40_000, + pruneMinimumTokens: 20_000, + toolOutputMaxChars: 2_000, +} as const; + +const DEFAULT_PROTECTED_TOOLS = new Set(["skill", "thread_read"]); + +// ── Token estimation ─────────────────────────────────────────────── + +/** + * Crude byte-based token estimate. We estimate ~4 chars per token, which + * matches the heuristic OpenCode and pi-ai both use for budgeting decisions. + * Provider-reported token counts (from pi-ai usage) are used where available; + * this estimator is for offline budgeting (cut-point selection, prune budget). + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export function estimateEntryTokens(entry: SessionEntry): number { + if (entry.type === "message") { + let total = estimateTokens(entry.content); + for (const part of entry.parts ?? []) { + if (part.type === "text") total += estimateTokens(part.text); + else if (part.type === "thinking") total += estimateTokens(part.text); + else if (part.type === "tool_call") { + if (part.args) total += estimateTokens(JSON.stringify(part.args)); + if (part.result !== undefined && !part.elided) { + total += estimateTokens(typeof part.result === "string" ? part.result : JSON.stringify(part.result)); + } + if (part.error) total += estimateTokens(part.error); + } + } + return total; + } + if (entry.type === "compaction") return estimateTokens(entry.summary); + if (entry.type === "branch_summary") return estimateTokens(entry.summary); + return 0; // decision_gate adds negligible context tokens +} + +export function estimateTotalTokens(entries: readonly SessionEntry[]): number { + let total = 0; + for (const e of entries) total += estimateEntryTokens(e); + return total; +} + +// ── Usable budget ────────────────────────────────────────────────── + +export function usableTokens(model: Model, cfg?: CompactionConfig): number { + const context = model.contextWindow ?? 0; + if (context === 0) return 0; + const reserve = + cfg?.reserveTokens ?? Math.min(DEFAULTS.reserveCap, model.maxTokens ?? DEFAULTS.reserveCap); + return Math.max(0, context - reserve); +} + +export function tailBudget(usable: number, cfg?: CompactionConfig): number { + const min = cfg?.minPreserveRecentTokens ?? DEFAULTS.minPreserveRecentTokens; + const max = cfg?.maxPreserveRecentTokens ?? DEFAULTS.maxPreserveRecentTokens; + const target = Math.floor(usable * 0.25); + return clamp(target, min, max); +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)); +} + +// ── Turn segmentation ────────────────────────────────────────────── + +export interface Turn { + /** Index into the entries array of the user message that starts this turn. */ + start: number; + /** Index of the next user-message turn boundary, or entries.length if last. */ + end: number; + /** Entry id of the user message at `start`. */ + id: string; +} + +/** + * Segment a list of entries into turns. A turn = [user message, ...everything until next user message). + * Decision gates and compaction entries that fall mid-turn stay in their owning turn. + * Existing CompactionEntry markers are NOT turn boundaries — they sit at the head as a + * single virtual prefix. The first turn starts at the first user message after any + * leading non-message entries (which usually means index 0). + */ +export function turns(entries: readonly SessionEntry[]): Turn[] { + const result: Turn[] = []; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (e.type !== "message" || e.role !== "user") continue; + result.push({ start: i, end: entries.length, id: e.id }); + } + for (let i = 0; i < result.length - 1; i++) { + result[i].end = result[i + 1].start; + } + return result; +} + +// ── Cut-point selection ──────────────────────────────────────────── + +export interface CutPoint { + /** Entries before this index go into the head (to be compacted). */ + cutIndex: number; + /** Entry id where the tail starts, or undefined if the tail is empty. */ + tailStartId: string | undefined; + /** True if we couldn't fit a full tail-turn budget; we kept what we could. */ + fallbackToFloor: boolean; +} + +export interface SelectCutPointOptions { + entries: readonly SessionEntry[]; + model: Model; + cfg?: CompactionConfig; + /** Override token estimator for tests. Defaults to estimateEntryTokens. */ + tokenize?: (entry: SessionEntry) => number; +} + +/** + * Pick a cut point so the tail (kept verbatim) fits within the tail budget + * derived from the model's usable context. Mirrors OpenCode's select(): + * + * - Compute tail budget from `usable * 0.25` clamped to [min, max]. + * - Take the last `tailTurns` turns and walk them oldest → newest from the end, + * accumulating size. Keep adding whole turns until the next one would + * overflow. If the very next (older) turn alone is too large to fit, split + * it: scan inside the turn for an entry whose suffix slice fits the + * remaining budget, and cut there. + * - If no tail can be preserved (e.g. the very last turn alone exceeds the + * budget and can't be split), keep the last turn anyway with + * fallbackToFloor=true so the orchestrator can decide to abort or proceed. + */ +export function selectCutPoint(opts: SelectCutPointOptions): CutPoint { + const { entries, model, cfg } = opts; + const tokenize = opts.tokenize ?? estimateEntryTokens; + const tailTurnsLimit = cfg?.tailTurns ?? DEFAULTS.tailTurns; + if (entries.length === 0 || tailTurnsLimit <= 0) { + return { cutIndex: entries.length, tailStartId: undefined, fallbackToFloor: false }; + } + + const usable = usableTokens(model, cfg); + const budget = tailBudget(usable, cfg); + const allTurns = turns(entries); + if (allTurns.length === 0) { + return { cutIndex: entries.length, tailStartId: undefined, fallbackToFloor: false }; + } + + // Take the last tailTurnsLimit turns as candidates for the tail. + const candidates = allTurns.slice(-tailTurnsLimit); + + // Walk newest → oldest, accumulating whole turns until we can't fit one. + let used = 0; + let keepStart = -1; + let keepStartId: string | undefined; + for (let i = candidates.length - 1; i >= 0; i--) { + const turn = candidates[i]; + const size = sumRange(entries, turn.start, turn.end, tokenize); + if (used + size <= budget) { + used += size; + keepStart = turn.start; + keepStartId = turn.id; + continue; + } + // This older turn can't fit whole — try to split it. + const remaining = budget - used; + const split = splitTurnForBudget({ + entries, + turn, + budget: remaining, + tokenize, + }); + if (split !== undefined) { + keepStart = split.cutIndex; + keepStartId = split.startId; + } + break; + } + + if (keepStart < 0) { + // Couldn't fit even the last turn within the tail budget. Floor: keep + // the most recent turn anyway so we always make progress. + const last = candidates[candidates.length - 1]; + return { + cutIndex: last.start, + tailStartId: last.id, + fallbackToFloor: true, + }; + } + + return { cutIndex: keepStart, tailStartId: keepStartId, fallbackToFloor: false }; +} + +interface TurnSplit { + cutIndex: number; + startId: string; +} + +function splitTurnForBudget(args: { + entries: readonly SessionEntry[]; + turn: Turn; + budget: number; + tokenize: (entry: SessionEntry) => number; +}): TurnSplit | undefined { + if (args.budget <= 0) return undefined; + if (args.turn.end - args.turn.start <= 1) return undefined; + // Try later and later split points until the suffix fits. + for (let start = args.turn.start + 1; start < args.turn.end; start++) { + const size = sumRange(args.entries, start, args.turn.end, args.tokenize); + if (size <= args.budget) { + const id = args.entries[start]?.id; + if (!id) return undefined; + return { cutIndex: start, startId: id }; + } + } + return undefined; +} + +function sumRange( + entries: readonly SessionEntry[], + start: number, + end: number, + tokenize: (entry: SessionEntry) => number, +): number { + let total = 0; + for (let i = start; i < end; i++) total += tokenize(entries[i]); + return total; +} + +// ── Pruning (cheap, no LLM) ──────────────────────────────────────── + +export interface PruneOptions { + entries: readonly SessionEntry[]; + cfg?: CompactionConfig; + /** Tool names exempt from pruning. Merged with cfg.protectedTools and ToolDef.protectedFromPruning. */ + protectedTools?: Set; +} + +export interface PruneResult { + /** entryId → list of tool_call callIds to mark elided (only filled if savedTokens >= pruneMinimumTokens). */ + toElide: Map; + savedTokens: number; + /** True if we'll commit (savedTokens >= pruneMinimumTokens). */ + willCommit: boolean; +} + +/** + * Walk entries newest → oldest. Track cumulative tool-output token estimate. + * Once the cumulative count exceeds `pruneProtectTokens`, mark every older + * tool-call result as elidable. Skip protected tools and tool calls that + * already have `elided: true`. + */ +export function planPrune(opts: PruneOptions): PruneResult { + const cfg = opts.cfg; + const protectTokens = cfg?.pruneProtectTokens ?? DEFAULTS.pruneProtectTokens; + const minimumTokens = cfg?.pruneMinimumTokens ?? DEFAULTS.pruneMinimumTokens; + const protectedTools = mergeProtectedTools(opts.protectedTools, cfg?.protectedTools); + + const toElide = new Map(); + let cumulative = 0; + let savedTokens = 0; + + for (let i = opts.entries.length - 1; i >= 0; i--) { + const entry = opts.entries[i]; + if (entry.type !== "message") continue; + if (!entry.parts) continue; + for (const part of entry.parts) { + if (part.type !== "tool_call") continue; + if (part.status !== "completed") continue; + if (part.elided) continue; + if (protectedTools.has(part.toolName)) continue; + const resultText = + part.result === undefined + ? "" + : typeof part.result === "string" + ? part.result + : JSON.stringify(part.result); + const size = estimateTokens(resultText); + cumulative += size; + if (cumulative <= protectTokens) continue; + // Past the protection window — mark this tool result for elision. + const list = toElide.get(entry.id) ?? []; + list.push(part.callId); + toElide.set(entry.id, list); + savedTokens += size; + } + } + + return { + toElide, + savedTokens, + willCommit: savedTokens >= minimumTokens, + }; +} + +function mergeProtectedTools( + base: Set | undefined, + fromCfg: string[] | undefined, +): Set { + const out = new Set(DEFAULT_PROTECTED_TOOLS); + if (base) for (const t of base) out.add(t); + if (fromCfg) for (const t of fromCfg) out.add(t); + return out; +} + +/** + * Apply a PruneResult to the entries by mutating the matching tool_call parts. + * The caller is responsible for persisting the mutation back to the SessionStore. + */ +export function applyPrune(entries: SessionEntry[], plan: PruneResult): void { + if (!plan.willCommit) return; + for (const entry of entries) { + if (entry.type !== "message") continue; + const elideIds = plan.toElide.get(entry.id); + if (!elideIds || elideIds.length === 0) continue; + const idSet = new Set(elideIds); + for (const part of entry.parts ?? []) { + if (part.type !== "tool_call") continue; + if (!idSet.has(part.callId)) continue; + part.elided = true; + part.result = { elided: true, reason: "pruned" }; + } + } +} + +// ── File context extraction ──────────────────────────────────────── + +const READ_TOOLS = new Set(["read", "grep", "glob"]); +const WRITE_TOOLS = new Set(["write", "edit"]); + +/** + * Walk the head entries' tool calls and pull out file paths, classifying + * each as `read` (tool was a reader) or `modified` (tool was a writer). + */ +export function extractFileContext( + entries: readonly SessionEntry[], +): { read: string[]; modified: string[] } { + const read = new Set(); + const modified = new Set(); + for (const entry of entries) { + if (entry.type !== "message") continue; + for (const part of entry.parts ?? []) { + if (part.type !== "tool_call") continue; + const path = extractPath(part.args); + if (!path) continue; + if (READ_TOOLS.has(part.toolName)) read.add(path); + else if (WRITE_TOOLS.has(part.toolName)) modified.add(path); + } + } + return { read: [...read], modified: [...modified] }; +} + +function extractPath(args: unknown): string | undefined { + if (!args || typeof args !== "object") return undefined; + const obj = args as Record; + const candidate = obj.path ?? obj.file ?? obj.filename ?? obj.target; + return typeof candidate === "string" ? candidate : undefined; +} + +// ── Summarizer ───────────────────────────────────────────────────── + +/** + * The required structured-markdown template. The engine relies on this + * shape downstream (e.g. for displaying a session-resume note) — keep + * sections in this exact order and casing. OpenCode pioneered this layout; + * we copy it verbatim because it works. + */ +const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside