merge: sync upstream pi-mono main into sproutclaw
Some checks failed
CI / build-check-test (push) Has been cancelled

Merge upstream/main (195 commits) while keeping sproutclaw-specific
README, webui workflow docs, and restored upstream .pi prompt templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-22 20:05:31 +08:00
642 changed files with 18785 additions and 26436 deletions

View File

@@ -2,6 +2,33 @@
## [Unreleased]
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources.
### Fixed
- Fixed tool-call preflight to stop preparing sibling tool calls after the run is aborted ([#4276](https://github.com/earendil-works/pi/issues/4276)).
- Fixed tail truncation for oversized single-line output that ends with a trailing newline ([#4715](https://github.com/earendil-works/pi/issues/4715)).
- Fixed Windows Node execution environment command spawns to hide helper console windows from background processes ([#4699](https://github.com/earendil-works/pi/issues/4699)).
## [0.75.3] - 2026-05-18
## [0.75.2] - 2026-05-18
## [0.75.1] - 2026-05-18
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
## [0.74.1] - 2026-05-16
## [0.74.0] - 2026-05-07
## [0.73.1] - 2026-05-07

View File

@@ -1,6 +1,6 @@
# AgentHarness lifecycle
`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly.
@@ -15,9 +15,22 @@ The intended rule is:
- runtime config setters update future snapshots without mutating the current provider request
- session writes made while busy are durably queued and flushed in deterministic order
- getters return latest harness config, not in-flight snapshots
- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead.
`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement.
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
## Error handling
The current split is:
- low-level capabilities and helpers use `Result<TValue, TError>` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers
- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored
- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause`
Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`.
## State model
The harness separates state into four categories.
@@ -31,6 +44,7 @@ Harness config is the latest runtime configuration set by the application or ext
- tools
- active tool names
- resources
- stream options
- system prompt or system prompt provider
Getters return harness config. They do not return the snapshot used by an in-flight provider request.
@@ -52,15 +66,21 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
- thinking level
- all tools
- active tools
- stream options
- derived session id
Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
### Session
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry.
### Pending session writes
Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`).
@@ -85,7 +105,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase
- `compact`
- `navigateTree`
Starting another structural operation while the harness is not idle throws.
Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`.
The following operations are allowed during a turn where appropriate:
@@ -112,8 +132,8 @@ Phase/settlement semantics are still provisional and need a full lifecycle pass.
Queue modes are live, not turn-snapshotted:
- `steeringMode`
- `followUpMode`
- `getSteeringMode()` / `setSteeringMode()`
- `getFollowUpMode()` / `setFollowUpMode()`
Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points.
@@ -125,9 +145,9 @@ At a save point the harness:
1. flushes pending session writes after the agent-emitted messages for that turn
2. creates a fresh turn snapshot if the low-level loop may continue
3. applies the fresh context/model/thinking-level state before the next provider request
3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request
This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points.
This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points.
The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary:
@@ -136,21 +156,26 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
## Hooks and events
Current hooks receive only the event payload. There is no extension context object yet.
The target hook system is described in [hooks.md](./hooks.md).
Event payloads describe what is happening. Harness getters describe latest config for future snapshots.
Summary:
The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events.
- `AgentHarness` emits typed hook events and consumes typed results.
- A single hooks implementation owns registration, cleanup, provenance, and result reducers.
- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result.
- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events.
- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types.
- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes.
A future extension context may expose the harness and a queued-write session facade.
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
## Planned session facade
Extensions should eventually interact with a harness-scoped session facade rather than the raw session.
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
Planned read semantics:
@@ -172,7 +197,9 @@ Agent-emitted messages are persisted on `message_end` to preserve transcript ord
## Abort
Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues.
Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues.
Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn.
Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup.
@@ -188,17 +215,271 @@ Branch summary generation is part of the tree navigation operation.
Auto-compaction and retry decision points are not implemented in `AgentHarness` yet.
## Final lifecycle hardening todo
## Test organization
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event:
Harness tests should stay focused by area instead of growing one large catch-all file.
- runtime config setters from low-level lifecycle events and harness events
- resource/tool/model/thinking updates during active turns and save points
- session writes from listeners and hooks, including writes from `settled`
- queue operations from turn events, tool events, and provider hooks
- rejected structural operations while busy
- abort from listeners/hooks
- getter behavior during active operations
- deterministic ordering of agent-emitted messages and pending listener writes
- no deadlocks when async listeners call harness APIs and await them
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
Current structure:
- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior.
- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics.
Preferred future structure:
- `agent-harness-resources.test.ts`: resource snapshot/loading semantics.
- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events.
- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior.
Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access.
Harness coverage is configured separately from the default package test run:
```bash
npm run test:harness
npm run coverage:harness
```
`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage.
## Implementation todo
This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom.
### 1. Add explicit tool registry read/update semantics
Status: In progress
Done:
- Added `setTools(tools, activeToolNames?)`.
- Added `setActiveTools(toolNames)`.
- Invalid active tool names reject with `AgentHarnessError`.
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
Remaining:
- Add `getTools()` semantics.
- Add `getActiveTools()` semantics.
- Decide and implement tool update observability events.
- Include active-tool-only updates in the runtime config observability plan.
Notes:
- Observability design: [observability.md](./observability.md)
### 2. Design per-`AgentHarness` model registry
Status: Planned
Done:
- Current `setModel()` behavior is preserved.
Remaining:
- Decide how applications supply the model registry.
- Decide whether the harness stores concrete `Model` objects, model references, or both.
- Validate model selection against the registry.
- Define model change semantics during active turns and save points.
### 3. Full `AgentHarness` lifecycle/state pass
Status: In progress
Done:
- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`.
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
- Added explicit `phase` in place of boolean idle state.
- Save points refresh context, model, thinking level, stream options, and session snapshot state.
- Pending session writes use session-entry shapes without generated fields.
- Pending session writes flush at save points, settlement, and failure cleanup.
- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images.
- `nextTurn` messages are inserted before the new user prompt.
- Structural compaction/tree operations restore phase with `finally`.
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
- Pending session writes flush one-by-one and are not dropped on failure.
- Queue drains roll back if queue-update notification fails.
- `message_end` persistence happens before subscriber notification.
- `abort()` signals cancellation before notifications and still waits for idle through notification errors.
- Idle model/thinking/tool updates validate and persist before committing in-memory state.
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
Remaining:
- Finalize phase/idle semantics.
- Audit whether `settled` can fire too early.
- Make session writes inside `settled` callbacks deterministic.
- Audit follow-up behavior around `agent_end`.
- Implement auto-compaction decision point.
- Implement retry handling.
- Verify `before_agent_start` hook semantics against coding-agent.
- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets.
- Document or change runtime config event timing while busy.
- Audit `abort()` barrier semantics.
### 4. Implement generic hook/event extension mechanism
Status: Designed in [hooks.md](./hooks.md), not implemented
Done:
- Removed `AgentHarnessContext`.
- Hooks receive only event payloads.
- `emitHook(event)` derives the hook type from `event.type`.
- Provider request/payload hooks have ordered transform semantics.
Remaining:
- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation.
- Move result chaining out of `AgentHarness` into reducer functions.
- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics.
- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks.
- Define the initial harness/context facades exposed through hook context.
- Preserve current provider hook behavior, including stream option patch deletion semantics.
- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage.
Notes:
- Hook design: [hooks.md](./hooks.md)
### 5. Spike semi-durable harness/session recovery
Status: Planned
Done:
- Wrote durability design: [durable-harness.md](./durable-harness.md)
Remaining:
- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs.
- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls.
- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers.
- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation.
- Prototype reducer-based recovery from session entries.
- Decide whether interrupted operations append user-visible messages or only internal operation entries.
Notes:
- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted.
- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior.
### 6. Final lifecycle hardening suite
Status: Planned
Done:
- None.
Remaining:
- Add broad listener/hook reentrancy tests across relevant events.
- Test runtime config setters from low-level lifecycle events and harness events.
- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options.
- Test resource/tool/model/thinking/stream-option updates during active turns and save points.
- Test session writes from listeners and hooks, including `settled` writes.
- Test queue operations from turn events, tool events, and provider hooks.
- Test rejected structural operations while busy.
- Test abort from listeners/hooks.
- Test getter behavior during active operations.
- Test deterministic ordering of agent-emitted messages and pending listener writes.
- Test no deadlocks when async listeners call harness APIs and await them.
- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation.
### 7. Later coding-agent migration plan
Status: Planned
Done:
- None.
Remaining:
- Map coding-agent resources to sourced loaders.
- Keep app-level resource dedupe/provenance outside the harness.
- Adapt extension loading to the future hook/session facade.
- Preserve UI/session behavior outside core.
- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks.
---
## Completed implementation todo
### 8. Remove `Agent` dependency from `AgentHarness`
Status: Done
Done:
- `AgentHarness` calls `runAgentLoop()` directly.
- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots.
- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping.
Remaining:
- None.
Notes:
- Broader listener/hook reentrancy coverage is tracked in item 6.
### 9. Finish curated provider/stream configuration
Status: Done
Done:
- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`.
- Stream options, headers, metadata, and derived session id are snapshotted per turn.
- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop.
- `getApiKeyAndHeaders()` resolves credentials per provider request.
- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented.
- Stream option patching supports explicit field deletion and ordered hook chaining.
- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior.
Remaining:
- None.
### 10. Complete low-level `Result` cleanup
Status: Done
Done:
- Added generic `Result<TValue, TError>` plus helpers.
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
- Split filesystem and shell capabilities.
- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`.
- Removed `NodeExecutionEnv` from browser-safe root exports.
- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling.
- Converted compaction and branch-summary helpers to typed result returns.
- Added `readTextLines()` so JSONL metadata loading reads only the header line.
- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful.
- Mapped filesystem errors crossing the session boundary to typed `SessionError`.
- Added typed branch-summary errors and cause-aware public harness error normalization.
- Resource loaders report structured diagnostics for non-`not_found` filesystem failures.
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill.
Remaining:
- None.
Notes:
- Keep low-level capability/helper APIs non-throwing where they return `Result`.
- Keep session storage/repo/session APIs throwing typed `SessionError`.
- Keep public structural harness failures normalized to `AgentHarnessError`.
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points.
- Audit generic harness utilities for Node globals as APIs are added.
- Audit package exports so browser/generic imports do not pull Node-only modules.
- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve.

View File

@@ -0,0 +1,181 @@
# Durable AgentHarness and session design
<!-- Synced from jot zmnps2zu. Edit this file in-repo going forward. -->
Durable AgentHarness / session design notes.
## Framing
A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app:
- tool implementations
- model/auth providers
- extensions and hook handlers
- resource loaders
- system-prompt callbacks/modifiers
The practical target is a semi-durable harness:
- session is the durable append-only state tree
- harness persists the state it owns into session entries
- the host app is responsible for recreating compatible non-persistable dependencies on resume
- recovery restarts from durable boundaries, not from an in-flight provider stream
## Session owns durable state
Treat session as all durable agent state, not just transcript history.
Existing session state already includes harness state:
- model changes
- thinking-level changes
- leaf entries
- labels
- compactions and branch summaries
- custom messages and custom entries
That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference.
## What the app must provide on resume
The app must recreate compatible runtime dependencies:
- model registry / model objects
- tool registry
- extension set, versions, and ordering
- resource loaders
- system prompt providers/hooks
- auth providers
- app-specific hooks
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## What harness should persist
Minimum useful durability entries:
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
- pending write application status
- operation start/finish/interruption
- turn start/finish
- provider request start/finish, if needed for recovery diagnostics
- tool call start/finish, if we want safe tool recovery
Potential entries:
```ts
type DurableHarnessEntry =
| QueueEnqueuedEntry
| QueueConsumedEntry
| PendingWriteEnqueuedEntry
| PendingWriteAppliedEntry
| OperationStartedEntry
| OperationFinishedEntry
| OperationInterruptedEntry
| TurnStartedEntry
| TurnFinishedEntry
| ProviderRequestStartedEntry
| ProviderRequestFinishedEntry
| ToolCallStartedEntry
| ToolCallFinishedEntry;
```
Every accepted mutation must be durable before the public API resolves.
## Recovery model
On startup:
1. Host app registers tools/models/extensions/resources/auth/hooks.
2. Harness opens session.
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
## Recovery policies
Default conservative policy:
- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle
- unfinished provider request: mark interrupted; do not retry automatically
- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent
- unfinished compaction: rerun if no compaction entry exists
- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe
Optional policy:
```ts
recovery: "mark_interrupted" | "retry_unfinished"
```
`retry_unfinished` must be guarded around non-idempotent tool calls.
## Critical scenarios
### Queues
- Crash before `queue_enqueued`: message was not accepted.
- Crash after `queue_enqueued`: message is restored.
- Crash after queue drain but before durable turn record: risk of loss/duplication.
- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed.
### Pending writes
- Crash before `pending_write_enqueued`: write was not accepted.
- Crash after enqueue before apply: recovery applies it.
- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied.
### Agent loop turn
- Crash before provider request: retry or mark interrupted.
- Crash during provider request: mark interrupted by default.
- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled.
- Crash after assistant message persisted: recover from durable message.
### Tool calls
- Crash after tool call starts but before result: external side effects may already have happened.
- Default recovery should not rerun non-idempotent tools.
- Tool calls need stable IDs and retry-safety metadata for automatic recovery.
### Compaction
- Crash before summary generation: rerun preparation/summary.
- Crash after generated summary but before compaction entry: rerun unless summary was journaled.
- Crash after compaction entry: operation is complete; append finish marker if missing.
### Branch summary / tree navigation
- Crash before summary: rerun or mark interrupted.
- Crash after summary entry before leaf entry: append missing leaf entry.
- Crash after leaf entry: operation is complete; append finish marker if missing.
## Minimum viable spike
1. Add durable queue entries.
2. Add durable pending write entries with deterministic target IDs.
3. Add operation start/finish/interrupted entries.
4. Add turn start with consumed queue IDs.
5. Recover by reducing the session log.
6. Mark unfinished agent turns interrupted by default.
7. Rerun unfinished compaction/tree operations only when no final entry exists.
8. Do not retry unfinished tool calls unless tool metadata says retry-safe.
## Open questions
- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs?
- Should resolved system prompt text be snapshotted per turn for audit/debug?
- Do we require strict dependency ID/version matching on resume?
- How much provider request data should be journaled?
- Should recovery append user-visible assistant interruption messages or only internal operation entries?
- Should storage support truncating a final partial JSONL line during recovery?

View File

@@ -0,0 +1,445 @@
# AgentHarness hooks design
<!-- Synced from jot 3utlzkxy. Edit this file in-repo going forward. -->
Final design.
## Core model
Events carry their result type as a type-only phantom:
```ts
declare const HookResult: unique symbol;
interface HookEvent<TType extends string, TResult = void> {
type: TType;
readonly [HookResult]?: TResult;
}
type ResultOf<E> = E extends { readonly [HookResult]?: infer R } ? R : void;
type HookHandler<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => ResultOf<E> | void | Promise<ResultOf<E> | void>;
type HookObserver<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => void | Promise<void>;
```
Example:
```ts
interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> {
type: "context";
messages: AgentMessage[];
}
interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> {
type: "tool_call";
toolName: string;
input: Record<string, unknown>;
}
interface MessageEndEvent extends HookEvent<"message_end"> {
type: "message_end";
message: AgentMessage;
}
```
No result map. No spec table. The event type defines its own result.
## Hooks interface
```ts
interface AgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx> {
context: Ctx;
setContext(ctx: Ctx): void;
observe(handler: HookObserver<E, Ctx>): () => void;
on<TType extends E["type"]>(
type: TType,
handler: HookHandler<Extract<E, { type: TType }>, Ctx>,
): () => void;
emit<TEvent extends E>(
event: TEvent,
signal?: AbortSignal,
): Promise<ResultOf<TEvent> | undefined>;
addCleanup(cleanup: () => void | Promise<void>): () => void;
clear(): Promise<void>;
dispose(): Promise<void>;
}
```
Important split:
- `observe()` sees all events, read-only, return ignored.
- `on(type, handler)` participates in that events semantics.
- `emit(event)` is the only thing `AgentHarness` calls.
- `clear()` removes observers/handlers and runs cleanups.
## Default implementation internals
```ts
class DefaultAgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx>
implements AgentHarnessHooks<E, Ctx> {
context: Ctx;
private observers = new Set<HookObserver<E, Ctx>>();
private handlers = new Map<string, Set<HookHandler<any, Ctx>>>();
private cleanups = new Set<() => void | Promise<void>>();
constructor(ctx: Ctx) {
this.context = ctx;
}
setContext(ctx: Ctx): void {
this.context = ctx;
}
observe(handler: HookObserver<E, Ctx>): () => void {
this.observers.add(handler);
return () => this.observers.delete(handler);
}
on(type, handler): () => void {
let handlers = this.handlers.get(type);
if (!handlers) {
handlers = new Set();
this.handlers.set(type, handlers);
}
handlers.add(handler);
return () => handlers.delete(handler);
}
async emit(event, signal?) {
for (const observer of this.observers) {
await observer(event, this.context, signal);
}
switch (event.type) {
case "context":
return this.emitContext(event, signal);
case "before_provider_request":
return this.emitBeforeProviderRequest(event, signal);
case "before_provider_payload":
return this.emitBeforeProviderPayload(event, signal);
case "before_agent_start":
return this.emitBeforeAgentStart(event, signal);
case "tool_call":
return this.emitToolCall(event, signal);
case "tool_result":
return this.emitToolResult(event, signal);
case "session_before_compact":
case "session_before_tree":
return this.emitFirstCancelOrLast(event, signal);
default:
await this.emitObservationHandlers(event, signal);
return undefined;
}
}
}
```
Internal casts are acceptable inside the implementation because `Map<string, ...>` loses specificity. Public API remains typed.
## Mutation semantics
### Observation
```ts
await hooks.emit({ type: "message_end", message }, signal);
```
Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type.
### Context transform
Handlers run in order. Each sees current messages.
```ts
let current = event;
for (const handler of handlers("context")) {
const result = await handler(current, ctx, signal);
if (result?.messages) {
current = { ...current, messages: result.messages };
}
}
return current.messages === event.messages ? undefined : { messages: current.messages };
```
### Provider request / payload
Sequential transform. Each handler sees previous output.
```ts
let current = event;
for (const handler of handlers("before_provider_payload")) {
const result = await handler(current, ctx, signal);
if (result !== undefined) {
current = { ...current, payload: result.payload };
}
}
return changed ? { payload: current.payload } : undefined;
```
### Before agent start
Collect injected messages, chain system prompt.
```ts
let systemPrompt = event.systemPrompt;
const messages = [];
for (const handler of handlers("before_agent_start")) {
const result = await handler({ ...event, systemPrompt }, ctx, signal);
if (result?.messages) messages.push(...result.messages);
if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt;
}
return messages.length || systemPrompt !== event.systemPrompt
? { messages, systemPrompt }
: undefined;
```
### Tool call
Sequential, early exit on block.
```ts
for (const handler of handlers("tool_call")) {
const result = await handler(event, ctx, signal);
if (result?.block) return result;
}
```
### Tool result
Sequential patch accumulation. Each handler sees current patched result.
```ts
let current = event;
let modified = false;
for (const handler of handlers("tool_result")) {
const result = await handler(current, ctx, signal);
if (!result) continue;
current = {
...current,
content: result.content ?? current.content,
details: result.details ?? current.details,
isError: result.isError ?? current.isError,
};
modified = true;
}
return modified
? { content: current.content, details: current.details, isError: current.isError }
: undefined;
```
### Session-before events
Sequential, early exit on cancel.
```ts
let last;
for (const handler of handlers(event.type)) {
const result = await handler(event, ctx, signal);
if (!result) continue;
last = result;
if (result.cancel) return result;
}
return last;
```
## Harness usage
Harness only does this:
```ts
await this.hooks.emit(event, signal);
```
or:
```ts
const result = await this.hooks.emit({ type: "context", messages }, signal);
return result?.messages ?? messages;
```
Harness does not store handlers, chain listeners, or know extension policy.
## Context
Context is a normal object, not rebuilt per emit.
```ts
const hooks = new CodingAgentHooks({
harness: harnessFacade,
session: sessionFacade,
ui: noUiFacade,
});
```
Later:
```ts
hooks.setContext({
...hooks.context,
ui: tuiFacade,
});
```
For dynamic state, prefer stable facades/methods over getter maze:
```ts
interface CodingAgentHookContext {
harness: HarnessFacade;
session: SessionFacade;
ui: UiFacade;
models: ModelFacade;
}
```
Per-run `signal` is passed as the third handler arg.
## Extension loading later
Extension loading can live next to harness and construct hooks:
```ts
const hooks = await loadExtensions({
paths,
context,
hooks: new CodingAgentHooks(context),
});
const harness = new AgentHarness({ ..., hooks });
```
The loader registers into hooks:
```ts
hooks.on("context", handler);
hooks.on("tool_call", handler);
hooks.addCleanup(cleanup);
```
For reload:
```ts
await hooks.clear();
const nextHooks = await loadExtensions(...);
harness.setHooks(nextHooks); // idle-only if supported
```
## Poking holes
### 1. Error policy must be explicit
Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely:
```ts
errorMode: "continue" | "throw"
onError(error)
```
For coding-agent, default should be `"continue"`.
### 2. Source metadata matters
Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes.
Probably needed:
```ts
const scope = hooks.createScope({ sourceInfo });
scope.on("context", handler);
scope.addCleanup(...);
```
Or `on(type, handler, { sourceInfo })`.
### 3. Some extension capabilities are registries, not hooks
These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host:
- tools
- commands
- shortcuts
- flags
- message renderers
- provider registrations
- OAuth providers
- custom model providers
That is fine. They do not belong in `AgentHarness`.
### 4. Existing coding-agent events can be represented
No blocker for:
- `context`
- `before_provider_request`
- `after_provider_response`
- `before_agent_start`
- `message_end`
- `tool_call`
- `tool_result`
- `input`
- `user_bash`
- `resources_discover`
- `session_before_*`
- `session_*`
- model/thinking selection events
- agent/turn/message/tool lifecycle events
They become additional event types handled by `CodingAgentHooks`.
### 5. Need to preserve exact old semantics
When porting coding-agent, special cases must be copied:
- `input`: transform chain, `handled` short-circuits.
- `user_bash`: first meaningful result wins.
- `message_end`: replacement must keep same role.
- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt.
- `resources_discover`: aggregate paths and keep extension source.
- `tool_call`: argument mutation remains visible to later handlers.
- `tool_result`: later handlers see prior patches.
The design allows all of that, but the default/coding hooks implementation must encode it.
### 6. `emit()` switch can miss custom mutation events
If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially.
### 7. Observer semantics are intentionally limited
Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler.
## Verdict
This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics.
--- Comments ---
Thread hn2xk0tzhj on "addCleanup(cleanup"
[tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe

View File

@@ -0,0 +1,376 @@
<!-- Synced from jot qe0ikdqs. Edit this file in-repo going forward. -->
# Pi Observability Design Notes
## Goal
Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor.
Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry.
## Mental model
A trace is one causal tree of work, e.g. one user turn.
A span is one timed operation in that tree. It is normally represented by IDs, not object pointers:
```ts
interface SpanRecord {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
startTime: number;
endTime?: number;
attributes: Record<string, unknown>;
status: "ok" | "error";
}
```
Example tree:
```text
traceId=t1 spanId=s1 parent=- name=pi.agent.prompt
traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn
traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request
traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call
traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry
```
## Async context
JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency.
`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts:
```ts
await Promise.all([
runWithPiContext({ userId: "alice" }, () => harness.prompt("A")),
runWithPiContext({ userId: "bob" }, () => harness.prompt("B")),
]);
```
Deep code can then read the correct current context for the active async chain.
Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter.
## Core design
Pi owns a small runtime-agnostic observability abstraction:
```ts
export interface PiObservabilityContext {
traceId?: string;
currentSpanId?: string;
userContext?: Record<string, unknown>;
}
export interface PiObservabilityEvent {
type: "start" | "end" | "error" | "event";
name: string;
traceId: string;
spanId?: string;
parentSpanId?: string;
timestamp: number;
durationMs?: number;
context?: Record<string, unknown>;
payload?: Record<string, unknown>;
error?: { name: string; message: string };
}
export interface PiObservability {
getContext(): PiObservabilityContext | undefined;
runWithContext<T>(context: PiObservabilityContext, fn: () => T): T;
emit(event: PiObservabilityEvent): void;
hasSubscribers(): boolean;
}
```
Public API:
```ts
export function configurePiObservability(observability: PiObservability): void;
export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void;
export function runWithPiContext<T>(userContext: Record<string, unknown>, fn: () => T): T;
export function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T;
```
`traceOperation()`:
1. reads the current context
2. creates `traceId` if missing
3. creates a new `spanId`
4. uses current span as `parentSpanId`
5. emits `start`
6. runs callback under child context
7. emits `end` or `error`
8. rethrows on error
Pseudo-code:
```ts
function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T {
const parent = getContext();
const traceId = parent?.traceId ?? createId();
const spanId = createId();
const parentSpanId = parent?.currentSpanId;
const child = { ...parent, traceId, currentSpanId: spanId };
emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload });
return runWithContext(child, () => {
try {
const result = fn();
// Promise-aware implementation emits end/error after settlement.
emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload });
return result;
} catch (error) {
emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) });
throw error;
}
});
}
```
## Runtime adapters
Core packages should not import Node-only APIs.
Possible implementations:
- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing.
- Browser/workers fallback: local subscriber set and limited/manual context propagation.
- Bun/Deno adapters: use runtime-specific async context if available.
For Node, diagnostics channels can be used as a passive event bus:
```ts
import { channel } from "diagnostics_channel";
channel("pi.observability").publish(event);
```
Subscribers can create OTel/Sentry spans without monkey-patching pi.
## What pi emits
Pi emits what happened. It does not create OTel/Sentry spans directly.
Initial minimal event names:
```text
pi.agent.prompt
pi.agent.skill
pi.agent.prompt_template
pi.agent.compaction
pi.agent.branch_navigation
pi.agent.session.append_entry
pi.ai.provider.request
```
Each operation emits:
```text
start
end
error
```
Later additions:
```text
pi.agent.turn
pi.agent.tool_call
pi.agent.queue_update
pi.ai.provider.retry
pi.ai.provider.first_token
pi.ai.provider.usage
pi.session.read
pi.session.write
```
## Minimal instrumentation points
### packages/agent
Wrap:
- `AgentHarness.prompt()`
- `AgentHarness.skill()`
- `AgentHarness.promptFromTemplate()`
- `AgentHarness.compact()`
- `AgentHarness.navigateTree()`
- `Session.appendTypedEntry()` or storage append facade
Example:
```ts
return traceOperation(
"pi.agent.prompt",
{
sessionId: turnState.sessionId,
provider: turnState.model.provider,
model: turnState.model.id,
promptLength: text.length,
imageCount: options?.images?.length ?? 0,
},
() => this.executeTurn(turnState, text, options),
);
```
Session write:
```ts
return traceOperation(
"pi.agent.session.append_entry",
{ entryType: entry.type },
async () => {
await this.unwrap(this.storage.appendEntry(entry));
return entry.id;
},
);
```
### packages/ai
Wrap common provider boundaries:
- `streamSimple()`
- `completeSimple()`
Example:
```ts
return traceOperation(
"pi.ai.provider.request",
{
api: model.api,
provider: model.provider,
model: model.id,
sessionId: options.sessionId,
reasoning: options.reasoning,
},
() => actualStreamSimple(model, context, options),
);
```
End/error payloads can include safe metadata:
- stop reason
- status code
- retry count
- input/output/total tokens
- cost total
- aborted/timeout flag
## Safety and redaction
Default payloads must be safe.
Safe by default:
- provider
- model
- API identifier
- session id
- entry type
- tool name
- status code
- stop reason
- token counts
- costs
- durations
Unsafe by default:
- prompts
- completions
- tool args
- tool results
- shell output
- file contents
- provider request payloads
- provider response bodies
- API keys
- headers
Content capture can be opt-in later with explicit redaction hooks.
## Listener behavior
Observability must never affect pi execution.
Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not.
## User context
Users can associate arbitrary context with a turn:
```ts
await runWithPiContext(
{
userId: "u123",
orgId: "acme",
region: "eu",
},
() => harness.prompt("fix this"),
);
```
Every emitted event inside that async chain includes the context:
```ts
{
type: "start",
name: "pi.ai.provider.request",
traceId: "t1",
spanId: "s3",
parentSpanId: "s1",
context: {
userId: "u123",
orgId: "acme",
region: "eu",
},
payload: {
provider: "anthropic",
model: "claude-sonnet-4",
},
}
```
An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON.
## Package story
Minimal initial package:
```text
packages/observability
runtime-agnostic context + traceOperation + subscribe
```
Then:
```text
packages/ai
emits pi.ai.* events
packages/agent
emits pi.agent.* / pi.session.* events
```
Optional later:
```text
packages/observability-node
AsyncLocalStorage + diagnostics_channel bridge
packages/otel
subscribes to pi events and creates OpenTelemetry spans
```
## Thesis
Pi defines a stable, safe event contract. Adapters define where events go.
This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching.

View File

@@ -1,10 +1,21 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.74.0",
"version": "0.75.4",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
@@ -12,15 +23,16 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "vitest --run",
"test:harness": "vitest --run --config vitest.harness.config.ts",
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.74.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"
"@earendil-works/pi-ai": "^0.75.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
},
"keywords": [
"ai",
@@ -37,11 +49,12 @@
"directory": "packages/agent"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
"@types/node": "24.12.4",
"@vitest/coverage-v8": "3.2.4",
"typescript": "5.9.3",
"vitest": "3.2.4"
}
}

View File

@@ -20,7 +20,7 @@ import type {
AgentToolCall,
AgentToolResult,
StreamFn,
} from "./types.js";
} from "./types.ts";
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
@@ -436,6 +436,10 @@ async function executeToolCallsSequential(
await emitToolResultMessage(toolResultMessage, emit);
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
if (signal?.aborted) {
break;
}
}
return {
@@ -471,6 +475,9 @@ async function executeToolCallsParallel(
} satisfies FinalizedToolCallOutcome;
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
if (signal?.aborted) {
break;
}
continue;
}
@@ -487,6 +494,9 @@ async function executeToolCallsParallel(
await emitToolExecutionEnd(finalized, emit);
return finalized;
});
if (signal?.aborted) {
break;
}
}
const orderedFinalizedCalls = await Promise.all(
@@ -578,6 +588,13 @@ async function prepareToolCall(
},
signal,
);
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
if (beforeResult?.block) {
return {
kind: "immediate",
@@ -586,6 +603,13 @@ async function prepareToolCall(
};
}
}
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
return {
kind: "prepared",
toolCall,

View File

@@ -8,7 +8,7 @@ import {
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
@@ -21,9 +21,12 @@ import type {
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
QueueMode,
StreamFn,
ToolExecutionMode,
} from "./types.js";
} from "./types.ts";
export type { QueueMode } from "./types.ts";
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
@@ -53,8 +56,6 @@ const DEFAULT_MODEL = {
maxTokens: 0,
} satisfies Model<any>;
export type QueueMode = "all" | "one-at-a-time";
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
isStreaming: boolean;
streamingMessage?: AgentMessage;
@@ -116,8 +117,11 @@ export interface AgentOptions {
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode;
constructor(public mode: QueueMode) {}
constructor(mode: QueueMode) {
this.mode = mode;
}
enqueue(message: AgentMessage): void {
this.messages.push(message);

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +1,81 @@
/**
* Branch summarization for tree navigation.
*
* When navigating to a different point in the session tree, this generates
* a summary of the branch being left so context isn't lost.
*/
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { Session, SessionTreeEntry } from "../types.js";
import { estimateTokens } from "./compaction.js";
} from "../messages.ts";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// Types
// ============================================================================
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
}
/** Details stored in BranchSummaryEntry.details for file tracking */
/** File-operation details stored on generated branch summary entries. */
export interface BranchSummaryDetails {
/** Files read while exploring the summarized branch. */
readFiles: string[];
/** Files modified while exploring the summarized branch. */
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export type { FileOperations } from "./utils.ts";
/** Prepared branch content for summarization. */
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */
/** Messages selected for the branch summary. */
messages: AgentMessage[];
/** File operations extracted from tool calls */
/** File operations extracted from the branch. */
fileOps: FileOperations;
/** Total estimated tokens in messages */
/** Estimated token count for selected messages. */
totalTokens: number;
}
/** Entries selected for branch summarization. */
export interface CollectEntriesResult {
/** Entries to summarize, in chronological order */
/** Entries to summarize in chronological order. */
entries: SessionTreeEntry[];
/** Common ancestor between old and new position, if any */
/** Deepest common ancestor between the previous leaf and target entry. */
commonAncestorId: string | null;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
/** Model used for summarization. */
model: Model<any>;
/** API key for the model */
/** API key forwarded to the provider. */
apiKey: string;
/** Request headers for the model */
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for cancellation */
/** Abort signal for the summarization request. */
signal: AbortSignal;
/** Optional custom instructions for summarization */
/** Optional instructions appended to or replacing the default prompt. */
customInstructions?: string;
/** If true, customInstructions replaces the default prompt instead of being appended */
/** Replace the default prompt with custom instructions instead of appending them. */
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
/** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number;
}
// ============================================================================
// Entry Collection
// ============================================================================
/**
* Collect entries that should be summarized when navigating from one position to another.
*
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
* along the way. Does NOT stop at compaction boundaries - those are included and their
* summaries become context.
*
* @param session - Session manager (read-only access)
* @param oldLeafId - Current position (where we're navigating from)
* @param targetId - Target position (where we're navigating to)
* @returns Entries to summarize and the common ancestor
*/
/** Collect entries that should be summarized before navigating to a different session tree entry. */
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null,
targetId: string,
): Promise<CollectEntriesResult> {
// If no old position, nothing to summarize
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id));
const targetPath = await session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
@@ -117,85 +83,48 @@ export async function collectEntriesForBranchSummary(
break;
}
}
// Collect entries from old leaf back to common ancestor
const entries: SessionTreeEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = await session.getEntry(current);
if (!entry) break;
if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`);
entries.push(entry as SessionTreeEntry);
current = entry.parentId;
}
// Reverse to get chronological order
entries.reverse();
return { entries, commonAncestorId };
}
// ============================================================================
// Entry to Message Conversion
// ============================================================================
/**
* Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message as AgentMessage;
return entry.message;
case "custom_message":
return createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
// These don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
case "session_info":
case "leaf":
return undefined;
}
}
/**
* Prepare entries for summarization with token budget.
*
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long.
*
* Also collects file operations from:
* - Tool calls in assistant messages
* - Existing branch_summary entries' details (for cumulative tracking)
*
* @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit)
*/
/** Prepare branch entries for summarization within an optional token budget. */
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
@@ -203,35 +132,26 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
// Modified files go into both edited and written for proper deduplication
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
@@ -242,10 +162,6 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
return { messages, fileOps, totalTokens };
}
// ============================================================================
// Summary Generation
// ============================================================================
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
@@ -280,34 +196,22 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of abandoned branch entries.
*
* @param entries - Session entries to summarize (chronological order)
* @param options - Generation options
*/
/** Generate a summary for abandoned branch entries. */
export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return { summary: "No content to summarize" };
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
}
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt
let instructions: string;
if (replaceInstructions && customInstructions) {
instructions = customInstructions;
@@ -325,37 +229,34 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
);
// Check if aborted or errored
if (response.stopReason === "aborted") {
return { aborted: true };
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
}
if (response.stopReason === "error") {
return { error: response.errorMessage || "Summarization failed" };
return err(
new BranchSummaryError(
"summarization_failed",
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
// Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return {
return ok({
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
};
});
}

View File

@@ -1,56 +1,47 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage, ThinkingLevel } from "../../types.js";
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import { buildSessionContext } from "../session/session.js";
import type { CompactionEntry, SessionTreeEntry } from "../types.js";
} from "../messages.ts";
import { buildSessionContext } from "../session/session.ts";
import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// File Operation Tracking
// ============================================================================
/** Details stored in CompactionEntry.details for file tracking */
/** File-operation details stored on generated compaction entries. */
export interface CompactionDetails {
/** Files read in the compacted history. */
readFiles: string[];
/** Files modified in the compacted history. */
modifiedFiles: string[];
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
/**
* Extract file operations from messages and previous compaction entries.
*/
function extractFileOperations(
messages: AgentMessage[],
entries: SessionTreeEntry[],
prevCompactionIndex: number,
): FileOperations {
const fileOps = createFileOps();
// Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
// fromHook field kept for session file compatibility
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
@@ -60,23 +51,12 @@ function extractFileOperations(
}
}
}
// Extract from tool calls in messages
for (const msg of messages) {
extractFileOpsFromMessage(msg, fileOps);
}
return fileOps;
}
// ============================================================================
// Message Extraction
// ============================================================================
/**
* Extract AgentMessage from an entry if it produces one.
* Returns undefined for entries that don't contribute to LLM context.
*/
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message as AgentMessage;
@@ -106,47 +86,39 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage
return getMessageFromEntry(entry);
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
/** Generated compaction data ready to be persisted as a compaction entry. */
export interface CompactionResult<T = unknown> {
/** Summary text that replaces compacted history in future context. */
summary: string;
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
/** Optional implementation-specific details stored with the compaction entry. */
details?: T;
}
// ============================================================================
// Types
// ============================================================================
/** Compaction thresholds and retention settings. */
export interface CompactionSettings {
/** Enable automatic compaction decisions. */
enabled: boolean;
/** Tokens reserved for summary prompt and output. */
reserveTokens: number;
/** Approximate recent-context tokens to keep after compaction. */
keepRecentTokens: number;
}
/** Default compaction settings used by the harness. */
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
/** Calculate total context tokens from provider usage. */
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
@@ -157,9 +129,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
/** Return usage from the last successful assistant message in session entries. */
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
@@ -171,10 +141,15 @@ export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | unde
return undefined;
}
/** Estimated context-token usage for a message list. */
export interface ContextUsageEstimate {
/** Estimated total context tokens. */
tokens: number;
/** Tokens reported by the most recent assistant usage block. */
usageTokens: number;
/** Estimated tokens after the most recent assistant usage block. */
trailingTokens: number;
/** Index of the message that provided usage, or null when none exists. */
lastUsageIndex: number | null;
}
@@ -186,10 +161,7 @@ function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; in
return undefined;
}
/**
* Estimate context tokens from messages, using the last assistant usage when available.
* If there are messages after the last usage, estimate their tokens with estimateTokens.
*/
/** Estimate context tokens for messages using provider usage when available. */
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages);
@@ -220,22 +192,13 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst
};
}
/**
* Check if compaction should trigger based on context usage.
*/
/** Return whether context usage exceeds the configured compaction threshold. */
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
/** Estimate token count for one message using a conservative character heuristic. */
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
@@ -261,7 +224,7 @@ export function estimateTokens(message: AgentMessage): number {
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
chars += block.name.length + safeJsonStringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
@@ -276,7 +239,7 @@ export function estimateTokens(message: AgentMessage): number {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
chars += 4800;
}
}
}
@@ -295,14 +258,6 @@ export function estimateTokens(message: AgentMessage): number {
return 0;
}
/**
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
@@ -332,10 +287,9 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
case "custom_message":
case "label":
case "session_info":
case "leaf":
break;
}
// branch_summary and custom_message are user-role messages, valid cut points
if (entry.type === "branch_summary" || entry.type === "custom_message") {
cutPoints.push(i);
}
@@ -343,15 +297,10 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
/** Find the user-visible message that starts the turn containing an entry. */
export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
// branch_summary and custom_message are user-role messages, can start a turn
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
@@ -365,31 +314,17 @@ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: numb
return -1;
}
/** Cut point selected for compaction. */
export interface CutPointResult {
/** Index of first entry to keep */
/** Index of the first entry retained after compaction. */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
/** Whether the selected cut point splits an in-progress turn. */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
export function findCutPoint(
entries: SessionTreeEntry[],
startIndex: number,
@@ -401,22 +336,15 @@ export function findCutPoint(
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
let cutIndex = cutPoints[0];
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message as AgentMessage);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
@@ -426,23 +354,16 @@ export function findCutPoint(
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at session header or compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
@@ -454,9 +375,9 @@ export function findCutPoint(
};
}
// ============================================================================
// Summarization
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -530,10 +451,7 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
*/
/** Generate or update a conversation summary for compaction. */
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
@@ -544,21 +462,17 @@ export async function generateSummary(
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
// Use update prompt if we have a previous summary, otherwise initial prompt
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
@@ -583,9 +497,16 @@ export async function generateSummary(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
}
if (response.stopReason === "error") {
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
return err(
new CompactionError(
"summarization_failed",
`Summarization failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
const textContent = response.content
@@ -593,37 +514,36 @@ export async function generateSummary(
.map((c) => c.text)
.join("\n");
return textContent;
return ok(textContent);
}
// ============================================================================
// Compaction Preparation (for extensions)
// ============================================================================
/** Prepared inputs for a compaction run. */
export interface CompactionPreparation {
/** UUID of first entry to keep */
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
/** Messages summarized into the history summary. */
messagesToSummarize: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
/** Prefix messages summarized separately when compaction splits a turn. */
turnPrefixMessages: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
/** Whether compaction splits a turn. */
isSplitTurn: boolean;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Summary from previous compaction, for iterative update */
/** Previous compaction summary used for iterative updates. */
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
/** File operations extracted from summarized history. */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
/** Settings used to prepare compaction. */
settings: CompactionSettings;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
}
let prevCompactionIndex = -1;
@@ -647,24 +567,18 @@ export function prepareCompaction(
const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Get UUID of first kept entry
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return undefined; // Session needs migration
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
}
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) messagesToSummarize.push(msg);
}
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
@@ -672,18 +586,14 @@ export function prepareCompaction(
if (msg) turnPrefixMessages.push(msg);
}
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
// Also extract file ops from turn prefix if splitting
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return {
return ok({
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
@@ -692,13 +602,9 @@ export function prepareCompaction(
previousSummary,
fileOps,
settings,
};
});
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
@@ -714,15 +620,9 @@ Summarize the prefix to provide context for the retained suffix:
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
export { serializeConversation } from "./utils.js";
export { serializeConversation } from "./utils.ts";
/** Generate compaction summary data from prepared session history. */
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
@@ -731,7 +631,7 @@ export async function compact(
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
): Promise<CompactionResult> {
): Promise<Result<CompactionResult, CompactionError>> {
const {
firstKeptEntryId,
messagesToSummarize,
@@ -743,11 +643,13 @@ export async function compact(
settings,
} = preparation;
// Generate summaries (can be parallel if both needed) and merge into one
if (!firstKeptEntryId) {
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
}
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? generateSummary(
@@ -761,7 +663,7 @@ export async function compact(
previousSummary,
thinkingLevel,
)
: Promise.resolve("No prior history."),
: Promise.resolve(ok<string, CompactionError>("No prior history.")),
generateTurnPrefixSummary(
turnPrefixMessages,
model,
@@ -772,11 +674,11 @@ export async function compact(
thinkingLevel,
),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
if (!historyResult.ok) return err(historyResult.error);
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
} else {
// Just generate history summary
summary = await generateSummary(
const summaryResult = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
@@ -787,27 +689,20 @@ export async function compact(
previousSummary,
thinkingLevel,
);
if (!summaryResult.ok) return err(summaryResult.error);
summary = summaryResult.value;
}
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
}
return {
return ok({
summary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles } as CompactionDetails,
};
});
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
@@ -816,8 +711,11 @@ async function generateTurnPrefixSummary(
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
@@ -836,13 +734,22 @@ async function generateTurnPrefixSummary(
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
}
if (response.stopReason === "error") {
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
return err(
new CompactionError(
"summarization_failed",
`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return ok(
response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n"),
);
}

View File

@@ -1,20 +1,17 @@
/**
* Shared utilities for compaction and branch summarization.
*/
import type { Message } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
// ============================================================================
// File Operation Tracking
// ============================================================================
import type { AgentMessage } from "../../types.ts";
/** File paths touched by a session branch or compaction range. */
export interface FileOperations {
/** Files read but not necessarily modified. */
read: Set<string>;
/** Files written by full-file write operations. */
written: Set<string>;
/** Files modified by edit operations. */
edited: Set<string>;
}
/** Create an empty file-operation accumulator. */
export function createFileOps(): FileOperations {
return {
read: new Set(),
@@ -23,9 +20,7 @@ export function createFileOps(): FileOperations {
};
}
/**
* Extract file operations from tool calls in an assistant message.
*/
/** Add file operations from assistant tool calls to an accumulator. */
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
@@ -55,10 +50,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
}
}
/**
* Compute final file lists from file operations.
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
/** Compute sorted read-only and modified file lists from accumulated operations. */
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
@@ -66,9 +58,7 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
/** Format file lists as summary metadata tags. */
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
@@ -81,31 +71,23 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
return `\n\n${sections.join("\n\n")}`;
}
// ============================================================================
// Message Serialization
// ============================================================================
/** Maximum characters for a tool result in serialized summaries. */
const TOOL_RESULT_MAX_CHARS = 2000;
/**
* Truncate text to a maximum character length for summarization.
* Keeps the beginning and appends a truncation marker.
*/
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function truncateForSummary(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const truncatedChars = text.length - maxChars;
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
}
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*
* Tool results are truncated to keep the summarization request within
* reasonable token budgets. Full content is not needed for summarization.
*/
/** Serialize LLM messages to plain text for summarization prompts. */
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
@@ -132,7 +114,7 @@ export function serializeConversation(messages: Message[]): string {
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
@@ -160,11 +142,3 @@ export function serializeConversation(messages: Message[]): string {
return parts.join("\n\n");
}
// ============================================================================
// Summarization System Prompt
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

View File

@@ -1,33 +1,61 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { constants, createReadStream } from "node:fs";
import {
access,
appendFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js";
import { createInterface } from "node:readline";
import {
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
ok,
type Result,
toError,
} from "../types.ts";
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
}
function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind {
function fileKindFromStats(stats: {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
}): FileKind | undefined {
if (stats.isFile()) return "file";
if (stats.isDirectory()) return "directory";
if (stats.isSymbolicLink()) return "symlink";
throw new FileError("invalid", "Unsupported file type");
return undefined;
}
function fileInfoFromStats(
path: string,
stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number },
): FileInfo {
return {
): Result<FileInfo, FileError> {
const kind = fileKindFromStats(stats);
if (!kind) return err(new FileError("invalid", "Unsupported file type", path));
return ok({
name: path.replace(/\/+$/, "").split("/").pop() ?? path,
path,
kind: fileKindFromStats(stats),
kind,
size: stats.size,
mtimeMs: stats.mtimeMs,
};
});
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -36,23 +64,30 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
function toFileError(error: unknown, path?: string): FileError {
if (error instanceof FileError) return error;
const cause = toError(error);
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, cause);
case "ENOENT":
return new FileError("not_found", message, path, { cause: error });
return new FileError("not_found", message, path, cause);
case "EACCES":
case "EPERM":
return new FileError("permission_denied", message, path, { cause: error });
return new FileError("permission_denied", message, path, cause);
case "ENOTDIR":
return new FileError("not_directory", message, path, { cause: error });
return new FileError("not_directory", message, path, cause);
case "EISDIR":
return new FileError("is_directory", message, path, { cause: error });
return new FileError("is_directory", message, path, cause);
case "EINVAL":
return new FileError("invalid", message, path, { cause: error });
return new FileError("invalid", message, path, cause);
}
}
return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error });
return new FileError("unknown", cause.message, path, cause);
}
function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
}
async function pathExists(path: string): Promise<boolean> {
@@ -71,7 +106,16 @@ async function runCommand(
): Promise<{ stdout: string; status: number | null }> {
return await new Promise((resolve) => {
let stdout = "";
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
resolve({ stdout: "", status: null });
return;
}
const timeout = setTimeout(() => {
if (child.pid) killProcessTree(child.pid);
}, timeoutMs);
@@ -100,12 +144,14 @@ async function findBashOnPath(): Promise<string | null> {
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> {
async function getShellConfig(
customShellPath?: string,
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return { shell: customShellPath, args: ["-c"] };
return ok({ shell: customShellPath, args: ["-c"] });
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
}
if (process.platform === "win32") {
const candidates: string[] = [];
@@ -115,24 +161,24 @@ async function getShellConfig(customShellPath?: string): Promise<{ shell: string
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return { shell: candidate, args: ["-c"] };
return ok({ shell: candidate, args: ["-c"] });
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return ok({ shell: bashOnPath, args: ["-c"] });
}
throw new Error("No bash shell found");
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return { shell: "/bin/bash", args: ["-c"] };
return ok({ shell: "/bin/bash", args: ["-c"] });
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return ok({ shell: bashOnPath, args: ["-c"] });
}
return { shell: "sh", args: ["-c"] };
return ok({ shell: "sh", args: ["-c"] });
}
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -149,6 +195,7 @@ function killProcessTree(pid: number): void {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
} catch {
// Ignore errors.
@@ -178,52 +225,83 @@ export class NodeExecutionEnv implements ExecutionEnv {
this.shellEnv = options.shellEnv;
}
async absolutePath(path: string): Promise<Result<string, FileError>> {
return ok(resolvePath(this.cwd, path));
}
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
return ok(join(...parts));
}
async exec(
command: string,
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
signal?: AbortSignal;
abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
},
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const { shell, args } = await getShellConfig(this.shellPath);
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
return await new Promise((resolvePromise, reject) => {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) return shellConfig;
return await new Promise((resolvePromise) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
});
let callbackError: ExecutionError | undefined;
let child: ReturnType<typeof spawn> | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutId =
const onAbort = () => {
if (child?.pid) {
killProcessTree(child.pid);
}
};
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
resolvePromise(result);
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
} catch (error) {
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
return;
}
timeoutId =
typeof options?.timeout === "number"
? setTimeout(() => {
timedOut = true;
if (child.pid) {
if (child?.pid) {
killProcessTree(child.pid);
}
}, options.timeout * 1000)
: undefined;
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
};
if (options?.signal) {
if (options.signal.aborted) {
if (options?.abortSignal) {
if (options.abortSignal.aborted) {
onAbort();
} else {
options.signal.addEventListener("abort", onAbort, { once: true });
options.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
@@ -231,137 +309,217 @@ export class NodeExecutionEnv implements ExecutionEnv {
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
options?.onStdout?.(chunk);
try {
options?.onStdout?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
options?.onStderr?.(chunk);
try {
options?.onStderr?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.on("error", (error) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
reject(error);
settle(err(new ExecutionError("spawn_error", error.message, error)));
});
child.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
if (options?.signal?.aborted) {
reject(new Error("aborted"));
if (callbackError) {
settle(err(callbackError));
return;
}
if (timedOut) {
reject(new Error(`timeout:${options?.timeout}`));
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return;
}
resolvePromise({ stdout, stderr, exitCode: code ?? 0 });
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted")));
return;
}
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
});
});
}
async readTextFile(path: string): Promise<string> {
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;
try {
return await readFile(resolved, "utf8");
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async readBinaryFile(path: string): Promise<Uint8Array> {
async readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string[]>(options?.abortSignal, resolved);
if (aborted) return aborted;
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
let stream: ReturnType<typeof createReadStream> | undefined;
let lineReader: ReturnType<typeof createInterface> | undefined;
try {
return await readFile(resolved);
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
const lines: string[] = [];
for await (const line of lineReader) {
const loopAbort = abortResult<string[]>(options?.abortSignal, resolved);
if (loopAbort) return loopAbort;
lines.push(line);
if (options?.maxLines !== undefined && lines.length >= options.maxLines) break;
}
const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved);
if (afterReadAbort) return afterReadAbort;
return ok(lines);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
} finally {
lineReader?.close();
stream?.destroy();
}
}
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
if (aborted) return aborted;
try {
return ok(await readFile(resolved, { signal: abortSignal }));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(abortSignal, resolved);
if (aborted) return aborted;
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
if (afterMkdirAbort) return afterMkdirAbort;
await writeFile(resolved, content, { signal: abortSignal });
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
await writeFile(resolved, content);
await appendFile(resolved, content);
return ok(undefined);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async fileInfo(path: string): Promise<FileInfo> {
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return fileInfoFromStats(resolved, await lstat(resolved));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async listDir(path: string): Promise<FileInfo[]> {
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
if (aborted) return aborted;
try {
const entries = await readdir(resolved, { withFileTypes: true });
const infos: FileInfo[] = [];
for (const entry of entries) {
const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
if (loopAbort) return loopAbort;
const entryPath = resolve(resolved, entry.name);
try {
infos.push(fileInfoFromStats(entryPath, await lstat(entryPath)));
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
if (info.ok) infos.push(info.value);
} catch (error) {
if (error instanceof FileError && error.code === "invalid") continue;
throw error;
return err(toFileError(error, entryPath));
}
}
return infos;
return ok(infos);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async realPath(path: string): Promise<string> {
async canonicalPath(path: string): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return await realpath(resolved);
return ok(await realpath(resolved));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async exists(path: string): Promise<boolean> {
async exists(path: string): Promise<Result<boolean, FileError>> {
const result = await this.fileInfo(path);
if (result.ok) return ok(true);
if (result.error.code === "not_found") return ok(false);
return err(result.error);
}
async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await this.fileInfo(path);
return true;
await mkdir(resolved, { recursive: options?.recursive ?? true });
return ok(undefined);
} catch (error) {
if (error instanceof FileError && error.code === "not_found") return false;
throw error;
return err(toFileError(error, resolved));
}
}
async createDir(path: string, options?: { recursive?: boolean }): Promise<void> {
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive });
}
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
return ok(undefined);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async createTempDir(prefix: string = "tmp-"): Promise<string> {
return await mkdtemp(join(tmpdir(), prefix));
async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> {
try {
return ok(await mkdtemp(join(tmpdir(), prefix)));
} catch (error) {
return err(toFileError(error));
}
}
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> {
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> {
const dir = await this.createTempDir("tmp-");
const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
await writeFile(filePath, "");
return filePath;
if (!dir.ok) return dir;
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
try {
await writeFile(filePath, "");
return ok(filePath);
} catch (error) {
return err(toFileError(error, filePath));
}
}
async cleanup(): Promise<void> {

View File

@@ -1,3 +0,0 @@
export { NodeExecutionEnv } from "./env/nodejs.js";
export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js";
export { FileError } from "./types.js";

View File

@@ -1,5 +1,5 @@
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../types.js";
import type { AgentMessage } from "../types.ts";
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
@@ -51,7 +51,7 @@ export interface CompactionSummaryMessage {
timestamp: number;
}
declare module "../types.js" {
declare module "../types.ts" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;

View File

@@ -1,10 +1,14 @@
import { parse } from "yaml";
import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js";
import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts";
export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed";
/** Warning produced while loading prompt templates. */
export interface PromptTemplateDiagnostic {
/** Diagnostic severity. Currently only warnings are emitted. */
type: "warning";
/** Stable diagnostic code. */
code: PromptTemplateDiagnosticCode;
/** Human-readable diagnostic message. */
message: string;
/** Path associated with the diagnostic. */
@@ -30,9 +34,20 @@ export async function loadPromptTemplates(
const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = [];
for (const path of Array.isArray(paths) ? paths : [paths]) {
const info = await safeFileInfo(env, path);
if (!info) continue;
const kind = await resolveKind(env, info);
const infoResult = await env.fileInfo(path);
if (!infoResult.ok) {
if (infoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: infoResult.error.message,
path,
});
}
continue;
}
const info = infoResult.value;
const kind = await resolveKind(env, info, diagnostics);
if (kind === "directory") {
const result = await loadTemplatesFromDir(env, info.path);
promptTemplates.push(...result.promptTemplates);
@@ -83,20 +98,20 @@ async function loadTemplatesFromDir(
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = [];
let entries: FileInfo[];
try {
entries = await env.listDir(dir);
} catch (error) {
const entriesResult = await env.listDir(dir);
if (!entriesResult.ok) {
diagnostics.push({
type: "warning",
message: errorMessage(error, "failed to list prompt template directory"),
code: "list_failed",
message: entriesResult.error.message,
path: dir,
});
return { promptTemplates, diagnostics };
}
const entries = entriesResult.value;
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (kind !== "file" || !entry.name.endsWith(".md")) continue;
const result = await loadTemplateFromFile(env, entry.path);
if (result.promptTemplate) promptTemplates.push(result.promptTemplate);
@@ -110,60 +125,92 @@ async function loadTemplateFromFile(
filePath: string,
): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> {
const diagnostics: PromptTemplateDiagnostic[] = [];
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<PromptTemplateFrontmatter>(rawContent);
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
} catch (error) {
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {
diagnostics.push({
type: "warning",
message: errorMessage(error, "failed to load prompt template"),
code: "read_failed",
message: rawContent.error.message,
path: filePath,
});
return { promptTemplate: null, diagnostics };
}
}
async function safeFileInfo(env: ExecutionEnv, path: string): Promise<FileInfo | undefined> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
const parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({
type: "warning",
code: "parse_failed",
message: parsed.error.message,
path: filePath,
});
return { promptTemplate: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
}
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
async function resolveKind(
env: ExecutionEnv,
info: FileInfo,
diagnostics: PromptTemplateDiagnostic[],
): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
try {
const realPath = await env.realPath(info.path);
const target = await env.fileInfo(realPath);
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
} catch {
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) {
if (canonicalPath.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: canonicalPath.error.message,
path: info.path,
});
}
return undefined;
}
const target = await env.fileInfo(canonicalPath.value);
if (!target.ok) {
if (target.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: target.error.message,
path: info.path,
});
}
return undefined;
}
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
}
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: toError(error) };
}
}
function basenameEnvPath(path: string): string {
@@ -172,10 +219,6 @@ function basenameEnvPath(path: string): string {
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
/** Parse an argument string using simple shell-style single and double quotes. */
export function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];

View File

@@ -0,0 +1,177 @@
import type {
FileSystem,
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts";
import {
createSessionId,
createTimestamp,
getEntriesToFork,
getFileSystemResultOrThrow,
toSession,
} from "./repo-utils.ts";
type JsonlSessionRepoFileSystem = Pick<
FileSystem,
| "cwd"
| "absolutePath"
| "joinPath"
| "readTextFile"
| "readTextLines"
| "writeFile"
| "appendFile"
| "listDir"
| "exists"
| "createDir"
| "remove"
>;
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private readonly fs: JsonlSessionRepoFileSystem;
private readonly sessionsRootInput: string;
private sessionsRoot: string | undefined;
constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) {
this.fs = options.fs;
this.sessionsRootInput = options.sessionsRoot;
}
private async getSessionsRoot(): Promise<string> {
if (!this.sessionsRoot) {
this.sessionsRoot = getFileSystemResultOrThrow(
await this.fs.absolutePath(this.sessionsRootInput),
`Failed to resolve sessions root ${this.sessionsRootInput}`,
);
}
return this.sessionsRoot;
}
private async getSessionDir(cwd: string): Promise<string> {
return getFileSystemResultOrThrow(
await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]),
`Failed to resolve session directory for ${cwd}`,
);
}
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
return getFileSystemResultOrThrow(
await this.fs.joinPath([
await this.getSessionDir(cwd),
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
]),
`Failed to resolve session file path for ${sessionId}`,
);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
) {
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) {
continue;
}
const files = getFileSystemResultOrThrow(
await this.fs.listDir(dir),
`Failed to list sessions in ${dir}`,
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
for (const file of files) {
try {
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
} catch (error) {
const cause = toError(error);
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause;
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.remove(metadata.path, { force: true }),
`Failed to delete session ${metadata.path}`,
);
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const storage = await JsonlSessionStorage.create(
this.fs,
await this.createSessionFilePath(options.cwd, id, createdAt),
{
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
},
);
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
const sessionsRoot = await this.getSessionsRoot();
if (
!getFileSystemResultOrThrow(
await this.fs.exists(sessionsRoot),
`Failed to check sessions root ${sessionsRoot}`,
)
) {
return [];
}
const entries = getFileSystemResultOrThrow(
await this.fs.listDir(sessionsRoot),
`Failed to list sessions root ${sessionsRoot}`,
);
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
}
}

View File

@@ -0,0 +1,293 @@
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
import { uuidv7 } from "./uuid.ts";
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
const label = entry.label?.trim();
if (label) {
labelsById.set(entry.targetId, label);
} else {
labelsById.delete(entry.targetId);
}
}
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
const labelsById = new Map<string, string>();
for (const entry of entries) {
updateLabelCache(labelsById, entry);
}
return labelsById;
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return uuidv7();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
}
function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError {
return new SessionError(
"invalid_entry",
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
cause,
);
}
function parseHeaderLine(line: string, filePath: string): SessionHeader {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
}
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidSession(filePath, "session header is missing timestamp");
}
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
throw invalidSession(filePath, "session header parentSession must be a string");
}
return {
type: "session",
version: 3,
id: parsed.id,
timestamp: parsed.timestamp,
cwd: parsed.cwd,
parentSession: parsed.parentSession,
};
}
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
}
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
}
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
}
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
return parsed as unknown as SessionTreeEntry;
}
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
createdAt: header.timestamp,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionMetadata> {
const lines = getFileSystemResultOrThrow(
await fs.readTextLines(filePath, { maxLines: 1 }),
`Failed to read session header ${filePath}`,
);
const line = lines[0];
if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
throw invalidSession(filePath, "missing session header");
}
async function loadJsonlStorage(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`);
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(lines[0]!, filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
entries.push(entry);
leafId = leafIdAfterEntry(entry);
}
return { header, entries, leafId };
}
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
private readonly fs: JsonlSessionStorageFileSystem;
private readonly filePath: string;
private readonly metadata: JsonlSessionMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private constructor(
fs: JsonlSessionStorageFileSystem,
filePath: string,
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
) {
this.fs = fs;
this.filePath = filePath;
this.metadata = headerToSessionMetadata(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.currentLeafId = leafId;
}
static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
static async create(
fs: JsonlSessionStorageFileSystem,
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
getFileSystemResultOrThrow(
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
`Failed to create session ${filePath}`,
);
return new JsonlSessionStorage(fs, filePath, header, [], null);
}
async getMetadata(): Promise<JsonlSessionMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) {
throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`);
}
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session leaf ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.currentLeafId = leafId;
}
async createEntryId(): Promise<string> {
return generateEntryId(this.byId);
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.byId.get(id);
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
}

