mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3340936d | |||
| f981861b67 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 5d87c7ad1f | |||
| fce506b3f9 | |||
| 9db5073eab | |||
| 775fce0177 | |||
| 4e74f77e44 | |||
| b980f74ed9 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 | |||
| 339fd50cb9 | |||
| 828a4b3f15 | |||
| a43de3f86d | |||
| fe3b2e8f90 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
---
|
||||
|
||||
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||
-245
@@ -1,245 +0,0 @@
|
||||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**Model Context**:
|
||||
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||
_Avoid_: System Context
|
||||
|
||||
**Instructions**:
|
||||
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Instruction Source**:
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**InstructionEntry**:
|
||||
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Instruction Update**:
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
@@ -102,6 +103,8 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.message.next.description": "Go to the next user message",
|
||||
"command.model.choose": "Choose model",
|
||||
"command.model.choose.description": "Select a different model",
|
||||
"command.mcp.toggle": "Toggle MCPs",
|
||||
"command.mcp.toggle.description": "Toggle MCPs",
|
||||
"command.mcp.toggle": "Manage MCP servers",
|
||||
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||
"command.agent.cycle": "Cycle agent",
|
||||
"command.agent.cycle.description": "Switch to the next agent",
|
||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||
@@ -307,9 +307,9 @@ export const dict = {
|
||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||
|
||||
"dialog.mcp.title": "MCPs",
|
||||
"dialog.mcp.title": "MCP servers",
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
"dialog.mcp.empty": "No MCP servers configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { TuiConfig } from "../../tui-config"
|
||||
import { Config } from "../../config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
@@ -35,13 +35,18 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const config = yield* TuiConfig.load()
|
||||
const config = yield* Config.Service
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
yield* run({
|
||||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
config: {
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { ConfigMigration } from "./migrate"
|
||||
import { Info } from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
readonly path: string
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const empty: Info = {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const lock = yield* Semaphore.make(1)
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
|
||||
const write = Effect.fnUntraced(function* (text: string) {
|
||||
const temp = file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||
yield* fs.rename(temp, file)
|
||||
})
|
||||
|
||||
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
)
|
||||
|
||||
const get = Effect.fn("cli.config.get")(function* () {
|
||||
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
})
|
||||
|
||||
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
lock
|
||||
.withPermits(1)(
|
||||
Effect.gen(function* () {
|
||||
yield* migrate
|
||||
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const errors: ParseError[] = []
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
}),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||
|
||||
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as Config from "./config"
|
||||
@@ -0,0 +1,142 @@
|
||||
export * as ConfigMigration from "./migrate"
|
||||
|
||||
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import type { Info } from "./schema"
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
|
||||
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly file: string
|
||||
readonly config: string
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
const temp = input.file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, input.file)
|
||||
yield* Effect.logInfo("migrated cli config", {
|
||||
from: [
|
||||
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||
].filter(Boolean),
|
||||
to: input.file,
|
||||
})
|
||||
})
|
||||
|
||||
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||
const plugins = [
|
||||
...(legacy?.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
) ?? []),
|
||||
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
||||
]
|
||||
const themeName = legacy?.theme ?? kv.theme
|
||||
const themeMode = kv.theme_mode_lock
|
||||
const attentionSoundPack = kv.attention_sound_pack
|
||||
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||
const thinking =
|
||||
kv.thinking_mode ??
|
||||
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||
|
||||
return {
|
||||
...(themeName !== undefined || themeMode !== undefined
|
||||
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
|
||||
: {}),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
scroll: {
|
||||
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
||||
...(legacy.scroll_acceleration?.enabled === undefined
|
||||
? {}
|
||||
: { acceleration: legacy.scroll_acceleration.enabled }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
||||
? {}
|
||||
: {
|
||||
attention: {
|
||||
...legacy?.attention,
|
||||
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
||||
},
|
||||
}),
|
||||
...(legacy?.diff_style === undefined &&
|
||||
kv.diff_wrap_mode === undefined &&
|
||||
kv.diff_viewer_show_file_tree === undefined &&
|
||||
kv.diff_viewer_single_patch === undefined &&
|
||||
diffView === undefined
|
||||
? {}
|
||||
: {
|
||||
diffs: {
|
||||
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
||||
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
||||
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
||||
...(diffView === undefined ? {} : { view: diffView }),
|
||||
},
|
||||
}),
|
||||
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
||||
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: {
|
||||
prompt: {
|
||||
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
||||
...(kv.paste_summary_enabled === undefined
|
||||
? {}
|
||||
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.sidebar === undefined &&
|
||||
kv.scrollbar_visible === undefined &&
|
||||
thinking === undefined &&
|
||||
kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: {
|
||||
session: {
|
||||
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
||||
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
||||
...(thinking === undefined ? {} : { thinking }),
|
||||
...(kv.exploration_grouping === undefined
|
||||
? {}
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: { onboarding: !kv.dismissed_getting_started }),
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
}
|
||||
|
||||
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (text === undefined) return undefined
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return undefined
|
||||
return Option.getOrUndefined(decodeRecord(value))
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Config } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Info = Schema.Struct({ ...Config.Info.fields })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Config } from "../config"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -13,18 +14,26 @@ export type Input<Value> =
|
||||
|
||||
type RuntimeHandler = (
|
||||
input: unknown,
|
||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (
|
||||
input: Input<Node>,
|
||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
) => Effect.Effect<
|
||||
void,
|
||||
any,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
|
||||
>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Config } from "./config"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
|
||||
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
this.themes.splice(index, 1)
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
|
||||
void resolveRunTheme(this.renderer).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
export function entrySyntax(theme: RunTheme): SyntaxStyle {
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
active.renderable.fg = entryColor(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(theme)
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
|
||||
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const syntax = createMemo(() => entrySyntax(theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
||||
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
||||
|
||||
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
)
|
||||
}
|
||||
|
||||
function blend(color: RGBA, bg: RGBA): RGBA {
|
||||
if (color.a >= 1) {
|
||||
return color
|
||||
}
|
||||
|
||||
return RGBA.fromValues(
|
||||
bg.r + (color.r - bg.r) * color.a,
|
||||
bg.g + (color.g - bg.g) * color.a,
|
||||
bg.b + (color.b - bg.b) * color.a,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
|
||||
if (!style) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return SyntaxStyle.fromStyles(
|
||||
Object.fromEntries(
|
||||
[...style.getAllStyles()].map(([name, value]) => [
|
||||
name,
|
||||
{
|
||||
...value,
|
||||
fg: value.fg ? blend(value.fg, bg) : value.fg,
|
||||
bg: value.bg ? blend(value.bg, bg) : value.bg,
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
@@ -502,10 +469,7 @@ function map(
|
||||
scrollbackTheme: TuiThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
syntax?: SyntaxStyle,
|
||||
subtleSyntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
|
||||
subtleSyntax?.destroy()
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
@@ -566,7 +530,6 @@ function map(
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: scrollbackTheme.diffAdded,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed),
|
||||
syntax,
|
||||
shared.generateSubtleSyntax(syntaxTheme),
|
||||
)
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export * as TuiConfig from "./tui-config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
|
||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const filepath = path.join(global.config, "tui.json")
|
||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
return TuiConfig.resolve(
|
||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
||||
{ terminalSuspend: process.platform !== "win32" },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||
return Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
scroll_speed: 2,
|
||||
scroll_acceleration: { enabled: true },
|
||||
diff_style: "stacked",
|
||||
mouse: false,
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(directory, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
attention_sound_pack: "custom.pack",
|
||||
diff_wrap_mode: "none",
|
||||
diff_viewer_show_file_tree: false,
|
||||
diff_viewer_single_patch: true,
|
||||
diff_viewer_view: "split",
|
||||
terminal_title_enabled: false,
|
||||
file_context_enabled: false,
|
||||
paste_summary_enabled: false,
|
||||
sidebar: "hide",
|
||||
scrollbar_visible: true,
|
||||
thinking_mode: "show",
|
||||
exploration_grouping: false,
|
||||
tips_hidden: true,
|
||||
dismissed_getting_started: true,
|
||||
animations_enabled: false,
|
||||
skipped_version: "9.9.9",
|
||||
which_key_layout: "overlay",
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { tips: false, onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
draft.mouse = false
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||
)
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../src/tui-config"
|
||||
|
||||
test("loads the global tui config", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
TuiConfig.load().pipe(
|
||||
Effect.provide(Global.layerWith({ config: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -59,11 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
yield* Effect.sync(() =>
|
||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
||||
)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
const existing = yield* find(options)
|
||||
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
|
||||
return existing.endpoint
|
||||
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
|
||||
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
@@ -138,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||
@@ -161,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (version !== undefined && health.value.version !== version) return undefined
|
||||
return { info, endpoint } satisfies LocalService
|
||||
return { info, endpoint, version: health.value.version } satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { rename, writeFile } from "node:fs/promises"
|
||||
|
||||
const [registration, mode] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
|
||||
let requests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
if (mode === "legacy") return Response.json({ healthy: true })
|
||||
return Response.json({ healthy: true, version: "test", pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
version: mode === "legacy" ? undefined : "test",
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
const shutdown = () => {
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service } from "../src/effect/index"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
spawn(registration, "modern")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const first = run(
|
||||
Service.start({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".first-request")
|
||||
|
||||
const resolved = await run(Service.start({ file: registration, version: "test" }))
|
||||
expect(resolved.url).toBe(original.url)
|
||||
|
||||
await writeFile(registration + ".release", "")
|
||||
await first
|
||||
|
||||
expect(starts).toEqual([])
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
await waitForFile(registration)
|
||||
|
||||
const starts: Service.StartReason[] = []
|
||||
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
})
|
||||
|
||||
function run<A, E>(effect: Effect.Effect<A, E, never>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
}
|
||||
|
||||
function spawn(registration: string, mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@ No limit has a default, on purpose: execution budgets are host policy. A host wi
|
||||
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
||||
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
||||
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
|
||||
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
|
||||
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
|
||||
|
||||
## Boundaries and Non-Goals
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# CodeMode Design and Status
|
||||
|
||||
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
|
||||
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
|
||||
|
||||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
||||
completed work instead of preserving checked-off chronology.
|
||||
|
||||
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
|
||||
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
|
||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
||||
## How CodeMode Works
|
||||
|
||||
### Purpose
|
||||
|
||||
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
|
||||
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
|
||||
and filter or aggregate results before returning them to the agent loop.
|
||||
|
||||
The goals are:
|
||||
|
||||
- Reduce model context consumed by large tool catalogs.
|
||||
- Avoid an agent round-trip between every dependent tool call.
|
||||
- Keep large intermediate results inside the program instead of sending them through model context.
|
||||
- Give generated code only the authority explicitly supplied by the host.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
|
||||
|
||||
### Runtime
|
||||
|
||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||
|
||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
|
||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||
executes it without `eval`.
|
||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
|
||||
remains Effect interruption.
|
||||
|
||||
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
|
||||
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
|
||||
advertised as `Promise<unknown>`.
|
||||
|
||||
### Discovery and model workflow
|
||||
|
||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
|
||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
||||
partial.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
||||
next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
5. Filter and aggregate inside the program, then return only the data needed by the model.
|
||||
|
||||
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
|
||||
path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
|
||||
### Tool execution
|
||||
|
||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
|
||||
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
|
||||
of work they started.
|
||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
||||
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
|
||||
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
|
||||
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
|
||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
|
||||
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
|
||||
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
|
||||
discarded. At most eight tool calls execute concurrently.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
||||
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
|
||||
host-added framing are intentionally outside the budgets.
|
||||
|
||||
### Data, files, and failures
|
||||
|
||||
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
|
||||
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
|
||||
boundary.
|
||||
|
||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
||||
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
||||
data, tool failures, limits, timeouts, execution failures, and warning truncation.
|
||||
|
||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
||||
attach them to the outer result, but the program receives only the structured tool output.
|
||||
|
||||
### V2 OpenCode adapter
|
||||
|
||||
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
||||
`packages/core/src/tool/execute.ts`:
|
||||
|
||||
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
|
||||
`Tools.Service`.
|
||||
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
|
||||
normally.
|
||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
||||
CodeMode namespaces instead of flattened model-facing names.
|
||||
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
|
||||
requests.
|
||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
||||
authorization.
|
||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
||||
- Nested call statuses are returned as final `execute` metadata for the TUI.
|
||||
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
|
||||
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
|
||||
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
|
||||
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
|
||||
supervised children; the outer settlement applies Core's normal output-retention policy.
|
||||
|
||||
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
|
||||
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
||||
inside CodeMode.
|
||||
|
||||
## Intentionally Unsupported
|
||||
|
||||
These are product boundaries rather than DSL backlog:
|
||||
|
||||
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
|
||||
supplied tools.
|
||||
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
|
||||
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
|
||||
side effects. Hosts and tools own those concerns.
|
||||
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
|
||||
|
||||
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
|
||||
represent accurately rather than guessing semantics.
|
||||
|
||||
## Decisions and Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
|
||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
||||
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
|
||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
||||
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
|
||||
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
||||
|
||||
## Remaining Work
|
||||
|
||||
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
|
||||
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
@@ -24,7 +24,7 @@ ultimate source of truth.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
## Values and literals
|
||||
@@ -171,14 +171,13 @@ ultimate source of truth.
|
||||
- [ ] `Array.prototype.toSpliced`.
|
||||
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
|
||||
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
|
||||
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
@@ -291,7 +290,6 @@ ultimate source of truth.
|
||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||
|
||||
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
||||
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||
`null` in render-only or OpenAPI tool calls.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { executeWithLimits } from "./interpreter/runtime.js"
|
||||
import { executeWithLimits } from "./interpreter/execute.js"
|
||||
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
||||
import type { Definition } from "./tool.js"
|
||||
|
||||
@@ -9,15 +9,15 @@ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescr
|
||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||
export type ExecutionLimits = {
|
||||
/**
|
||||
* Wall-clock milliseconds before execution is interrupted; result delivery additionally
|
||||
* waits for tool interruption cleanup. No default: absent means no timeout.
|
||||
* Wall-clock milliseconds before interruption. Result delivery waits for tool cleanup.
|
||||
* No default: absent means no timeout.
|
||||
*/
|
||||
readonly timeoutMs?: number
|
||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||
readonly maxToolCalls?: number
|
||||
/**
|
||||
* Maximum UTF-8 bytes retained from the result value and logs; warnings have a separate
|
||||
* budget of the same size. Fixed truncation notices and host formatting are additional.
|
||||
* Maximum UTF-8 bytes retained from the result and logs. Warnings have a separate equal budget;
|
||||
* truncation notices and host formatting are additional.
|
||||
*/
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
@@ -94,7 +94,6 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
||||
export const Success = Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Json,
|
||||
// Runtime-authored non-fatal diagnostics; program console output stays in `logs`.
|
||||
warnings: Schema.optionalKey(Schema.Array(Diagnostic)),
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import { ToolError } from "../tool-error.js"
|
||||
import { copyOut, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
|
||||
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { coerceToString, createAggregateErrorValue, createErrorValue, errorConstructors } from "../stdlib/value.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof InterpreterRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolError) {
|
||||
return { kind: "ToolFailure", message: error.message }
|
||||
}
|
||||
|
||||
if (error instanceof ProgramThrow) {
|
||||
const value = error.value
|
||||
let message: string
|
||||
if (containsRuntimeReference(value)) {
|
||||
// Never expose runtime reference internals through thrown values.
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { message?: unknown }).message === "string"
|
||||
) {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
}
|
||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
||||
}
|
||||
|
||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: "Execution exceeded the maximum nesting depth.",
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: String(error),
|
||||
}
|
||||
}
|
||||
|
||||
export const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
|
||||
return createErrorValue(name, normalizeError(thrown).message)
|
||||
}
|
||||
|
||||
export const constructErrorValue = (name: string, args: Array<unknown>, node: AstNode): SafeObject => {
|
||||
if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
const errors = spreadItems(args[0])
|
||||
if (errors === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
// Error values must not alias caller-owned arrays.
|
||||
return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1]))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { copyIn, copyOut, ToolRuntime, type HostTools, type Services } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js"
|
||||
import { PromiseRuntime } from "./promises.js"
|
||||
import { Interpreter } from "./runtime.js"
|
||||
|
||||
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
toolCalls: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Allocate execution state inside suspension so reused Effects never share it.
|
||||
return Effect.suspend(() => {
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
{
|
||||
onToolCallStart: options.onToolCallStart,
|
||||
onToolCallEnd: options.onToolCallEnd,
|
||||
},
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
// Set only after copy-out so timeouts cannot report invalid values as completed.
|
||||
let returned: { value: DataValue; promises: PromiseRuntime<Services<Tools>> } | undefined
|
||||
|
||||
const base = Effect.acquireUseRelease(
|
||||
Scope.make("parallel"),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
returned = { value: result, promises }
|
||||
const warnings = yield* promises.interrupt()
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}),
|
||||
(scope, exit) => Scope.close(scope, exit),
|
||||
)
|
||||
const timeoutMs = limits.timeoutMs
|
||||
const operation =
|
||||
timeoutMs === undefined
|
||||
? base
|
||||
: base.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () =>
|
||||
Effect.sync(() => {
|
||||
if (returned === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}
|
||||
// Keep the timeout warning first so truncation preserves it.
|
||||
return {
|
||||
ok: true,
|
||||
value: returned.value,
|
||||
warnings: [
|
||||
{
|
||||
kind: "TimeoutExceeded",
|
||||
message: `The program returned, but background work was still running at the ${timeoutMs}ms timeout and was interrupted. Await all started promises.`,
|
||||
},
|
||||
...returned.promises.diagnostics(),
|
||||
],
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
return operation.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.interrupt
|
||||
: Effect.succeed({
|
||||
ok: false,
|
||||
error: normalizeError(Cause.squash(cause)),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result),
|
||||
),
|
||||
Effect.map((result) =>
|
||||
limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
const parsed = parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
}) as unknown
|
||||
|
||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
||||
}
|
||||
|
||||
return parsed as ProgramNode
|
||||
}
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
||||
|
||||
// Drop a replacement character produced by truncating inside a UTF-8 sequence.
|
||||
const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
if (bytes.byteLength <= maxBytes) return value
|
||||
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
|
||||
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
|
||||
}
|
||||
|
||||
// Warnings have a separate budget so result data cannot starve diagnostics.
|
||||
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
let truncated = false
|
||||
|
||||
let value: DataValue = null
|
||||
let valueBytes = 0
|
||||
if (result.ok) {
|
||||
const serialized = JSON.stringify(result.value) ?? "null"
|
||||
const bytes = utf8ByteLength(serialized)
|
||||
if (bytes > maxOutputBytes) {
|
||||
truncated = true
|
||||
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
|
||||
valueBytes = maxOutputBytes
|
||||
} else {
|
||||
value = result.value
|
||||
valueBytes = bytes
|
||||
}
|
||||
}
|
||||
|
||||
const warnings = result.ok ? (result.warnings ?? []) : []
|
||||
const keptWarnings: Array<Diagnostic> = []
|
||||
let warningBytes = 0
|
||||
for (const warning of warnings) {
|
||||
const bytes = utf8ByteLength(JSON.stringify(warning)) + 1
|
||||
if (warningBytes + bytes > maxOutputBytes) break
|
||||
warningBytes += bytes
|
||||
keptWarnings.push(warning)
|
||||
}
|
||||
if (keptWarnings.length < warnings.length) {
|
||||
truncated = true
|
||||
keptWarnings.push({
|
||||
kind: "Truncated",
|
||||
message: `${warnings.length - keptWarnings.length} additional warnings omitted by the output limit.`,
|
||||
})
|
||||
}
|
||||
|
||||
const logs = result.logs ?? []
|
||||
const kept: Array<string> = []
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
let logBytes = 0
|
||||
for (const line of logs) {
|
||||
const lineBytes = utf8ByteLength(line) + 1
|
||||
if (logBytes + lineBytes > logBudget) break
|
||||
logBytes += lineBytes
|
||||
kept.push(line)
|
||||
}
|
||||
if (kept.length < logs.length) {
|
||||
truncated = true
|
||||
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
|
||||
}
|
||||
|
||||
if (!truncated) return result
|
||||
const warningsPart = keptWarnings.length > 0 ? { warnings: keptWarnings } : {}
|
||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||
return result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value,
|
||||
...warningsPart,
|
||||
...logsPart,
|
||||
truncated: true,
|
||||
toolCalls: result.toolCalls,
|
||||
}
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
GlobalMethodReference,
|
||||
IntrinsicReference,
|
||||
InterpreterRuntimeError,
|
||||
PromiseCapabilityFunction,
|
||||
supportedSyntaxMessage,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { rejectCircularInsertion } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
import { invokeMathMethod } from "../stdlib/math.js"
|
||||
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
|
||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
||||
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
|
||||
import { invokeStringStatic } from "../stdlib/string.js"
|
||||
import { invokeUriFunction, invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
|
||||
import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../stdlib/value.js"
|
||||
|
||||
export type CallbackRunner<R> = {
|
||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
export const invokeIntrinsic = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
ref: IntrinsicReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (typeof ref.receiver === "string") {
|
||||
if (
|
||||
(ref.name === "replace" || ref.name === "replaceAll") &&
|
||||
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
|
||||
) {
|
||||
return invokeStringReplacer(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (typeof ref.receiver === "number") {
|
||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
|
||||
export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console")
|
||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
|
||||
if (
|
||||
ref.namespace === "RegExp" ||
|
||||
ref.namespace === "Map" ||
|
||||
ref.namespace === "Set" ||
|
||||
ref.namespace === "URLSearchParams"
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||
}
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase":
|
||||
result = value.toLowerCase()
|
||||
break
|
||||
case "toUpperCase":
|
||||
result = value.toUpperCase()
|
||||
break
|
||||
case "trim":
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
case "localeCompare":
|
||||
result = value.localeCompare(str(0))
|
||||
break
|
||||
case "normalize": {
|
||||
const form = optStr(0)
|
||||
try {
|
||||
result = value.normalize(form)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
node,
|
||||
).as("RangeError")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
const requestedLimit = optNum(1)
|
||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
break
|
||||
}
|
||||
case "slice":
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
result = value.indexOf(str(0), optNum(1))
|
||||
break
|
||||
case "lastIndexOf":
|
||||
result = value.lastIndexOf(str(0), optNum(1))
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
break
|
||||
}
|
||||
if (name === "replace") {
|
||||
result = value.replace(str(0), str(1))
|
||||
break
|
||||
}
|
||||
result = value.replaceAll(str(0), str(1))
|
||||
break
|
||||
}
|
||||
case "match": {
|
||||
const pattern = toHostRegex(args[0], name, node)
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (pattern.global) return boundedData(matched, "String.match result")
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "matchAll": {
|
||||
const pattern = toHostRegex(args[0], name, node, "g")
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
return Array.from(value.matchAll(pattern), matchToValue)
|
||||
}
|
||||
case "search": {
|
||||
result = value.search(toHostRegex(args[0], name, node))
|
||||
break
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart":
|
||||
result = value.padStart(num(0), optStr(1))
|
||||
break
|
||||
case "padEnd":
|
||||
result = value.padEnd(num(0), optStr(1))
|
||||
break
|
||||
case "charAt":
|
||||
result = value.charAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "at":
|
||||
result = value.at(optNum(0) ?? 0)
|
||||
break
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "codePointAt":
|
||||
result = value.codePointAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "toString":
|
||||
result = value
|
||||
break
|
||||
case "concat": {
|
||||
result = value.concat(...args.map((_, index) => str(index)))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
return Array.isArray(args[0])
|
||||
case "of":
|
||||
return [...args]
|
||||
case "from": {
|
||||
if (args.length > 1) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
const source = args[0]
|
||||
if (source instanceof SandboxPromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === "object" &&
|
||||
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
|
||||
typeof (source as { length?: unknown }).length === "number"
|
||||
) {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from expects an array, string, Map, Set, or array-like value.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeStringReplacer = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
value: string,
|
||||
name: "replace" | "replaceAll",
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
|
||||
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
|
||||
const collect = (...callbackArgs: Array<unknown>): string => {
|
||||
const match = callbackArgs[0]
|
||||
const groups = callbackArgs[callbackArgs.length - 1]
|
||||
const hasGroups = groups !== null && typeof groups === "object"
|
||||
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
|
||||
if (typeof match !== "string" || typeof offset !== "number") {
|
||||
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
|
||||
}
|
||||
if (hasGroups) {
|
||||
const safeGroups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
if (!isBlockedMember(key)) safeGroups[key] = group
|
||||
}
|
||||
callbackArgs[callbackArgs.length - 1] = safeGroups
|
||||
}
|
||||
matches.push({ match, offset, args: callbackArgs })
|
||||
return match
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof SandboxRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
if (typeof pattern !== "string") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern, collect)
|
||||
else value.replaceAll(pattern, collect)
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const output: Array<string> = []
|
||||
let end = 0
|
||||
for (const match of matches) {
|
||||
const replacement = yield* apply(match.args)
|
||||
const resolved =
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
||||
? yield* runner.settlePromise(replacement)
|
||||
: replacement
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
coerceToString(boundedData(resolved, `String.${name} replacer result`)),
|
||||
)
|
||||
end = match.offset + match.match.length
|
||||
}
|
||||
output.push(value.slice(end))
|
||||
return boundedData(output.join(""), `String.${name} result`)
|
||||
})
|
||||
}
|
||||
|
||||
export const applyCollectionCallback = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction) &&
|
||||
!(callback instanceof PromiseCapabilityFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: callback instanceof PromiseCapabilityFunction
|
||||
? Effect.sync(() => callback.settle(callbackArgs[0]))
|
||||
: runner.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
switch (name) {
|
||||
case "get":
|
||||
return Effect.succeed(target.map.get(args[0]))
|
||||
case "has":
|
||||
return Effect.succeed(target.map.has(args[0]))
|
||||
case "set":
|
||||
return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
switch (name) {
|
||||
case "has":
|
||||
return Effect.succeed(target.set.has(args[0]))
|
||||
case "add":
|
||||
return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
|
||||
const requireArgs = (count: number): void => {
|
||||
if (args.length < count) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
switch (name) {
|
||||
case "append": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.append(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "delete": {
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => {
|
||||
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
|
||||
else target.params.delete(arg(0))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "get":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.get(arg(0)))
|
||||
case "getAll":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.getAll(arg(0)))
|
||||
case "has":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => (args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0))))
|
||||
case "set": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.set(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "sort":
|
||||
return Effect.sync(() => {
|
||||
target.params.sort()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.params.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.params.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
|
||||
case "toString":
|
||||
return Effect.sync(() => target.params.toString())
|
||||
case "forEach": {
|
||||
requireArgs(1)
|
||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeArrayMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Array<unknown>,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number")
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
case "join": {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2)
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(
|
||||
args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
||||
)
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
|
||||
case "concat":
|
||||
return Effect.succeed(target.concat(...args))
|
||||
case "flat":
|
||||
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
|
||||
case "reverse":
|
||||
return Effect.succeed(target.reverse())
|
||||
case "sort":
|
||||
return Effect.map(sortArray(runner, target, args[0], node), (sorted) => {
|
||||
target.splice(0, target.length, ...sorted)
|
||||
return target
|
||||
})
|
||||
case "toSorted":
|
||||
return sortArray(runner, target, args[0], node)
|
||||
case "toReversed":
|
||||
return Effect.succeed([...target].reverse())
|
||||
case "with": {
|
||||
const index = optNumber(args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) {
|
||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
||||
}
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return Effect.succeed(copied)
|
||||
}
|
||||
case "push": {
|
||||
// Validate all insertions before mutating to avoid partial cyclic updates.
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
target.push(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "unshift": {
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
target.unshift(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "pop":
|
||||
return Effect.succeed(target.pop())
|
||||
case "shift":
|
||||
return Effect.succeed(target.shift())
|
||||
case "splice": {
|
||||
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
|
||||
const start = optNumber(args[0], "start") ?? 0
|
||||
if (args.length === 1) return Effect.succeed(target.splice(start))
|
||||
const deleteCount = optNumber(args[1], "delete count") ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
|
||||
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
|
||||
}
|
||||
case "fill": {
|
||||
rejectCircularInsertion(target, args[0], "Array.fill result", node)
|
||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
||||
}
|
||||
case "copyWithin":
|
||||
return Effect.succeed(
|
||||
target.copyWithin(
|
||||
optNumber(args[0], "target index") ?? 0,
|
||||
optNumber(args[1], "start") ?? 0,
|
||||
optNumber(args[2], "end"),
|
||||
),
|
||||
)
|
||||
case "keys":
|
||||
return Effect.succeed(Array.from(target.keys()))
|
||||
case "values":
|
||||
return Effect.succeed([...target])
|
||||
case "entries":
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
|
||||
return Effect.gen(function* () {
|
||||
// Fix iteration length while reading existing elements live.
|
||||
const length = target.length
|
||||
switch (name) {
|
||||
case "map": {
|
||||
const values: Array<unknown> = []
|
||||
values.length = length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
values[index] = yield* apply([target[index], index, target])
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "flatMap": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const mapped = yield* apply([target[index], index, target])
|
||||
if (Array.isArray(mapped)) values.push(...mapped)
|
||||
else values.push(mapped)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "filter": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) values.push(item)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "find":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findIndex":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
case "some":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (yield* apply([target[index], index, target])) return true
|
||||
}
|
||||
return false
|
||||
case "every":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (!(yield* apply([target[index], index, target]))) return false
|
||||
}
|
||||
return true
|
||||
case "forEach":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (index in target) yield* apply([target[index], index, target])
|
||||
}
|
||||
return undefined
|
||||
case "reduce": {
|
||||
let start = 0
|
||||
let accumulator = args[1]
|
||||
if (args.length < 2) {
|
||||
while (start < length && !(start in target)) start += 1
|
||||
if (start === length)
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node).as(
|
||||
"TypeError",
|
||||
)
|
||||
accumulator = target[start]
|
||||
start += 1
|
||||
}
|
||||
for (let index = start; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "reduceRight": {
|
||||
let start = length - 1
|
||||
let accumulator = args[1]
|
||||
if (args.length < 2) {
|
||||
while (start >= 0 && !(start in target)) start -= 1
|
||||
if (start < 0)
|
||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node).as(
|
||||
"TypeError",
|
||||
)
|
||||
accumulator = target[start]
|
||||
start -= 1
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
const sortArray = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
if (!(comparator instanceof CodeModeFunction)) {
|
||||
return Effect.sync(() =>
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
if (items.length <= 1) return Effect.succeed(items)
|
||||
const midpoint = Math.floor(items.length / 2)
|
||||
return Effect.gen(function* () {
|
||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
||||
const right = yield* mergeSort(items.slice(midpoint))
|
||||
const merged: Array<unknown> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
// Treat a NaN comparator result as equal to preserve stable ordering.
|
||||
const order = coerceToNumber(yield* runner.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
||||
else merged.push(right[rightIndex++])
|
||||
}
|
||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
||||
})
|
||||
}
|
||||
const defined = target.filter((item) => item !== undefined)
|
||||
const undefinedCount = target.length - defined.length
|
||||
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
|
||||
}
|
||||
@@ -76,8 +76,6 @@ export class PromiseInstanceMethodReference {
|
||||
) {}
|
||||
}
|
||||
|
||||
// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes
|
||||
// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS.
|
||||
export class PromiseCapabilityFunction {
|
||||
constructor(readonly settle: (value: unknown) => void) {}
|
||||
}
|
||||
@@ -114,8 +112,6 @@ export class UriFunction {
|
||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||
}
|
||||
|
||||
// The global `search` built-in: synchronous tool discovery that shares the tool admission
|
||||
// pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
export class SearchFunction {}
|
||||
|
||||
export class ProgramThrow {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
InterpreterRuntimeError,
|
||||
ProgramThrow,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
PromiseMethodReference,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { caughtErrorValue, normalizeError } from "./errors.js"
|
||||
import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { SandboxPromise } from "../values.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<SandboxPromise>()
|
||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||
private readonly observed = new WeakSet<SandboxPromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
this.active.delete(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
|
||||
this.ids.delete(promise)
|
||||
return
|
||||
}
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
this.failures.set(id, {
|
||||
...failure,
|
||||
message: `Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||
})
|
||||
})
|
||||
return promise
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: SandboxPromise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
diagnostics(): Array<Diagnostic> {
|
||||
return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||
}
|
||||
|
||||
// Re-check because a straggler can create promises before its interruption lands.
|
||||
interrupt(): Effect.Effect<Array<Diagnostic>> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (self.active.size > 0) {
|
||||
yield* Fiber.interruptAll([...self.active].map((promise) => promise.fiber))
|
||||
}
|
||||
return self.diagnostics()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
|
||||
|
||||
export const invokePromiseMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
ref: PromiseMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (ref.name === "resolve") {
|
||||
const value = args[0]
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||
}
|
||||
|
||||
const spread = spreadItems(args[0])
|
||||
if (spread === undefined) {
|
||||
return promises.create(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
).as("TypeError"),
|
||||
),
|
||||
)
|
||||
}
|
||||
const items = Array.from(spread)
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof SandboxPromise) promises.markObserved(item)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(
|
||||
settleAfterTurn(
|
||||
Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const exit = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) {
|
||||
// Teardown interruption is not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(Cause.squash(exit.cause)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
return promises.create(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
||||
}
|
||||
case "any": {
|
||||
const flipped = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.flatMap(promises.await(item), (exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
||||
return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause)))
|
||||
})
|
||||
: Effect.fail(new PromiseAnyFulfilled(item)),
|
||||
)
|
||||
const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe(
|
||||
Effect.flatMap((reasons) =>
|
||||
Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
return promises.create(settleAfterTurn(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const invokePromiseInstanceMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
return chainFinally(runner, promises, ref.promise, reactionHandler(args[0], method, node), method, node)
|
||||
}
|
||||
const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined
|
||||
const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node)
|
||||
return chainReaction(runner, promises, ref.promise, onFulfilled, onRejected, method, node)
|
||||
}
|
||||
|
||||
export const constructPromise = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
||||
return runner.settlePromise(value)
|
||||
}),
|
||||
)
|
||||
box.own = promise
|
||||
const resolve = new PromiseCapabilityFunction((value) => {
|
||||
Deferred.doneUnsafe(deferred, Exit.succeed(value))
|
||||
})
|
||||
const reject = new PromiseCapabilityFunction((value) => {
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value)))
|
||||
})
|
||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
||||
if (!Exit.isSuccess(executed)) {
|
||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
}
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
// Settle one reaction turn after the deciding member, after its existing reactions.
|
||||
const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
|
||||
|
||||
class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction
|
||||
|
||||
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof PromiseCapabilityFunction
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeofValue(value) === "function") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
yield* Effect.yieldNow
|
||||
return exit
|
||||
})
|
||||
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
onFulfilled: ReactionHandler | undefined,
|
||||
onRejected: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const box: { derived?: SandboxPromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
||||
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
|
||||
return result
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
box.derived = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
cleanup: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
if (cleanup !== undefined) {
|
||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
||||
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
|
||||
}
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
PromiseMethodReference,
|
||||
PromiseNamespace,
|
||||
SearchFunction,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxPromise } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
|
||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsRuntimeReference(item, seen))
|
||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Sandbox values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsOpaqueReference(item, seen))
|
||||
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
if (value === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
for (const item of items) rejectCircularInsertion(container, item, label, node, seen)
|
||||
seen.delete(value)
|
||||
}
|
||||
|
||||
export const typeofValue = (value: unknown): string => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
}
|
||||
return typeof value
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { type AstNode, type Binding, InterpreterRuntimeError } from "./model.js"
|
||||
|
||||
export class ScopeStack {
|
||||
private readonly scopes: Array<Map<string, Binding>>
|
||||
|
||||
constructor(scopes: Array<Map<string, Binding>>) {
|
||||
this.scopes = scopes
|
||||
}
|
||||
|
||||
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.current()
|
||||
|
||||
const existing = scope.get(name)
|
||||
if (existing && existing.initialized !== false) {
|
||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
}
|
||||
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
get(name: string, node: AstNode): unknown {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (binding.initialized === false) {
|
||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
return binding.value
|
||||
}
|
||||
|
||||
set(name: string, value: unknown, node: AstNode): unknown {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (!binding.mutable) {
|
||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
binding.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
resolve(name: string): Binding | undefined {
|
||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
||||
const scope = this.scopes[index]
|
||||
const binding = scope?.get(name)
|
||||
|
||||
if (binding) {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
current(): Map<string, Binding> {
|
||||
const scope = this.scopes[this.scopes.length - 1]
|
||||
|
||||
if (!scope) {
|
||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
push(scope: Map<string, Binding> = new Map()): void {
|
||||
this.scopes.push(scope)
|
||||
}
|
||||
|
||||
pop(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
|
||||
capture(): Array<Map<string, Binding>> {
|
||||
return this.scopes.slice()
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,8 @@ export type {
|
||||
} from "./types.js"
|
||||
|
||||
/**
|
||||
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
||||
* operation. Auth is resolved host-side via `auth.resolve` and never
|
||||
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
||||
* operations land in `skipped`.
|
||||
* Builds one CodeMode tool per representable OpenAPI 3.x operation. Auth remains host-side,
|
||||
* tools require `HttpClient.HttpClient`, and unrepresentable operations land in `skipped`.
|
||||
*/
|
||||
export const fromSpec = (options: Options): Result => {
|
||||
const document = options.spec
|
||||
|
||||
@@ -56,7 +56,7 @@ const buildRequest = (
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
||||
// Validate model input before auth resolution can refresh credentials.
|
||||
const url = buildUrl(plan, input)
|
||||
if (url instanceof ToolError) return yield* Effect.fail(url)
|
||||
const missing = plan.fields.find(
|
||||
@@ -77,7 +77,6 @@ const buildRequest = (
|
||||
request = serialized
|
||||
}
|
||||
|
||||
// Host headers first, then declared header parameters.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "header") continue
|
||||
@@ -169,7 +168,7 @@ const applyCredentials = (
|
||||
continue
|
||||
}
|
||||
if (credential.type === "basic") {
|
||||
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
||||
// Basic auth credentials are UTF-8; btoa rejects non-Latin-1 input.
|
||||
const duplicate = add(
|
||||
"header",
|
||||
"authorization",
|
||||
@@ -183,7 +182,6 @@ const applyCredentials = (
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
// apiKey: the carrier comes from the scheme declaration.
|
||||
if (definition.type !== "apiKey") {
|
||||
return toolError(
|
||||
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
||||
@@ -212,8 +210,7 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
|
||||
),
|
||||
)
|
||||
if (fieldValue instanceof ToolError) return fieldValue
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
// URL normalization collapses encoded `.` and `..`, which could retarget the request.
|
||||
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
||||
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value
|
||||
export const nonEmptyString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" && value !== "" ? value : undefined
|
||||
|
||||
// Guards record lookups keyed by spec- or model-controlled names against
|
||||
// prototype-inherited values (e.g. a parameter named `toString`).
|
||||
// Spec- and model-controlled keys must not resolve inherited properties.
|
||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
@@ -106,7 +105,7 @@ const operationParameters = (
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<PlannedField>> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
// OpenAPI operation parameters override path parameters with the same location and name.
|
||||
const declared = new Map<
|
||||
string,
|
||||
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
||||
|
||||
@@ -22,9 +22,8 @@ export type SecurityScheme =
|
||||
| { readonly type: "openIdConnect" }
|
||||
|
||||
/**
|
||||
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
||||
* comes from the scheme definition, not the credential. `header` is the escape
|
||||
* hatch for nonstandard schemes.
|
||||
* Credential material returned by a host auth resolver. `apiKey` uses the scheme's carrier;
|
||||
* `header` supports nonstandard schemes.
|
||||
*/
|
||||
export type Credential =
|
||||
| { readonly type: "bearer"; readonly token: string }
|
||||
@@ -33,9 +32,7 @@ export type Credential =
|
||||
| { readonly type: "header"; readonly name: string; readonly value: string }
|
||||
|
||||
/**
|
||||
* Resolves credential material for one named security scheme at call time.
|
||||
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
||||
* the call rather than falling through.
|
||||
* Resolves credentials at call time. `undefined` tries the next OR alternative; failure aborts.
|
||||
*/
|
||||
export type AuthResolver = (context: {
|
||||
readonly name: string
|
||||
@@ -74,9 +71,7 @@ export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok
|
||||
export type InputLocation = "path" | "query" | "header" | "body"
|
||||
|
||||
export type InputField = {
|
||||
/** Model-visible field name after cross-location collision handling. */
|
||||
readonly inputName: string
|
||||
/** Original parameter or body-property name used on the wire. */
|
||||
readonly name: string
|
||||
readonly location: InputLocation
|
||||
readonly required: boolean
|
||||
@@ -92,7 +87,6 @@ export type OperationInput = {
|
||||
readonly body: Body | undefined
|
||||
}
|
||||
|
||||
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||
|
||||
export type Plan = {
|
||||
|
||||
@@ -1,4 +1,122 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
|
||||
/** Console formatting recursion ceiling; deeper values render as "...". */
|
||||
export const MAX_CONSOLE_DEPTH = 32
|
||||
const MAX_CONSOLE_DEPTH = 32
|
||||
|
||||
export const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
|
||||
if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0])
|
||||
if (name === "table") return formatConsoleTable(args[0], args[1])
|
||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
||||
return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}`
|
||||
}
|
||||
|
||||
const formatConsoleArgument = (value: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (value instanceof SandboxURL) return coerceToString(value)
|
||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
||||
}
|
||||
return `{${Object.entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (containsOpaqueReference(value)) return "[CodeMode reference]"
|
||||
const data = boundedData(value, "console.table argument")
|
||||
const columns = consoleTableColumns(columnsArgument)
|
||||
const rows = consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
const header = ["(index)", ...keys].join("\t")
|
||||
return [
|
||||
header,
|
||||
...rows.map((row) => [row.index, ...keys.map((key) => formatConsoleTableCell(row.values[key]))].join("\t")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
const consoleTableRows = (
|
||||
data: unknown,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
}
|
||||
return { Value: value }
|
||||
}
|
||||
|
||||
const formatConsoleTableCell = (value: unknown): string => {
|
||||
if (value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { PromiseMethodName } from "../interpreter/model.js"
|
||||
|
||||
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "any", "resolve", "reject"])
|
||||
|
||||
/** Maximum number of eagerly forked tool calls that may run concurrently. */
|
||||
export const TOOL_CALL_CONCURRENCY = 8
|
||||
|
||||
@@ -4,12 +4,9 @@ export const stringMethods = new Set([
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
|
||||
@@ -44,36 +44,30 @@ type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] e
|
||||
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
|
||||
: never
|
||||
|
||||
/** Minimal audit record retained for each admitted tool call. */
|
||||
export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Decoded tool call observed immediately before tool execution. */
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
}
|
||||
|
||||
/** Completed tool call observed immediately after tool execution settles. */
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure"
|
||||
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Non-throwing observation hooks fired around each admitted tool call. */
|
||||
export type ToolCallHooks<R = never> = {
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
}
|
||||
|
||||
/** Model-visible description of one schema-backed tool. */
|
||||
export type ToolDescription = {
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
@@ -113,11 +107,6 @@ export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
|
||||
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||
* RangeError would.
|
||||
*/
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
@@ -152,21 +141,7 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
/**
|
||||
* Validates and copies a value against the plain-data contract (depth, circularity, plain
|
||||
* objects only, blocked properties, data-only leaves).
|
||||
*
|
||||
* Two modes share the walk:
|
||||
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
|
||||
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
|
||||
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
|
||||
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
|
||||
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
|
||||
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
|
||||
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
|
||||
*
|
||||
* Both modes reject un-awaited promises with an await-hinting diagnostic.
|
||||
*/
|
||||
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
|
||||
@@ -185,10 +160,6 @@ const copyBounded = (
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
|
||||
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
|
||||
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
|
||||
// JSON.stringify already does at any tool boundary.
|
||||
typeof value === "number"
|
||||
) {
|
||||
return value
|
||||
@@ -198,8 +169,6 @@ const copyBounded = (
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
|
||||
// model exactly how to fix the program instead.
|
||||
if (value instanceof SandboxPromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
@@ -208,9 +177,6 @@ const copyBounded = (
|
||||
}
|
||||
|
||||
if (preserveSandboxValues) {
|
||||
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
|
||||
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||
// real boundary still serializes them below).
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
@@ -221,8 +187,6 @@ const copyBounded = (
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
|
||||
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
|
||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
@@ -241,9 +205,6 @@ const copyBounded = (
|
||||
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
// Sandbox value types (and their host counterparts, which a host tool may legitimately
|
||||
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
|
||||
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
|
||||
if (value instanceof SandboxDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
@@ -274,7 +235,7 @@ const copyBounded = (
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||
if (preserveSandboxValues) {
|
||||
// Array metadata is not serialized, but intra-sandbox copies must retain it.
|
||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
if (isBlockedMember(key)) {
|
||||
@@ -305,9 +266,6 @@ const copyBounded = (
|
||||
|
||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
if (value === undefined && undefinedAsNull) return null
|
||||
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
|
||||
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
|
||||
// have no JSON representation, so JSON.stringify would produce null anyway.
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
@@ -353,18 +311,10 @@ export type DiscoveryPlan = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
|
||||
readonly namespace: string
|
||||
/** Lowercased path + description + input property names/descriptions, for substring matching. */
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a query into lowercased search terms. camelCase boundaries are split
|
||||
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
|
||||
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
|
||||
* tokenize alike. Empties and the `*` wildcard are dropped.
|
||||
*/
|
||||
const tokenize = (query: string): Array<string> =>
|
||||
query
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
@@ -372,13 +322,6 @@ const tokenize = (query: string): Array<string> =>
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((term) => term.length > 0 && term !== "*")
|
||||
|
||||
/**
|
||||
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
|
||||
* query term ("issues") still matches indexed text that only carries the singular
|
||||
* ("issue"). Matching is one-directional substring containment, so the variants are
|
||||
* needed only on the query side; scoring weights are unchanged - each field check
|
||||
* passes when ANY form matches.
|
||||
*/
|
||||
const termForms = (term: string): Array<string> => {
|
||||
const forms = [term]
|
||||
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
|
||||
@@ -400,8 +343,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
request.namespace === undefined
|
||||
? searchIndex
|
||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||
// A query that names one tool path exactly (canonical path or rendered JavaScript
|
||||
// expression) is a lookup, not a search: return that tool alone.
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
@@ -411,9 +352,6 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
||||
)
|
||||
const terms = tokenize(query).map(termForms)
|
||||
// Additive field-weighted scoring, summed across terms: exact path or path segment
|
||||
// (20) > path substring (8) > description substring (4) > any searchable text,
|
||||
// including input parameter names and descriptions (2).
|
||||
const ranked =
|
||||
exact !== undefined
|
||||
? [exact]
|
||||
@@ -451,15 +389,12 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
}),
|
||||
})
|
||||
|
||||
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
|
||||
// advertised signature is rendered by hand instead of through `describeDefinition`.
|
||||
const searchSignature = (() => {
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
// Keep the tool description concise; the full schema documentation remains in the signature.
|
||||
const line = tool.description.split("\n", 1)[0]!.trim()
|
||||
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
@@ -479,21 +414,10 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
.toLowerCase(),
|
||||
})
|
||||
|
||||
/** The runtime search index over every described tool. Search is always registered. */
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
/**
|
||||
* Budgeted catalog: every namespace is always listed with its tool count; full call
|
||||
* signatures are inlined against the `catalogBudget` (estimated tokens,
|
||||
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
|
||||
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
|
||||
* a namespace whose next line does not fit is done while the others keep going - so every
|
||||
* namespace gets some representation before any namespace gets everything. The section
|
||||
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
|
||||
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
|
||||
* tool count even at budget 0.
|
||||
*/
|
||||
// Budget signatures round-robin so every namespace remains visible.
|
||||
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||
@@ -510,12 +434,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||
|
||||
// Select which signatures fit the budget before emitting, so the list can state
|
||||
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
|
||||
// alphabetical), every namespace still holding un-inlined tools tries to place its
|
||||
// next-cheapest line against the shared budget; a namespace whose next line does not
|
||||
// fit is done - the others keep going - so every namespace gets some representation
|
||||
// before any namespace gets everything.
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
@@ -547,10 +465,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
|
||||
const empty = described.length === 0
|
||||
|
||||
// Section order is deliberate: workflow first (the top is the least likely part of a long
|
||||
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
|
||||
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
|
||||
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
|
||||
const intro = [
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
@@ -562,8 +476,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
]
|
||||
|
||||
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
|
||||
// catalog already shows every signature, so step 1 picks from the list instead.
|
||||
const workflow = empty
|
||||
? []
|
||||
: [
|
||||
@@ -627,8 +539,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
for (const [namespace, group] of ordered) {
|
||||
const picked = shown.get(namespace)!
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
@@ -651,13 +561,6 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The enumerable names at one node of the callable tool tree - namespace names at the root,
|
||||
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
|
||||
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
|
||||
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
|
||||
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||
*/
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
for (const segment of path) {
|
||||
@@ -705,18 +608,12 @@ export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/**
|
||||
* The built-in `search` global: a synchronous discovery call that shares the tool
|
||||
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
*/
|
||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const make = <R>(
|
||||
tools: HostTools<R>,
|
||||
/** Undefined means unlimited tool calls. */
|
||||
maxToolCalls: number | undefined,
|
||||
searchIndex: ReadonlyArray<SearchEntry>,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
@@ -724,8 +621,7 @@ export const make = <R>(
|
||||
const calls: Array<ToolCall> = []
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
|
||||
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||
// End hooks observe settled success or failure; interruption emits neither outcome.
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
|
||||
@@ -5,13 +5,8 @@ const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> &
|
||||
|
||||
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
|
||||
|
||||
/**
|
||||
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
|
||||
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
|
||||
*/
|
||||
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
|
||||
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
|
||||
|
||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
@@ -27,16 +22,10 @@ const intersection = (members: ReadonlyArray<string>): string => {
|
||||
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
|
||||
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
|
||||
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
|
||||
*/
|
||||
const MAX_RENDER_DEPTH = 8
|
||||
|
||||
type RenderContext = {
|
||||
readonly definitions: Readonly<Record<string, JsonSchema>>
|
||||
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
|
||||
readonly pretty: boolean
|
||||
}
|
||||
|
||||
@@ -64,10 +53,6 @@ const hasUnresolvedRef = (
|
||||
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
|
||||
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||
*/
|
||||
const docTags = (schema: JsonSchema): Array<string> => {
|
||||
const tags: Array<string> = []
|
||||
if (schema.deprecated === true) tags.push("@deprecated")
|
||||
@@ -75,9 +60,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
try {
|
||||
const rendered = JSON.stringify(schema.default)
|
||||
if (rendered !== undefined) tags.push(`@default ${rendered}`)
|
||||
} catch {
|
||||
// unserializable default: skip rather than emit a broken tag
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
||||
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
||||
@@ -85,13 +68,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
|
||||
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
|
||||
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
|
||||
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
|
||||
* callers can prepend it directly to the field line.
|
||||
*/
|
||||
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
|
||||
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
||||
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||
@@ -128,17 +105,11 @@ const renderSchema = (
|
||||
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
|
||||
const alternatives = schema.anyOf ?? schema.oneOf
|
||||
if (alternatives) {
|
||||
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
|
||||
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
|
||||
// real JSON Schema unions such as `string | number` or `number | null` must keep
|
||||
// every branch.
|
||||
if (
|
||||
alternatives.some((item) => item.type === "number") &&
|
||||
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
||||
)
|
||||
return "number"
|
||||
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
|
||||
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
|
||||
if (
|
||||
alternatives.length === 2 &&
|
||||
alternatives[0]?.type === "object" &&
|
||||
@@ -183,7 +154,6 @@ const renderSchema = (
|
||||
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
|
||||
}
|
||||
|
||||
// Pretty: an indented block, each described field preceded by its JSDoc comment.
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map(
|
||||
@@ -208,7 +178,6 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a raw JSON Schema document as a TypeScript type string. */
|
||||
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
|
||||
try {
|
||||
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
|
||||
@@ -217,20 +186,12 @@ export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): stri
|
||||
}
|
||||
}
|
||||
|
||||
/** One input property of a tool, extracted best-effort from its input schema. */
|
||||
export type InputProperty = {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The property names, descriptions, and required flags of a tool's input schema - the raw
|
||||
* material for search text. Best-effort: Effect Schemas go through their
|
||||
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
|
||||
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
|
||||
* Anything unresolvable yields `[]` (search falls back to path + description).
|
||||
*/
|
||||
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(definition.input)
|
||||
@@ -262,20 +223,11 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
|
||||
* multiline block with schema descriptions and constraints as JSDoc comments on the
|
||||
* fields; the default stays the compact single-line form.
|
||||
*/
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's result; tools without an output schema
|
||||
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
|
||||
*/
|
||||
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
definition.output === undefined
|
||||
? "unknown"
|
||||
@@ -283,18 +235,9 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
|
||||
? toTypeScript(definition.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||
|
||||
/**
|
||||
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
|
||||
* JSON-Schema-described inputs pass through unvalidated (render-only).
|
||||
*/
|
||||
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||
|
||||
/**
|
||||
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
|
||||
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
|
||||
* the host value through unchanged.
|
||||
*/
|
||||
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
definition.output !== undefined && isEffectSchema(definition.output)
|
||||
? Schema.decodeUnknownSync(definition.output)(value)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
/**
|
||||
* JSON Schema subset accepted for render-only tool schemas.
|
||||
*
|
||||
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
|
||||
* signature only - CodeMode performs no validation against it. This is the natural shape for
|
||||
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
|
||||
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
|
||||
* these schemas.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
readonly type?: string | ReadonlyArray<string>
|
||||
@@ -41,10 +38,8 @@ export type Definition<R = never> = {
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||
}
|
||||
|
||||
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
|
||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||
|
||||
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
|
||||
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
|
||||
/** Options for defining one CodeMode tool. */
|
||||
@@ -61,29 +56,9 @@ export const isDefinition = <R = never>(value: unknown): value is Definition<R>
|
||||
/**
|
||||
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
||||
*
|
||||
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
|
||||
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
|
||||
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
|
||||
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
|
||||
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
|
||||
* host result is exposed as-is. The host tool remains responsible for authorization and
|
||||
* durable side-effect handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const lookup = Tool.make({
|
||||
* description: "Look up an order",
|
||||
* input: Schema.Struct({ id: Schema.String }),
|
||||
* output: Schema.Struct({ status: Schema.String }),
|
||||
* run: ({ id }) => Effect.succeed({ status: "open" }),
|
||||
* })
|
||||
*
|
||||
* const fromJsonSchema = Tool.make({
|
||||
* description: "Call an adapter-described tool",
|
||||
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
||||
* run: (input) => callHost(input),
|
||||
* })
|
||||
* ```
|
||||
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
||||
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
|
||||
* and durable side effects.
|
||||
*/
|
||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
|
||||
@@ -26,9 +26,11 @@
|
||||
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js
|
||||
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js
|
||||
* - test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js
|
||||
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js
|
||||
* - test/built-ins/Array/prototype/flatMap/depth-always-one.js
|
||||
@@ -210,6 +212,11 @@ const cases = [
|
||||
code: `let calls = 0; const result = [1].reduce(() => { calls += 1; return 2 }); return [result, calls]`,
|
||||
expected: [1, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js",
|
||||
code: `let accessed = false; const result = [11].reduce((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
|
||||
expected: [true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; input.reduce(() => 1); return input`,
|
||||
@@ -225,6 +232,11 @@ const cases = [
|
||||
code: `let calls = 0; const result = [1].reduceRight(() => { calls += 1; return 2 }); return [result, calls]`,
|
||||
expected: [1, 0],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js",
|
||||
code: `let accessed = false; const result = [11].reduceRight((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
|
||||
expected: [true, true],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js",
|
||||
code: `const input = [1, 2, 3, 4, 5]; input.reduceRight(() => 1); return input`,
|
||||
@@ -323,3 +335,46 @@ describe("Test262 Array callback adaptations", () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("Array callback regressions", () => {
|
||||
test("reduce and reduceRight find the first present element", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const left = []
|
||||
left[2] = 3
|
||||
const right = []
|
||||
right[0] = 4
|
||||
right[3] = 1
|
||||
right.pop()
|
||||
return [left.reduce((a, b) => a + b), right.reduceRight((a, b) => a + b)]
|
||||
`),
|
||||
).toEqual([3, 4])
|
||||
})
|
||||
|
||||
test("reduce and reduceRight reject arrays containing only holes", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const values = []
|
||||
values[2] = 1
|
||||
values.pop()
|
||||
let left
|
||||
let right
|
||||
try { values.reduce((a, b) => a + b) } catch (error) { left = error.name }
|
||||
try { values.reduceRight((a, b) => a + b) } catch (error) { right = error.name }
|
||||
return [left, right]
|
||||
`),
|
||||
).toEqual(["TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test("findLast returns the value observed before predicate mutation", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const values = [1]
|
||||
return values.findLast((item, index, array) => {
|
||||
array[index] = 2
|
||||
return true
|
||||
})
|
||||
`),
|
||||
).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,22 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not rewrite explicit safe tool failures", async () => {
|
||||
const result = await run(
|
||||
Tool.make({
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.ok ? undefined : result.error).toStrictEqual({
|
||||
kind: "ToolFailure",
|
||||
message: "File not found: /tmp/report.json",
|
||||
})
|
||||
})
|
||||
|
||||
test("sanitizes unknown host failures and defects", async () => {
|
||||
for (const failure of [
|
||||
Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })),
|
||||
|
||||
@@ -302,9 +302,12 @@ describe("CodeMode-specific string behavior", () => {
|
||||
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
||||
})
|
||||
|
||||
test("trimLeft/trimRight alias trimStart/trimEnd", async () => {
|
||||
expect(await value(`return " x ".trimLeft()`)).toBe("x ")
|
||||
expect(await value(`return " x ".trimRight()`)).toBe(" x")
|
||||
test("does not expose obsolete string aliases", async () => {
|
||||
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("caps live tool-call concurrency at the fixed internal constant (8)", async () => {
|
||||
test("does not cap live tool-call concurrency", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
@@ -572,8 +572,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
{ trace },
|
||||
)
|
||||
expect(result).toBe(20)
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
expect(trace.maxActive).toBeLessThanOrEqual(8)
|
||||
expect(trace.maxActive).toBe(20)
|
||||
})
|
||||
|
||||
test("resolves the empty array", async () => {
|
||||
|
||||
@@ -49,12 +49,6 @@
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T8.js
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T9.js
|
||||
* - test/built-ins/String/prototype/substring/S15.5.4.15_A2_T10.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_SuccessNoLocation.js
|
||||
* - test/built-ins/String/prototype/includes/String.prototype.includes_FailBadLocation.js
|
||||
@@ -460,67 +454,6 @@ const cases = [
|
||||
expected: ["this_is_"],
|
||||
labels: ['#1: __string.substring(0,8) === "this_is_"'],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/start-negative.js",
|
||||
code: `return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]`,
|
||||
expected: ["c", "bc", "abc", "abc", "c"],
|
||||
labels: ["-1", "-2", "-3", "size + intStart < 0", "floating point rounding semantics"],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-negative.js",
|
||||
code: `return [
|
||||
"abc".substr(0, -1), "abc".substr(0, -2), "abc".substr(0, -3), "abc".substr(0, -4),
|
||||
"abc".substr(1, -1), "abc".substr(1, -2), "abc".substr(1, -3), "abc".substr(1, -4),
|
||||
"abc".substr(2, -1), "abc".substr(2, -2), "abc".substr(2, -3), "abc".substr(2, -4),
|
||||
"abc".substr(3, -1), "abc".substr(3, -2), "abc".substr(3, -3), "abc".substr(3, -4),
|
||||
]`,
|
||||
expected: ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""],
|
||||
labels: [
|
||||
"0, -1", "0, -2", "0, -3", "0, -4", "1, -1", "1, -2", "1, -3", "1, -4",
|
||||
"2, -1", "2, -2", "2, -3", "2, -4", "3, -1", "3, -2", "3, -3", "3, -4",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-positive.js",
|
||||
code: `return [
|
||||
"abc".substr(0, 1), "abc".substr(0, 2), "abc".substr(0, 3), "abc".substr(0, 4),
|
||||
"abc".substr(1, 1), "abc".substr(1, 2), "abc".substr(1, 3), "abc".substr(1, 4),
|
||||
"abc".substr(2, 1), "abc".substr(2, 2), "abc".substr(2, 3), "abc".substr(2, 4),
|
||||
"abc".substr(3, 1), "abc".substr(3, 2), "abc".substr(3, 3), "abc".substr(3, 4),
|
||||
]`,
|
||||
expected: ["a", "ab", "abc", "abc", "b", "bc", "bc", "bc", "c", "c", "c", "c", "", "", "", ""],
|
||||
labels: [
|
||||
"0, 1", "0, 1", "0, 1", "0, 1", "1, 1", "1, 1", "1, 1", "1, 1",
|
||||
"2, 1", "2, 1", "2, 1", "2, 1", "3, 1", "3, 1", "3, 1", "3, 1",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-falsey.js",
|
||||
code: `return ["abc".substr(0, NaN), "abc".substr(1, NaN), "abc".substr(2, NaN), "abc".substr(3, NaN)]`,
|
||||
expected: ["", "", "", ""],
|
||||
labels: ["start: 0, length: NaN", "start: 1, length: NaN", "start: 2, length: NaN", "start: 3, length: NaN"],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/length-undef.js",
|
||||
code: `return [
|
||||
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
|
||||
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
|
||||
]`,
|
||||
expected: ["abc", "bc", "c", "", "abc", "bc", "c", ""],
|
||||
labels: [
|
||||
"start: 0, length: unspecified", "start: 1, length: unspecified", "start: 2, length: unspecified", "start: 3, length: unspecified",
|
||||
"start: 0, length: undefined", "start: 1, length: undefined", "start: 2, length: undefined", "start: 3, length: undefined",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js",
|
||||
code: `return [
|
||||
"\uD834\uDF06".substr(0), "\uD834\uDF06".substr(1), "\uD834\uDF06".substr(2),
|
||||
"\uD834\uDF06".substr(0, 0), "\uD834\uDF06".substr(0, 1), "\uD834\uDF06".substr(0, 2),
|
||||
]`,
|
||||
expected: ["\uD834\uDF06", "\uDF06", "", "", "\uD834", "\uD834\uDF06"],
|
||||
labels: ["start: 0", "start: 1", "start: 2", "end: 0", "end: 1", "end: 2"],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/String/prototype/includes/String.prototype.includes_FailMissingLetter.js",
|
||||
code: `return ["word".includes("a", 0)]`, expected: [false], labels: ['"word".includes("a", 0)'],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": false
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noUnusedLocals": true
|
||||
}
|
||||
}
|
||||
|
||||
+88
-16
@@ -15,20 +15,43 @@ import type {
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
FinishReason,
|
||||
InvalidProviderOutputReason,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
NotFound,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
classifyApiFailure,
|
||||
isLLMError,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
InvalidArgumentError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
JSONParseError,
|
||||
LoadAPIKeyError,
|
||||
LoadSettingError,
|
||||
NoContentGeneratedError,
|
||||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
catch: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: (error) => llmError("readStream", error),
|
||||
onError: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -608,7 +631,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError("stream", event.error))
|
||||
return Effect.fail(llmError(event.error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,16 +689,65 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof LLMError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
return new LLMError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
reason,
|
||||
})
|
||||
const BODY_LIMIT = 16_384
|
||||
|
||||
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
|
||||
if (!headers) return undefined
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
const value = headers["retry-after"]
|
||||
if (!value) return undefined
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||
const date = Date.parse(value)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
|
||||
// AI SDK route reports failures identically to native protocol routes. An
|
||||
// `APICallError` without a status code is the AI SDK's representation of a
|
||||
// network-level failure (connect refused, reset, DNS), not an API rejection.
|
||||
function llmError(error: unknown): LLMError {
|
||||
if (isLLMError(error)) return error
|
||||
if (APICallError.isInstance(error)) {
|
||||
if (error.statusCode === undefined) {
|
||||
return new ConnectionError({ message: error.message, url: error.url, cause: error })
|
||||
}
|
||||
return classifyApiFailure({
|
||||
message: error.message,
|
||||
status: error.statusCode,
|
||||
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
|
||||
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
|
||||
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
|
||||
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
|
||||
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
|
||||
return new Authentication({ message: error.message })
|
||||
}
|
||||
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
|
||||
if (
|
||||
InvalidPromptError.isInstance(error) ||
|
||||
InvalidArgumentError.isInstance(error) ||
|
||||
UnsupportedFunctionalityError.isInstance(error)
|
||||
) {
|
||||
return new BadRequest({ message: error.message })
|
||||
}
|
||||
if (
|
||||
InvalidResponseDataError.isInstance(error) ||
|
||||
JSONParseError.isInstance(error) ||
|
||||
TypeValidationError.isInstance(error) ||
|
||||
EmptyResponseBodyError.isInstance(error) ||
|
||||
NoContentGeneratedError.isInstance(error)
|
||||
) {
|
||||
return new MalformedResponse({ message: error.message })
|
||||
}
|
||||
return new APIError({ message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -200,7 +200,6 @@ const layer = Layer.effect(
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ export const Flag = {
|
||||
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
||||
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
||||
},
|
||||
get CODEMODE_ENABLED() {
|
||||
return process.env["CODEMODE_ENABLED"] === undefined || truthy("CODEMODE_ENABLED")
|
||||
},
|
||||
get OPENCODE_TUI_CONFIG() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as McpGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -18,11 +17,7 @@ type Summary = typeof Summary.Type
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...(Flag.CODEMODE_ENABLED
|
||||
? [
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
]
|
||||
: []),
|
||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
@@ -81,7 +76,7 @@ export const layer = Layer.effect(
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
if (PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
|
||||
return source(Instructions.removed)
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -90,12 +85,9 @@ export const layer = Layer.effect(
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return (
|
||||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
|
||||
owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
return owned.some(
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.catchIf(isLLMError, (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import {
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
@@ -227,17 +218,10 @@ const layer = Layer.effect(
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
if (publisher.hasProviderError()) return
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
@@ -317,22 +301,21 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
llmFailure?._tag === "LLM.ContextOverflow" &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||
"completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
// A thrown LLM failure records the assistant failure unless a provider failure
|
||||
// was already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
@@ -349,7 +332,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
// The provider-failed flag is only set while consuming the stream (content-filter
|
||||
// step finish), so it is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
@@ -543,7 +527,8 @@ const layer = Layer.effect(
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
|
||||
@@ -438,10 +438,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
@@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
switch (error._tag) {
|
||||
case "LLM.RateLimit":
|
||||
case "LLM.ServerError":
|
||||
case "LLM.ConnectionError":
|
||||
case "LLM.TimeoutError":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
case "LLM.Authentication":
|
||||
case "LLM.PermissionDenied":
|
||||
case "LLM.NotFound":
|
||||
case "LLM.QuotaExceeded":
|
||||
case "LLM.ContentPolicy":
|
||||
case "LLM.ContextOverflow":
|
||||
case "LLM.MalformedResponse":
|
||||
case "LLM.BadRequest":
|
||||
case "LLM.NoRoute":
|
||||
case "LLM.APIError":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
const exhaustive: never = error
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
|
||||
return failure.cause.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
|
||||
)
|
||||
if (!streamed || failed) return
|
||||
if (!streamed) return
|
||||
const title = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
if (isLLMError(cause)) {
|
||||
switch (cause._tag) {
|
||||
case "LLM.RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.message }
|
||||
case "LLM.Authentication":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.PermissionDenied":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.NotFound":
|
||||
return { type: "provider.not-found", message: cause.message }
|
||||
case "LLM.QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.message }
|
||||
case "LLM.ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.message }
|
||||
case "LLM.ContextOverflow":
|
||||
return { type: "provider.context-overflow", message: cause.message }
|
||||
case "LLM.ConnectionError":
|
||||
return { type: "provider.transport", message: cause.message }
|
||||
case "LLM.TimeoutError":
|
||||
return { type: "provider.timeout", message: cause.message }
|
||||
case "LLM.ServerError":
|
||||
return { type: "provider.internal", message: cause.message }
|
||||
case "LLM.MalformedResponse":
|
||||
return { type: "provider.invalid-output", message: cause.message }
|
||||
case "LLM.BadRequest":
|
||||
return { type: "provider.invalid-request", message: cause.message }
|
||||
case "LLM.NoRoute":
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
case "LLM.APIError":
|
||||
return { type: "provider.unknown", message: cause.message }
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
const exhaustive: never = cause
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
group, which flattens direct model names to `<group>_<tool>`, and may be deferred from direct model exposure.
|
||||
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
`codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -58,16 +58,16 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
if (registration.group === undefined) {
|
||||
const path = registration.name
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`Deferred tool namespace conflict: ${path}`)
|
||||
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
|
||||
tools[path] = value
|
||||
continue
|
||||
}
|
||||
const path = registration.name
|
||||
const namespace = registration.group
|
||||
const group = tools[namespace]
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}`)
|
||||
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
|
||||
if (group) {
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`Deferred tool namespace conflict: ${namespace}.${path}`)
|
||||
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
|
||||
group[path] = value
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -137,6 +137,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
import { MCP } from "../mcp"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
@@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard(
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([group, record]) => tools.register(record, { group, deferred: Flag.CODEMODE_ENABLED }),
|
||||
([group, record]) => tools.register(record, { group }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -106,6 +106,7 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -139,6 +139,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ToolRegistry from "./registry"
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
@@ -56,7 +55,7 @@ const registryLayer = Layer.effect(
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly group?: string
|
||||
readonly deferred: boolean
|
||||
readonly codemode: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
@@ -132,7 +131,8 @@ const registryLayer = Layer.effect(
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.group)
|
||||
if (entries.length === 0) return
|
||||
const reserved = options?.deferred ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||
@@ -149,7 +149,7 @@ const registryLayer = Layer.effect(
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
deferred: options?.deferred ?? false,
|
||||
codemode,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -168,18 +168,17 @@ const registryLayer = Layer.effect(
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions) {
|
||||
const direct = new Map<string, Registration>()
|
||||
const deferred = new Map<string, Registration>()
|
||||
const codemode = new Map<string, Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (registration.deferred && !Flag.CODEMODE_ENABLED) continue
|
||||
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||
if (registration.deferred) deferred.set(name, registration)
|
||||
if (registration.codemode) codemode.set(name, registration)
|
||||
else direct.set(name, registration)
|
||||
}
|
||||
const execute =
|
||||
deferred.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(deferred) : undefined
|
||||
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
|
||||
return {
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
|
||||
@@ -132,6 +132,8 @@ export const Plugin = {
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -276,6 +278,7 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -97,6 +97,7 @@ export const Plugin = {
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -208,6 +208,7 @@ export const Plugin = {
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -174,6 +174,7 @@ export const Plugin = {
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -252,6 +252,7 @@ export const Plugin = {
|
||||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -91,6 +91,7 @@ export const Plugin = {
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -876,7 +876,7 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -936,6 +936,7 @@ describe("Config", () => {
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
"outside",
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
@@ -951,6 +952,8 @@ describe("Config", () => {
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
|
||||
@@ -260,6 +260,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
@@ -273,7 +274,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
it.effect("groups tool names and routes codemode registrations through execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -289,9 +290,9 @@ describe("PluginV2", () => {
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.add("plain", tool("Plain"))
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7" })
|
||||
draft.add("search", tool("Search"), { group: "context 7", deferred: true })
|
||||
draft.add("plain", tool("Plain"), { codemode: false })
|
||||
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
|
||||
draft.add("search", tool("Search"), { group: "context 7" })
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
@@ -330,6 +331,7 @@ describe("PluginV2", () => {
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -135,6 +135,7 @@ describe("fromPromise", () => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
MalformedResponse,
|
||||
ModelID,
|
||||
NoRoute,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
RouteID,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
test("maps every LLM error tag to the open wire type", () => {
|
||||
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
|
||||
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
|
||||
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
|
||||
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
|
||||
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
|
||||
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
|
||||
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
|
||||
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
@@ -71,23 +69,31 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
new RateLimit({ message: "rate" }),
|
||||
new ServerError({ message: "internal", status: 500 }),
|
||||
new ConnectionError({ message: "reset" }),
|
||||
new TimeoutError({ message: "timed out" }),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
new Authentication({ message: "auth" }),
|
||||
new PermissionDenied({ message: "forbidden" }),
|
||||
new NotFound({ message: "missing" }),
|
||||
new QuotaExceeded({ message: "quota" }),
|
||||
new ContentPolicy({ message: "blocked" }),
|
||||
new ContextOverflow({ message: "too long" }),
|
||||
new MalformedResponse({ message: "output" }),
|
||||
new BadRequest({ message: "request" }),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
new APIError({ message: "unknown" }),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { ConnectionError } from "@opencode-ai/llm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
SessionExecution.terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("ToolRegistry", () => {
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
})
|
||||
}, { codemode: false })
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
@@ -95,8 +95,8 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const shared = make()
|
||||
yield* service.register({ first: shared })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") })
|
||||
yield* service.register({ first: shared }, { codemode: false })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
|
||||
Tool.withPermission(shared, "question")
|
||||
|
||||
expect(
|
||||
@@ -110,7 +110,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reuses model definitions across requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const first = yield* toolDefinitions(service)
|
||||
const second = yield* toolDefinitions(service)
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
@@ -135,7 +135,7 @@ describe("ToolRegistry", () => {
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* service
|
||||
.register({ echo: make() })
|
||||
.register({ echo: make() }, { codemode: false })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
@@ -161,7 +161,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -184,7 +184,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
@@ -203,7 +203,7 @@ describe("ToolRegistry", () => {
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const materialized = yield* service.materialize()
|
||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("ToolRegistry", () => {
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
@@ -248,7 +248,7 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ bounded: make() })
|
||||
yield* service.register({ bounded: make() }, { codemode: false })
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
@@ -282,7 +282,7 @@ describe("ToolRegistry", () => {
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
@@ -319,7 +319,7 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
@@ -334,10 +334,10 @@ describe("ToolRegistry", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("advertised") }).pipe(Scope.provide(scope))
|
||||
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
const request = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({ echo: constant("replacement") })
|
||||
yield* service.register({ echo: constant("replacement") }, { codemode: false })
|
||||
|
||||
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
|
||||
@@ -347,9 +347,9 @@ describe("ToolRegistry", () => {
|
||||
it.effect("reveals the previous registration after an overlay closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: constant("base") })
|
||||
yield* service.register({ echo: constant("base") }, { codemode: false })
|
||||
const overlay = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("overlay") }).pipe(Scope.provide(overlay))
|
||||
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
|
||||
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
@@ -357,37 +357,31 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes deferred tools advertised in a model request", () =>
|
||||
it.effect("executes codemode tools advertised in a model request", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* service
|
||||
.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register(
|
||||
{
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
|
||||
const settlement = yield* materialized.settle({
|
||||
...call("execute"),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
APIError,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContextOverflow,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
RateLimit,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMError,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
@@ -69,8 +71,9 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responses: ScriptedResponse[] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||
let streamGate: Deferred.Deferred<void> | undefined
|
||||
@@ -93,9 +96,12 @@ const client = Layer.succeed(
|
||||
responseStream = undefined
|
||||
return stream
|
||||
}
|
||||
const scripted = responses === undefined ? response : (responses.shift() ?? [])
|
||||
const events = streamFailure
|
||||
? Stream.fail(streamFailure)
|
||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
||||
: Array.isArray(scripted)
|
||||
? Stream.fromIterable(scripted)
|
||||
: scripted
|
||||
if (!streamGate) return events
|
||||
return Stream.unwrap(
|
||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||
@@ -261,7 +267,7 @@ const echo = Layer.effectDiscard(
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}),
|
||||
}, { codemode: false }),
|
||||
),
|
||||
)
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
@@ -483,26 +489,16 @@ const setup = Effect.gen(function* () {
|
||||
return yield* SessionV2.Service
|
||||
})
|
||||
|
||||
const providerUnavailable = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
|
||||
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
|
||||
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -782,7 +778,7 @@ describe("SessionRunnerLLM", () => {
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Use application context")
|
||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||
|
||||
@@ -827,7 +823,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
.pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
@@ -852,7 +848,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
@@ -1614,14 +1610,14 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
|
||||
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.text("Active complete", "text-active"),
|
||||
reply.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
reply.text("Steer complete", "text-steer"),
|
||||
reply.text("Queue complete", "text-queue"),
|
||||
@@ -1758,14 +1754,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1875,7 +1871,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
|
||||
reply.text("Must not run", "text-after-failed-compaction"),
|
||||
]
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
@@ -1898,10 +1894,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
|
||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
@@ -1928,7 +1921,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
reply.text("Recovered", "text-final-unknown-limit"),
|
||||
]
|
||||
@@ -1948,7 +1941,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
reply.text("Recovered", "text-final-undersized-limit"),
|
||||
]
|
||||
@@ -1966,10 +1959,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("persists a second context overflow after one recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const overflow = () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
|
||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
@@ -1985,16 +1975,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("recovers once from a raw context overflow failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responseStream = Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
responseStream = Stream.fail(contextOverflow())
|
||||
responses = [
|
||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
@@ -2013,10 +1994,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -2027,7 +2005,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
}),
|
||||
)
|
||||
expect(context.slice(-3)).toMatchObject([
|
||||
@@ -2041,10 +2019,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
@@ -3189,7 +3164,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call blocked")
|
||||
|
||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||
@@ -3221,7 +3196,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call declined")
|
||||
|
||||
response = reply.tool("call-declined", "declined", {})
|
||||
@@ -3261,7 +3236,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Call corrected")
|
||||
|
||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||
@@ -3321,7 +3296,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({ permissionfail: permissionFail })
|
||||
yield* registry.register({ permissionfail: permissionFail }, { codemode: false })
|
||||
yield* admit(session, "Reject permission")
|
||||
responses = [
|
||||
reply.tool("call-permission", "permissionfail", {}),
|
||||
@@ -3366,7 +3341,7 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
})
|
||||
}, { codemode: false })
|
||||
yield* admit(session, "Ask then stop")
|
||||
|
||||
responses = [reply.tool("call-question", "question", {}), []]
|
||||
@@ -3627,7 +3602,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail durably")
|
||||
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 })],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3644,7 +3622,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail before step")
|
||||
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3732,13 +3710,15 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after output")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
],
|
||||
contextOverflow(),
|
||||
)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3892,11 +3872,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
@@ -3935,11 +3911,13 @@ describe("SessionRunnerLLM", () => {
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
@@ -3966,11 +3944,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool durably")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3997,11 +3974,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Defect while provider fails")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -443,6 +443,12 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
@@ -454,6 +460,13 @@ describe("ShellTool", () => {
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
expect((yield* shell.wait(id)).status).toBe("timeout")
|
||||
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
|
||||
description: idleCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
state: "completed",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
Vendored
+4
-3
@@ -315,11 +315,12 @@ export default Plugin.define({
|
||||
Unsupported characters in tool and group names are normalized to underscores.
|
||||
The resulting exposed key must begin with a letter and contain at most 64
|
||||
letters, digits, underscores, or hyphens. Set `options` on the declaration to
|
||||
configure registration with `{ group, deferred }`:
|
||||
configure registration with `{ group, codemode }`:
|
||||
|
||||
- `group` prefixes and groups the exposed tool name.
|
||||
- `deferred: true` makes the tool available through the deferred `execute`
|
||||
tool instead of exposing it directly.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `assistantMessageID`, and `toolCallID`.
|
||||
|
||||
Vendored
+14
-5
@@ -1,12 +1,21 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed an OpenCode host in an Effect application."
|
||||
description: "Embed OpenCode directly in your application."
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
|
||||
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
|
||||
OpenCode server and routes API calls through its HTTP router in memory. It opens
|
||||
no HTTP listener and adds no network hop between the client and server.
|
||||
We're working on a general-purpose SDK for embedding OpenCode directly inside
|
||||
your application. The regular SDK is coming soon.
|
||||
|
||||
An Effect-native version is available now for applications built with Effect.
|
||||
Its current documentation is below. For other applications, run OpenCode as a
|
||||
server and use the [TypeScript client](/build/client) in the meantime.
|
||||
|
||||
## Effect
|
||||
|
||||
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
|
||||
@@ -3,14 +3,19 @@ title: "Migrate from V1"
|
||||
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
|
||||
---
|
||||
|
||||
<Note>
|
||||
The only intentional breaking changes in V2 are the server API and the plugin API. All other functionality is intended
|
||||
to remain compatible with V1.
|
||||
</Note>
|
||||
## Breaking changes
|
||||
|
||||
Existing config files, agent definitions, command definitions, skills, and other files in `.opencode/` should continue to
|
||||
work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than an expected
|
||||
migration requirement.
|
||||
V2 has three intentional breaking changes:
|
||||
|
||||
- [Plugins](#plugins) use a new plugin API.
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
|
||||
<Tip>
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
@@ -466,6 +471,58 @@ If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the appl
|
||||
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected
|
||||
project details. See [Instructions](/instructions).
|
||||
|
||||
## TUI configuration
|
||||
|
||||
V1 loaded `tui.json(c)` from the global config directory and from project directories discovered while walking up from
|
||||
the current directory. V2 instead stores CLI and TUI settings in one global file:
|
||||
|
||||
```text
|
||||
~/.config/opencode/cli.json
|
||||
```
|
||||
|
||||
The CLI owns this file. The background service does not load it, and V2 does not discover or merge project-local
|
||||
`tui.json(c)` or `cli.json` files.
|
||||
|
||||
The native V2 format groups related settings. For example:
|
||||
|
||||
```jsonc
|
||||
// V1: ~/.config/opencode/tui.json
|
||||
{
|
||||
"theme": "tokyonight",
|
||||
"scroll_speed": 2,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
// V2: ~/.config/opencode/cli.json
|
||||
{
|
||||
"theme": {
|
||||
"name": "tokyonight"
|
||||
},
|
||||
"scroll": {
|
||||
"speed": 2,
|
||||
"acceleration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V2 migrates the global TUI configuration automatically. On the first CLI or TUI startup, when `cli.json` does not already
|
||||
exist, it:
|
||||
|
||||
- Reads `~/.config/opencode/tui.json`.
|
||||
- Reads persisted TUI preferences from the legacy `kv.json` state file.
|
||||
- Converts supported settings to the native grouped format and writes `~/.config/opencode/cli.json`.
|
||||
- Leaves the V1 files unchanged so V1 can continue using them.
|
||||
|
||||
Migration runs only while `cli.json` is absent. Once that file exists, V2 treats it as the source of truth and does not
|
||||
continually synchronize later changes from `tui.json` or `kv.json`. If you created `cli.json` before starting V2, merge any
|
||||
V1 settings you still need into it manually.
|
||||
|
||||
Project-local V1 TUI configuration is not migrated because V2 has no project-local CLI configuration. Move settings you
|
||||
still want into the global `cli.json`; when multiple projects used different values for the same setting, choose the
|
||||
global behavior you want V2 to use.
|
||||
|
||||
## Plugins
|
||||
|
||||
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
|
||||
|
||||
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
|
||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||
|
||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||
@@ -291,7 +291,7 @@ Use this order for every protocol module:
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||
|
||||
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
||||
+6
-14
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
return yield* new MalformedResponse({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
})
|
||||
const object = yield* tool._decode(call.input).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
new MalformedResponse({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message: providerErrorMessage(event),
|
||||
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event),
|
||||
code: event.error?.type,
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "error") return Effect.fail(onError(event))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { BedrockEventStream } from "./bedrock-event-stream"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
}
|
||||
|
||||
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
|
||||
const message =
|
||||
event.internalServerException?.message ??
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [LLMEvent.providerError({ message })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message,
|
||||
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
const exception = (
|
||||
[
|
||||
["internalServerException", event.internalServerException],
|
||||
["modelStreamErrorException", event.modelStreamErrorException],
|
||||
["serviceUnavailableException", event.serviceUnavailableException],
|
||||
["throttlingException", event.throttlingException],
|
||||
["validationException", event.validationException],
|
||||
] as const
|
||||
).find((entry) => entry[1] !== undefined)
|
||||
if (exception) {
|
||||
return yield* classifyApiFailure({
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
})
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` is a hard failure that emits a
|
||||
// `provider-error`. All three end the stream — kept in one set so `step` and
|
||||
// the protocol's `terminal` predicate stay in sync.
|
||||
// `finish` event; `response.failed` is a hard failure that fails the stream
|
||||
// with a classified `LLMError`. All three end the stream — kept in one set so
|
||||
// `step` and the protocol's `terminal` predicate stay in sync.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
return LLMEvent.providerError({
|
||||
message,
|
||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event, fallback),
|
||||
code: event.code || event.error?.code || event.response?.error?.code || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses response failed")],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses stream error")],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
|
||||
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
BadRequest,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
})
|
||||
new MalformedResponse({ route, message, raw })
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
||||
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
||||
* (message) => invalidRequest(message)` aliases out of every
|
||||
* route so the error constructor lives in one place. If we ever extend
|
||||
* `InvalidRequestReason` with route context or trace metadata, the change
|
||||
* lands here.
|
||||
* `BadRequest` with route context or trace metadata, the change lands here.
|
||||
*/
|
||||
export const invalidRequest = (message: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
})
|
||||
export const invalidRequest = (message: string) => new BadRequest({ message })
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
|
||||
}
|
||||
|
||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
||||
result instanceof LLMError
|
||||
isLLMError(result)
|
||||
|
||||
/**
|
||||
* Register a tool call whose start event arrived before any argument deltas.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderMetadata,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
ServerError,
|
||||
type LLMError,
|
||||
} from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
@@ -27,7 +41,102 @@ const patterns = [
|
||||
export const isContextOverflow = (message: string) =>
|
||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||
|
||||
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
|
||||
overloaded_error: serverError,
|
||||
api_error: serverError,
|
||||
server_error: serverError,
|
||||
internal_error: serverError,
|
||||
server_is_overloaded: serverError,
|
||||
internalServerException: serverError,
|
||||
serviceUnavailableException: serverError,
|
||||
modelStreamErrorException: serverError,
|
||||
rate_limit_error: rateLimit,
|
||||
rate_limit_exceeded: rateLimit,
|
||||
too_many_requests: rateLimit,
|
||||
throttlingException: rateLimit,
|
||||
authentication_error: (_input, common) => new Authentication(common),
|
||||
permission_error: (_input, common) => new PermissionDenied(common),
|
||||
not_found_error: (_input, common) => new NotFound(common),
|
||||
invalid_request_error: (_input, common) => new BadRequest(common),
|
||||
invalid_prompt: (_input, common) => new BadRequest(common),
|
||||
validationException: (_input, common) => new BadRequest(common),
|
||||
}
|
||||
|
||||
export interface ApiFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly requestID?: string | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
type CommonFields = {
|
||||
readonly message: string
|
||||
readonly status: number | undefined
|
||||
readonly code: string | undefined
|
||||
readonly requestID: string | undefined
|
||||
readonly http: HttpContext | undefined
|
||||
readonly providerMetadata: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
function serverError(input: ApiFailure, common: CommonFields) {
|
||||
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
|
||||
}
|
||||
|
||||
function rateLimit(input: ApiFailure, common: CommonFields) {
|
||||
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
|
||||
}
|
||||
|
||||
/**
|
||||
* One classifier for every failure a remote API deliberately reports.
|
||||
* Protocols call it with in-stream error payloads, the request executor with
|
||||
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
|
||||
* three surfaces produce identical `LLMError` tags.
|
||||
*
|
||||
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
|
||||
* HTTP status, provider code, then the generic `APIError` fallback.
|
||||
*/
|
||||
export const classifyApiFailure = (input: ApiFailure): LLMError => {
|
||||
const common: CommonFields = {
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
requestID: input.requestID,
|
||||
http: input.http,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}
|
||||
const body = input.http?.body ?? ""
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
if (
|
||||
clientScoped &&
|
||||
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
|
||||
isContextOverflow(input.message) ||
|
||||
(body.length > 0 && isContextOverflow(body)))
|
||||
)
|
||||
return new ContextOverflow(common)
|
||||
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
|
||||
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
|
||||
if (input.status === 401) return new Authentication(common)
|
||||
if (input.status === 403) return new PermissionDenied(common)
|
||||
if (input.status === 404) return new NotFound(common)
|
||||
if (input.status === 429) {
|
||||
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
|
||||
return rateLimit(input, common)
|
||||
}
|
||||
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
|
||||
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
|
||||
return new BadRequest(common)
|
||||
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
|
||||
if (byCode) return byCode(input, common)
|
||||
return new APIError(common)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user