View File

@@ -1,6 +1,6 @@
import type { Session, SessionMetadata, SessionRepo } from "../../types.js";
import { InMemorySessionStorage } from "../storage/memory.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.ts";
import { InMemorySessionStorage } from "./memory-storage.ts";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.ts";
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
private sessions = new Map<string, Session<SessionMetadata>>();
@@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
const session = this.sessions.get(metadata.id);
if (!session) {
throw new Error(`Session not found: ${metadata.id}`);
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
}
return session;
}
@@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
};
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries, leafId });
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
const session = toSession(storage);
this.sessions.set(metadata.id, session);
return session;

View File

@@ -1,6 +1,11 @@
import { randomUUID } from "node:crypto";
import { v7 as uuidv7 } from "uuid";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import {
type LeafEntry,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.ts";
import { uuidv7 } from "./uuid.ts";
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
@@ -22,42 +27,61 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
return uuidv7();
}
export class InMemorySessionStorage implements SessionStorage {
private readonly metadata: SessionMetadata;
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
implements SessionStorage<TMetadata>
{
private readonly metadata: TMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private leafId: string | null;
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) {
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
this.entries = options?.entries ? [...options.entries] : [];
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(this.entries);
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
this.leafId = null;
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new Error(`Entry ${this.leafId} not found`);
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() };
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
}
async getMetadata(): Promise<SessionMetadata> {
async getMetadata(): Promise<TMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
return this.leafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = leafId;
}
@@ -69,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage {
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.leafId = entry.id;
this.leafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
@@ -90,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}

View File

@@ -1,6 +1,13 @@
import { v7 as uuidv7 } from "uuid";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import { Session } from "../session.js";
import {
type FileError,
type Result,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.ts";
import { Session } from "./session.ts";
import { uuidv7 } from "./uuid.ts";
export function createSessionId(): string {
return uuidv7();
@@ -14,6 +21,14 @@ export function toSession<TMetadata extends SessionMetadata>(storage: SessionSto
return new Session(storage);
}
export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue {
if (!result.ok) {
const code = result.error.code === "not_found" ? "not_found" : "storage";
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
}
return result.value;
}
export async function getEntriesToFork(
storage: SessionStorage,
options: { entryId?: string; position?: "before" | "at" },
@@ -21,14 +36,14 @@ export async function getEntriesToFork(
if (!options.entryId) return storage.getEntries();
const target = await storage.getEntry(options.entryId);
if (!target) {
throw new Error(`Entry ${options.entryId} not found`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`);
}
let effectiveLeafId: string | null;
if ((options.position ?? "before") === "at") {
effectiveLeafId = target.id;
} else {
if (target.type !== "message" || target.message.role !== "user") {
throw new Error(`Entry ${options.entryId} is not a user message`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`);
}
effectiveLeafId = target.parentId;
}

View File

@@ -1,109 +0,0 @@
import { constants } from "node:fs";
import { access, mkdir, readdir, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type {
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private sessionsRoot: string;
constructor(options: { sessionsRoot: string }) {
this.sessionsRoot = resolve(options.sessionsRoot);
}
private getSessionDir(cwd: string): string {
return join(this.sessionsRoot, encodeCwd(cwd));
}
private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string {
return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
await mkdir(this.sessionsRoot, { recursive: true });
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (!(await exists(metadata.path))) {
throw new Error(`Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!(await exists(dir))) continue;
const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
for (const filePath of files) {
try {
sessions.push(await loadJsonlSessionMetadata(filePath));
} catch {
// Ignore invalid session files when listing a directory.
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
await rm(metadata.path, { force: true });
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
if (!(await exists(this.sessionsRoot))) return [];
const entries = await readdir(this.sessionsRoot, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name));
}
}

View File

@@ -1,6 +1,6 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.js";
import type { AgentMessage } from "../../types.ts";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
@@ -15,7 +15,8 @@ import type {
SessionStorage,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../types.js";
} from "../types.ts";
import { SessionError } from "../types.ts";
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
@@ -206,7 +207,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
if (!(await this.storage.getEntry(targetId))) {
throw new Error(`Entry ${targetId} not found`);
throw new SessionError("not_found", `Entry ${targetId} not found`);
}
return this.appendTypedEntry({
type: "label",
@@ -233,7 +234,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
summary?: { summary: string; details?: unknown; fromHook?: boolean },
): Promise<string | undefined> {
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
throw new Error(`Entry ${entryId} not found`);
throw new SessionError("not_found", `Entry ${entryId} not found`);
}
await this.storage.setLeafId(entryId);
if (!summary) return undefined;

View File

@@ -1,205 +0,0 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
const label = entry.label?.trim();
if (label) {
labelsById.set(entry.targetId, label);
} else {
labelsById.delete(entry.targetId);
}
}
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
const labelsById = new Map<string, string>();
for (const entry of entries) {
updateLabelCache(labelsById, entry);
}
return labelsById;
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
createdAt: header.timestamp,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
export async function loadJsonlSessionMetadata(filePath: string): Promise<JsonlSessionMetadata> {
const stream = createReadStream(filePath, { encoding: "utf8" });
const lines = createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of lines) {
if (!line.trim()) break;
try {
const header = JSON.parse(line) as SessionHeader;
return headerToSessionMetadata(header, resolve(filePath));
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
}
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
} finally {
lines.close();
stream.destroy();
}
}
async function loadJsonlStorage(filePath: string): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = await readFile(filePath, "utf8");
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
}
let header: SessionHeader;
try {
header = JSON.parse(lines[0]!) as SessionHeader;
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line) as SessionTreeEntry;
entries.push(entry);
leafId = entry.id;
} catch {
// ignore malformed entry lines
}
}
return { header, entries, leafId };
}
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
private readonly filePath: string;
private readonly metadata: JsonlSessionMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) {
this.filePath = resolve(filePath);
this.metadata = headerToSessionMetadata(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.currentLeafId = leafId;
}
static async open(filePath: string): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const loaded = await loadJsonlStorage(resolvedPath);
return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId);
}
static async create(
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, `${JSON.stringify(header)}\n`);
return new JsonlSessionStorage(resolvedPath, header, [], null);
}
async getMetadata(): Promise<JsonlSessionMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
}
this.currentLeafId = leafId;
}
async createEntryId(): Promise<string> {
return generateEntryId(this.byId);
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = entry.id;
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.byId.get(id);
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
}

View File

@@ -0,0 +1,54 @@
let lastTimestamp = -Infinity;
let sequence = 0;
function fillRandomBytes(bytes: Uint8Array): void {
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
crypto.getRandomValues(bytes);
return;
}
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
export function uuidv7(): string {
const random = new Uint8Array(16);
fillRandomBytes(random);
const timestamp = Date.now();
if (timestamp > lastTimestamp) {
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
lastTimestamp = timestamp;
} else {
sequence = (sequence + 1) >>> 0;
if (sequence === 0) {
lastTimestamp++;
}
}
const bytes = new Uint8Array(16);
bytes[0] = (lastTimestamp / 0x10000000000) & 0xff;
bytes[1] = (lastTimestamp / 0x100000000) & 0xff;
bytes[2] = (lastTimestamp / 0x1000000) & 0xff;
bytes[3] = (lastTimestamp / 0x10000) & 0xff;
bytes[4] = (lastTimestamp / 0x100) & 0xff;
bytes[5] = lastTimestamp & 0xff;
bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f);
bytes[7] = (sequence >>> 20) & 0xff;
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
bytes[9] = (sequence >>> 6) & 0xff;
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
bytes[11] = random[11];
bytes[12] = random[12];
bytes[13] = random[13];
bytes[14] = random[14];
bytes[15] = random[15];
return formatUuid(bytes);
}
function formatUuid(bytes: Uint8Array): string {
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
}

View File

@@ -1,6 +1,6 @@
import ignore from "ignore";
import { parse } from "yaml";
import type { ExecutionEnv, Skill } from "./types.js";
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts";
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
@@ -8,10 +8,19 @@ const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
type IgnoreMatcher = ReturnType<typeof ignore>;
export type SkillDiagnosticCode =
| "file_info_failed"
| "list_failed"
| "read_failed"
| "parse_failed"
| "invalid_metadata";
/** Warning produced while loading skills. */
export interface SkillDiagnostic {
/** Diagnostic severity. Currently only warnings are emitted. */
type: "warning";
/** Stable diagnostic code. */
code: SkillDiagnosticCode;
/** Human-readable diagnostic message. */
message: string;
/** Path associated with the diagnostic. */
@@ -44,8 +53,20 @@ export async function loadSkills(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
const rootInfo = await safeFileInfo(env, dir);
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
const rootInfoResult = await env.fileInfo(dir);
if (!rootInfoResult.ok) {
if (rootInfoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: rootInfoResult.error.message,
path: dir,
});
}
continue;
}
const rootInfo = rootInfoResult.value;
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
@@ -89,23 +110,34 @@ async function loadSkillsFromDirInternal(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
if (!(await env.exists(dir))) return { skills, diagnostics };
const dirInfo = await safeFileInfo(env, dir);
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>;
try {
entries = await env.listDir(dir);
} catch {
const dirInfoResult = await env.fileInfo(dir);
if (!dirInfoResult.ok) {
if (dirInfoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: dirInfoResult.error.message,
path: dir,
});
}
return { skills, diagnostics };
}
const dirInfo = dirInfoResult.value;
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
const entriesResult = await env.listDir(dir);
if (!entriesResult.ok) {
diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir });
return { skills, diagnostics };
}
const entries = entriesResult.value;
for (const entry of entries) {
if (entry.name !== "SKILL.md") continue;
const fullPath = entry.path;
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (kind !== "file") continue;
const relPath = relativeEnvPath(rootDir, fullPath);
if (ignoreMatcher.ignores(relPath)) continue;
@@ -119,7 +151,7 @@ async function loadSkillsFromDirInternal(
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = entry.path;
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (!kind) continue;
const relPath = relativeEnvPath(rootDir, fullPath);
@@ -142,22 +174,41 @@ async function loadSkillsFromDirInternal(
return { skills, diagnostics };
}
async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise<void> {
async function addIgnoreRules(
env: ExecutionEnv,
ig: IgnoreMatcher,
dir: string,
rootDir: string,
diagnostics: SkillDiagnostic[],
): Promise<void> {
const relativeDir = relativeEnvPath(rootDir, dir);
const prefix = relativeDir ? `${relativeDir}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = joinEnvPath(dir, filename);
const info = await safeFileInfo(env, ignorePath);
if (info?.kind !== "file") continue;
try {
const content = await env.readTextFile(ignorePath);
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
} catch {}
const info = await env.fileInfo(ignorePath);
if (!info.ok) {
if (info.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: info.error.message,
path: ignorePath,
});
}
continue;
}
if (info.value.kind !== "file") continue;
const content = await env.readTextFile(ignorePath);
if (!content.ok) {
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
continue;
}
const patterns = content.value
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
}
}
@@ -184,40 +235,47 @@ async function loadSkillFromFile(
filePath: string,
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
const diagnostics: SkillDiagnostic[] = [];
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent);
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
for (const error of validateDescription(frontmatter.description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const name = frontmatter.name || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description: frontmatter.description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
diagnostics.push({ type: "warning", message, path: filePath });
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {
diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath });
return { skill: null, diagnostics };
}
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath });
return { skill: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
for (const error of validateDescription(description)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
const name = frontmatterName || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
if (!description || description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
}
function validateName(name: string, parentDirName: string): string[] {
@@ -242,39 +300,53 @@ function validateDescription(description: string | undefined): string[] {
return errors;
}
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
}
async function safeFileInfo(
env: ExecutionEnv,
path: string,
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: toError(error) };
}
}
async function resolveKind(
env: ExecutionEnv,
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
info: FileInfo,
diagnostics: SkillDiagnostic[],
): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
try {
const realPath = await env.realPath(info.path);
const target = await env.fileInfo(realPath);
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
} catch {
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) {
if (canonicalPath.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: canonicalPath.error.message,
path: info.path,
});
}
return undefined;
}
const target = await env.fileInfo(canonicalPath.value);
if (!target.ok) {
if (target.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: target.error.message,
path: info.path,
});
}
return undefined;
}
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
}
function joinEnvPath(base: string, child: string): string {

View File

@@ -1,4 +1,4 @@
import type { Skill } from "./types.js";
import type { Skill } from "./types.ts";
export function formatSkillsForSystemPrompt(skills: Skill[]): string {
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);

View File

@@ -1,7 +1,41 @@
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { QueueMode } from "../agent.js";
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js";
import type { Session } from "./session/session.js";
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
import type { Session } from "./session/session.ts";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */
export function getOrThrow<TValue, TError>(result: Result<TValue, TError>): TValue {
if (!result.ok) throw result.error;
return result.value;
}
/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */
export function getOrUndefined<TValue extends object, TError>(result: Result<TValue, TError>): TValue | undefined {
return result.ok ? result.value : undefined;
}
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
export function toError(error: unknown): Error {
if (error instanceof Error) return error;
if (typeof error === "string") return new Error(error);
try {
return new Error(JSON.stringify(error));
} catch {
return new Error(String(error));
}
}
/**
* Skill loaded from a `SKILL.md` file or provided by an application.
@@ -43,11 +77,39 @@ export interface AgentHarnessResources<
skills?: TSkill[];
}
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
/** Curated provider request options owned by the harness and snapshotted per turn. */
export interface AgentHarnessStreamOptions {
/** Preferred transport forwarded to the stream function. */
transport?: Transport;
/** Provider request timeout in milliseconds. */
timeoutMs?: number;
/** Maximum provider retry attempts. */
maxRetries?: number;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Additional request headers merged with auth and lifecycle headers. */
headers?: Record<string, string>;
/** Provider metadata forwarded with requests. */
metadata?: SimpleStreamOptions["metadata"];
/** Provider cache retention hint. */
cacheRetention?: SimpleStreamOptions["cacheRetention"];
}
/** Per-request stream option patch returned by provider hooks. */
export interface AgentHarnessStreamOptionsPatch
extends Omit<Partial<AgentHarnessStreamOptions>, "headers" | "metadata"> {
/** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */
headers?: Record<string, string | undefined>;
/** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */
metadata?: Record<string, unknown | undefined>;
}
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
export type FileKind = "file" | "directory" | "symlink";
/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
export type FileErrorCode =
| "aborted"
| "not_found"
| "permission_denied"
| "not_directory"
@@ -56,28 +118,121 @@ export type FileErrorCode =
| "not_supported"
| "unknown";
/** Error thrown by {@link ExecutionEnv} file operations. */
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
constructor(
/** Backend-independent error code. */
public code: FileErrorCode,
message: string,
/** Absolute addressed path associated with the failure, when available. */
public path?: string,
options?: ErrorOptions,
) {
super(message, options);
/** Backend-independent error code. */
public code: FileErrorCode;
/** Absolute addressed path associated with the failure, when available. */
public path?: string;
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "FileError";
this.code = code;
this.path = path;
}
}
/** Metadata for one filesystem object in an {@link ExecutionEnv}. */
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
export type ExecutionErrorCode =
| "aborted"
| "timeout"
| "shell_unavailable"
| "spawn_error"
| "callback_error"
| "unknown";
/** Error returned by {@link ExecutionEnv.exec}. */
export class ExecutionError extends Error {
/** Backend-independent error code. */
public code: ExecutionErrorCode;
constructor(code: ExecutionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "ExecutionError";
this.code = code;
}
}
/** Stable compaction error codes returned by compaction helpers. */
export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown";
/** Error returned by compaction helpers. */
export class CompactionError extends Error {
/** Backend-independent error code. */
public code: CompactionErrorCode;
constructor(code: CompactionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "CompactionError";
this.code = code;
}
}
/** Stable branch-summary error codes returned by branch summarization helpers. */
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
/** Error returned by branch summarization helpers. */
export class BranchSummaryError extends Error {
/** Backend-independent error code. */
public code: BranchSummaryErrorCode;
constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "BranchSummaryError";
this.code = code;
}
}
export type SessionErrorCode =
| "not_found"
| "invalid_session"
| "invalid_entry"
| "invalid_fork_target"
| "storage"
| "unknown";
/** Error thrown by session storage, repositories, and session tree operations. */
export class SessionError extends Error {
/** Session subsystem error code. */
public code: SessionErrorCode;
constructor(code: SessionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "SessionError";
this.code = code;
}
}
export type AgentHarnessErrorCode =
| "busy"
| "invalid_state"
| "invalid_argument"
| "session"
| "hook"
| "auth"
| "compaction"
| "branch_summary"
| "unknown";
/** Public AgentHarness failure with a stable top-level classification. */
export class AgentHarnessError extends Error {
public code: AgentHarnessErrorCode;
constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "AgentHarnessError";
this.code = code;
}
}
/** Metadata for one filesystem object in a {@link FileSystem}. */
export interface FileInfo {
/** Basename of {@link path}. */
name: string;
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
path: string;
/** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
kind: FileKind;
/** Size in bytes for the addressed filesystem object. */
size: number;
@@ -85,16 +240,16 @@ export interface FileInfo {
mtimeMs: number;
}
/** Options for {@link ExecutionEnv.exec}. */
/** Options for {@link Shell.exec}. */
export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. */
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>;
/** Timeout in seconds. Implementations should reject when the command exceeds this duration. */
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. */
signal?: AbortSignal;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */
@@ -102,50 +257,80 @@ export interface ExecutionEnvExecOptions {
}
/**
* Filesystem and process execution environment used by the harness.
* Filesystem capability used by the harness.
*
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute
* addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}.
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
*
* File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors.
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
*/
export interface ExecutionEnv {
/** Current working directory for relative paths and command execution. */
export interface FileSystem {
/** Current working directory for relative paths. */
cwd: string;
/** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Join path segments in the filesystem namespace without requiring the result to exist. */
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a UTF-8 text file. */
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>>;
/** Read a binary file. */
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
/** Create or overwrite a file, creating parent directories when supported. */
writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** Create or append to a file, creating parent directories when supported. */
appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** Return metadata for the addressed path without following symlinks. */
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
/** List direct children of a directory without following symlinks. */
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
/** Return the canonical path for an existing path, resolving symlinks where supported. */
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
createDir(
path: string,
options?: { recursive?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
remove(
path: string,
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
createTempFile(options?: {
prefix?: string;
suffix?: string;
abortSignal?: AbortSignal;
}): Promise<Result<string, FileError>>;
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Shell execution capability used by the harness. */
export interface Shell {
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ExecutionEnvExecOptions,
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
/** Read a UTF-8 text file. Throws {@link FileError}. */
readTextFile(path: string): Promise<string>;
/** Read a binary file. Throws {@link FileError}. */
readBinaryFile(path: string): Promise<Uint8Array>;
/** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */
writeFile(path: string, content: string | Uint8Array): Promise<void>;
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */
fileInfo(path: string): Promise<FileInfo>;
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */
listDir(path: string): Promise<FileInfo[]>;
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */
realPath(path: string): Promise<string>;
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */
exists(path: string): Promise<boolean>;
/** Create a directory. */
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
/** Remove a file or directory. */
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
/** Create a temporary directory and return its absolute path. */
createTempDir(prefix?: string): Promise<string>;
/** Create a temporary file and return its absolute path. */
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
/** Release resources owned by the environment. */
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Release shell resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Filesystem and process execution environment used by the harness. */
export interface ExecutionEnv extends FileSystem, Shell {}
export interface SessionTreeEntryBase {
type: string;
id: string;
@@ -211,6 +396,11 @@ export interface SessionInfoEntry extends SessionTreeEntryBase {
name?: string;
}
export interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null;
}
export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
@@ -220,7 +410,8 @@ export type SessionTreeEntry =
| CustomEntry
| CustomMessageEntry
| LabelEntry
| SessionInfoEntry;
| SessionInfoEntry
| LeafEntry;
export interface SessionContext {
messages: AgentMessage[];
@@ -242,6 +433,7 @@ export interface JsonlSessionMetadata extends SessionMetadata {
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
getMetadata(): Promise<TMetadata>;
getLeafId(): Promise<string | null>;
/** Persist a leaf entry that records the active session-tree leaf. */
setLeafId(leafId: string | null): Promise<void>;
createEntryId(): Promise<string>;
appendEntry(entry: SessionTreeEntry): Promise<void>;
@@ -254,7 +446,7 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
getEntries(): Promise<SessionTreeEntry[]>;
}
export type { Session } from "./session/session.js";
export type { Session } from "./session/session.ts";
export interface SessionCreateOptions {
id?: string;
@@ -298,20 +490,6 @@ export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
: never
: never;
export interface AgentHarnessTurnState<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
> {
messages: AgentMessage[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
systemPrompt: string;
model: Model<any>;
thinkingLevel: ThinkingLevel;
tools: TTool[];
activeTools: TTool[];
}
export interface QueueUpdateEvent {
type: "queue_update";
steer: AgentMessage[];
@@ -353,6 +531,14 @@ export interface ContextEvent {
export interface BeforeProviderRequestEvent {
type: "before_provider_request";
model: Model<any>;
sessionId: string;
streamOptions: AgentHarnessStreamOptions;
}
export interface BeforeProviderPayloadEvent {
type: "before_provider_payload";
model: Model<any>;
payload: unknown;
}
@@ -440,6 +626,7 @@ export type AgentHarnessOwnEvent<
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
| ContextEvent
| BeforeProviderRequestEvent
| BeforeProviderPayloadEvent
| AfterProviderResponseEvent
| ToolCallEvent
| ToolResultEvent
@@ -465,6 +652,10 @@ export interface ContextResult {
}
export interface BeforeProviderRequestResult {
streamOptions?: AgentHarnessStreamOptionsPatch;
}
export interface BeforeProviderPayloadResult {
payload: unknown;
}
@@ -497,6 +688,7 @@ export type AgentHarnessEventResultMap = {
before_agent_start: BeforeAgentStartResult | undefined;
context: ContextResult | undefined;
before_provider_request: BeforeProviderRequestResult | undefined;
before_provider_payload: BeforeProviderPayloadResult | undefined;
after_provider_response: undefined;
tool_call: ToolCallResult | undefined;
tool_result: ToolResultPatch | undefined;
@@ -580,11 +772,9 @@ export interface GenerateBranchSummaryOptions {
}
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
summary: string;
readFiles: string[];
modifiedFiles: string[];
}
export interface AgentHarnessOptions<
@@ -613,6 +803,8 @@ export interface AgentHarnessOptions<
getApiKeyAndHeaders?: (
model: Model<any>,
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
@@ -620,4 +812,4 @@ export interface AgentHarnessOptions<
followUpMode?: QueueMode;
}
export type { AgentHarness } from "./agent-harness.js";
export type { AgentHarness } from "./agent-harness.ts";

View File

@@ -1,9 +1,13 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExecutionEnv, ExecutionEnvExecOptions } from "../types.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js";
import {
type ExecutionEnv,
type ExecutionEnvExecOptions,
ExecutionError,
err,
ok,
type Result,
toError,
} from "../types.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> {
onChunk?: (chunk: string) => void;
@@ -17,6 +21,12 @@ export interface ShellCaptureResult {
fullOutputPath?: string;
}
function toExecutionError(error: unknown): ExecutionError {
if (error instanceof ExecutionError) return error;
const cause = toError(error);
return new ExecutionError("unknown", cause.message, cause);
}
export function sanitizeBinaryOutput(str: string): string {
return Array.from(str)
.filter((char) => {
@@ -34,41 +44,62 @@ export async function executeShellWithCapture(
env: ExecutionEnv,
command: string,
options?: ShellCaptureOptions,
): Promise<ShellCaptureResult> {
): Promise<Result<ShellCaptureResult, ExecutionError>> {
const outputChunks: string[] = [];
let outputBytes = 0;
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
const encoder = new TextEncoder();
let tempFilePath: string | undefined;
let tempFileStream: WriteStream | undefined;
let totalBytes = 0;
let fullOutputPath: string | undefined;
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
let captureError: ExecutionError | undefined;
const ensureTempFile = () => {
if (tempFilePath) return;
const id = randomBytes(8).toString("hex");
tempFilePath = join(tmpdir(), `bash-${id}.log`);
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of outputChunks) {
tempFileStream.write(chunk);
}
const appendFullOutput = (text: string): void => {
if (!fullOutputPath || captureError) return;
const path = fullOutputPath;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const appendResult = await env.appendFile(path, text, options?.abortSignal);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const ensureFullOutputFile = (initialContent: string): void => {
if (fullOutputPath || captureError) return;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const tempFile = await env.createTempFile({
prefix: "bash-",
suffix: ".log",
abortSignal: options?.abortSignal,
});
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
fullOutputPath = tempFile.value;
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const onChunk = (chunk: string) => {
totalBytes += Buffer.byteLength(chunk, "utf-8");
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
if (totalBytes > DEFAULT_MAX_BYTES) {
ensureTempFile();
try {
totalBytes += encoder.encode(chunk).byteLength;
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
ensureFullOutputFile(outputChunks.join("") + text);
} else {
appendFullOutput(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
} catch (error) {
captureError = toExecutionError(error);
}
if (tempFileStream) {
tempFileStream.write(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
};
try {
@@ -77,37 +108,36 @@ export async function executeShellWithCapture(
onStdout: onChunk,
onStderr: onChunk,
});
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
const tailOutput = outputChunks.join("");
const truncationResult = truncateTail(tailOutput);
if (truncationResult.truncated && !fullOutputPath) {
ensureFullOutputFile(tailOutput);
}
tempFileStream?.end();
const cancelled = options?.signal?.aborted ?? false;
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: cancelled ? undefined : result.exitCode,
const writeResult = await writeChain;
if (!writeResult.ok) return err(writeResult.error);
if (captureError) return err(captureError);
if (!result.ok) {
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath,
});
}
return err(result.error);
}
const cancelled = options?.abortSignal?.aborted ?? false;
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: cancelled ? undefined : result.value.exitCode,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
} catch (err) {
if (options?.signal?.aborted) {
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
}
tempFileStream?.end();
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
}
tempFileStream?.end();
throw err;
fullOutputPath,
});
} catch (error) {
return err(toExecutionError(error));
}
}

View File

@@ -44,6 +44,64 @@ export interface TruncationOptions {
maxBytes?: number;
}
interface RuntimeBuffer {
byteLength(content: string, encoding: "utf8"): number;
}
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
const nonAsciiPattern = /[^\x00-\x7f]/;
function utf8ByteLength(content: string): number {
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");
const firstNonAscii = content.search(nonAsciiPattern);
if (firstNonAscii === -1) return content.length;
let bytes = firstNonAscii;
for (let i = firstNonAscii; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
i++;
} else {
bytes += 3;
}
} else {
bytes += 3;
}
}
return bytes;
}
function replaceUnpairedSurrogates(content: string): string {
let output = "";
for (let i = 0; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
output += content[i] + content[i + 1];
i++;
continue;
}
}
output += "<22>";
} else if (code >= 0xdc00 && code <= 0xdfff) {
output += "<22>";
} else {
output += content[i];
}
}
return output;
}
/**
* Format bytes as human-readable size.
*/
@@ -68,7 +126,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
const totalLines = lines.length;
@@ -90,7 +148,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
}
// Check if first line alone exceeds byte limit
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
const firstLineBytes = utf8ByteLength(lines[0]);
if (firstLineBytes > maxBytes) {
return {
content: "",
@@ -114,7 +172,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
for (let i = 0; i < lines.length && i < maxLines; i++) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
@@ -131,7 +189,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
const finalOutputBytes = utf8ByteLength(outputContent);
return {
content: outputContent,
@@ -158,8 +216,9 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
const totalLines = lines.length;
// Check if no truncation needed
@@ -187,7 +246,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
@@ -196,7 +255,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
if (outputLinesArr.length === 0) {
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
outputLinesArr.unshift(truncatedLine);
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
outputBytesCount = utf8ByteLength(truncatedLine);
lastLinePartial = true;
}
break;
@@ -212,7 +271,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
const finalOutputBytes = utf8ByteLength(outputContent);
return {
content: outputContent,
@@ -234,20 +293,40 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
const buf = Buffer.from(str, "utf-8");
if (buf.length <= maxBytes) {
return str;
if (maxBytes <= 0) return "";
let outputBytes = 0;
let start = str.length;
let needsReplacement = false;
for (let i = str.length; i > 0; ) {
let characterStart = i - 1;
const code = str.charCodeAt(characterStart);
let characterBytes: number;
let unpairedSurrogate = false;
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
const previous = str.charCodeAt(characterStart - 1);
if (previous >= 0xd800 && previous <= 0xdbff) {
characterStart--;
characterBytes = 4;
} else {
characterBytes = 3;
unpairedSurrogate = true;
}
} else if (code >= 0xd800 && code <= 0xdfff) {
characterBytes = 3;
unpairedSurrogate = true;
} else {
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
}
if (outputBytes + characterBytes > maxBytes) break;
outputBytes += characterBytes;
start = characterStart;
needsReplacement ||= unpairedSurrogate;
i = characterStart;
}
// Start from the end, skip maxBytes back
let start = buf.length - maxBytes;
// Find a valid UTF-8 boundary (start of a character)
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
start++;
}
return buf.slice(start).toString("utf-8");
const output = str.slice(start);
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
}
/**

View File

@@ -1,13 +1,16 @@
// Core Agent
export * from "./agent.js";
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.js";
export * from "./harness/agent-harness.js";
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.js";
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
@@ -21,21 +24,21 @@ export {
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.js";
export * from "./harness/execution-env.js";
export * from "./harness/messages.js";
export * from "./harness/prompt-templates.js";
export * from "./harness/session/repo/jsonl.js";
export * from "./harness/session/repo/memory.js";
export * from "./harness/session/repo/shared.js";
export * from "./harness/session/session.js";
export * from "./harness/skills.js";
export * from "./harness/system-prompt.js";
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
// Harness
export * from "./harness/types.js";
export * from "./harness/utils/shell-output.js";
export * from "./harness/utils/truncate.js";
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
// Proxy utilities
export * from "./proxy.js";
export * from "./proxy.ts";
// Types
export * from "./types.js";
export * from "./types.ts";

View File

@@ -0,0 +1,2 @@
export { NodeExecutionEnv } from "./harness/env/nodejs.ts";
export * from "./index.ts";

View File

@@ -35,6 +35,14 @@ export type StreamFn = (
*/
export type ToolExecutionMode = "sequential" | "parallel";
/**
* Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
*
* - "all": drain and inject every queued message at that point.
* - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
*/
export type QueueMode = "all" | "one-at-a-time";
/** A single tool call content block emitted by an assistant message. */
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;

View File

@@ -8,8 +8,8 @@ import {
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.js";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
// Mock stream for testing - mimics MockAssistantStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View File

@@ -1,6 +1,6 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { Agent } from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View File

@@ -11,8 +11,8 @@ import {
type UserMessage,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { Agent, type AgentEvent } from "../src/index.js";
import { calculateTool } from "./utils/calculate.js";
import { Agent, type AgentEvent } from "../src/index.ts";
import { calculateTool } from "./utils/calculate.ts";
const registrations: FauxProviderRegistration[] = [];

View File

@@ -0,0 +1,206 @@
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import { calculateTool } from "../utils/calculate.ts";
const registrations: Array<{ unregister(): void }> = [];
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
return new AgentHarness(options);
}
function captureOptions(options: StreamOptions | undefined): StreamOptions {
return {
...options,
headers: options?.headers ? { ...options.headers } : undefined,
metadata: options?.metadata ? { ...options.metadata } : undefined,
};
}
describe("AgentHarness stream configuration", () => {
it("snapshots stream options and merges auth headers before provider request hooks", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
headers: { "x-base": "base" },
metadata: { base: true },
cacheRetention: "none",
},
getApiKeyAndHeaders: async () => ({ apiKey: "secret", headers: { "x-auth": "auth" } }),
});
harness.on("before_provider_request", (event) => {
expect(event.sessionId).toBe("session-1");
expect(event.streamOptions.headers).toEqual({ "x-base": "base", "x-auth": "auth" });
return {
streamOptions: {
headers: { "x-hook": "hook" },
metadata: { hook: true },
},
};
});
await harness.prompt("hello");
expect(capturedOptions).toMatchObject({
apiKey: "secret",
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
sessionId: "session-1",
cacheRetention: "none",
});
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-auth": "auth", "x-hook": "hook" });
expect(capturedOptions?.metadata).toEqual({ base: true, hook: true });
});
it("chains provider request patches and supports deletion semantics", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
headers: { keep: "base", remove: "base" },
metadata: { keep: "base", remove: "base" },
},
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" });
return {
streamOptions: {
headers: { first: "1", remove: undefined },
metadata: { first: 1, remove: undefined },
},
};
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" });
expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 });
return {
streamOptions: {
timeoutMs: undefined,
headers: { second: "2" },
metadata: undefined,
},
};
});
await harness.prompt("hello");
expect(capturedOptions?.timeoutMs).toBeUndefined();
expect(capturedOptions?.maxRetries).toBe(2);
expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" });
expect(capturedOptions?.metadata).toBeUndefined();
});
it("uses updated stream options for save-point snapshots without mutating the active request", async () => {
const capturedOptions: StreamOptions[] = [];
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage("done");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [calculateTool],
streamOptions: { timeoutMs: 1000, headers: { turn: "first" } },
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } });
}
});
await harness.prompt("hello");
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions[0].timeoutMs).toBe(1000);
expect(capturedOptions[0].headers).toEqual({ turn: "first" });
expect(capturedOptions[1].timeoutMs).toBe(2000);
expect(capturedOptions[1].headers).toEqual({ turn: "second" });
});
it("chains provider payload hooks", async () => {
const seenPayloads: unknown[] = [];
let finalPayload: unknown;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
async (_context, options, _state, model) => {
finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model);
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first"] } };
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first", "second"] } };
});
await harness.prompt("hello");
expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]);
expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] });
});
});

View File

@@ -1,11 +1,13 @@
import { getModel } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
import type { AgentTool } from "../../src/types.js";
import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
import type { AgentMessage, AgentTool } from "../../src/types.ts";
import { calculateTool } from "../utils/calculate.ts";
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
interface AppSkill extends Skill {
source: "project" | "user";
@@ -19,6 +21,39 @@ interface AppTool extends AgentTool {
source: "builtin" | "extension";
}
const registrations: Array<{ unregister(): void }> = [];
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
return messages.flatMap((message) => {
if (message.role !== "user") return [];
if (typeof message.content === "string") return [message.content];
if (!Array.isArray(message.content)) return [];
return message.content.flatMap((part) => {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return [];
return "text" in part && typeof part.text === "string" ? [part.text] : [];
});
});
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function getReasoning(options: unknown): unknown {
if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined;
return options.reasoning;
}
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
describe("AgentHarness", () => {
it("constructs directly and exposes queue modes", () => {
const session = new Session(new InMemorySessionStorage());
@@ -28,18 +63,399 @@ describe("AgentHarness", () => {
env,
session,
model: initialModel,
thinkingLevel: "high",
systemPrompt: "You are helpful.",
steeringMode: "all",
followUpMode: "all",
});
expect(harness.env).toBe(env);
expect(harness.agent.state.model).toBe(initialModel);
expect(harness.steeringMode).toBe("all");
expect(harness.followUpMode).toBe("all");
harness.steeringMode = "one-at-a-time";
harness.followUpMode = "one-at-a-time";
expect(harness.agent.steeringMode).toBe("one-at-a-time");
expect(harness.agent.followUpMode).toBe("one-at-a-time");
expect(harness.getModel()).toBe(initialModel);
expect(harness.getThinkingLevel()).toBe("high");
expect(harness.getSteeringMode()).toBe("all");
expect(harness.getFollowUpMode()).toBe("all");
harness.setSteeringMode("one-at-a-time");
harness.setFollowUpMode("one-at-a-time");
expect(harness.getSteeringMode()).toBe("one-at-a-time");
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
});
it("drains one queued steering message at a time and emits queue updates", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
steeringMode: "one-at-a-time",
});
const steerQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
steerQueueLengths.push(event.steer.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.steer("one");
harness.steer("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(steerQueueLengths).toEqual([1, 2, 1, 0]);
});
it("appends before_agent_start messages and persists them", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
let requestText: string[] = [];
registration.setResponses([
(context) => {
requestText = textFromUserMessages(context.messages);
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
harness.on("before_agent_start", () => ({
messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }],
}));
await harness.prompt("hello");
const persistedText = (await session.getEntries()).flatMap((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return [];
const content = entry.message.content;
if (typeof content === "string") return [content];
return content.flatMap((part) => (part.type === "text" ? [part.text] : []));
});
expect(requestText).toEqual(["hello", "hook"]);
expect(persistedText).toEqual(["hello", "hook"]);
});
it("abort clears steer and follow-up queues but preserves next-turn messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
let releaseFirstResponse: (() => void) | undefined;
let abortedSignal: AbortSignal | undefined;
const firstResponseReleased = new Promise<void>((resolve) => {
releaseFirstResponse = resolve;
});
const secondRequestText: string[] = [];
registration.setResponses([
async (_context, options) => {
abortedSignal = options?.signal;
await firstResponseReleased;
return fauxAssistantMessage("aborted-ish");
},
(context) => {
secondRequestText.push(...textFromUserMessages(context.messages));
return fauxAssistantMessage("second");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = [];
harness.subscribe((event) => {
if (event.type === "queue_update") {
queueUpdates.push({
steer: event.steer.length,
followUp: event.followUp.length,
nextTurn: event.nextTurn.length,
});
}
});
const firstPrompt = harness.prompt("first");
await new Promise((resolve) => setTimeout(resolve, 0));
harness.steer("steer");
harness.followUp("follow");
harness.nextTurn("next");
const abortResultPromise = harness.abort();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(abortedSignal?.aborted).toBe(true);
releaseFirstResponse?.();
const abortResult = await abortResultPromise;
await firstPrompt;
await harness.prompt("second");
expect(abortResult.clearedSteer).toHaveLength(1);
expect(abortResult.clearedFollowUp).toHaveLength(1);
expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 });
expect(secondRequestText).toEqual(["first", "next", "second"]);
});
it("drains follow-up messages one at a time after the agent would otherwise stop", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
followUpMode: "one-at-a-time",
});
const followUpQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
followUpQueueLengths.push(event.followUp.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.followUp("one");
harness.followUp("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(followUpQueueLengths).toEqual([1, 2, 1, 0]);
});
it("settles thrown hook failures with persisted assistant error messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("should not be used")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
const events: string[] = [];
harness.subscribe((event) => {
events.push(event.type);
});
harness.on("context", () => {
throw new Error("context exploded");
});
const response = await harness.prompt("hello");
await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" });
const entries = await session.getEntries();
const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
expect(response.stopReason).toBe("error");
expect(response.errorMessage).toBe("context exploded");
expect(messages[0]?.role).toBe("user");
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" });
expect(events).toContain("agent_end");
expect(events).toContain("settled");
});
it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => {
const registration = registerFauxProvider({
models: [
{ id: "first", reasoning: true },
{ id: "second", reasoning: true },
],
});
registrations.push(registration);
const secondModel = registration.getModel("second");
if (!secondModel) throw new Error("missing second faux model");
const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = [];
registration.setResponses([
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage("done");
},
]);
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
thinkingLevel: "off",
resources: {
skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }],
},
systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt",
tools: [calculateTool],
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
void harness.setModel(secondModel);
void harness.setThinkingLevel("high");
void harness.setResources({
skills: [
{ name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" },
],
});
void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]);
}
});
await harness.prompt("hello");
expect(captured).toEqual([
{ modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] },
{ modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] },
]);
});
it("orders pending listener session writes after agent-emitted messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
let wrotePendingMessage = false;
harness.subscribe(async (event) => {
if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) {
wrotePendingMessage = true;
await harness.appendMessage({
role: "custom",
customType: "listener",
content: "listener write",
display: true,
timestamp: Date.now(),
} as AgentMessage);
}
});
await harness.prompt("hello");
const entries = await session.getEntries();
const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : []));
expect(roles).toEqual(["user", "assistant", "custom"]);
});
it("waitForIdle waits for external run settlement and awaited listeners", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const barrier = deferred();
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
let listenerFinished = false;
harness.subscribe(async (event) => {
if (event.type === "agent_end") {
await barrier.promise;
listenerFinished = true;
}
});
const promptPromise = harness.prompt("hello");
let idleResolved = false;
const idlePromise = harness.waitForIdle().then(() => {
idleResolved = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(idleResolved).toBe(false);
expect(listenerFinished).toBe(false);
barrier.resolve();
await Promise.all([promptPromise, idlePromise]);
expect(idleResolved).toBe(true);
expect(listenerFinished).toBe(true);
});
it("runs tool_call and tool_result hooks through the direct loop", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
tools: [calculateTool],
});
const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = [];
harness.on("tool_call", (event) => {
seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression });
return undefined;
});
harness.on("tool_result", (event) => {
expect(event.toolCallId).toBe("call-1");
expect(event.toolName).toBe("calculate");
return {
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
terminate: true,
};
});
await harness.prompt("hello");
const toolResult = (await session.getEntries()).find(
(entry) => entry.type === "message" && entry.message.role === "toolResult",
);
expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]);
expect(toolResult).toMatchObject({
type: "message",
message: {
role: "toolResult",
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
},
});
});
it("preserves app resource types for getters and update events", async () => {

View File

@@ -2,32 +2,40 @@ import {
type AssistantMessage,
type FauxProviderRegistration,
fauxAssistantMessage,
type Message,
type Model,
registerFauxProvider,
type Usage,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CompactionPreparation,
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "../../src/harness/compaction/compaction.js";
import { buildSessionContext } from "../../src/harness/session/session.js";
} from "../../src/harness/compaction/compaction.ts";
import { buildSessionContext } from "../../src/harness/session/session.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
CompactionSettings,
CustomMessageEntry,
MessageEntry,
ModelChangeEntry,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../../src/harness/types.js";
import type { AgentMessage } from "../../src/types.js";
} from "../../src/harness/types.ts";
import { getOrThrow } from "../../src/harness/types.ts";
import type { AgentMessage } from "../../src/types.ts";
let nextId = 0;
function createId(): string {
@@ -113,14 +121,17 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str
};
}
function createFauxModel(reasoning: boolean): { faux: FauxProviderRegistration; model: Model<string> } {
function createFauxModel(
reasoning: boolean,
maxTokens = 8192,
): { faux: FauxProviderRegistration; model: Model<string> } {
const faux = registerFauxProvider({
models: [
{
id: reasoning ? "reasoning-model" : "non-reasoning-model",
reasoning,
contextWindow: 200000,
maxTokens: 8192,
maxTokens,
},
],
});
@@ -175,6 +186,133 @@ describe("harness compaction", () => {
expect(entries[result.firstKeptEntryIndex]?.type).toBe("message");
});
it("covers cut-point and turn-start edge cases", () => {
const thinking = createThinkingLevelEntry("high");
const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id);
expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: modelChange.id,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1);
const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1);
expect(result.firstKeptEntryIndex).toBe(0);
const toolResult = createMessageEntry({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "tool output" }],
isError: false,
timestamp: Date.now(),
});
expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const user = createMessageEntry(createUserMessage("user"));
const compaction = createCompactionEntry("summary", user.id, user.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2);
});
it("estimates tokens and context usage across supported message roles", () => {
const usage = createMockUsage(10, 5, 3, 2);
const assistant = createAssistantMessage("assistant", usage);
const assistantWithThinkingAndTool: AssistantMessage = {
...assistant,
content: [
{ type: "thinking", thinking: "thinking" },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } },
],
};
const customString: AgentMessage = {
role: "custom",
customType: "note",
content: "custom text",
display: true,
timestamp: Date.now(),
};
const toolResultWithImage: AgentMessage = {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{ type: "text", text: "tool text" },
{ type: "image", mimeType: "image/png", data: "abc" },
],
isError: false,
timestamp: Date.now(),
};
const bashExecution: AgentMessage = {
role: "bashExecution",
command: "npm run check",
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
timestamp: Date.now(),
};
const branchSummaryMessage: AgentMessage = {
role: "branchSummary",
summary: "branch",
fromId: "x",
timestamp: Date.now(),
};
const compactionSummaryMessage: AgentMessage = {
role: "compactionSummary",
summary: "compact",
tokensBefore: 123,
timestamp: Date.now(),
};
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
expect(estimateTokens(customString)).toBeGreaterThan(0);
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
expect(estimateTokens(bashExecution)).toBeGreaterThan(0);
expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0);
expect(
getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]),
).toBe(usage);
expect(
getLastAssistantUsage([
createMessageEntry({ ...assistant, stopReason: "aborted" }),
createMessageEntry({ ...assistant, stopReason: "error" }),
]),
).toBeUndefined();
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
usageTokens: 20,
lastUsageIndex: 0,
});
});
it("builds session context with a compaction entry", () => {
const u1 = createMessageEntry(createUserMessage("1"));
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
@@ -207,13 +345,78 @@ describe("harness compaction", () => {
const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id);
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id);
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3];
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
expect(preparation?.previousSummary).toBe("First summary");
expect(preparation?.firstKeptEntryId).toBeTruthy();
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
});
it("prepares split-turn compaction with prior file-operation details", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1"));
const assistantMessage: AssistantMessage = {
...createAssistantMessage("assistant msg 1"),
content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }],
};
const a1 = createMessageEntry(assistantMessage, u1.id);
const compaction1: CompactionEntry = {
...createCompactionEntry("First summary", u1.id, a1.id),
details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] },
};
const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id);
const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id);
const preparation = getOrThrow(
prepareCompaction([u1, a1, compaction1, u2, a2], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true });
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]);
expect([...preparation!.fileOps.read]).toContain("old-read.ts");
expect([...preparation!.fileOps.edited]).toContain("old-edit.ts");
expect([...preparation!.fileOps.written]).toContain("written.ts");
});
it("prepares custom and branch summary entries for summarization", () => {
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: null,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
const user = createMessageEntry(createUserMessage("keep"), customMessage.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id);
const preparation = getOrThrow(
prepareCompaction([branchSummary, customMessage, user, assistant], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]);
});
it("does not prepare compaction when there is nothing valid to compact", () => {
const compaction = createCompactionEntry("already compacted", "entry-keep");
expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
});
it("serializes conversation with truncated tool results", () => {
const longContent = "x".repeat(5000);
const messages = convertMessages([
@@ -241,16 +444,18 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(
messages,
reasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
getOrThrow(
await generateSummary(
messages,
reasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[0]).toMatchObject({ reasoning: "medium", apiKey: "test-key" });
@@ -261,7 +466,9 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off");
getOrThrow(
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"),
);
expect(seenOptions[1]).not.toHaveProperty("reasoning");
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
@@ -271,20 +478,177 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(
messages,
nonReasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
getOrThrow(
await generateSummary(
messages,
nonReasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[2]).not.toHaveProperty("reasoning");
});
it("includes previous summaries and custom instructions in generateSummary prompts", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
let promptText = "";
const { faux, model } = createFauxModel(false);
faux.setResponses([
(context) => {
const message = context.messages[0];
const content = message?.role === "user" ? message.content : [];
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const summary = getOrThrow(
await generateSummary(
messages,
model,
2000,
"test-key",
{ "x-test": "yes" },
undefined,
"focus",
"old summary",
),
);
expect(summary).toContain("Test summary");
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
expect(promptText).toContain("Additional focus: focus");
});
it("returns error results for failed or aborted summary generations", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
const errorResult = await generateSummary(messages, errorModel, 2000, "test-key");
expect(errorResult).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: boom" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
const abortedResult = await generateSummary(messages, abortedModel, 2000, "test-key");
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
});
it("clamps compaction summary maxTokens to the model output cap", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(false, 128000);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 600000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
};
getOrThrow(await compact(preparation, model, "test-key"));
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
});
it("returns compaction error results without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
expect(await compact(preparation, historyModel, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
});
const { model: invalidModel } = createFauxModel(false);
const invalidResult = await compact(
{ ...preparation, messagesToSummarize: [], firstKeptEntryId: "" },
invalidModel,
"test-key",
);
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
});
it("passes reasoning through turn-prefix summaries when enabled", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(true);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Original Request\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
getOrThrow(await compact(preparation, model, "test-key", undefined, undefined, undefined, "high"));
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
});
it("returns turn-prefix compaction errors without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
expect(await compact(preparation, model, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
expect(await compact(preparation, abortedModel, "test-key")).toMatchObject({
ok: false,
error: { code: "aborted", message: "prefix stopped" },
});
});
it("returns a compaction result with file details", async () => {
const u1 = createMessageEntry(createUserMessage("read a file"));
const assistantMessage: AssistantMessage = {
@@ -294,17 +658,17 @@ describe("harness compaction", () => {
const a1 = createMessageEntry(assistantMessage, u1.id);
const u2 = createMessageEntry(createUserMessage("continue"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id);
const preparation = prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS);
const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
const result = await compact(preparation!, model, "test-key");
const result = getOrThrow(await compact(preparation!, model, "test-key"));
expect(result.summary.length).toBeGreaterThan(0);
expect(result.firstKeptEntryId).toBeTruthy();
expect(result.details).toBeDefined();
});
});
function convertMessages(messages: any[]): any[] {
function convertMessages(messages: Message[]): Message[] {
return messages;
}

View File

@@ -1,8 +1,10 @@
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { createTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts";
import { createTempDir } from "./session-test-utils.ts";
const chmodRestorePaths: string[] = [];
@@ -19,61 +21,69 @@ describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("nested", { recursive: true });
await env.writeFile("nested/file.txt", "hello");
expect(await env.readTextFile("nested/file.txt")).toBe("hello");
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello");
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
getOrThrow(await env.createDir("nested/child"));
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]);
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
const entries = await env.listDir("nested");
const entries = getOrThrow(await env.listDir("nested/child"));
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "file.txt",
path: join(root, "nested/file.txt"),
path: join(root, "nested/child/file.txt"),
kind: "file",
size: 5,
});
expect(typeof entries[0]!.mtimeMs).toBe("number");
expect(await env.exists("nested/file.txt")).toBe(true);
await env.remove("nested/file.txt");
expect(await env.exists("nested/file.txt")).toBe(false);
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
getOrThrow(await env.remove("nested/child/file.txt"));
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
});
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("dir", { recursive: true });
await env.writeFile("dir/file.txt", "hello");
getOrThrow(await env.createDir("dir", { recursive: true }));
getOrThrow(await env.writeFile("dir/file.txt", "hello"));
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
await symlink(join(root, "dir"), join(root, "dir-link"));
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" });
expect(await env.fileInfo("dir/file.txt")).toMatchObject({
expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
name: "dir",
path: join(root, "dir"),
kind: "directory",
});
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
name: "file.txt",
path: join(root, "dir/file.txt"),
kind: "file",
size: 5,
});
expect(await env.fileInfo("file-link")).toMatchObject({
expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
name: "file-link",
path: join(root, "file-link"),
kind: "symlink",
});
expect(await env.fileInfo("dir-link")).toMatchObject({
expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
name: "dir-link",
path: join(root, "dir-link"),
kind: "symlink",
});
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt")));
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
});
it("lists symlinks as symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.txt", "hello");
getOrThrow(await env.writeFile("target.txt", "hello"));
await symlink(join(root, "target.txt"), join(root, "link.txt"));
const entries = await env.listDir(".");
const entries = getOrThrow(await env.listDir("."));
expect(
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
).toEqual([
@@ -82,41 +92,112 @@ describe("NodeExecutionEnv", () => {
]);
});
it("throws FileError for missing paths and keeps exists false for missing paths", async () => {
it("stops reading text lines at the requested limit", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
expect(await env.exists("missing.txt")).toBe(false);
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree"));
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]);
});
it("throws FileError for listing non-directories", async () => {
it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("file.txt", "hello");
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError);
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" });
const info = await env.fileInfo("missing.txt");
expect(info.ok).toBe(false);
if (!info.ok) {
expect(info.error).toBeInstanceOf(FileError);
expect(info.error).toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
}
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
});
it("returns FileError for listing non-directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const result = await env.listDir("file.txt");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(FileError);
expect(result.error).toMatchObject({ code: "not_directory" });
}
});
it("appends to new files and creates parent directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
});
it("creates temporary directories and files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const tempDir = await env.createTempDir("node-env-test-");
const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
await expect(access(tempDir)).resolves.toBeUndefined();
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" });
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
await expect(access(tempFile)).resolves.toBeUndefined();
expect(tempFile.endsWith(".txt")).toBe(true);
});
it("honors createDir recursive false and remove recursive/force options", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const createResult = await env.createDir("missing/child", { recursive: false });
expect(createResult.ok).toBe(false);
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
const removeDirectory = await env.remove("dir", { recursive: false });
expect(removeDirectory.ok).toBe(false);
getOrThrow(await env.remove("dir", { recursive: true }));
expect(getOrThrow(await env.exists("dir"))).toBe(false);
const removeMissing = await env.remove("missing", { force: false });
expect(removeMissing.ok).toBe(false);
getOrThrow(await env.remove("missing", { force: true }));
});
it("returns aborted results for pre-aborted cancellable file operations", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const controller = new AbortController();
controller.abort();
const signal = controller.signal;
const results = await Promise.all([
env.readTextFile("file.txt", signal),
env.readTextLines("file.txt", { abortSignal: signal }),
env.readBinaryFile("file.txt", signal),
env.writeFile("other.txt", "hello", signal),
env.listDir(".", signal),
]);
for (const result of results) {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
}
});
it("cleanup is best-effort", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.cleanup()).resolves.toBeUndefined();
});
it("executes commands in cwd with env overrides", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
});
const result = getOrThrow(
await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
}),
);
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
@@ -125,25 +206,83 @@ describe("NodeExecutionEnv", () => {
const env = new NodeExecutionEnv({ cwd: root });
let stdout = "";
let stderr = "";
const result = await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
});
const result = getOrThrow(
await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
}),
);
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
expect(stdout).toBe("out");
expect(stderr).toBe("err");
});
it("rejects aborted commands", async () => {
it("returns non-zero command exit codes as successful execution results", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await env.exec("exit 7"));
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
});
it("returns timeout errors for commands exceeding the timeout", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("sleep 5", { timeout: 0.01 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
});
it("returns callback errors from exec stream handlers", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("printf out", {
onStdout: () => {
throw new Error("callback failed");
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" });
});
it("returns shell unavailable and spawn errors", async () => {
const root = createTempDir();
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
const missingShell = await missingShellEnv.exec("printf ok");
expect(missingShell.ok).toBe(false);
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
const shellPath = join(root, "not-executable-shell");
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, "not executable"));
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const spawnError = await spawnErrorEnv.exec("printf ok");
expect(spawnError.ok).toBe(false);
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
});
it("returns an aborted result for aborted commands", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
const promise = env.exec("sleep 5", { signal: controller.signal });
const promise = env.exec("sleep 5", { abortSignal: controller.signal });
controller.abort();
await expect(promise).rejects.toThrow("aborted");
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
});
it("captures large shell output to a full output file through the execution env", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
expect(result.truncated).toBe(true);
expect(result.fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
expect(result.output.length).toBeLessThan(fullOutput.length);
});
});

View File

@@ -1,13 +1,13 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import {
formatPromptTemplateInvocation,
loadPromptTemplates,
loadSourcedPromptTemplates,
} from "../../src/harness/prompt-templates.js";
import { createTempDir } from "./session-test-utils.js";
} from "../../src/harness/prompt-templates.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadPromptTemplates", () => {
it("loads markdown templates non-recursively from one or more dirs", async () => {

View File

@@ -1,8 +1,9 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js";
import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
@@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => {
describe("JsonlSessionRepo", () => {
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const cwd = "/tmp/my-project";
const otherCwd = "/tmp/other-project";
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
const metadata = await session.getMetadata();
@@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
const root = createTempDir();
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
const sourceMetadata = await source.getMetadata();
const user1 = await source.appendMessage(createUserMessage("one"));

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.js";
import { formatSkillInvocation } from "../../src/harness/skills.js";
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.ts";
import { formatSkillInvocation } from "../../src/harness/skills.ts";
describe("resource formatting helpers", () => {
it("formats skill invocations with additional instructions", () => {

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { uuidv7 } from "../../src/harness/session/uuid.ts";
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const TIMESTAMP = 0x0123456789ab;
function parseTimestamp(uuid: string): number {
return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("uuidv7", () => {
it("uses the RFC 9562 layout and preserves monotonic order", () => {
const randomValues = [
new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
new Uint8Array(16),
new Uint8Array(16),
];
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length));
return bytes;
});
vi.stubGlobal("crypto", { getRandomValues });
const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP);
try {
const first = uuidv7();
const second = uuidv7();
const third = uuidv7();
expect(first).toBe("01234567-89ab-7fff-bfff-f91122334455");
expect(second).toBe("01234567-89ab-7fff-bfff-fc0000000000");
expect(third).toBe("01234567-89ac-7000-8000-000000000000");
expect(first).toMatch(UUID_V7_RE);
expect(second).toMatch(UUID_V7_RE);
expect(third).toMatch(UUID_V7_RE);
expect(parseTimestamp(first)).toBe(TIMESTAMP);
expect(parseTimestamp(second)).toBe(TIMESTAMP);
expect(parseTimestamp(third)).toBe(TIMESTAMP + 1);
expect(first < second).toBe(true);
expect(second < third).toBe(true);
expect(getRandomValues).toHaveBeenCalledTimes(3);
} finally {
dateNow.mockRestore();
}
});
});

View File

@@ -1,11 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { Session } from "../../src/harness/session/session.js";
import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { SessionStorage } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { SessionStorage } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts";
async function runSessionSuite(
name: string,
@@ -128,7 +129,8 @@ runSessionSuite(
"Session with JSONL storage",
async () => {
const dir = createTempDir();
return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
const env = new NodeExecutionEnv({ cwd: dir });
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
},
() => {
const dir = getLatestTempDir();
@@ -138,10 +140,10 @@ runSessionSuite(
const header = JSON.parse(lines[0]!);
expect(header.type).toBe("session");
expect(header.version).toBe(3);
for (const line of lines.slice(1)) {
const entry = JSON.parse(line);
const entries = lines.slice(1).map((line) => JSON.parse(line));
expect(entries.some((entry) => entry.type === "leaf")).toBe(true);
for (const entry of entries) {
expect(entry.type).not.toBe("entry");
expect(entry.type).not.toBe("leaf");
expect(typeof entry.id).toBe("string");
}
},

View File

@@ -1,9 +1,9 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js";
import { createTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadSkills", () => {
it("loads SKILL.md files through the execution environment", async () => {
@@ -93,6 +93,7 @@ Use this skill.
expect(diagnostics).toEqual([
{
type: "warning",
code: "invalid_metadata",
message: "description is required",
path: join(root, "user/broken/SKILL.md"),
source: { type: "user" },

View File

@@ -1,10 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionStorage", () => {
it("returns configured session metadata", async () => {
@@ -13,7 +14,7 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getMetadata()).toEqual(metadata);
});
it("copies initial entries and tracks leaf independently", async () => {
it("copies initial entries and persists leaf changes", async () => {
const entry: MessageEntry = {
type: "message",
id: "entry-1",
@@ -28,12 +29,12 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getLeafId()).toBe("entry-1");
await storage.setLeafId(null);
expect(await storage.getLeafId()).toBeNull();
expect((await storage.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: null });
});
it("rejects invalid leaf ids", async () => {
const storage = new InMemorySessionStorage();
await expect(storage.setLeafId("missing")).rejects.toThrow("Entry missing not found");
expect(() => new InMemorySessionStorage({ leafId: "missing" })).toThrow("Entry missing not found");
});
it("finds entries by type", async () => {
@@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => {
describe("JsonlSessionStorage", () => {
it("throws for missing files when opening", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
});
it("writes the header on create", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
expect(await storage.getLeafId()).toBeNull();
@@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => {
it("throws for malformed session headers", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
writeFileSync(filePath, "not json\n");
await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
});
it("ignores malformed entry lines", async () => {
it("throws for malformed entry lines", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
@@ -152,15 +157,14 @@ describe("JsonlSessionStorage", () => {
message: createUserMessage("one"),
};
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
const storage = await JsonlSessionStorage.open(filePath);
expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]);
expect(await storage.getLeafId()).toBe("entry-1");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "invalid_entry" });
});
it("creates and reads session metadata from the header", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, {
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
parentSessionPath: "/tmp/parent.jsonl",
@@ -179,13 +183,14 @@ describe("JsonlSessionStorage", () => {
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata);
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
});
it("loads existing entries and reconstructs leaf", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
const root: MessageEntry = {
type: "message",
id: "root",
@@ -201,16 +206,21 @@ describe("JsonlSessionStorage", () => {
};
await storage.appendEntry(root);
await storage.appendEntry(child);
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLeafId()).toBe("child");
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
await loaded.setLeafId("root");
const reloaded = await JsonlSessionStorage.open(env, filePath);
expect(await reloaded.getLeafId()).toBe("root");
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
});
it("finds entries by type", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@@ -224,8 +234,9 @@ describe("JsonlSessionStorage", () => {
it("maintains label lookup", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@@ -252,11 +263,11 @@ describe("JsonlSessionStorage", () => {
label: undefined,
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("reads session metadata from only the first JSONL line", async () => {
it("reads session metadata through the line-reading filesystem operation", async () => {
const dir = createTempDir();
const filePath = join(dir, "session.jsonl");
const header = {
@@ -266,9 +277,18 @@ describe("JsonlSessionStorage", () => {
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
};
const malformedSecondLine = "{".repeat(10000);
writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`);
expect(await loadJsonlSessionMetadata(filePath)).toEqual({
const metadata = await loadJsonlSessionMetadata(
{
readTextLines: async () => ok([JSON.stringify(header)]),
readTextFile: async () => {
throw new Error("readTextFile should not be called for metadata");
},
writeFile: async () => ok(undefined),
appendFile: async () => ok(undefined),
},
filePath,
);
expect(metadata).toEqual({
id: "session-1",
createdAt: "2026-01-01T00:00:00.000Z",
cwd: dir,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.ts";
const visibleSkill = {
name: "visible",

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts";
const encoder = new TextEncoder();
function byteLength(content: string): number {
return encoder.encode(content).length;
}
function bufferTail(content: string, maxBytes: number): string {
const bytes = Buffer.from(content, "utf8");
if (bytes.length <= maxBytes) return content;
let start = bytes.length - maxBytes;
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
return bytes.subarray(start).toString("utf8");
}
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
const totalBytes = Buffer.byteLength(input, "utf8");
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
for (const maxBytes of values) {
const result = truncateTail(input, { maxBytes, maxLines: 10 });
const expected = bufferTail(input, maxBytes);
if (result.content !== expected) {
throw new Error(
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`,
);
}
const outputBytes = Buffer.byteLength(result.content, "utf8");
if (outputBytes > maxBytes) {
throw new Error(
`tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`,
);
}
}
}
function sampledByteLimits(input: string): number[] {
const totalBytes = Buffer.byteLength(input, "utf8");
const candidates = [
0,
1,
2,
3,
4,
5,
8,
Math.floor(totalBytes / 2) - 1,
Math.floor(totalBytes / 2),
Math.floor(totalBytes / 2) + 1,
totalBytes - 8,
totalBytes - 5,
totalBytes - 4,
totalBytes - 3,
totalBytes - 2,
totalBytes - 1,
totalBytes,
totalBytes + 1,
totalBytes + 4,
];
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
}
describe("truncate utilities", () => {
it("counts UTF-8 bytes without Node Buffer", () => {
const content = "aé🙂\nb";
const result = truncateHead(content, { maxBytes: 100, maxLines: 10 });
expect(result.truncated).toBe(false);
expect(result.totalBytes).toBe(byteLength(content));
expect(result.outputBytes).toBe(byteLength(content));
expect(result.totalBytes).toBe(9);
});
it("truncates head on UTF-8 byte limits without partial lines", () => {
const content = "éé\nabc";
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
expect(result.content).toBe("éé");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.outputBytes).toBe(4);
expect(result.firstLineExceedsLimit).toBe(false);
});
it("reports head truncation when the first line exceeds the byte limit", () => {
const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.firstLineExceedsLimit).toBe(true);
});
it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => {
const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 });
expect(result.content).toBe("🙂b");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(5);
});
it("truncates an oversized single line with a trailing newline", () => {
const input = `${"X".repeat(300_000)}\n`;
const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 });
expect(result.content).toBe("X".repeat(1024));
expect(result.outputBytes).toBe(1024);
expect(result.outputLines).toBe(1);
expect(result.lastLinePartial).toBe(true);
expect(result.truncatedBy).toBe("bytes");
});
it("drops an oversized trailing character when it cannot fit in tail byte limit", () => {
const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(0);
});
it("matches Buffer tail truncation semantics for surrogate edge cases", () => {
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩‍💻"];
for (const input of inputs) assertMatchesBufferTail(input);
});
it("matches Buffer tail truncation semantics across deterministic fuzz cases", () => {
const alphabet = [
"a",
"\u007f",
"\u0080",
"é",
"\u07ff",
"\u0800",
"中",
"\ud7ff",
"\ud800",
"\ud83d",
"\udc00",
"\ude42",
"🙂",
"\ue000",
"\uffff",
];
function checkExhaustive(prefix: string, depth: number): void {
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
if (depth === 0) return;
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
}
checkExhaustive("", 3);
let seed = 0x12345678;
function random(): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0x100000000;
}
for (let i = 0; i < 1_000; i++) {
let input = "";
const length = Math.floor(random() * 80);
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
assertMatchesBufferTail(input, sampledByteLimits(input));
}
});
});

View File

@@ -1,23 +1,24 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import {
AgentHarness,
formatSkillsForSystemPrompt,
loadSourcedPromptTemplates,
loadSourcedSkills,
NodeExecutionEnv,
type PromptTemplate,
Session,
type Skill,
} from "../../src/index.js";
} from "../../src/index.ts";
type Source = { type: "project" | "user" | "path"; dir: string };
type SourcedSkill = Skill & { source: Source };
type SourcedPromptTemplate = PromptTemplate & { source: Source };
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } });
const { skills: sourcedSkills } = await loadSourcedSkills<Source, SourcedSkill>(
env,

View File

@@ -1,5 +1,5 @@
import { type Static, Type } from "typebox";
import type { AgentTool, AgentToolResult } from "../../src/types.js";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface CalculateResult extends AgentToolResult<undefined> {
content: Array<{ type: "text"; text: string }>;

View File

@@ -1,5 +1,5 @@
import { type Static, Type } from "typebox";
import type { AgentTool, AgentToolResult } from "../../src/types.js";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
testTimeout: 30000,
include: ["test/harness/**/*.test.ts"],
coverage: {
provider: "v8",
all: true,
include: ["src/harness/**/*.ts", "src/agent.ts", "src/agent-loop.ts"],
exclude: ["src/**/*.d.ts"],
reporter: ["text", "html", "lcov"],
reportsDirectory: "coverage/harness",
},
},
});

View File

@@ -2,13 +2,78 @@
## [Unreleased]
### Fixed
- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
### Fixed
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).
- Fixed OpenAI prompt cache keys to clamp session-derived values to the 64-character API limit across OpenAI Responses, Chat Completions, Codex Responses, and Azure OpenAI Responses ([#4720](https://github.com/earendil-works/pi/issues/4720)).
## [0.75.3] - 2026-05-18
## [0.75.2] - 2026-05-18
### Fixed
- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests ([#4678](https://github.com/earendil-works/pi/issues/4678)).
## [0.75.1] - 2026-05-18
### Fixed
- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo ([#4342](https://github.com/earendil-works/pi/issues/4342)).
- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream ([#4223](https://github.com/earendil-works/pi/issues/4223)).
- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier ([#4232](https://github.com/earendil-works/pi/issues/4232)).
- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)).
- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)).
### Removed
- Removed non-working OpenAI Codex fast model variants.
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
### Fixed
- Fixed OpenAI Codex generated model metadata to use the current upstream model list ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed GitHub Copilot GPT model thinking metadata to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed `streamSimple()` defaults for models whose advertised output limit is effectively their full context window to avoid impossible default requests ([#4614](https://github.com/earendil-works/pi/issues/4614)).
## [0.74.1] - 2026-05-16
### Added
- Added image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Added Together AI as a built-in OpenAI-compatible provider with generated model metadata and `TOGETHER_API_KEY` authentication ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)).
### Fixed
- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)).
- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)).
- Fixed Fireworks provider caching compatibility by adding session affinity headers and model metadata compat settings ([#4358](https://github.com/earendil-works/pi-mono/pull/4358) by [@yanirz](https://github.com/yanirz)).
- Fixed OpenAI Codex WebSocket transport to respect proxy environment variables under Bun ([#4354](https://github.com/earendil-works/pi-mono/pull/4354) by [@haoqixu](https://github.com/haoqixu)).
- Fixed OpenRouter cache usage normalization to preserve cached-token semantics without treating cached tokens as cache writes.
- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents.
- Fixed compiled Bun binaries failing to start outside the repo when Bedrock proxy support tried to resolve `proxy-from-env` from external `node_modules` ([#4513](https://github.com/earendil-works/pi/issues/4513)).
- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID.
- Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off.
- Fixed Inception Mercury 2 tool calling on OpenRouter by marking `off` as unsupported in `thinkingLevelMap`, so the openai-completions provider omits the reasoning param instead of defaulting to `{reasoning:{effort:"none"}}` (which puts Mercury 2 in instant mode, disabling tool calls).
- Fixed OpenAI Codex SSE retries to honor `retry-after-ms` and `retry-after` headers before falling back to exponential backoff.
- Fixed context overflow detection for LiteLLM-wrapped OpenAI-compatible errors using `exceeds the model's maximum context length of ... tokens` wording ([#4563](https://github.com/earendil-works/pi/issues/4563)).
- Fixed `streamSimple()` defaults to respect model output limits above 32000 tokens instead of clamping provider requests to 32000 ([#4539](https://github.com/earendil-works/pi/issues/4539)).
## [0.74.0] - 2026-05-07

View File

@@ -1122,7 +1122,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` |
| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` |
| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` |
When set, the library automatically uses these keys:

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.74.0",
"version": "0.75.4",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@@ -60,26 +60,22 @@
],
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "npx tsx scripts/generate-models.ts",
"generate-image-models": "npx tsx scripts/generate-image-models.ts",
"generate-models": "node scripts/generate-models.ts",
"generate-image-models": "node scripts/generate-image-models.ts",
"build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.91.1",
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@google/genai": "^1.40.0",
"@mistralai/mistralai": "^2.2.0",
"typebox": "^1.1.24",
"chalk": "^5.6.2",
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.1",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"openai": "6.26.0",
"partial-json": "^0.1.7",
"proxy-agent": "^6.5.0",
"undici": "^7.19.1",
"zod-to-json-schema": "^3.24.6"
"partial-json": "0.1.7",
"typebox": "1.1.38"
},
"keywords": [
"ai",
@@ -99,11 +95,11 @@
"directory": "packages/ai"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"canvas": "^3.2.0",
"vitest": "^3.2.4"
"@types/node": "24.12.4",
"canvas": "3.2.3",
"vitest": "3.2.4"
}
}

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env tsx
#!/usr/bin/env node
import { writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { ImagesModel } from "../src/types.js";
import type { ImagesModel } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -109,7 +109,7 @@ function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): st
return `// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.js";
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
${providerEntries}

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env tsx
#!/usr/bin/env node
import { writeFileSync } from "fs";
import { join, dirname } from "path";
@@ -8,14 +8,8 @@ import {
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
CLOUDFLARE_WORKERS_AI_BASE_URL,
} from "../src/providers/cloudflare.js";
import {
Api,
type AnthropicMessagesCompat,
KnownProvider,
Model,
type OpenAICompletionsCompat,
} from "../src/types.js";
} from "../src/providers/cloudflare.ts";
import type { AnthropicMessagesCompat, Api, KnownProvider, Model, OpenAICompletionsCompat } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -203,6 +197,9 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
) {
mergeThinkingLevelMap(model, { off: null });
}
if (model.provider === "github-copilot" && model.id.startsWith("gpt-5")) {
mergeThinkingLevelMap(model, { minimal: "low" });
}
if (
model.api === "openai-responses" &&
model.provider === "openai" &&
@@ -237,8 +234,12 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) {
mergeThinkingLevelMap(model, { minimal: "low" });
}
if (model.provider === "openai-codex" && model.id === "gpt-5.1-codex-mini") {
mergeThinkingLevelMap(model, { minimal: "medium", low: "medium", medium: "medium", high: "high" });
if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) {
// Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling.
// Mark "off" unsupported so the openai-completions provider omits the reasoning param
// instead of defaulting to {reasoning:{effort:"none"}} (see openai-completions.ts:575).
// Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary.
mergeThinkingLevelMap(model, { off: null });
}
}
@@ -764,6 +765,16 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
// Fireworks prompt caching uses automatic prefix matching + session affinity.
// x-session-affinity routes requests to the same replica for cache hits.
// cache_control on tools and eager_input_streaming are not supported.
// See: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility
compat: {
sendSessionAffinityHeaders: true,
supportsEagerToolInputStreaming: false,
supportsCacheControlOnTools: false,
supportsLongCacheRetention: false,
},
});
}
}
@@ -1056,11 +1067,15 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
// Built-in `xiaomi` targets the API billing endpoint (single stable URL,
// keys from platform.xiaomimimo.com). The three `xiaomi-token-plan-*`
// providers cover prepaid Token Plan endpoints in cn / ams / sgp.
const xiaomiCompat: OpenAICompletionsCompat = {
requiresReasoningContentOnAssistantMessages: true,
thinkingFormat: "deepseek",
};
const xiaomiVariants = [
{ provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/anthropic" },
{ provider: "xiaomi-token-plan-cn", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic" },
{ provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic" },
{ provider: "xiaomi-token-plan-sgp", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic" },
{ provider: "xiaomi", baseUrl: "https://api.xiaomimimo.com/v1" },
{ provider: "xiaomi-token-plan-cn", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1" },
{ provider: "xiaomi-token-plan-ams", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1" },
{ provider: "xiaomi-token-plan-sgp", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1" },
] as const;
if (data.xiaomi?.models) {
@@ -1072,9 +1087,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
models.push({
id: modelId,
name: m.name || modelId,
api: "anthropic-messages",
api: "openai-completions",
provider,
baseUrl,
compat: xiaomiCompat,
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
@@ -1480,42 +1496,6 @@ async function generateModels() {
const CODEX_CONTEXT = 272000;
const CODEX_MAX_TOKENS = 128000;
const codexModels: Model<"openai-codex-responses">[] = [
{
id: "gpt-5.1",
name: "GPT-5.1",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.1-codex-mini",
name: "GPT-5.1 Codex Mini",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.2",
name: "GPT-5.2",
@@ -1528,18 +1508,6 @@ async function generateModels() {
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
@@ -1552,6 +1520,18 @@ async function generateModels() {
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.4",
name: "GPT-5.4",
@@ -1564,6 +1544,18 @@ async function generateModels() {
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 mini",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.5",
name: "GPT-5.5",
@@ -1576,36 +1568,36 @@ async function generateModels() {
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: CODEX_MAX_TOKENS,
},
];
allModels.push(...codexModels);
// Add missing Grok models
if (!allModels.some(m => m.provider === "xai" && m.id === "grok-code-fast-1")) {
allModels.push({
const missingGrokModels: Model<"openai-completions">[] = [
{
id: "grok-3",
name: "Grok 3",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
provider: "xai",
reasoning: false,
input: ["text"],
cost: { input: 3, output: 15, cacheRead: 0.75, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 8192,
},
{
id: "grok-3-fast",
name: "Grok 3 Fast",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
provider: "xai",
reasoning: false,
input: ["text"],
cost: { input: 5, output: 25, cacheRead: 1.25, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 8192,
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1",
api: "openai-completions",
@@ -1621,7 +1613,12 @@ async function generateModels() {
},
contextWindow: 32768,
maxTokens: 8192,
});
},
];
for (const model of missingGrokModels) {
if (!allModels.some(m => m.provider === model.provider && m.id === model.id)) {
allModels.push(model);
}
}
// Add missing Mistral Medium 3.5 model until models.dev includes it
@@ -1860,7 +1857,7 @@ async function generateModels() {
let output = `// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import type { Model } from "./types.js";
import type { Model } from "./types.ts";
export const MODELS = {
`;

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env tsx
#!/usr/bin/env node
import { createCanvas } from "canvas";
import { writeFileSync } from "fs";
import { mkdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -27,7 +27,6 @@ const buffer = canvas.toBuffer("image/png");
const outputPath = join(__dirname, "..", "test", "data", "red-circle.png");
// Ensure the directory exists
import { mkdirSync } from "fs";
mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true });
writeFileSync(outputPath, buffer);

View File

@@ -6,7 +6,7 @@ import type {
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "./types.js";
} from "./types.ts";
export type ApiStreamFunction = (
model: Model<Api>,

View File

@@ -1,4 +1,4 @@
import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.js";
import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
export const bedrockProviderModule = {
streamBedrock,

View File

@@ -2,8 +2,8 @@
import { createInterface } from "node:readline";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.js";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.js";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
const AUTH_FILE = "auth.json";
const PROVIDERS = getOAuthProviders();

View File

@@ -1,4 +1,4 @@
// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui)
// NEVER convert to top-level imports - breaks browser/Vite builds
let _existsSync: typeof import("node:fs").existsSync | null = null;
let _homedir: typeof import("node:os").homedir | null = null;
let _join: typeof import("node:path").join | null = null;
@@ -23,7 +23,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { KnownProvider } from "./types.js";
import type { KnownProvider } from "./types.ts";
let _procEnvCache: Map<string, string> | null = null;

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.js";
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
"openrouter": {
@@ -230,6 +230,171 @@ export const IMAGE_MODELS = {
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v3": {
id: "recraft/recraft-v3",
name: "Recraft: Recraft V3",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4": {
id: "recraft/recraft-v4",
name: "Recraft: Recraft V4",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro": {
id: "recraft/recraft-v4-pro",
name: "Recraft: Recraft V4 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro-vector": {
id: "recraft/recraft-v4-pro-vector",
name: "Recraft: Recraft V4 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-vector": {
id: "recraft/recraft-v4-vector",
name: "Recraft: Recraft V4 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1": {
id: "recraft/recraft-v4.1",
name: "Recraft: Recraft V4.1",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro": {
id: "recraft/recraft-v4.1-pro",
name: "Recraft: Recraft V4.1 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro-vector": {
id: "recraft/recraft-v4.1-pro-vector",
name: "Recraft: Recraft V4.1 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility": {
id: "recraft/recraft-v4.1-utility",
name: "Recraft: Recraft V4.1 Utility",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility-pro": {
id: "recraft/recraft-v4.1-utility-pro",
name: "Recraft: Recraft V4.1 Utility Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-vector": {
id: "recraft/recraft-v4.1-vector",
name: "Recraft: Recraft V4.1 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast": {
id: "sourceful/riverflow-v2-fast",
name: "Sourceful: Riverflow V2 Fast",
@@ -305,5 +470,20 @@ export const IMAGE_MODELS = {
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
name: "xAI: Grok Imagine Image Quality",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
},
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;

View File

@@ -1,5 +1,5 @@
import { IMAGE_MODELS } from "./image-models.generated.js";
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.js";
import { IMAGE_MODELS } from "./image-models.generated.ts";
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts";
const imageModelRegistry: Map<string, Map<string, ImagesModel<ImagesApi>>> = new Map();

View File

@@ -1,4 +1,4 @@
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.js";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts";
export type ImagesApiFunction = (
model: ImagesModel<ImagesApi>,

View File

@@ -1,7 +1,7 @@
import "./providers/images/register-builtins.js";
import "./providers/images/register-builtins.ts";
import { getImagesApiProvider } from "./images-api-registry.js";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.js";
import { getImagesApiProvider } from "./images-api-registry.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";
function resolveImagesApiProvider(api: ImagesApi) {
const provider = getImagesApiProvider(api);

View File

@@ -1,34 +1,34 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
export * from "./api-registry.js";
export * from "./env-api-keys.js";
export * from "./image-models.js";
export * from "./images.js";
export * from "./images-api-registry.js";
export * from "./models.js";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
export * from "./providers/faux.js";
export type { GoogleOptions } from "./providers/google.js";
export type { GoogleThinkingLevel } from "./providers/google-shared.js";
export type { GoogleVertexOptions } from "./providers/google-vertex.js";
export * from "./providers/images/register-builtins.js";
export type { MistralOptions } from "./providers/mistral.js";
export * from "./api-registry.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
export * from "./images-api-registry.ts";
export * from "./models.ts";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts";
export * from "./providers/faux.ts";
export type { GoogleOptions } from "./providers/google.ts";
export type { GoogleThinkingLevel } from "./providers/google-shared.ts";
export type { GoogleVertexOptions } from "./providers/google-vertex.ts";
export * from "./providers/images/register-builtins.ts";
export type { MistralOptions } from "./providers/mistral.ts";
export type {
OpenAICodexResponsesOptions,
OpenAICodexWebSocketDebugStats,
} from "./providers/openai-codex-responses.js";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.js";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
export * from "./providers/register-builtins.js";
export * from "./session-resources.js";
export * from "./stream.js";
export * from "./types.js";
export * from "./utils/diagnostics.js";
export * from "./utils/event-stream.js";
export * from "./utils/json-parse.js";
} from "./providers/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts";
export * from "./providers/register-builtins.ts";
export * from "./session-resources.ts";
export * from "./stream.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
@@ -40,7 +40,7 @@ export type {
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";
export * from "./utils/validation.js";
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { MODELS } from "./models.generated.js";
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.js";
import { MODELS } from "./models.generated.ts";
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts";
const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();

View File

@@ -1 +1 @@
export * from "./utils/oauth/index.js";
export * from "./utils/oauth/index.ts";

View File

@@ -20,8 +20,9 @@ import {
type ToolConfiguration,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import type { DocumentType } from "@smithy/types";
import { calculateCost } from "../models.js";
import { calculateCost } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -39,12 +40,13 @@ import type {
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
export type BedrockThinkingDisplay = "summarized" | "omitted";
@@ -156,30 +158,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
};
}
if (
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.NO_PROXY ||
process.env.http_proxy ||
process.env.https_proxy ||
process.env.no_proxy
) {
const nodeHttpHandler = await import("@smithy/node-http-handler");
const proxyAgent = await import("proxy-agent");
const agent = new proxyAgent.ProxyAgent();
const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl);
if (proxyAgents) {
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
// on `http2` module and has no support for http agent.
// Use NodeHttpHandler to support http agent.
config.requestHandler = new nodeHttpHandler.NodeHttpHandler({
httpAgent: agent,
httpsAgent: agent,
});
// Use NodeHttpHandler to support HTTP(S) proxy agents.
config.requestHandler = new NodeHttpHandler(proxyAgents);
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
const nodeHttpHandler = await import("@smithy/node-http-handler");
config.requestHandler = new nodeHttpHandler.NodeHttpHandler();
config.requestHandler = new NodeHttpHandler();
}
} else {
// Non-Node environment (browser): fall back to us-east-1 since
@@ -196,12 +183,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
try {
const client = new BedrockRuntimeClient(config);
const cacheRetention = resolveCacheRetention(options.cacheRetention);
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
let commandInput = {
modelId: model.id,
messages: convertMessages(context, model, cacheRetention),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
inferenceConfig: {
...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
},
toolConfig: convertToolConfig(context.tools, options.toolChoice),
@@ -327,8 +315,10 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
} satisfies BedrockOptions);
}
// Undefined means the caller did not request an output cap; let the helper use the model cap.
// Do not coerce to 0 here, or the thinking budget would become the entire maxTokens value.
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
base.maxTokens,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
@@ -631,24 +621,31 @@ function convertMessages(
const m = transformedMessages[i];
switch (m.role) {
case "user":
case "user": {
const content: ContentBlock[] = [];
if (typeof m.content === "string") {
content.push({ text: sanitizeSurrogates(m.content) });
} else {
for (const c of m.content) {
switch (c.type) {
case "text":
content.push({ text: sanitizeSurrogates(c.text) });
break;
case "image":
content.push({ image: createImageBlock(c.mimeType, c.data) });
break;
default:
continue;
}
}
}
if (content.length === 0) continue;
result.push({
role: ConversationRole.USER,
content:
typeof m.content === "string"
? [{ text: sanitizeSurrogates(m.content) }]
: m.content.map((c) => {
switch (c.type) {
case "text":
return { text: sanitizeSurrogates(c.text) };
case "image":
return { image: createImageBlock(c.mimeType, c.data) };
default:
throw new Error("Unknown user content type");
}
}),
content,
});
break;
}
case "assistant": {
// Skip assistant messages with empty content (e.g., from aborted requests)
// Bedrock rejects messages with empty content arrays
@@ -699,7 +696,7 @@ function convertMessages(
}
break;
default:
throw new Error("Unknown assistant content type");
continue;
}
}
// Skip if all content blocks were filtered out
@@ -758,7 +755,7 @@ function convertMessages(
break;
}
default:
throw new Error("Unknown message role");
continue;
}
}

View File

@@ -6,8 +6,8 @@ import type {
MessageParam,
RawMessageStreamEvent,
} from "@anthropic-ai/sdk/resources/messages.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { calculateCost } from "../models.ts";
import type {
AnthropicMessagesCompat,
Api,
@@ -26,16 +26,16 @@ import type {
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveCloudflareBaseUrl } from "./cloudflare.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
import { resolveCloudflareBaseUrl } from "./cloudflare.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
/**
* Resolve cache retention preference.
@@ -165,9 +165,16 @@ const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
function getAnthropicCompat(model: Model<"anthropic-messages">): Required<AnthropicMessagesCompat> {
// Auto-detect session affinity and cache control support from provider
const isFireworks = model.provider === "fireworks";
const isCloudflareAiGatewayAnthropic =
model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic");
return {
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks,
sendSessionAffinityHeaders:
model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic),
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks,
};
}
@@ -463,6 +470,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
});
}
const cacheRetention = options?.cacheRetention ?? resolveCacheRetention();
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const created = createClient(
model,
apiKey,
@@ -470,6 +480,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
shouldUseFineGrainedToolStreamingBeta(model, context),
options?.headers,
copilotDynamicHeaders,
cacheSessionId,
);
client = created.client;
isOAuth = created.isOAuthToken;
@@ -740,8 +751,10 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
} satisfies AnthropicOptions);
}
// Undefined means the caller did not request an output cap; let the helper use the model cap.
// Do not coerce to 0 here, or the thinking budget would become the entire max_tokens value.
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
base.maxTokens,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
@@ -766,6 +779,7 @@ function createClient(
useFineGrainedToolStreamingBeta: boolean,
optionsHeaders?: Record<string, string>,
dynamicHeaders?: Record<string, string>,
sessionId?: string,
): { client: Anthropic; isOAuthToken: boolean } {
// Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in.
// The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it.
@@ -847,8 +861,11 @@ function createClient(
}
// API key auth
const sessionAffinityHeaders: Record<string, string | null> =
sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {};
const client = new Anthropic({
apiKey,
authToken: null,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
@@ -857,6 +874,7 @@ function createClient(
"anthropic-dangerous-direct-browser-access": "true",
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
sessionAffinityHeaders,
model.headers,
optionsHeaders,
),
@@ -875,7 +893,7 @@ function buildParams(
const params: MessageCreateParamsStreaming = {
model: model.id,
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
max_tokens: options?.maxTokens ?? model.maxTokens,
stream: true,
};
@@ -912,11 +930,12 @@ function buildParams(
}
if (context.tools && context.tools.length > 0) {
const compat = getAnthropicCompat(model);
params.tools = convertTools(
context.tools,
isOAuthToken,
getAnthropicCompat(model).supportsEagerToolInputStreaming,
cacheControl,
compat.supportsEagerToolInputStreaming,
compat.supportsCacheControlOnTools ? cacheControl : undefined,
);
}

View File

@@ -1,7 +1,7 @@
import { AzureOpenAI } from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { clampThinkingLevel } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -10,11 +10,12 @@ import type {
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
import { buildBaseOptions } from "./simple-options.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
const DEFAULT_AZURE_API_VERSION = "v1";
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]);
@@ -40,6 +41,22 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
return mappedDeployment || model.id;
}
function formatAzureOpenAIError(error: unknown): string {
if (error instanceof Error) {
const status = (error as Error & { status?: unknown }).status;
const statusCode = typeof status === "number" ? status : undefined;
if (statusCode !== undefined) {
return `Azure OpenAI API error (${statusCode}): ${error.message}`;
}
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
// Azure OpenAI Responses-specific options
export interface AzureOpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -119,7 +136,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
delete (block as { partialJson?: string }).partialJson;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatAzureOpenAIError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
@@ -245,7 +262,7 @@ function buildParams(
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: options?.sessionId,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
};
if (options?.maxTokens) {

View File

@@ -1,4 +1,4 @@
import type { Api, Model } from "../types.js";
import type { Api, Model } from "../types.ts";
/** Workers AI direct endpoint. */
export const CLOUDFLARE_WORKERS_AI_BASE_URL =

View File

@@ -1,4 +1,4 @@
import { registerApiProvider, unregisterApiProviders } from "../api-registry.js";
import { registerApiProvider, unregisterApiProviders } from "../api-registry.ts";
import type {
AssistantMessage,
AssistantMessageEventStream,
@@ -14,8 +14,8 @@ import type {
ToolCall,
ToolResultMessage,
Usage,
} from "../types.js";
import { createAssistantMessageEventStream } from "../utils/event-stream.js";
} from "../types.ts";
import { createAssistantMessageEventStream } from "../utils/event-stream.ts";
const DEFAULT_API = "faux";
const DEFAULT_PROVIDER = "faux";

View File

@@ -1,4 +1,4 @@
import type { Message } from "../types.js";
import type { Message } from "../types.ts";
// Copilot expects X-Initiator to indicate whether the request is user-initiated
// or agent-initiated (e.g. follow-up after assistant/tool messages).

View File

@@ -3,9 +3,9 @@
*/
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transform-messages.js";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { transformMessages } from "./transform-messages.ts";
type GoogleApiType = "google-generative-ai" | "google-vertex";

View File

@@ -7,7 +7,7 @@ import {
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { calculateCost, clampThinkingLevel } from "../models.js";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -21,10 +21,10 @@ import type {
ThinkingBudgets,
ThinkingContent,
ToolCall,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import type { GoogleThinkingLevel } from "./google-shared.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
@@ -32,8 +32,8 @@ import {
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared.js";
import { buildBaseOptions } from "./simple-options.js";
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";

View File

@@ -4,8 +4,8 @@ import {
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost, clampThinkingLevel } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -19,10 +19,10 @@ import type {
ThinkingContent,
ThinkingLevel,
ToolCall,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import type { GoogleThinkingLevel } from "./google-shared.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
@@ -30,8 +30,8 @@ import {
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared.js";
import { buildBaseOptions } from "./simple-options.js";
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";

View File

@@ -6,7 +6,7 @@ import type {
ChatCompletionContentPartText,
ChatCompletionCreateParamsNonStreaming,
} from "openai/resources/chat/completions.js";
import { getEnvApiKey } from "../../env-api-keys.js";
import { getEnvApiKey } from "../../env-api-keys.ts";
import type {
AssistantImages,
ImageContent,
@@ -15,9 +15,9 @@ import type {
ImagesModel,
ImagesOptions,
TextContent,
} from "../../types.js";
import { headersToRecord } from "../../utils/headers.js";
import { sanitizeSurrogates } from "../../utils/sanitize-unicode.js";
} from "../../types.ts";
import { headersToRecord } from "../../utils/headers.ts";
import { sanitizeSurrogates } from "../../utils/sanitize-unicode.ts";
interface OpenRouterGeneratedImage {
image_url?: string | { url?: string };

View File

@@ -1,6 +1,6 @@
import { registerImagesApiProvider } from "../../images-api-registry.js";
import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.js";
import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.js";
import { registerImagesApiProvider } from "../../images-api-registry.ts";
import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts";
import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts";
interface OpenRouterImagesProviderModule {
generateImagesOpenRouter: typeof generateImagesOpenRouterFunction;
@@ -21,7 +21,7 @@ function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, erro
}
function loadOpenRouterImagesProviderModule(): Promise<OpenRouterImagesProviderModule> {
openRouterImagesProviderModulePromise ||= import("./openrouter.js").then(
openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then(
(module) => module as OpenRouterImagesProviderModule,
);
return openRouterImagesProviderModulePromise;

View File

@@ -6,8 +6,8 @@ import type {
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost, clampThinkingLevel } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
Context,
@@ -21,13 +21,13 @@ import type {
ThinkingContent,
Tool,
ToolCall,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { shortHash } from "../utils/hash.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
const MISTRAL_TOOL_CALL_ID_LENGTH = 9;
const MAX_MISTRAL_ERROR_BODY_CHARS = 4000;

View File

@@ -6,7 +6,7 @@ import type {
ResponseStreamEvent,
} from "openai/resources/responses/responses.js";
// NEVER convert to top-level runtime imports - breaks browser/Vite builds (web-ui)
// NEVER convert to top-level runtime imports - breaks browser/Vite builds
let _os: typeof NodeOs | null = null;
type DynamicImport = (specifier: string) => Promise<unknown>;
@@ -20,9 +20,9 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import { getEnvApiKey } from "../env-api-keys.js";
import { clampThinkingLevel } from "../models.js";
import { registerSessionResourceCleanup } from "../session-resources.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { clampThinkingLevel } from "../models.ts";
import { registerSessionResourceCleanup } from "../session-resources.ts";
import type {
Api,
AssistantMessage,
@@ -32,16 +32,17 @@ import type {
StreamFunction,
StreamOptions,
Usage,
} from "../types.js";
} from "../types.ts";
import {
appendAssistantMessageDiagnostic,
createAssistantMessageDiagnostic,
formatThrownValue,
} from "../utils/diagnostics.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
import { buildBaseOptions } from "./simple-options.js";
} from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
// ============================================================================
// Configuration
@@ -254,7 +255,29 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
const errorText = await response.text();
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
const delayMs = BASE_DELAY_MS * 2 ** attempt;
let delayMs = BASE_DELAY_MS * 2 ** attempt;
const retryAfterMs = response.headers.get("retry-after-ms");
if (retryAfterMs !== null) {
const millis = Number(retryAfterMs);
if (Number.isFinite(millis)) {
delayMs = Math.max(0, millis);
}
} else {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
delayMs = Math.max(0, seconds * 1000);
} else {
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
delayMs = Math.max(0, date - Date.now());
}
}
}
}
await sleep(delayMs, options?.signal);
continue;
}
@@ -356,7 +379,7 @@ function buildRequestBody(
input: messages,
text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"],
prompt_cache_key: options?.sessionId,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
tool_choice: "auto",
parallel_tool_calls: true,
};
@@ -711,7 +734,35 @@ type WebSocketConstructor = new (
protocols?: string | string[] | { headers?: Record<string, string> },
) => WebSocketLike;
function getWebSocketConstructor(): WebSocketConstructor | null {
let _cachedWebsocket: WebSocketConstructor | null = null;
async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
if (_cachedWebsocket) return _cachedWebsocket;
// bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489
// TODO: remove this when bun supports proxy envs in websocket.
if (
process?.versions?.bun &&
(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
) {
const m = await dynamicImport("proxy-from-env");
const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl;
_cachedWebsocket = class extends WebSocket {
constructor(url: string | URL, options?: string | string[] | Record<string, unknown>) {
let _opts: Record<string, unknown> = {};
if (Array.isArray(options) || typeof options === "string") {
_opts = { protocols: options };
} else {
_opts = { ...options };
}
const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any);
}
};
return _cachedWebsocket;
}
const ctor = (globalThis as { WebSocket?: unknown }).WebSocket;
if (typeof ctor !== "function") return null;
return ctor as unknown as WebSocketConstructor;
@@ -760,7 +811,7 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke
}
async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise<WebSocketLike> {
const WebSocketCtor = getWebSocketConstructor();
const WebSocketCtor = await getWebSocketConstructor();
if (!WebSocketCtor) {
throw new Error("WebSocket transport is not available in this runtime");
}

View File

@@ -10,8 +10,8 @@ import type {
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
} from "openai/resources/chat/completions.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost, clampThinkingLevel } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
CacheRetention,
@@ -29,15 +29,16 @@ import type {
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
/**
* Check if conversation messages contain tool calls or tool results.
@@ -165,6 +166,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
let textBlock: TextContent | null = null;
let thinkingBlock: ThinkingContent | null = null;
let hasFinishReason = false;
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
const blocks = output.content as StreamingBlock[];
@@ -288,6 +290,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
if (finishReasonResult.errorMessage) {
output.errorMessage = finishReasonResult.errorMessage;
}
hasFinishReason = true;
}
if (choice.delta) {
@@ -324,7 +327,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
if (foundReasoningField) {
const delta = deltaFields[foundReasoningField];
if (typeof delta === "string" && delta.length > 0) {
const block = ensureThinkingBlock(foundReasoningField);
const thinkingSignature =
model.provider === "opencode-go" && foundReasoningField === "reasoning"
? "reasoning_content"
: foundReasoningField;
const block = ensureThinkingBlock(thinkingSignature);
block.thinking += delta;
stream.push({
type: "thinking_delta",
@@ -390,6 +397,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
if (output.stopReason === "error") {
throw new Error(output.errorMessage || "Provider returned an error stop reason");
}
if (!hasFinishReason) {
throw new Error("Stream ended without finish_reason");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
@@ -507,7 +517,7 @@ function buildParams(
prompt_cache_key:
(model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") ||
(cacheRetention === "long" && compat.supportsLongCacheRetention)
? options?.sessionId
? clampOpenAIPromptCacheKey(options?.sessionId)
: undefined,
prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined,
};
@@ -839,7 +849,10 @@ export function convertMessages(
}
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
if (model.provider === "opencode-go" && signature === "reasoning") {
signature = "reasoning_content";
}
if (signature && signature.length > 0) {
(assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n");
}
@@ -997,17 +1010,17 @@ function parseChunkUsage(
model: Model<"openai-completions">,
): AssistantMessage["usage"] {
const promptTokens = rawUsage.prompt_tokens || 0;
const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0;
const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0;
const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
// Normalize to pi-ai semantics:
// - cacheRead: hits from cache created by previous requests only
// - cacheWrite: tokens written to cache in this request
// Some OpenAI-compatible providers (observed on OpenRouter) report cached_tokens
// as (previous hits + current writes). In that case, remove cacheWrite from cacheRead.
const cacheReadTokens =
cacheWriteTokens > 0 ? Math.max(0, reportedCachedTokens - cacheWriteTokens) : reportedCachedTokens;
// Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read
// tokens (hits). OpenAI does not document or emit cache_write_tokens, but
// OpenRouter-compatible providers can include it as a separate write count.
// OpenRouter's own provider/tests affirm the separate mapping:
// https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409
// Do not subtract writes from cached_tokens, otherwise spec-compliant
// providers are under-reported. DS4 mirrors this contract too:
// https://github.com/antirez/ds4/pull/29
const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
// OpenAI completion_tokens already includes reasoning_tokens.
const outputTokens = rawUsage.completion_tokens || 0;

View File

@@ -0,0 +1,8 @@
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined {
if (key === undefined) return undefined;
const chars = Array.from(key);
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key;
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("");
}

View File

@@ -12,7 +12,7 @@ import type {
ResponseReasoningItem,
ResponseStreamEvent,
} from "openai/resources/responses/responses.js";
import { calculateCost } from "../models.js";
import { calculateCost } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -26,12 +26,12 @@ import type {
Tool,
ToolCall,
Usage,
} from "../types.js";
import type { AssistantMessageEventStream } from "../utils/event-stream.js";
import { shortHash } from "../utils/hash.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transform-messages.js";
} from "../types.ts";
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { transformMessages } from "./transform-messages.ts";
// =============================================================================
// Utilities

View File

@@ -1,7 +1,7 @@
import OpenAI from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { clampThinkingLevel } from "../models.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
@@ -13,13 +13,14 @@ import type {
StreamFunction,
StreamOptions,
Usage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
import { buildBaseOptions } from "./simple-options.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
@@ -51,6 +52,22 @@ function getPromptCacheRetention(
return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined;
}
function formatOpenAIResponsesError(error: unknown): string {
if (error instanceof Error) {
const status = (error as Error & { status?: unknown }).status;
const statusCode = typeof status === "number" ? status : undefined;
if (statusCode !== undefined) {
return `OpenAI API error (${statusCode}): ${error.message}`;
}
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
// OpenAI Responses-specific options
export interface OpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -130,7 +147,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
delete (block as { partialJson?: string }).partialJson;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatOpenAIResponsesError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
@@ -224,7 +241,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
model: model.id,
input: messages,
stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
store: false,
};

View File

@@ -1,4 +1,4 @@
import { clearApiProviders, registerApiProvider } from "../api-registry.js";
import { clearApiProviders, registerApiProvider } from "../api-registry.ts";
import type {
Api,
AssistantMessage,
@@ -8,17 +8,17 @@ import type {
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import type { BedrockOptions } from "./amazon-bedrock.js";
import type { AnthropicOptions } from "./anthropic.js";
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.js";
import type { GoogleOptions } from "./google.js";
import type { GoogleVertexOptions } from "./google-vertex.js";
import type { MistralOptions } from "./mistral.js";
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.js";
import type { OpenAICompletionsOptions } from "./openai-completions.js";
import type { OpenAIResponsesOptions } from "./openai-responses.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import type { BedrockOptions } from "./amazon-bedrock.ts";
import type { AnthropicOptions } from "./anthropic.ts";
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.ts";
import type { GoogleOptions } from "./google.ts";
import type { GoogleVertexOptions } from "./google-vertex.ts";
import type { MistralOptions } from "./mistral.ts";
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.ts";
import type { OpenAICompletionsOptions } from "./openai-completions.ts";
import type { OpenAIResponsesOptions } from "./openai-responses.ts";
interface LazyProviderModule<
TApi extends Api,
@@ -86,7 +86,10 @@ interface BedrockProviderModule {
) => AsyncIterable<AssistantMessageEvent>;
}
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => import(specifier);
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => {
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
return import(runtimeSpecifier);
};
let anthropicProviderModulePromise:
| Promise<LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions>>
@@ -203,7 +206,7 @@ function createLazySimpleStream<
function loadAnthropicProviderModule(): Promise<
LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions>
> {
anthropicProviderModulePromise ||= import("./anthropic.js").then((module) => {
anthropicProviderModulePromise ||= import("./anthropic.ts").then((module) => {
const provider = module as AnthropicProviderModule;
return {
stream: provider.streamAnthropic,
@@ -216,7 +219,7 @@ function loadAnthropicProviderModule(): Promise<
function loadAzureOpenAIResponsesProviderModule(): Promise<
LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions>
> {
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.js").then((module) => {
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.ts").then((module) => {
const provider = module as AzureOpenAIResponsesProviderModule;
return {
stream: provider.streamAzureOpenAIResponses,
@@ -229,7 +232,7 @@ function loadAzureOpenAIResponsesProviderModule(): Promise<
function loadGoogleProviderModule(): Promise<
LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions>
> {
googleProviderModulePromise ||= import("./google.js").then((module) => {
googleProviderModulePromise ||= import("./google.ts").then((module) => {
const provider = module as GoogleProviderModule;
return {
stream: provider.streamGoogle,
@@ -242,7 +245,7 @@ function loadGoogleProviderModule(): Promise<
function loadGoogleVertexProviderModule(): Promise<
LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions>
> {
googleVertexProviderModulePromise ||= import("./google-vertex.js").then((module) => {
googleVertexProviderModulePromise ||= import("./google-vertex.ts").then((module) => {
const provider = module as GoogleVertexProviderModule;
return {
stream: provider.streamGoogleVertex,
@@ -255,7 +258,7 @@ function loadGoogleVertexProviderModule(): Promise<
function loadMistralProviderModule(): Promise<
LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions>
> {
mistralProviderModulePromise ||= import("./mistral.js").then((module) => {
mistralProviderModulePromise ||= import("./mistral.ts").then((module) => {
const provider = module as MistralProviderModule;
return {
stream: provider.streamMistral,
@@ -268,7 +271,7 @@ function loadMistralProviderModule(): Promise<
function loadOpenAICodexResponsesProviderModule(): Promise<
LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions>
> {
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.js").then((module) => {
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.ts").then((module) => {
const provider = module as OpenAICodexResponsesProviderModule;
return {
stream: provider.streamOpenAICodexResponses,
@@ -281,7 +284,7 @@ function loadOpenAICodexResponsesProviderModule(): Promise<
function loadOpenAICompletionsProviderModule(): Promise<
LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions>
> {
openAICompletionsProviderModulePromise ||= import("./openai-completions.js").then((module) => {
openAICompletionsProviderModulePromise ||= import("./openai-completions.ts").then((module) => {
const provider = module as OpenAICompletionsProviderModule;
return {
stream: provider.streamOpenAICompletions,
@@ -294,7 +297,7 @@ function loadOpenAICompletionsProviderModule(): Promise<
function loadOpenAIResponsesProviderModule(): Promise<
LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions>
> {
openAIResponsesProviderModulePromise ||= import("./openai-responses.js").then((module) => {
openAIResponsesProviderModulePromise ||= import("./openai-responses.ts").then((module) => {
const provider = module as OpenAIResponsesProviderModule;
return {
stream: provider.streamOpenAIResponses,
@@ -310,7 +313,7 @@ function loadBedrockProviderModule(): Promise<
if (bedrockProviderModuleOverride) {
return Promise.resolve(bedrockProviderModuleOverride);
}
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.js").then((module) => {
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.ts").then((module) => {
const provider = module as BedrockProviderModule;
return {
stream: provider.streamBedrock,

View File

@@ -1,9 +1,9 @@
import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types.js";
import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types.ts";
export function buildBaseOptions(model: Model<Api>, options?: SimpleStreamOptions, apiKey?: string): StreamOptions {
export function buildBaseOptions(_model: Model<Api>, options?: SimpleStreamOptions, apiKey?: string): StreamOptions {
return {
temperature: options?.temperature,
maxTokens: options?.maxTokens ?? (model.maxTokens > 0 ? Math.min(model.maxTokens, 32000) : undefined),
maxTokens: options?.maxTokens,
signal: options?.signal,
apiKey: apiKey || options?.apiKey,
transport: options?.transport,
@@ -24,7 +24,8 @@ export function clampReasoning(effort: ThinkingLevel | undefined): Exclude<Think
}
export function adjustMaxTokensForThinking(
baseMaxTokens: number,
// Undefined means no explicit caller cap. Use the model cap and fit thinking inside it.
baseMaxTokens: number | undefined,
modelMaxTokens: number,
reasoningLevel: ThinkingLevel,
customBudgets?: ThinkingBudgets,
@@ -40,7 +41,8 @@ export function adjustMaxTokensForThinking(
const minOutputTokens = 1024;
const level = clampReasoning(reasoningLevel)!;
let thinkingBudget = budgets[level]!;
const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
const maxTokens =
baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);

View File

@@ -7,7 +7,7 @@ import type {
TextContent,
ToolCall,
ToolResultMessage,
} from "../types.js";
} from "../types.ts";
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";

View File

@@ -1,6 +1,6 @@
import "./providers/register-builtins.js";
import "./providers/register-builtins.ts";
import { getApiProvider } from "./api-registry.js";
import { getApiProvider } from "./api-registry.ts";
import type {
Api,
AssistantMessage,
@@ -10,9 +10,9 @@ import type {
ProviderStreamOptions,
SimpleStreamOptions,
StreamOptions,
} from "./types.js";
} from "./types.ts";
export { getEnvApiKey } from "./env-api-keys.js";
export { getEnvApiKey } from "./env-api-keys.ts";
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);

View File

@@ -1,7 +1,7 @@
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js";
import type { AssistantMessageEventStream } from "./utils/event-stream.js";
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts";
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
export type { AssistantMessageEventStream } from "./utils/event-stream.js";
export type { AssistantMessageEventStream } from "./utils/event-stream.ts";
export type KnownApi =
| "openai-completions"
@@ -419,6 +419,22 @@ export interface AnthropicMessagesCompat {
supportsEagerToolInputStreaming?: boolean;
/** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */
supportsLongCacheRetention?: boolean;
/**
* Whether to send the `x-session-affinity` header from `options.sessionId`
* when caching is enabled. Required for providers like Fireworks that use
* session affinity for prompt cache routing (requests to the same replica
* maximize cache hits).
* Default: false.
*/
sendSessionAffinityHeaders?: boolean;
/**
* Whether the provider supports Anthropic-style `cache_control` markers on
* tool definitions. When false, `cache_control` is omitted from tool params.
* Some Anthropic-compatible providers (e.g., Fireworks) do not support this
* field on tools and may reject or ignore it.
* Default: true.
*/
supportsCacheControlOnTools?: boolean;
}
/**

View File

@@ -1,4 +1,4 @@
import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
import type { AssistantMessage, AssistantMessageEvent } from "../types.ts";
// Generic event stream class for async iteration
export class EventStream<T, R = T> implements AsyncIterable<T> {
@@ -7,11 +7,12 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(
private isComplete: (event: T) => boolean,
private extractResult: (event: T) => R,
) {
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});

View File

@@ -0,0 +1,123 @@
import type { Agent as HttpAgent } from "node:http";
import type { Agent as HttpsAgent } from "node:https";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
const DEFAULT_PROXY_PORTS: Record<string, number> = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443,
};
export interface NodeHttpProxyAgents {
httpAgent: HttpAgent;
httpsAgent: HttpsAgent;
}
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
function getProxyEnv(key: string): string {
return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "";
}
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
if (targetUrl instanceof URL) {
return targetUrl;
}
try {
return new URL(targetUrl);
} catch {
return undefined;
}
}
function shouldProxyHostname(hostname: string, port: number): boolean {
const noProxy = getProxyEnv("no_proxy").toLowerCase();
if (!noProxy) {
return true;
}
if (noProxy === "*") {
return false;
}
return noProxy.split(/[,\s]/).every((proxy) => {
if (!proxy) {
return true;
}
const parsedProxy = proxy.match(/^(.+):(\d+)$/);
let proxyHostname = parsedProxy ? parsedProxy[1] : proxy;
const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2]!, 10) : 0;
if (proxyPort && proxyPort !== port) {
return true;
}
if (!/^[.*]/.test(proxyHostname)) {
return hostname !== proxyHostname;
}
if (proxyHostname.startsWith("*")) {
proxyHostname = proxyHostname.slice(1);
}
return !hostname.endsWith(proxyHostname);
});
}
function getProxyForUrl(targetUrl: string | URL): string {
const parsedUrl = parseProxyTargetUrl(targetUrl);
if (!parsedUrl?.protocol || !parsedUrl.host) {
return "";
}
const protocol = parsedUrl.protocol.split(":", 1)[0]!;
const hostname = parsedUrl.host.replace(/:\d*$/, "");
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
if (!shouldProxyHostname(hostname, port)) {
return "";
}
let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy");
if (proxy && !proxy.includes("://")) {
proxy = `${protocol}://${proxy}`;
}
return proxy;
}
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined {
const proxy = getProxyForUrl(targetUrl);
if (!proxy) {
return undefined;
}
let proxyUrl: URL;
try {
proxyUrl = new URL(proxy);
} catch (error) {
throw new Error(
`Invalid proxy URL ${JSON.stringify(proxy)}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") {
throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`);
}
return proxyUrl;
}
export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined {
const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl);
if (!proxyUrl) {
return undefined;
}
return {
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
};
}

View File

@@ -6,9 +6,9 @@
*/
import type { Server } from "node:http";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
import { generatePKCE } from "./pkce.js";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
type CallbackServerInfo = {
server: Server;

View File

@@ -2,9 +2,9 @@
* GitHub Copilot OAuth flow
*/
import { getModels } from "../../models.js";
import type { Api, Model } from "../../types.js";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";
import { getModels } from "../../models.ts";
import type { Api, Model } from "../../types.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
type CopilotCredentials = OAuthCredentials & {
enterpriseUrl?: string;

View File

@@ -8,7 +8,7 @@
*/
// Anthropic
export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts";
// GitHub Copilot
export {
getGitHubCopilotBaseUrl,
@@ -16,20 +16,20 @@ export {
loginGitHubCopilot,
normalizeDomain,
refreshGitHubCopilotToken,
} from "./github-copilot.js";
} from "./github-copilot.ts";
// OpenAI Codex (ChatGPT OAuth)
export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.js";
export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts";
export * from "./types.js";
export * from "./types.ts";
// ============================================================================
// Provider Registry
// ============================================================================
import { anthropicOAuthProvider } from "./anthropic.js";
import { githubCopilotOAuthProvider } from "./github-copilot.js";
import { openaiCodexOAuthProvider } from "./openai-codex.js";
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.js";
import { anthropicOAuthProvider } from "./anthropic.ts";
import { githubCopilotOAuthProvider } from "./github-copilot.ts";
import { openaiCodexOAuthProvider } from "./openai-codex.ts";
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts";
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
anthropicOAuthProvider,

View File

@@ -5,7 +5,7 @@
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui)
// NEVER convert to top-level imports - breaks browser/Vite builds
let _randomBytes: typeof import("node:crypto").randomBytes | null = null;
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
@@ -17,9 +17,9 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
import { generatePKCE } from "./pkce.js";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";

View File

@@ -1,4 +1,4 @@
import type { Api, Model } from "../../types.js";
import type { Api, Model } from "../../types.ts";
export type OAuthCredentials = {
refresh: string;

Some files were not shown because too many files have changed in this diff Show More