Compare commits

...

37 Commits

Author SHA1 Message Date
Aiden Cline 1043615df2 docs(tui): explain global form routing 2026-07-05 23:43:51 -05:00
Aiden Cline 96bfd4912d refactor(tui): simplify global form identity 2026-07-05 23:16:56 -05:00
Aiden Cline 57dd2fba7d fix(tui): preserve global form ownership 2026-07-05 22:47:20 -05:00
Aiden Cline 684d288b9c fix(tui): scope global forms by location 2026-07-05 22:36:04 -05:00
Aiden Cline 780c99bc2e refactor(core): route questions through forms (#35422) 2026-07-05 22:28:05 -05:00
Aiden Cline 5bcf8d5a0b feat(tui): render session forms (#35421) 2026-07-05 19:35:30 -05:00
Dax Raad 652e8ff3fb fix(client): resolve codegen workspace version 2026-07-05 18:31:15 -04:00
Dax Raad 7b88de47c3 chore(client): publish client package 2026-07-05 18:21:23 -04:00
Dax Raad 33cb536879 feat(plugin): publish generation updates 2026-07-05 17:21:39 -04:00
Dax Raad 6ffecf9345 fix(core): reload local plugins on file changes 2026-07-05 16:54:17 -04:00
Dax Raad 08741f6b93 fix(core): scope observability to server 2026-07-05 16:22:04 -04:00
Dax Raad baacb2e776 fix(core): tolerate invalid OTLP configuration 2026-07-05 16:14:02 -04:00
Dax Raad a75978815a feat(server): add loaded locations debug endpoint 2026-07-05 15:59:44 -04:00
Dax Raad c3d26c4912 feat(core): improve runtime observability 2026-07-05 15:59:44 -04:00
Dax Raad a9b7bd9e2f feat(core): manage configurable plugin generations 2026-07-05 15:59:44 -04:00
Aiden Cline f9d1d3b259 refactor(codemode): namespace public types (#35435) 2026-07-05 11:51:50 -05:00
Aiden Cline f950497173 refactor(codemode): remove generic agent tool (#35420) 2026-07-05 10:41:21 -05:00
Aiden Cline 391aa38281 test(core): serve skill fixtures locally (#35429) 2026-07-05 10:40:55 -05:00
Aiden Cline 29e8502bb0 fix(tui): align integration empty state (#35414) 2026-07-05 09:08:09 -05:00
Kit Langton 44b182fe23 fix(core): validate scalar newtypes (#35381) 2026-07-04 21:43:08 -04:00
Kit Langton 905123b9c0 fix(protocol): keep internal events off SSE (#35378) 2026-07-04 21:12:45 -04:00
Kit Langton bb3b6a2f65 fix(protocol): expose MCP tool change events (#35373) 2026-07-04 20:47:33 -04:00
Aiden Cline b8efb33cde feat(codemode): add OpenAPI tool adapter (#35362) 2026-07-04 16:28:11 -05:00
Aiden Cline d65ecd4a90 feat(core): expose deferred tools through execute (#35361) 2026-07-04 16:20:56 -05:00
Simon Klee 57fb3e5cc5 fix(run): align mini with current session contracts (#35354) 2026-07-04 21:14:01 +02:00
Simon Klee ba07481b59 fix(run): restore subprocess output contracts 2026-07-04 21:06:32 +02:00
Aiden Cline b99759c7de fix(core): enforce mcp tool permissions (#35345) 2026-07-04 13:03:40 -05:00
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator 2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
193 changed files with 35354 additions and 2567 deletions
-36
View File
@@ -1,36 +0,0 @@
export default {
id: "Orchestrator",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("orchestrator", (agent) => {
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
agent.mode = "primary"
agent.system = [
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
"Synthesize minion results, decide next steps, and report back concisely.",
].join("\n")
})
agents.update("minion", (agent) => {
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
agent.mode = "subagent"
agent.model = { providerID: "opencode", id: "glm-5.2" }
agent.system = [
"You are minion, a focused execution subagent for this repository.",
"Complete the specific task delegated to you by Orchestrator using the available tools.",
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
"Do not delegate to other subagents; execute the assigned work yourself.",
].join("\n")
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
})
})
},
}
+19
View File
@@ -104,6 +104,25 @@ bun dev api <operationId> --param key=value
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
+1
View File
@@ -151,6 +151,7 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
+2
View File
@@ -321,6 +321,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
@@ -529,6 +530,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"version": "0.0.0",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
+8
View File
@@ -862,6 +862,13 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly location: LocationApi<E>
@@ -888,4 +895,5 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
}
@@ -1043,6 +1043,11 @@ const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
@@ -1069,6 +1074,7 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroup22(raw["server.reference"]),
projectCopy: adaptGroup23(raw["server.projectCopy"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -173,6 +173,7 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1448,6 +1449,19 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
debug: {
location: (requestOptions?: RequestOptions) =>
request<DebugLocationOutput>(
{
method: "GET",
path: `/api/debug/location`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
}
}
@@ -928,6 +928,7 @@ export type SessionContextOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1621,6 +1622,7 @@ export type SessionMessageOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1820,6 +1822,7 @@ export type MessageListOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -4971,6 +4974,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "plugin.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly created: number
@@ -6000,3 +6011,5 @@ export type VcsDiffOutput = {
readonly status?: "added" | "deleted" | "modified"
}>
}
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
+2
View File
@@ -30,7 +30,9 @@ test("exposes every standard HTTP API group", () => {
"reference",
"projectCopy",
"vcs",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([
"list",
+8
View File
@@ -5,6 +5,14 @@
- 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.
## OpenAPI
- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason.
- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed.
- Render unresolved schema constructs as `unknown`, never as invented TypeScript names.
- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values.
- Test supported behavior directly; do not reproduce adapter algorithms in tests.
## Future Design Notes
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
+42 -19
View File
@@ -4,7 +4,7 @@ Effect-native confined code execution over explicit, schema-described tools.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
The package is currently private to this workspace. Its API is designed around three uses:
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
```ts
// One execution
@@ -13,9 +13,6 @@ yield * CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code)
// One agent-facing code tool
const codeTool = runtime.agentTool()
```
## Install
@@ -63,7 +60,7 @@ const result =
`)
```
`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
@@ -86,6 +83,8 @@ const tool = Tool.make({
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
### `CodeMode.execute`
Use `CodeMode.execute` for a single execution:
@@ -116,31 +115,32 @@ const runtime = CodeMode.make({
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute }
runtime.execute(source) // CodeMode.Result
```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
### Results
```ts
type ExecuteResult = ExecuteSuccess | ExecuteFailure
type Result = Success | Failure
interface ExecuteSuccess {
interface Success {
readonly ok: true
readonly value: Schema.Json
readonly value: CodeMode.DataValue
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
interface ExecuteFailure {
interface Failure {
readonly ok: false
readonly error: Diagnostic
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
```
@@ -152,6 +152,31 @@ interface ExecuteFailure {
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
```ts
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const api = OpenAPI.fromSpec({
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
auth: {
resolve: ({ name, scopes, operation }) =>
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
},
})
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
```
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
## Discovery
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
@@ -279,7 +304,7 @@ import { toolError } from "@opencode-ai/codemode"
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
## Authority Boundary
@@ -308,12 +333,10 @@ A program cannot gain authority through prose or generated code. It can only exe
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
- Host interruption remains interruption rather than an `ExecuteFailure`.
- Host interruption remains interruption rather than a `CodeMode.Failure`.
## Non-Goals
+24 -17
View File
@@ -220,9 +220,9 @@ wave; both packages typecheck clean.
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`tool-schema.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
anymore). Types `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
cosmetic, fixed in Wave 4.
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
@@ -237,7 +237,7 @@ wave; both packages typecheck clean.
so failures are typed and observable). `message` is the model-safe failure message
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
fire no end event (timeout kills the whole execution anyway).
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
- **Limits collapse**: public `CodeMode.ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
later deleted that type and the internal limit system entirely.
@@ -246,7 +246,7 @@ maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other kn
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
`CodeMode.Result`). UTF-8-safe truncation (no split code points). (The in-sandbox
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
truncation is now the only result-size mechanism.)
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
@@ -313,7 +313,7 @@ real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still
packages typecheck clean.
- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing
inline/search modes are gone - `DiscoveryMode` deleted, `DiscoveryOptions` is just
inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just
`{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to
`maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of
the old opencode
@@ -340,7 +340,7 @@ packages typecheck clean.
read-the-description-before-calling guidance. (The flat prose layout this wave produced
was later replaced wholesale by the markdown-section restructure - see Post-wave fixes -
which also deleted this wave's worked example.)
- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no
- **Cosmetic renderer fixes** (`renderSchema` in `tool-schema.ts`): an object schema with no
properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission
(`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}`
(was `{ } | Array<unknown>`).
@@ -469,7 +469,7 @@ adapter needed **no changes**.
`rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD),
replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path +
description + input-schema property names + their `description` strings - extracted by
the new `inputProperties` helper in `tool.ts` (Effect Schemas via
the new `inputProperties` helper in `tool-schema.ts` (Effect Schemas via
`Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas
read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to
path + description). Queries tokenize on camelCase boundaries + non-alphanumeric
@@ -542,7 +542,7 @@ budget; namespaces must always be present):
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
the package stays dependency-free; keep in sync if the core heuristic changes.
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
@@ -558,7 +558,7 @@ budget; namespaces must always be present):
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
configurable knobs; the internal limit system dies):
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
- `CodeMode.ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
@@ -575,7 +575,7 @@ configurable knobs; the internal limit system dies):
`maxCollectionLength` (every array-length/object-field-count check - this knob was
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
`ExecuteResultSchema` (fine - the package is unreleased).
`CodeMode.Result` (fine - the package is unreleased).
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
only because it produces a clearer error than a native stack-overflow RangeError; still
@@ -602,7 +602,7 @@ configurable knobs; the internal limit system dies):
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
test - superseded by the package timeout regression test); rewrote the helpers that used
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
`InternalExecutionLimits` as a convenience to plain `CodeMode.ExecutionLimits`
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
suites: 34 + 16.
@@ -633,7 +633,7 @@ Semantics: each described input/output field carries its schema `description` as
express surface as JSDoc tags - `@deprecated`, `@default <json>` (unserializable defaults
skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`;
multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed,
untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a
untagged fields get no comment. Implementation: `renderSchema` in `tool-schema.ts` grew a
`RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus
a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have
looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public
@@ -849,7 +849,7 @@ section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
that relied on the old default now asserts the oversized result reaches the shared
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
**Docs polish** (post-API-review): stale `CodeMode.DiscoveryOptions` JSDoc fixed (claimed default
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a
regular dependency; hosts depend on it themselves because the API surface is Effect-typed).
@@ -949,16 +949,16 @@ child calls" gap):
**Signature rendering + compound-assignment parity fixes** (externally reported, both
verified real with failing tests before fixing):
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
- **Non-identifier property names in rendered signatures** (`src/tool-schema.ts`): `renderSchema`
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
single `field` closure both the compact and pretty renderings share. The
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
`identifierSegment` regex now lives in `tool-schema.ts` (internal) and `tool-runtime.ts`'s
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
- **Numeric schema unions keep their real alternatives** (`src/tool-schema.ts`): the old
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
etc.). The collapse is now restricted to Effect's number-schema artifact
@@ -1132,6 +1132,12 @@ Post-MVP (logged, not blocking an experimental flag):
- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
`session/system.ts:110-126`) still inject prose referencing server-native tool
names that are no longer directly callable under code mode.
- [ ] Tool-tree path segments named `__proto__`, `constructor`, or `prototype` are included
in discovery but rejected by `ToolRuntime` resolution even when supplied as safe own
properties on null-prototype host records. Hosts should preserve registered names rather
than invent incompatible aliases. CodeMode should own a consistent policy: safely admit
these names as own tool-tree members, reject them before catalog generation with a clear
diagnostic, or define one canonical escaping contract.
### Backlog / loose ends (non-blocking, any order)
@@ -1203,7 +1209,8 @@ Post-MVP (logged, not blocking an experimental flag):
the workspace is the implementation source of truth for v4 behavior questions.
- File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make;
`src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path;
`src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value
`src/tool.ts` - public `Tool` definitions; `src/tool-schema.ts` - schema rendering and decoding;
`src/values.ts` - sandbox value
types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`.
- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a
registry tool service - `CodeModeTool` + `catalogInstructions`; formerly
+54 -106
View File
@@ -19,8 +19,7 @@ import { ToolError } from "./tool-error.js"
import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
export { ToolError, toolError } from "./tool-error.js"
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
@@ -74,50 +73,20 @@ export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
}
/** A normalized program diagnostic safe to return across an agent tool boundary. */
export type Diagnostic = {
readonly kind: DiagnosticKind
readonly message: string
readonly location?: { readonly line: number; readonly column: number }
readonly suggestions?: ReadonlyArray<string>
}
/** A JSON value that can cross the confined interpreter boundary. */
export type DataValue = Schema.Json
/** Successful execution after the result has crossed the plain-data boundary. */
export type ExecuteSuccess = {
readonly ok: true
readonly value: DataValue
readonly logs?: ReadonlyArray<string>
/** Present when the value or logs were truncated to fit `maxOutputBytes`. */
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
/** Failed execution with calls admitted before the diagnostic was produced. */
export type ExecuteFailure = {
readonly ok: false
readonly error: Diagnostic
readonly logs?: ReadonlyArray<string>
/** Present when the logs were truncated to fit `maxOutputBytes`. */
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
export type ExecuteResult = ExecuteSuccess | ExecuteFailure
/** Reusable CodeMode configuration shared by `execute` and `agentTool`. */
export type CodeModeOptions<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
readonly discovery?: DiscoveryOptions
}
/** Input schema for the single agent-facing tool produced by `runtime.agentTool()`. */
export const ExecuteInputSchema = Schema.Struct({ code: Schema.String })
/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
export type Input = typeof Input.Type
const DiagnosticKindSchema = Schema.Literals([
export const DiagnosticKind = Schema.Literals([
"ParseError",
"UnsupportedSyntax",
"UnknownTool",
@@ -129,49 +98,52 @@ const DiagnosticKindSchema = Schema.Literals([
"ToolFailure",
"ExecutionFailure",
])
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind = typeof DiagnosticKind.Type
/** Structured success or diagnostic result schema returned by CodeMode execution. */
export const ExecuteResultSchema = Schema.Union([
Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
}),
Schema.Struct({
ok: Schema.Literal(false),
error: Schema.Struct({
kind: DiagnosticKindSchema,
message: Schema.String,
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
}),
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
}),
])
export const Diagnostic = Schema.Struct({
kind: DiagnosticKind,
message: Schema.String,
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
})
/** A normalized program diagnostic safe to return across an agent tool boundary. */
export type Diagnostic = typeof Diagnostic.Type
/** Agent-facing projection of a configured CodeMode runtime. */
export type AgentToolDefinition<R = never> = {
readonly name: "code"
readonly description: string
readonly input: typeof ExecuteInputSchema
readonly output: typeof ExecuteResultSchema
readonly execute: (input: { readonly code: string }) => Effect.Effect<ExecuteResult, never, R>
}
const ToolCallSchema = Schema.Struct({ name: Schema.String })
export const Success = Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(ToolCallSchema),
})
/** Successful execution after the result has crossed the plain-data boundary. */
export type Success = typeof Success.Type
export const Failure = Schema.Struct({
ok: Schema.Literal(false),
error: Diagnostic,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(ToolCallSchema),
})
/** Failed execution with calls admitted before the diagnostic was produced. */
export type Failure = typeof Failure.Type
/** Schema for the structured success or diagnostic returned by CodeMode execution. */
export const Result = Schema.Union([Success, Failure])
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
export type Result = typeof Result.Type
/** Reusable confined runtime over one explicit tool tree. */
export type CodeModeRuntime<R = never> = {
export type Runtime<R = never> = {
/** Lists schema-described tool paths provided by the host. */
readonly catalog: () => ReadonlyArray<ToolDescription>
/** Builds model-facing syntax guidance and visible tool signatures. */
readonly instructions: () => string
/** Projects the configured runtime as one agent-facing `code` tool. */
readonly agentTool: () => AgentToolDefinition<R>
/** Executes a program using this runtime's configured host tools. */
readonly execute: (code: string) => Effect.Effect<ExecuteResult, never, R>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
type SourcePosition = {
@@ -286,19 +258,6 @@ const errorBrandName = (value: unknown): string | undefined =>
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
: undefined
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind =
| "ParseError"
| "UnsupportedSyntax"
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded"
| "TimeoutExceeded"
| "ToolFailure"
| "ExecutionFailure"
const arrayMethods = new Set([
"map",
"filter",
@@ -3954,7 +3913,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
): Effect.Effect<Result, never, Services<Tools>> => {
const hooks = {
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
@@ -3986,7 +3945,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
value: result,
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult
} satisfies Result
}).pipe((program) => {
const timeoutMs = limits.timeoutMs
if (timeoutMs === undefined) return program
@@ -3999,7 +3958,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult),
} satisfies Result),
}),
)
})
@@ -4013,7 +3972,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult),
} satisfies Result),
),
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
)
@@ -4037,7 +3996,7 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
*/
const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => {
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
let truncated = false
let value: DataValue = null
@@ -4079,7 +4038,7 @@ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResu
export const execute = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
): Effect.Effect<Result, never, Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
@@ -4088,18 +4047,17 @@ export const execute = <const Tools extends Record<string, unknown>>(
/**
* Creates an Effect-native runtime over explicit, schema-described tools.
*
* Use `execute` for host-driven execution or `agentTool` to expose one confined code tool to an
* agent framework. Tool requirements remain in the returned Effect environment.
* Use `execute` for host-driven execution. Tool requirements remain in the returned Effect environment.
*
* @example
* ```ts
* const runtime = CodeMode.make({ tools: { orders: { lookup } } })
* const code = runtime.agentTool()
* const result = runtime.execute("return await tools.orders.lookup({ id: 'order_42' })")
* ```
*/
export const make = <const Tools extends Record<string, unknown> = {}>(
options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>,
): CodeModeRuntime<Services<Tools>> => {
options: Options<Tools> = {} as Options<Tools>,
): Runtime<Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
const limits = resolveExecutionLimits(options.limits)
@@ -4111,16 +4069,6 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
return {
catalog: () => catalog,
instructions: () => instructions,
agentTool: () => ({
name: "code",
description: instructions,
input: ExecuteInputSchema,
output: ExecuteResultSchema,
execute: ({ code }) => executeProgram(code),
}),
execute: executeProgram,
}
}
/** Constructors for one-shot and reusable CodeMode execution. */
export const CodeMode = { make, execute }
+4 -21
View File
@@ -1,21 +1,4 @@
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
export { Tool } from "./tool.js"
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
export type {
AgentToolDefinition,
CodeModeOptions,
CodeModeRuntime,
DataValue,
Diagnostic,
DiagnosticKind,
DiscoveryOptions,
ExecuteFailure,
ExecuteOptions,
ExecuteResult,
ExecuteSuccess,
ExecutionLimits,
ToolCall,
ToolCallStarted,
ToolDescription,
} from "./codemode.js"
export * as CodeMode from "./codemode.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { ToolError, toolError } from "./tool-error.js"
+19
View File
@@ -0,0 +1,19 @@
# OpenAPI Follow-ups
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
- Cookie parameters, authentication, and cookie-header merging.
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
- External references and complete nested `$defs` support.
- Relative or templated server URLs and server variables.
- Base URLs containing query strings or fragments.
- Runtime response-schema validation and full content negotiation.
- Binary response values and explicit byte-oriented return types.
- Request/response projection for `readOnly` and `writeOnly` properties.
- SSE, WebSocket, and other streaming transports.
- Recovery of responses rejected by a status-filtering `HttpClient`.
- Configurable request and response size limits.
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
- Strict UTF-8 and empty-body validation for JSON responses.
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
- Complete malformed-security-scheme validation and broader auth-combination coverage.
+130
View File
@@ -0,0 +1,130 @@
import { HttpClient } from "effect/unstable/http"
import { make, type Definition } from "../tool.js"
import { invoke } from "./runtime.js"
import {
componentDefinitions,
inputSchema,
isRecord,
methods,
nonEmptyString,
operationInput,
operationOutput,
operationPath,
operationSecurityRequirements,
securityRequirements,
securitySchemes,
specServerUrl,
validateBaseUrl,
} from "./spec.js"
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
export type {
AuthResolver,
Credential,
Document,
Operation,
Options,
Result,
SecurityScheme,
Skipped,
Tools,
} 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`.
*/
export const fromSpec = (options: Options): Result => {
const document = options.spec
const schemes = securitySchemes(document)
const defaultSecurity = securityRequirements(document.security)
const definitions = componentDefinitions(document)
const paths = isRecord(document.paths) ? document.paths : {}
const used = new Set<string>()
const namespaces = new Set<string>()
const skipped: Array<Skipped> = []
const tools = Object.create(null) as Tools
for (const [path, pathValue] of Object.entries(paths)) {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
path,
summary: nonEmptyString(operationValue.summary),
description: nonEmptyString(operationValue.description),
}
const output = operationOutput(document, operationValue, definitions)
if (!output.ok) {
skipped.push({ method: operation.method, path, reason: output.reason })
continue
}
const resolvedBaseUrl = (() => {
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
return specServerUrl(document)
})()
if (!resolvedBaseUrl.ok) {
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
continue
}
const parsedInput = operationInput(document, pathValue, operationValue)
if (!parsedInput.ok) {
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
continue
}
const input = parsedInput.value
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
if (!security.ok) {
skipped.push({ method: operation.method, path, reason: security.reason })
continue
}
const plan = {
operation,
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
fields: input.fields,
body: input.body,
security: security.value,
schemes,
auth: options.auth,
headers: options.headers ?? {},
}
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
tools,
segments,
make({
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, definitions),
output: output.value,
run: (input) => invoke(plan, input),
}),
)
}
}
return { tools, skipped }
}
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
const [head, ...rest] = path
if (head === undefined) return
if (rest.length === 0) {
tools[head] = definition
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, definition)
}
+324
View File
@@ -0,0 +1,324 @@
import { Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
import { ToolError, toolError } from "../tool-error.js"
import { isRecord, own } from "./spec.js"
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const maxErrorBodyChars = 1_024
const maxResponseBodyBytes = 50 * 1024 * 1024
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
Effect.gen(function* () {
const value = isRecord(input) ? input : {}
let request = yield* buildRequest(plan, value)
const auth = yield* resolveAuth(plan)
for (const [name, item] of Object.entries(auth.query)) {
request = HttpClientRequest.setUrlParam(request, name, item)
}
request = HttpClientRequest.setHeaders(request, auth.headers)
const client = yield* HttpClient.HttpClient
const response = yield* client
.execute(request)
.pipe(
Effect.catch((cause) =>
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
),
)
const text = yield* readResponseBody(response, plan)
const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
if (response.status < 200 || response.status >= 300) {
const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
const summary =
rendered === "" || rendered === "null"
? "no response body"
: rendered.length > maxErrorBodyChars
? `${rendered.slice(0, maxErrorBodyChars)}...`
: rendered
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
)
}
if (json && Option.isNone(decoded)) {
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
)
}
return parsed
})
const buildRequest = (
plan: Plan,
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.
const url = buildUrl(plan, input)
if (url instanceof ToolError) return yield* Effect.fail(url)
const missing = plan.fields.find(
(field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
)
if (missing !== undefined) {
const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
}
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
for (const field of plan.fields) {
if (field.location !== "query") continue
const item = own(input, field.inputName)
if (item === undefined) continue
const serialized = serializeQuery(request, field, item)
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
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
const item = own(input, field.inputName)
if (item === undefined) continue
const serialized = serializeSimple(field, item, String)
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
request = HttpClientRequest.setHeader(request, field.name, serialized)
}
const setBody = (value: unknown, mediaType: string) =>
HttpClientRequest.bodyJson(request, value).pipe(
Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
Effect.mapError((cause) =>
toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
),
)
if (plan.body?.mode === "value") {
const field = plan.fields.find((field) => field.location === "body")
const body = field === undefined ? undefined : own(input, field.inputName)
if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
}
if (plan.body?.mode === "object") {
const entries = plan.fields.flatMap((field) => {
if (field.location !== "body") return []
const item = own(input, field.inputName)
return item === undefined ? [] : [[field.name, item] as const]
})
if (plan.body.required || entries.length > 0) {
request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
}
}
return request
})
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
Effect.gen(function* () {
const none: AppliedAuth = { headers: {}, query: {} }
if (plan.security.length === 0) return none
const unavailable: Array<string> = []
alternatives: for (const requirement of plan.security) {
const names = Object.keys(requirement)
if (names.length === 0) return none
const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
for (const name of names) {
const scheme = own(plan.schemes, name)
if (scheme === undefined || plan.auth === undefined) {
unavailable.push(name)
continue alternatives
}
const credential = yield* plan.auth.resolve({
name,
definition: scheme,
scopes: requirement[name] ?? [],
operation: plan.operation,
})
if (credential === undefined) {
unavailable.push(name)
continue alternatives
}
credentials.push([name, scheme, credential])
}
const applied = applyCredentials(credentials)
return applied instanceof ToolError ? yield* Effect.fail(applied) : applied
}
return yield* Effect.fail(
toolError(
`${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`,
),
)
})
const applyCredentials = (
credentials: ReadonlyArray<readonly [string, SecurityScheme, Credential]>,
): AppliedAuth | ToolError => {
const headers = new Map<string, string>()
const query = new Map<string, string>()
const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
const target = carrier === "header" ? headers : query
if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
target.set(name, value)
}
for (const [name, definition, credential] of credentials) {
if (credential.type === "bearer") {
const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
if (duplicate !== undefined) return duplicate
continue
}
if (credential.type === "basic") {
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
const duplicate = add(
"header",
"authorization",
`Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
)
if (duplicate !== undefined) return duplicate
continue
}
if (credential.type === "header") {
const duplicate = add("header", credential.name.toLowerCase(), credential.value)
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.`,
)
}
if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
const duplicate = add(definition.in, parameter, credential.value)
if (duplicate !== undefined) return duplicate
}
return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
}
const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
let url = plan.url
for (const field of plan.fields) {
if (field.location !== "path") continue
const item = own(input, field.inputName)
if (item === undefined) {
return toolError(`Missing required path parameter '${field.inputName}'.`)
}
const fieldValue = serializeSimple(field, item, (value) =>
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
),
)
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.
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
return toolError(`Invalid path parameter '${field.inputName}'.`)
}
url = url.replaceAll(`{${field.name}}`, fieldValue)
}
const unresolved = url.match(/\{[^{}]+\}/)
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
return url
}
const serializeSimple = (
field: Plan["fields"][number],
value: unknown,
encode: (value: string) => string,
): string | ToolError => {
const scalar = (item: unknown): string | ToolError =>
item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
: encode(String(item))
if (Array.isArray(value)) {
const items = value.map(scalar)
const invalid = items.find((item): item is ToolError => item instanceof ToolError)
return invalid ?? items.join(",")
}
if (!isRecord(value)) return scalar(value)
const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
const rendered = scalar(item)
if (rendered instanceof ToolError) return [rendered]
return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
})
const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
return invalid ?? entries.join(",")
}
const serializeQuery = (
request: HttpClientRequest.HttpClientRequest,
field: Plan["fields"][number],
value: unknown,
): HttpClientRequest.HttpClientRequest | ToolError => {
if (field.style === "deepObject") {
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
}, request)
}
if (Array.isArray(value)) {
const rendered = serializeSimple(field, value, String)
if (rendered instanceof ToolError) return rendered
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return value.reduce(
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
request,
)
}
if (isRecord(value) && field.explode) {
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, name, String(item))
}, request)
}
const rendered = serializeSimple(field, value, String)
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
}
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
let size = 0
yield* Stream.runForEach(response.stream, (chunk) => {
if (size + chunk.byteLength > maxResponseBodyBytes) {
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
body.copy(grown, 0, 0, size)
body = grown
}
body.set(chunk, size)
size += chunk.byteLength
return Effect.void
}).pipe(
Effect.catch((cause) => {
if (cause instanceof ToolError) return Effect.fail(cause)
if (cause.reason._tag === "EmptyBodyError") return Effect.void
return Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
)
}),
)
return new TextDecoder().decode(body.subarray(0, size))
})
+507
View File
@@ -0,0 +1,507 @@
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
import type { JsonSchema } from "../tool.js"
import { isBlockedMember } from "../tool-runtime.js"
import type {
Body,
Document,
InputField,
OperationInput,
Parsed,
SecurityRequirement,
SecurityScheme,
} from "./types.js"
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
const parameterLocations = ["path", "query", "header"] as const
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? 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`).
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
Object.hasOwn(record, key) ? record[key] : undefined
export const resolve = (document: Document, value: unknown): unknown => {
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
if (!isRecord(current)) return current
const ref = nonEmptyString(current.$ref)
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
const target = ref
.slice(2)
.split("/")
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
return target === undefined ? current : next(target, new Set([...seen, ref]))
}
return next(value, new Set())
}
const projectSchema = (document: Document, value: unknown): JsonSchema => {
if (!isRecord(value)) return {}
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
? fromSchemaOpenApi3_0(value)
: fromSchemaOpenApi3_1(value)
return Object.keys(normalized.definitions).length === 0
? normalized.schema
: { ...normalized.schema, $defs: normalized.definitions }
}
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
const components = isRecord(document.components) ? document.components : {}
const schemas = isRecord(components.schemas) ? components.schemas : {}
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
}
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
if (Object.keys(definitions).length === 0) return schema
const local = isRecord(schema.$defs) ? schema.$defs : {}
return { ...schema, $defs: { ...definitions, ...local } }
}
const isJsonMediaType = (mediaType: string): boolean => {
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
return normalized === "application/json" || normalized.endsWith("+json")
}
const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
if (!isRecord(value)) return false
const schema = resolve(document, value.schema)
return isRecord(schema) && schema.format === "binary"
}
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
}
const isFlattenableObjectBody = (
schema: unknown,
requestRequired: boolean,
): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
isRecord(schema) &&
requestRequired &&
schema.type === "object" &&
isRecord(schema.properties) &&
schema.additionalProperties === false &&
schema.nullable !== true &&
schema.allOf === undefined &&
schema.anyOf === undefined &&
schema.oneOf === undefined
type PlannedField = Omit<InputField, "inputName">
const operationParameters = (
document: Document,
pathItem: Record<string, unknown>,
operation: Record<string, unknown>,
): Parsed<ReadonlyArray<PlannedField>> => {
// Operation-level parameters override path-level ones sharing (location, name).
const declared = new Map<
string,
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
>()
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
const resolved = resolve(document, raw)
if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
const name = nonEmptyString(resolved.name)
const location = nonEmptyString(resolved.in)
if (name === undefined || location === undefined)
return { ok: false, reason: "parameter declaration is missing name or location" }
declared.set(`${location}:${name}`, { name, location, parameter: resolved })
}
const unordered: Array<PlannedField> = []
for (const item of declared.values()) {
const name = item.name
const location = item.location
const resolved = item.parameter
if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
if (location !== "path" && location !== "query" && location !== "header") {
return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
}
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
if (resolved.schema === undefined && resolved.content === undefined) {
return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
}
if (resolved.content !== undefined)
return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
return { ok: false, reason: `parameter '${name}' has an invalid style` }
}
if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
}
if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
}
if (resolved.allowReserved === true)
return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
}
if (location !== "query" && declaredStyle !== "simple") {
return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
}
const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
if (style === "deepObject" && !explode) {
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
}
const base = projectSchema(document, resolved.schema)
const description = nonEmptyString(resolved.description)
unordered.push({
name,
location,
required: resolved.required === true || location === "path",
style,
explode,
schema: {
...base,
...(base.description === undefined && description !== undefined ? { description } : {}),
},
})
}
return {
ok: true,
value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
}
}
const operationBody = (
document: Document,
operation: Record<string, unknown>,
): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
const resolved = resolve(document, operation.requestBody)
if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
const content = isRecord(resolved.content) ? resolved.content : {}
const selected = jsonContent(content)
if (selected === undefined) {
return {
ok: false,
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
}
}
const schema = resolve(document, selected.schema)
const required = resolved.required === true
if (!isFlattenableObjectBody(schema, required)) {
return {
ok: true,
value: {
fields: [
{
name: "body",
location: "body",
required,
schema: projectSchema(document, selected.schema),
style: undefined,
explode: undefined,
},
],
body: { required, mode: "value", mediaType: selected.mediaType },
},
}
}
const requiredProperties = new Set(
Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
)
return {
ok: true,
value: {
fields: Object.entries(schema.properties).map(([name, value]) => ({
name,
location: "body" as const,
required: required && requiredProperties.has(name),
schema: projectSchema(document, value),
style: undefined,
explode: undefined,
})),
body: { required, mode: "object", mediaType: selected.mediaType },
},
}
}
export const operationInput = (
document: Document,
pathItem: Record<string, unknown>,
operation: Record<string, unknown>,
): Parsed<OperationInput> => {
const parameters = operationParameters(document, pathItem, operation)
if (!parameters.ok) return parameters
const requestBody = operationBody(document, operation)
if (!requestBody.ok) return requestBody
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
ok: true,
value: {
fields: fields.map((field) => {
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
const next = (index: number): string => {
const candidate = index === 1 ? base : `${base}_${index}`
return used.has(candidate) ? next(index + 1) : candidate
}
const inputName = next(1)
used.add(inputName)
return { ...field, inputName }
}),
body: requestBody.value.body,
},
}
}
export const inputSchema = (
fields: ReadonlyArray<InputField>,
definitions: Readonly<Record<string, JsonSchema>>,
): JsonSchema => {
const required = fields.filter((field) => field.required).map((field) => field.inputName)
return withDefinitions(
{
type: "object",
properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
...(required.length === 0 ? {} : { required }),
},
definitions,
)
}
const successfulResponses = (
document: Document,
operation: Record<string, unknown>,
): Parsed<ReadonlyArray<Record<string, unknown>>> => {
if (!isRecord(operation.responses)) return { ok: true, value: [] }
const entries = Object.entries(operation.responses)
const selected = [
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
]
const responses: Array<Record<string, unknown>> = []
for (const [, value] of selected) {
const resolved = resolve(document, value)
if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
return { ok: false, reason: "successful response declaration is invalid or unresolved" }
}
responses.push(resolved)
}
return { ok: true, value: responses }
}
export const operationOutput = (
document: Document,
operation: Record<string, unknown>,
definitions: Readonly<Record<string, JsonSchema>>,
): Parsed<JsonSchema | undefined> => {
if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
const responses = successfulResponses(document, operation)
if (!responses.ok) return responses
const streams = responses.value.some(
(response) =>
isRecord(response.content) &&
Object.keys(response.content).some(
(mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
),
)
if (streams) return { ok: false, reason: "SSE operations are not supported" }
const binary = responses.value.some(
(response) =>
isRecord(response.content) &&
Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
)
if (binary) return { ok: false, reason: "binary responses are not supported" }
const outcomes: Array<JsonSchema> = []
for (const response of responses.value) {
if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
const content = isRecord(response.content) ? response.content : {}
if (Object.keys(content).length === 0) {
outcomes.push({ type: "null" })
continue
}
for (const [mediaType, value] of Object.entries(content)) {
if (!isJsonMediaType(mediaType)) {
outcomes.push({ type: "string" })
continue
}
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
outcomes.push(projectSchema(document, value.schema))
}
}
if (outcomes.length === 0) return { ok: true, value: undefined }
return {
ok: true,
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
}
}
const sanitizeOperationSegment = (raw: string): string => {
const base =
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
return isBlockedMember(base) ? `${base}_2` : base
}
const fallbackOperationId = (method: string, path: string): string =>
[
method,
...path
.split("/")
.filter((part) => part !== "")
.flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
.flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
]
.map((word, index) => {
const lower = word.toLowerCase()
return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
})
.join("")
export const operationPath = (
method: string,
path: string,
operation: Record<string, unknown>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): ReadonlyArray<string> => {
const raw = nonEmptyString(operation.operationId)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
if (isOperationPathAvailable(segments, used, namespaces)) return segments
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
if (conflict >= 0 && conflict + 1 < segments.length) {
const collapsed = segments.flatMap((segment, index) => {
if (index === conflict) {
const next = segments[index + 1] ?? ""
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
}
return index === conflict + 1 ? [] : [segment]
})
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
}
const fallback = segments.join("_")
const next = (index: number): string => {
const candidate = `${fallback}_${index}`
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
}
return [next(2)]
}
const isOperationPathAvailable = (
segments: ReadonlyArray<string>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): boolean => {
const key = segments.join(".")
if (used.has(key) || namespaces.has(key)) return false
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
}
export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
const server = asArray(source.servers).find(isRecord)
const url = server === undefined ? undefined : nonEmptyString(server.url)
if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
if (/\{[^{}]+\}/.test(url)) {
return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
}
return validateBaseUrl(url)
}
export const validateBaseUrl = (value: string): Parsed<string> => {
if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
const url = URL.parse(value)
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
}
if (url.search !== "" || url.hash !== "") {
return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
}
return { ok: true, value }
}
export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
if (value === undefined) return { ok: true, value: [] }
if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
const requirements: Array<SecurityRequirement> = []
for (const item of value) {
if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
for (const [name, scopes] of Object.entries(item)) {
if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
if (parsed.length !== scopes.length) {
return { ok: false, reason: "security requirement scopes are not string arrays" }
}
requirement[name] = parsed
}
requirements.push(requirement)
}
return { ok: true, value: requirements }
}
export const operationSecurityRequirements = (
value: unknown,
defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
schemes: Readonly<Record<string, SecurityScheme>>,
): Parsed<ReadonlyArray<SecurityRequirement>> => {
const parsed = value === undefined ? defaults : securityRequirements(value)
if (!parsed.ok) return parsed
const supported = parsed.value.filter((requirement) =>
Object.keys(requirement).every((name) => {
const scheme = own(schemes, name)
return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
}),
)
if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
const cookieScheme = names.find((name) => {
const definition = own(schemes, name)
return definition?.type === "apiKey" && definition.in === "cookie"
})
return {
ok: false,
reason:
cookieScheme === undefined
? `security requirement references missing or malformed scheme: ${names.join(", ")}`
: `cookie authentication '${cookieScheme}' is not supported`,
}
}
export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
const components = isRecord(document.components) ? document.components : {}
const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
return Object.fromEntries(
Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
const resolved = resolve(document, value)
if (!isRecord(resolved)) return []
const type = nonEmptyString(resolved.type)
if (type === "apiKey") {
const carrier = nonEmptyString(resolved.in)
const parameter = nonEmptyString(resolved.name)
if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
return [[name, { type, name: parameter, in: carrier }] as const]
}
if (type === "http") {
const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
return scheme === undefined ? [] : [[name, { type, scheme }] as const]
}
if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
return []
}),
)
}
+112
View File
@@ -0,0 +1,112 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import type { Definition, JsonSchema } from "../tool.js"
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
export type Document = Record<string, unknown>
/** The operation identity handed to auth resolution and errors. */
export type Operation = {
readonly operationId: string | undefined
readonly method: string
readonly path: string
readonly summary: string | undefined
readonly description: string | undefined
}
/** A resolved OpenAPI security scheme from `components.securitySchemes`. */
export type SecurityScheme =
| { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" }
| { readonly type: "http"; readonly scheme: string }
| { readonly type: "oauth2" }
| { 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.
*/
export type Credential =
| { readonly type: "bearer"; readonly token: string }
| { readonly type: "basic"; readonly username: string; readonly password: string }
| { readonly type: "apiKey"; readonly value: string }
| { 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.
*/
export type AuthResolver = (context: {
readonly name: string
readonly definition: SecurityScheme
readonly scopes: ReadonlyArray<string>
readonly operation: Operation
}) => Effect.Effect<Credential | undefined, unknown>
export type Options = {
readonly spec: Document
/** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */
readonly baseUrl?: string | undefined
/** Host credential resolution, keyed by security scheme name. */
readonly auth?: { readonly resolve: AuthResolver } | undefined
/** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */
readonly headers?: Readonly<Record<string, string>> | undefined
}
/** An operation that could not be represented as a tool, and why. */
export type Skipped = {
readonly method: string
readonly path: string
readonly reason: string
}
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Result = {
/** Tool subtree; the host places it under a key in its `tools` tree. */
readonly tools: Tools
readonly skipped: ReadonlyArray<Skipped>
}
export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string }
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
readonly schema: JsonSchema
readonly style: "simple" | "form" | "deepObject" | undefined
readonly explode: boolean | undefined
}
export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string }
export type OperationInput = {
readonly fields: ReadonlyArray<InputField>
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 = {
readonly operation: Operation
readonly url: string
readonly fields: ReadonlyArray<InputField>
readonly body: Body | undefined
readonly security: ReadonlyArray<SecurityRequirement>
readonly schemes: Readonly<Record<string, SecurityScheme>>
readonly auth: { readonly resolve: AuthResolver } | undefined
readonly headers: Readonly<Record<string, string>>
}
export type AppliedAuth = {
readonly headers: Readonly<Record<string, string>>
readonly query: Readonly<Record<string, string>>
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Token estimation for budgeting model-facing text. Copied from
* `@opencode-ai/core/util/token` (chars / 4) so this package stays
* dependency-free; keep the two in sync if the heuristic ever changes.
*/
export * as Token from "./token.js"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
+43 -42
View File
@@ -6,31 +6,35 @@ import {
identifierSegment,
inputProperties,
inputTypeScript,
isDefinition as isToolDefinition,
outputTypeScript,
type Definition,
} from "./tool.js"
import { estimate } from "./token.js"
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
export type Services<Tools> = ServicesOf<Tools, []>
type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends object
? string extends keyof Tools
? never
: Services<Tools[keyof Tools]>
: never
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools
? ServicesOf<Tools[string], [...Depth, unknown]>
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
@@ -290,17 +294,16 @@ const definitions = <R>(
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).flatMap(({ path, definition }) => {
const description = describeDefinition(path, definition)
return [{ path, definition, description }]
})
definitions(tools).map(({ path, definition }) => ({
path,
definition,
description: {
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
},
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
@@ -351,16 +354,10 @@ const termForms = (term: string): Array<string> => {
return forms
}
const firstLine = (text: string) => text.split("\n", 1)[0]!.trim()
/** One-line description used on inline catalog lines; the full text stays in search results. */
const brief = (text: string, max = 120) => {
const line = firstLine(text)
return line.length > max ? line.slice(0, max - 1) + "..." : line
}
const catalogLine = (tool: ToolDescription) => {
const description = brief(tool.description)
// Inline catalog lines use only a compact first line; full text stays in search results.
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}`
}
@@ -430,7 +427,7 @@ export const discoveryPlan = <R>(
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) =>
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
),
}))
let used = 0
@@ -439,7 +436,7 @@ export const discoveryPlan = <R>(
const stillActive: typeof active = []
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimate(catalogLine(tool))
const cost = estimateTokens(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
selection.queue.shift()
selection.picked.add(tool)
@@ -636,9 +633,6 @@ export type ToolRuntime<R = never> = {
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
const failureMessage = (error: unknown): string =>
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
@@ -657,9 +651,16 @@ export const make = <R>(
const startedAt = Date.now()
return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) =>
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }),
),
Effect.tapError((error) => {
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({
...call,
durationMs: Date.now() - startedAt,
outcome: "failure",
message,
})
}),
)
}
+301
View File
@@ -0,0 +1,301 @@
import { JsonPointer, Schema } from "effect"
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
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) =>
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
const intersection = (members: ReadonlyArray<string>): string => {
const concrete = members.filter((member) => member !== "unknown")
if (concrete.length === 0) return "unknown"
if (concrete.length === 1) return concrete[0] ?? "unknown"
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
}
const hasUnresolvedRef = (
schema: JsonSchema,
definitions: Readonly<Record<string, JsonSchema>>,
seen: ReadonlySet<string> = new Set(),
visited: ReadonlySet<JsonSchema> = new Set(),
): boolean => {
if (visited.has(schema)) return false
const nextVisited = new Set([...visited, schema])
if (schema.$ref !== undefined) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
}
return [
...(schema.anyOf ?? []),
...(schema.oneOf ?? []),
...(schema.allOf ?? []),
...Object.values(schema.properties ?? {}),
...(schema.items === undefined ? [] : [schema.items]),
...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []),
].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")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
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.
*/
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+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
const nested =
schema.definitions === undefined && schema.$defs === undefined
? ctx
: { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
if (schema.$ref) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
return intersection([
renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen),
])
}
if (schema.const !== undefined) return renderLiteral(schema.const)
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" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen))
if (members.some((member) => member === "unknown")) return "unknown"
return intersection([
members.join(" | "),
renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen),
])
}
if (schema.allOf) {
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
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(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** 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 })
} catch {
return "unknown"
}
}
/** 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)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* 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"
: isEffectSchema(definition.output)
? 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)
: value
+8 -260
View File
@@ -13,6 +13,7 @@ export type JsonSchema = {
readonly const?: unknown
readonly anyOf?: ReadonlyArray<JsonSchema>
readonly oneOf?: ReadonlyArray<JsonSchema>
readonly allOf?: ReadonlyArray<JsonSchema>
readonly properties?: Readonly<Record<string, JsonSchema>>
readonly required?: ReadonlyArray<string>
readonly items?: JsonSchema
@@ -29,25 +30,25 @@ export type JsonSchema = {
}
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type ToolSchema = Schema.Decoder<unknown> | JsonSchema
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
export type Definition<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: ToolSchema
readonly output: ToolSchema | undefined
readonly input: SchemaType
readonly output: SchemaType | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
@@ -57,256 +58,6 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
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) =>
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/**
* 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
}
/**
* 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")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
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.
*/
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+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
if (schema.$ref) {
const name = schema.$ref.split("/").pop()
if (!name || !ctx.definitions[name]) return name ?? "unknown"
if (seen.has(name)) return name // recursive type: reference by name rather than loop
return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name]))
}
if (schema.const !== undefined) return renderLiteral(schema.const)
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" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ")
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, ctx, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
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(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** 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 })
} catch {
return "unknown"
}
}
/** 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)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const name = schema.$ref.split("/").pop()
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* 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"
: isEffectSchema(definition.output)
? 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)
: value
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
*
@@ -334,7 +85,7 @@ export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unkn
* })
* ```
*/
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
_tag: "CodeModeTool",
@@ -343,6 +94,3 @@ export const make = <I extends ToolSchema, const O extends ToolSchema | undefine
output: options.output,
run: (input) => options.run(input as InputType<I>),
})
/** Constructors for schema-backed tools exposed inside CodeMode programs. */
export const Tool = { make, isDefinition }
+12 -27
View File
@@ -1,16 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
Tool,
toolError,
type ExecutionLimits,
} from "../src/index.js"
import type { Definition } from "../src/tool.js"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Definition<never>) =>
const run = (tool: Tool.Definition<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
@@ -235,7 +227,7 @@ describe("CodeMode console capture", () => {
logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"],
toolCalls: [],
})
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("keeps logs captured before failures", async () => {
@@ -356,7 +348,7 @@ describe("CodeMode output budget", () => {
})
test("truncates an oversized result value with a marker instead of failing", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(
CodeMode.execute({
code: `return { data: "${"x".repeat(200)}" }`,
@@ -371,11 +363,11 @@ describe("CodeMode output budget", () => {
expect(result.value).toMatch(
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
)
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("keeps leading logs within the remaining budget and marks the cut", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(
CodeMode.execute({
code: `
@@ -501,24 +493,17 @@ describe("CodeMode public contract", () => {
const tools = { orders: { lookup } }
const source = `return await tools.orders.lookup({ id: "order_42" })`
test("keeps one-shot, reusable, and agent-tool execution equivalent", async () => {
test("keeps one-shot and reusable execution equivalent", async () => {
const runtime = CodeMode.make({ tools })
const agentTool = runtime.agentTool()
const [oneShot, reusable, projected] = await Promise.all([
const [oneShot, reusable] = await Promise.all([
Effect.runPromise(CodeMode.execute({ tools, code: source })),
Effect.runPromise(runtime.execute(source)),
Effect.runPromise(agentTool.execute({ code: source })),
])
expect(reusable).toStrictEqual(oneShot)
expect(projected).toStrictEqual(oneShot)
expect(agentTool.name).toBe("code")
expect(agentTool.input).toBe(ExecuteInputSchema)
expect(agentTool.output).toBe(ExecuteResultSchema)
expect(agentTool.description).toBe(runtime.instructions())
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(
projected,
)
const input: CodeMode.Input = { code: source }
expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
@@ -1035,7 +1020,7 @@ describe("CodeMode public contract", () => {
value: { top: null, nested: [1, null] },
toolCalls: [],
})
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("rejects invalid configuration and discovery limits", async () => {
+245
View File
@@ -0,0 +1,245 @@
{
"openapi": "3.1.0",
"info": {
"title": "CodeMode Happy Path",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.test/v1"
}
],
"security": [
{
"BearerAuth": []
}
],
"paths": {
"/users/{userId}": {
"parameters": [
{
"$ref": "#/components/parameters/UserId"
}
],
"get": {
"operationId": "users.get",
"summary": "Get a user",
"parameters": [
{
"name": "include",
"in": "query",
"style": "form",
"explode": false,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "verbose",
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "X-Trace-ID",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/UserResponse"
}
}
},
"delete": {
"operationId": "users.remove",
"summary": "Remove a user",
"responses": {
"204": {
"description": "Removed"
}
}
}
},
"/users": {
"post": {
"operationId": "users.create",
"summary": "Create a user",
"security": [
{
"ApiKey": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"role": {
"type": "string",
"enum": [
"admin",
"member"
]
}
},
"required": [
"name",
"email"
],
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/vnd.example+json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
},
"/search": {
"get": {
"operationId": "search.run",
"summary": "Search users",
"security": [],
"parameters": [
{
"name": "filter",
"in": "query",
"style": "deepObject",
"explode": true,
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string"
},
"page": {
"type": "integer"
}
},
"required": [
"query"
],
"additionalProperties": false
}
},
{
"name": "tags",
"in": "query",
"style": "form",
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"description": "Summary",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
"parameters": {
"UserId": {
"name": "userId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"responses": {
"UserResponse": {
"description": "A user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
},
"schemas": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"role": {
"type": "string",
"enum": [
"admin",
"member"
]
}
},
"required": [
"id",
"name",
"email"
],
"additionalProperties": false
}
},
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer"
},
"ApiKey": {
"type": "apiKey",
"in": "query",
"name": "api_key"
}
}
}
}
File diff suppressed because it is too large Load Diff
+958
View File
@@ -0,0 +1,958 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { CodeMode, OpenAPI, Tool } from "../src/index.js"
import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js"
const baseUrl = "http://localhost:4096"
type Document = OpenAPI.Document
type Recorded = {
readonly method: string
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
const opencodeSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/opencode-v2-openapi.json", import.meta.url)).json() as Promise<Document>
}
const happyPathSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/openapi-happy-path.json", import.meta.url)).json() as Promise<Document>
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
const layer = Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request) =>
Effect.gen(function* () {
const body =
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
const url = Option.map(HttpClientRequest.toUrl(request), (resolved) => resolved.toString())
requests.push({
method: request.method,
url: Option.getOrElse(url, () => request.url),
headers: { ...request.headers },
body,
})
return HttpClientResponse.fromWeb(request, respond(request))
}),
),
)
return { requests, layer }
}
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
const singleOperation = (operation: Record<string, unknown>, method = "get"): Document => ({
openapi: "3.1.0",
paths: { "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } } },
})
describe("OpenAPI.fromSpec", () => {
test("covers a representative API from generation through execution", async () => {
const resolutions: Array<string> = []
const client = recordingClient((request) => {
const url = Option.getOrElse(HttpClientRequest.toUrl(request), () => new URL(request.url))
if (request.method === "POST") {
return new Response(
JSON.stringify({ id: "user-2", name: "Grace", email: "grace@example.test", role: "admin" }),
{ status: 201, headers: { "content-type": "application/vnd.example+json" } },
)
}
if (request.method === "DELETE") return new Response(null, { status: 204 })
if (url.pathname === "/search") {
return new Response("2 matches", { headers: { "content-type": "text/plain" } })
}
return json({ id: "user-1", name: "Ada", email: "ada@example.test", role: "member" })
})
const api = OpenAPI.fromSpec({
spec: await happyPathSpec(),
baseUrl,
auth: {
resolve: ({ name }) => {
resolutions.push(name)
return Effect.succeed(
name === "BearerAuth"
? { type: "bearer", token: "bearer-secret" }
: { type: "apiKey", value: "api-secret" },
)
},
},
})
const get = toolAt(api.tools, "users.get")
const create = toolAt(api.tools, "users.create")
const search = toolAt(api.tools, "search.run")
const remove = toolAt(api.tools, "users.remove")
expect(api.skipped).toEqual([])
if (!Tool.isDefinition(get) || !Tool.isDefinition(create) || !Tool.isDefinition(search) || !Tool.isDefinition(remove)) {
throw new Error("happy-path fixture did not generate every operation")
}
expect(inputTypeScript(get)).toBe(
'{ userId: string; include?: Array<string>; verbose?: boolean; "X-Trace-ID"?: string }',
)
expect(inputTypeScript(create)).toBe('{ name: string; email: string; role?: "admin" | "member" }')
expect(inputTypeScript(search)).toBe("{ filter?: { query: string; page?: number }; tags?: Array<string> }")
expect(inputTypeScript(remove)).toBe("{ userId: string }")
expect(outputTypeScript(get)).toContain("id: string")
expect(outputTypeScript(create)).toContain('role?: "admin" | "member"')
expect(outputTypeScript(search)).toBe("string")
expect(outputTypeScript(remove)).toBe("null")
const result = await Effect.runPromise(
CodeMode.make({ tools: { api: api.tools } })
.execute(`
const user = await tools.api.users.get({
userId: "user-1",
include: ["profile", "permissions"],
verbose: true,
"X-Trace-ID": "trace-1",
})
const created = await tools.api.users.create({
name: "Grace",
email: "grace@example.test",
role: "admin",
})
const summary = await tools.api.search.run({
filter: { query: "effect", page: 2 },
tags: ["typescript", "runtime"],
})
const removed = await tools.api.users.remove({ userId: "user-1" })
return { user, created, summary, removed }
`)
.pipe(Effect.provide(client.layer)),
)
expect(result).toMatchObject({
ok: true,
value: {
user: { id: "user-1", name: "Ada" },
created: { id: "user-2", name: "Grace" },
summary: "2 matches",
removed: null,
},
})
expect(resolutions).toEqual(["BearerAuth", "ApiKey", "BearerAuth"])
expect(client.requests).toHaveLength(4)
const getUrl = new URL(client.requests[0]!.url)
expect(getUrl.pathname).toBe("/users/user-1")
expect(getUrl.searchParams.get("include")).toBe("profile,permissions")
expect(getUrl.searchParams.get("verbose")).toBe("true")
expect(client.requests[0]!.headers["x-trace-id"]).toBe("trace-1")
expect(client.requests[0]!.headers.authorization).toBe("Bearer bearer-secret")
const createUrl = new URL(client.requests[1]!.url)
expect(createUrl.searchParams.get("api_key")).toBe("api-secret")
expect(client.requests[1]!.body).toEqual({ name: "Grace", email: "grace@example.test", role: "admin" })
const searchUrl = new URL(client.requests[2]!.url)
expect(searchUrl.searchParams.get("filter[query]")).toBe("effect")
expect(searchUrl.searchParams.get("filter[page]")).toBe("2")
expect(searchUrl.searchParams.getAll("tags")).toEqual(["typescript", "runtime"])
expect(client.requests[2]!.headers.authorization).toBeUndefined()
expect(new URL(client.requests[3]!.url).pathname).toBe("/users/user-1")
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
})
test("converts representative opencode operations into the expected tool shape", async () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
const sessionGet = toolAt(result.tools, "v2.session.get")
expect(Tool.isDefinition(sessionGet)).toBe(true)
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
expect(Tool.isDefinition(switchAgent)).toBe(true)
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put")
expect(Tool.isDefinition(contextEntryPut)).toBe(true)
if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated")
expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
const response = { responses: { 200: { description: "Success" } } }
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: { ...response, operationId: "group.item" } },
"/second": { get: { ...response, operationId: "group.item" } },
"/third": { get: { ...response, operationId: "group..other" } },
},
},
})
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/users": { get: response, post: response },
"/users/{id}": { get: response, patch: response, delete: response },
"/organizations/{organizationId}/users/{id}": { get: response },
},
},
}).tools
for (const path of [
"getUsers",
"postUsers",
"getUsersById",
"patchUsersById",
"deleteUsersById",
"getOrganizationsByOrganizationidUsersById",
]) {
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
}
})
test("lets operation parameters override matching path parameters", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/test": {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
},
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
expect(inputTypeScript(tool)).toBe("{ limit: number }")
})
test("normalizes OpenAPI 3.0 schemas with Effect", () => {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.0.3",
paths: {
"/search": {
get: {
operationId: "search",
parameters: [
{
in: "query",
name: "value",
schema: { type: "string", nullable: true, minLength: 2 },
},
],
responses: { 200: { description: "Success" } },
},
},
},
},
})
const search = toolAt(result.tools, "search")
expect(Tool.isDefinition(search)).toBe(true)
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
const schema: unknown = search.input
const input = isRecord(schema) ? schema : {}
const properties = isRecord(input.properties) ? input.properties : {}
const value = isRecord(properties.value) ? properties.value : {}
expect(value.minLength).toBe(2)
})
test("preserves schema-local definitions alongside component definitions", () => {
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/test": {
get: {
operationId: "test",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: { $ref: "#/$defs/Local", $defs: { Local: { type: "string" } } },
},
},
},
},
},
},
},
components: { schemas: { Global: { type: "number" } } },
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
})
test("documents that the opencode fixture is unauthenticated", async () => {
const spec = await opencodeSpec()
const components = isRecord(spec.components) ? spec.components : {}
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
test("exposes real opencode operations through CodeMode discovery", async () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
if (!result.ok) return
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.v2.health.get",
description: "Check whether the API server is ready to accept requests.",
},
],
})
expect(JSON.stringify(result.value)).toContain("healthy: true")
})
test("invokes real opencode path parameters and JSON request bodies", async () => {
const { requests, layer } = recordingClient((request) => {
if (request.method === "GET") return json({ id: "ses_123" })
return json({ id: "ses_456" })
})
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.v2.session.create({ id: "ses_456" })
return { existing, created }
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests).toHaveLength(2)
expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
expect(requests[1]).toMatchObject({
method: "POST",
url: "http://localhost:4096/api/session",
body: { id: "ses_456" },
})
})
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
await Effect.runPromise(
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
})
test("serializes supported simple and form parameter shapes", async () => {
const client = recordingClient(() => json({ ok: true }))
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/items/{keys}": {
get: {
operationId: "items",
parameters: [
{ name: "keys", in: "path", required: true, schema: { type: "array", items: { type: "string" } } },
{ name: "tags", in: "query", style: "form", explode: false, schema: { type: "array" } },
{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } },
{ name: "nullable", in: "query", required: true, schema: { type: ["string", "null"] } },
{ name: "constructor", in: "query", schema: { type: "string" } },
{ name: "meta", in: "header", style: "simple", explode: true, schema: { type: "object" } },
],
responses: { 200: { description: "Success" } },
},
},
},
},
})
const tool = toolAt(result.tools, "items")
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
await Effect.runPromise(
tool
.run({
keys: ["a!", "b*"],
tags: ["x", "y"],
filter: { state: "open", page: 2 },
nullable: null,
constructor_2: "safe",
meta: { a: "b", c: "d" },
})
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
expect(url.pathname).toBe("/items/a%21,b%2A")
expect(url.searchParams.get("tags")).toBe("x,y")
expect(url.searchParams.get("state")).toBe("open")
expect(url.searchParams.get("page")).toBe("2")
expect(url.searchParams.get("nullable")).toBe("null")
expect(url.searchParams.get("constructor")).toBe("safe")
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
await expect(
Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
})
test("skips unsupported parameter encodings and malformed security", () => {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
security: [{ bearer: [] }],
paths: {
"/cookie": {
get: {
operationId: "cookie",
parameters: [{ name: "session", in: "cookie", schema: { type: "string" } }],
responses: { 200: { description: "Success" } },
},
},
"/reserved": {
get: {
operationId: "reserved",
parameters: [{ name: "query", in: "query", allowReserved: true, schema: { type: "string" } }],
responses: { 200: { description: "Success" } },
},
},
"/invalid-style": {
get: {
operationId: "invalidStyle",
parameters: [{ name: "query", in: "query", style: 42, schema: { type: "string" } }],
responses: { 200: { description: "Success" } },
},
},
"/security": {
get: { operationId: "security", security: null, responses: { 200: { description: "Success" } } },
},
},
},
})
expect(result.tools).toEqual({})
expect(result.skipped.map((item) => item.reason)).toEqual([
"cookie parameter 'session' is not supported",
"parameter 'query' uses unsupported allowReserved encoding",
"parameter 'query' has an invalid style",
"security declaration is not an array",
])
})
test("fails closed on prototype-named missing security schemes", () => {
const result = OpenAPI.fromSpec({
baseUrl,
spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }),
})
expect(result.tools).toEqual({})
expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__")
})
test("resolves bearer authentication without exposing it as input", async () => {
const contexts: Array<Parameters<OpenAPI.AuthResolver>[0]> = []
const client = recordingClient(() => json({ ok: true }))
const spec = {
...singleOperation({ operationId: undefined }),
security: [{ bearer: [] }],
components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
} satisfies Document
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec,
auth: {
resolve: (context) => {
contexts.push(context)
return Effect.succeed({ type: "bearer", token: "secret" })
},
},
}).tools,
"getTest",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
expect(inputTypeScript(tool)).toBe("{}")
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
expect(contexts).toEqual([
{
name: "bearer",
definition: { type: "http", scheme: "bearer" },
scopes: [],
operation: {
operationId: undefined,
method: "GET",
path: "/test",
summary: undefined,
description: undefined,
},
},
])
})
test("applies authentication carriers without prototype or collision loss", async () => {
const client = recordingClient(() => json({ ok: true }))
const authenticated = (security: ReadonlyArray<Record<string, ReadonlyArray<string>>>, schemes: Record<string, unknown>) =>
OpenAPI.fromSpec({
baseUrl,
spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } },
auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" }) },
})
const prototype = toolAt(
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
"test",
)
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
const duplicate = toolAt(
authenticated(
[{ first: [], second: [] }],
{
first: { type: "apiKey", in: "header", name: "x-key" },
second: { type: "apiKey", in: "header", name: "x-key" },
},
).tools,
"test",
)
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"multiple credentials",
)
const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } })
expect(cookie.tools).toEqual({})
expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported")
const alternative = OpenAPI.fromSpec({
baseUrl,
spec: {
...singleOperation({}),
security: [{ cookie: [] }, { bearer: [] }],
components: {
securitySchemes: {
cookie: { type: "apiKey", in: "cookie", name: "session" },
bearer: { type: "http", scheme: "bearer" },
},
},
},
auth: {
resolve: ({ name }) =>
Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
},
})
const alternativeTool = toolAt(alternative.tools, "test")
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
})
test("honors server precedence and rejects ambiguous base URLs", async () => {
const client = recordingClient(() => json({ ok: true }))
const spec = {
...singleOperation({ servers: [{ url: "https://operation.example/v1" }] }),
servers: [{ url: "https://document.example" }],
} satisfies Document
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
expect(invalid.tools).toEqual({})
expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment")
const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" })
expect(malformed.tools).toEqual({})
expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL")
})
test("resolves chained response refs before detecting unsupported transports", () => {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
...singleOperation({ responses: { 200: { $ref: "#/components/responses/First" } } }),
components: {
responses: {
First: { $ref: "#/components/responses/Stream" },
Stream: { content: { "text/event-stream": { schema: { type: "string" } } } },
},
},
},
})
expect(result.tools).toEqual({})
expect(result.skipped[0]?.reason).toBe("SSE operations are not supported")
})
test("resolves response schemas before detecting binary output", () => {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
...singleOperation({
responses: {
200: {
content: { "text/plain": { schema: { $ref: "#/components/schemas/File" } } },
},
},
}),
components: { schemas: { File: { type: "string", format: "binary" } } },
},
})
expect(result.tools).toEqual({})
expect(result.skipped[0]?.reason).toBe("binary responses are not supported")
})
test("validates composite parameters before resolving auth", async () => {
const resolutions: Array<string> = []
const client = recordingClient(() => json({ ok: true }))
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: {
...singleOperation({
parameters: [{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }],
}),
security: [{ bearer: [] }],
components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
},
auth: {
resolve: ({ name }) => {
resolutions.push(name)
return Effect.succeed({ type: "bearer", token: "secret" })
},
},
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
await expect(
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
expect(resolutions).toEqual([])
expect(client.requests).toEqual([])
})
test("preserves JSON media types and rejects unencodable bodies", async () => {
const client = recordingClient(() => json({ ok: true }))
const tool = toolAt(
OpenAPI.fromSpec({
baseUrl,
spec: singleOperation(
{
requestBody: {
required: true,
content: { "application/merge-patch+json": { schema: { type: "object" } } },
},
},
"post",
),
}).tools,
"test",
)
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"Invalid JSON body",
)
})
test("rejects oversized and malformed JSON responses", async () => {
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
const oversized = recordingClient(
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
)
const malformed = recordingClient(
() => new Response("{", { headers: { "content-type": "application/json" } }),
)
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
"returned malformed JSON",
)
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
"response exceeds 50 MiB",
)
})
test("keeps non-JSON responses raw and unions every success output", async () => {
const spec = singleOperation({
responses: {
200: { description: "Text", content: { "text/plain": { schema: { type: "string" } } } },
204: { description: "Empty" },
},
})
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
expect(outputTypeScript(tool)).toBe("string | null")
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
})
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
expect(requests).toHaveLength(0)
})
test("prefixes cross-location collisions and reconstructs the HTTP request", async () => {
const spec = {
openapi: "3.1.0",
info: { title: "collision", version: "1.0.0" },
paths: {
"/echo": {
post: {
operationId: "echo",
requestBody: {
required: true,
content: { "application/json": { schema: { type: "string" } } },
},
responses: { "204": { description: "Echoed" } },
},
},
"/things/{id}": {
post: {
operationId: "things.update",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
{ name: "id", in: "query", required: true, schema: { type: "string" } },
{ name: "path_id", in: "query", schema: { type: "string" } },
{ name: "id", in: "header", required: true, schema: { type: "string" } },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
},
},
},
responses: { "204": { description: "Updated" } },
},
},
},
} satisfies Document
const { requests, layer } = recordingClient(() => new Response(null, { status: 204 }))
const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
const update = toolAt(tools, "things.update")
const echo = toolAt(tools, "echo")
expect(Tool.isDefinition(update)).toBe(true)
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
expect(inputTypeScript(update)).toBe(
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
)
expect(Tool.isDefinition(echo)).toBe(true)
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
expect(inputTypeScript(echo)).toBe("{ body: string }")
const runtime = CodeMode.make({ tools })
const result = await Effect.runPromise(
runtime
.execute(
`
const updated = await tools.things.update({ path_id: "path", query_id: "query", path_id_2: "literal", header_id: "header", body_id: "body" })
const echoed = await tools.echo({ body: "hello" })
return { updated, echoed }
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests).toHaveLength(2)
expect(new URL(requests[0]!.url).pathname).toBe("/things/path")
expect(new URL(requests[0]!.url).searchParams.get("id")).toBe("query")
expect(new URL(requests[0]!.url).searchParams.get("path_id")).toBe("literal")
expect(requests[0]!.headers.id).toBe("header")
expect(requests[0]!.body).toStrictEqual({ id: "body" })
expect(requests[1]!.body).toBe("hello")
})
test("keeps bodies nested when flattening would lose schema semantics", () => {
const body = (schema: Record<string, unknown>, required = true) => ({
required,
content: { "application/json": { schema } },
})
const spec = {
openapi: "3.1.0",
info: { title: "bodies", version: "1.0.0" },
paths: Object.fromEntries(
[
[
"optional",
body(
{
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
false,
),
],
["dictionary", body({ type: "object", additionalProperties: { type: "string" } })],
[
"composed",
body({
type: "object",
allOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }],
additionalProperties: false,
}),
],
[
"nullable",
body({
type: ["object", "null"],
properties: { name: { type: "string" } },
additionalProperties: false,
}),
],
].map(([name, requestBody]) => [
`/body/${name}`,
{
post: {
operationId: `body.${name}`,
requestBody,
responses: { "204": { description: "Accepted" } },
},
},
]),
),
} satisfies Document
const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
const tool = toolAt(tools, `body.${name}`)
expect(Tool.isDefinition(tool)).toBe(true)
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
const input = isRecord(tool.input) ? tool.input : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
}
const optional = toolAt(tools, "body.optional")
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
})
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { ToolRuntime } from "../src/tool-runtime.js"
// Runs a CodeMode program with no host tools and returns the ExecuteResult. These tests pin the
// Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the
// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
//
+7 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool, toolError, type ExecuteResult, type ExecutionLimits } from "../src/index.js"
import { CodeMode, Tool, toolError } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
@@ -48,7 +48,10 @@ const failingTool = Tool.make({
run: () => Effect.fail(toolError("Lookup refused")),
})
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
const run = (
code: string,
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
): Promise<CodeMode.Result> => {
const trace = options.trace ?? makeTrace()
return Effect.runPromise(
CodeMode.execute({
@@ -59,13 +62,13 @@ const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits }
)
}
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
const result = await run(code, options)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => {
const result = await run(code, options)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
+67 -4
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode } from "../src/index.js"
import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js"
import { CodeMode, Tool } from "../src/index.js"
import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
@@ -159,8 +159,8 @@ describe("pretty signature rendering", () => {
$ref: "#/$defs/Node",
$defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } },
} as const
expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: Node; name?: string }")
expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: Node")
expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: unknown; name?: string }")
expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: unknown")
let deep: Record<string, unknown> = { type: "string" }
for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } }
@@ -170,6 +170,43 @@ describe("pretty signature rendering", () => {
expect(rendered).toContain("next?:")
}
})
test("intersects ref and union siblings instead of discarding them", () => {
expect(
jsonSchemaToTypeScript({
$ref: "#/$defs/User",
properties: { active: { type: "boolean" } },
required: ["active"],
$defs: {
User: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
},
}),
).toBe("{ id: string } & { active: boolean }")
expect(
jsonSchemaToTypeScript({
type: "object",
properties: { common: { type: "boolean" } },
required: ["common"],
anyOf: [
{ type: "object", properties: { name: { type: "string" } }, required: ["name"] },
{ type: "object", properties: { count: { type: "number" } }, required: ["count"] },
],
}),
).toBe("({ name: string } | { count: number }) & { common: boolean }")
expect(jsonSchemaToTypeScript({ $ref: "https://example.com/schema.json" })).toBe("unknown")
expect(
jsonSchemaToTypeScript({
$ref: "#/$defs/User/properties/id",
$defs: { User: { type: "object" }, id: { type: "string" } },
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: ["object", "null"],
properties: { name: { type: "string" } },
}),
).toBe("{ name?: string } | null")
})
})
describe("non-identifier property names render as quoted keys", () => {
@@ -268,6 +305,32 @@ describe("union schemas render every alternative", () => {
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
allOf: [
{ type: "object", properties: { id: { type: "string" } } },
{ type: ["string", "null"] },
],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }] }),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
}),
).toBe("string")
})
})
describe("pretty signatures in search results", () => {
+1
View File
@@ -90,6 +90,7 @@
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
+1 -1
View File
@@ -102,7 +102,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
description: "Named local directories or Git repositories available as external context",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
description: "Ordered plugin enablement directives and external package declarations",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent"
@@ -34,7 +34,7 @@ const agentKeys = new Set([
])
export const Plugin = define({
id: "config-agent",
id: "opencode.config.agent",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command"
@@ -13,7 +13,7 @@ import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = define({
id: "config-command",
id: "opencode.config.command",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
-168
View File
@@ -1,168 +0,0 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema, Stream } from "effect"
import { createRequire } from "node:module"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
let importGeneration = 0
const moduleCache = createRequire(import.meta.url).cache
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const active = new Set<string>()
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, unknown> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: entry.path,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) =>
Effect.filter(items, (item) => fs.isDir(item), {
concurrency: "unbounded",
}),
),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(
directories.sort(),
(directory) => resolvePackageEntrypoint(fs, directory),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
files.sort()
for (const file of files) configured.push({ package: file })
for (const file of packages) configured.push({ package: file })
}
}
return yield* Effect.forEach(configured, (ref) =>
Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
const plugins = yield* load()
const next = new Set(plugins.map((plugin) => plugin.id))
for (const id of active) {
if (!next.has(id)) yield* ctx.plugin.remove(id)
}
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
active.clear()
for (const id of next) active.add(id)
})
yield* reconcile()
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconcile()),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function cacheBust(entrypoint: string) {
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
url.searchParams.set("opencode-reload", String(++importGeneration))
return url.href
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})
+2 -2
View File
@@ -1,12 +1,12 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
export const Plugin = define({
id: "config-provider",
id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
@@ -11,7 +11,7 @@ import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "core/config-reference",
id: "opencode.config.reference",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
@@ -10,7 +10,7 @@ import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "config-skill",
id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
+24
View File
@@ -0,0 +1,24 @@
export * as EventLogger from "./event-logger"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "./effect/app-node"
import { EventV2 } from "./event"
const Types = new Set([
"agent.updated",
"catalog.updated",
"command.updated",
"config.updated",
])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] })
+36 -9
View File
@@ -160,15 +160,22 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const liveBounded = (events: Interface, capacity: number) =>
export const liveBounded = (
events: Interface,
options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean },
) =>
Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(options.capacity)
const unsubscribe = yield* events.listen((event) =>
Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
),
),
options.accept && !options.accept(event)
? Effect.void
: Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted
? Effect.void
: Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid),
),
),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
return Stream.fromQueue(queue)
@@ -576,12 +583,32 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
Stream.unwrap(
Effect.serviceOption(Location.Service).pipe(
Effect.map((location) =>
Option.match(location, {
onNone: () => stream,
onSome: (location) =>
stream.pipe(
Stream.filter(
(event) =>
!event.location ||
(event.location.directory === location.directory &&
event.location.workspaceID === location.workspaceID),
),
),
}),
),
),
)
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live)
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = (
aggregateID: string,
+1 -1
View File
@@ -45,7 +45,7 @@ const FILES = [
"**/.nyc_output/**",
]
export const PATTERNS = [...FILES, ...FOLDERS]
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
@@ -34,46 +34,53 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
if (!home) {
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
}),
)
export const node = makeLocationNode({
+3
View File
@@ -52,6 +52,9 @@ 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"]
},
+6 -5
View File
@@ -21,6 +21,7 @@ export type When = Form.When
export const State = Form.State
export type State = typeof State.Type
export type TerminalState = Exclude<State, { readonly status: "pending" }>
export const Answer = Form.Answer
export type Answer = typeof Answer.Type
@@ -78,7 +79,7 @@ export interface ListInput {
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
@@ -91,7 +92,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fo
interface Entry {
readonly form: Info
readonly state: State
readonly deferred: Deferred.Deferred<State>
readonly deferred: Deferred.Deferred<TerminalState>
}
export const layer = Layer.effect(
@@ -141,7 +142,7 @@ export const layer = Layer.effect(
const entry: Entry = {
form,
state: { status: "pending" },
deferred: yield* Deferred.make<State>(),
deferred: yield* Deferred.make<TerminalState>(),
}
yield* Cache.set(forms, id, entry)
yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
@@ -185,7 +186,7 @@ export const layer = Layer.effect(
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
const next: State = { status: "answered", answer: input.answer }
const next: TerminalState = { status: "answered", answer: input.answer }
yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
yield* Cache.set(forms, input.id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
@@ -198,7 +199,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: State = { status: "cancelled" }
const next: TerminalState = { status: "cancelled" }
yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* Cache.set(forms, id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
+55 -5
View File
@@ -5,12 +5,16 @@ import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { Config } from "./config"
import { LayerNode } from "./effect/layer-node"
import { Node } from "./effect/app-node"
import { makeLocationNode, Node } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { EventV2 } from "./event"
import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { FSUtil } from "./fs-util"
import { Generate } from "./generate"
import { Form } from "./form"
import { Global } from "./global"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image"
import { Integration } from "./integration"
@@ -18,15 +22,20 @@ import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
import { ModelsDev } from "./models-dev"
import { Npm } from "./npm"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { PluginRuntime } from "./plugin/runtime"
import { SdkPlugins } from "./plugin/sdk"
import { PluginSupervisor } from "./plugin/supervisor"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { Ripgrep } from "./ripgrep"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
@@ -42,11 +51,49 @@ import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { WebSearchTool } from "./tool/websearch"
import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs"
export { LocationServiceMap } from "./location-service-map"
const pluginSupervisorNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: PluginSupervisor.layer,
deps: [
PluginV2.node,
SdkPlugins.node,
AgentV2.node,
Catalog.node,
CommandV2.node,
Config.node,
EventV2.node,
FileMutation.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
PermissionV2.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
SessionTodo.node,
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearchTool.configNode,
],
})
const locationServiceNodes = [
Location.node,
Config.node,
@@ -57,12 +104,11 @@ const locationServiceNodes = [
Catalog.node,
AISDK.node,
PluginV2.node,
PluginInternal.node,
pluginSupervisorNode,
ProjectCopy.node,
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
LocationWatcher.node,
Pty.node,
Shell.node,
SkillV2.node,
@@ -92,6 +138,8 @@ const locationServiceNodes = [
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
@@ -106,6 +154,7 @@ export function buildLocationServiceMap(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
@@ -116,9 +165,10 @@ export function buildLocationServiceMap(
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("booting location services", {
Effect.logInfo("location services booted", {
directory: ref.directory,
workspaceID: ref.workspaceID,
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
+10 -2
View File
@@ -1,6 +1,7 @@
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"
@@ -17,6 +18,11 @@ 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))}]\`.`,
]
: []),
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
@@ -64,15 +70,17 @@ export const layer = Layer.effect(
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return SystemContext.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
// Hide a server only when every tool it contributes is wholly denied for this agent.
// Instructions are useful only when this agent can reach at least one server tool.
const visible = instructions
.filter((item) => {
const owned = tools.filter((tool) => tool.server === item.server)
return (
owned.length === 0 ||
(!Flag.CODEMODE_ENABLED && owned.length === 0) ||
owned.some(
(tool) =>
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
+7 -1
View File
@@ -8,6 +8,12 @@ import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
@@ -19,6 +25,6 @@ export const layer = Layer.unwrap(
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
).pipe(Layer.catchCause(() => local))
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
+62 -98
View File
@@ -1,16 +1,15 @@
export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { EventV2 } from "./event"
import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
@@ -20,16 +19,8 @@ import { State } from "./state"
import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks"
export const ID = Plugin.ID
export type ID = typeof ID.Type
export const Info = Plugin.Info
export type Info = Plugin.Info
export const Event = Plugin.Event
export interface Interface {
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly wait: (id: ID) => Effect.Effect<void>
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
}
@@ -39,110 +30,83 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>()
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
const failures = new Map<ID, Exit.Exit<void, never>>()
let host: Parameters<PluginDefinition["effect"]>[0]
const active = new Map<typeof ID.Type, Scope.Closeable>()
const lock = Semaphore.makeUnsafe(1)
let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = []
let host: Parameters<Plugin["effect"]>[0]
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`))
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly { readonly plugin: Plugin; readonly version?: string }[],
) {
const definitions = plugins.map((entry) => ({
...entry.plugin,
id: ID.make(entry.plugin.id),
...(entry.version === undefined ? {} : { version: entry.version }),
}))
const ids = new Set<typeof ID.Type>()
for (const definition of definitions) {
if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`))
ids.add(definition.id)
}
yield* locks.withLock(id)(
Effect.sync(() => {
loading.add(id)
failures.delete(id)
}).pipe(
Effect.andThen(
State.batch(
Effect.gen(function* () {
const existing = active.get(id)
active.delete(id)
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
yield* lock.withPermit(
Effect.gen(function* () {
if (
generation !== undefined &&
generation.length === definitions.length &&
generation.every(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
)
) {
return
}
generation = undefined
const exit = yield* State.batch(
Effect.gen(function* () {
const scopes = Array.from(active.values()).toReversed()
active.clear()
const inherit = yield* State.inherit()
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
for (const definition of definitions) {
const child = yield* Scope.fork(scope)
yield* effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe(
inherit,
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }),
Effect.andThen(events.publish(Event.Added, { id: definition.id })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
yield* events.publish(Event.Added, { id })
active.set(id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
discard: true,
})
waiters.delete(id)
}),
),
),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) return Effect.void
failures.set(id, exit)
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
discard: true,
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
}),
Effect.ensuring(Effect.sync(() => loading.delete(id))),
),
)
})
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
if (loading.has(id)) return yield* Effect.die(new Error(`Cannot remove plugin ${id} while it is loading`))
yield* locks.withLock(id)(
State.batch(
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
failures.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
),
)
})
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
const waiter = yield* Deferred.make<void>()
const pending = yield* locks.withLock(id)(
Effect.sync(() => {
if (active.has(id)) return false
const failure = failures.get(id)
if (failure) return failure
const current = waiters.get(id) ?? new Set()
current.add(waiter)
waiters.set(id, current)
return true
}),
)
if (!pending) return
if (typeof pending !== "boolean") return yield* pending
yield* Deferred.await(waiter).pipe(
Effect.ensuring(
locks.withLock(id)(
Effect.sync(() => {
const current = waiters.get(id)
current?.delete(waiter)
if (current?.size === 0) waiters.delete(id)
if (Exit.isFailure(loaded)) return loaded
active.set(definition.id, child)
}
return Exit.void
}),
),
),
)
if (Exit.isFailure(exit)) return yield* exit
generation = definitions.map((definition) => ({
id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }),
}))
yield* events.publish(Event.Updated, {})
}),
)
})
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
generation = []
yield* State.batch(Scope.close(scope, exit))
}),
)
const service = Service.of({
add,
remove,
wait,
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
}),
+2 -2
View File
@@ -1,7 +1,7 @@
export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { AgentV2 } from "../agent"
import { Global } from "../global"
@@ -100,7 +100,7 @@ Rules:
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
export const Plugin = define({
id: "agent",
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
+2 -2
View File
@@ -1,13 +1,13 @@
export * as CommandPlugin from "./command"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { Location } from "../location"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({
id: "command",
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
yield* ctx.command.transform((draft) => {
+2 -6
View File
@@ -23,8 +23,6 @@ import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T>
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
@@ -160,7 +158,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
event: {
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
subscribe: () => events.live().pipe(Stream.filter(EventManifest.isServer)),
},
integration: {
list: () => response(integration.list()),
@@ -275,8 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
plugin: {
list: () => response(plugin.list()),
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
},
reference: {
list: () => response(reference.list()),
@@ -302,7 +298,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
register: (input) => tools.register(input),
register: (input, options) => tools.register(input, options),
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
+123 -167
View File
@@ -1,21 +1,20 @@
export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
@@ -25,187 +24,144 @@ import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { State } from "../state"
import { ToolRegistry } from "../tool/registry"
import { Tools } from "../tool/tools"
import { HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { QuestionTool } from "../tool/question"
import { ReadTool } from "../tool/read"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ReadTool } from "../tool/read"
import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite"
import { Tools } from "../tool/tools"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
export type Requirements =
| AgentV2.Service
| Catalog.Service
| CommandV2.Service
| Config.Service
| EventV2.Service
| FileMutation.Service
| FileSystem.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
| Image.Service
| Integration.Service
| Location.Service
| LocationMutation.Service
| ModelsDev.Service
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
| QuestionV2.Service
| ReadToolFileSystem.Service
| Reference.Service
| Ripgrep.Service
| SessionInstructions.Service
| SessionTodo.Service
| Shell.Service
| SkillV2.Service
| Tools.Service
| WebSearchTool.ConfigService
export interface Plugin<R = never> {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}
export function define<R>(plugin: Plugin<R>) {
return plugin
}
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service
const services = Context.mergeAll(
Context.make(Catalog.Service, yield* Catalog.Service),
Context.make(CommandV2.Service, yield* CommandV2.Service),
Context.make(Integration.Service, yield* Integration.Service),
Context.make(AgentV2.Service, yield* AgentV2.Service),
Context.make(Config.Service, yield* Config.Service),
Context.make(Location.Service, yield* Location.Service),
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
Context.make(Npm.Service, yield* Npm.Service),
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
Context.make(FileSystem.Service, yield* FileSystem.Service),
Context.make(Global.Service, yield* Global.Service),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
Context.make(FileMutation.Service, yield* FileMutation.Service),
Context.make(Image.Service, yield* Image.Service),
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
Context.make(QuestionV2.Service, yield* QuestionV2.Service),
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
Context.make(SkillV2.Service, yield* SkillV2.Service),
Context.make(Reference.Service, yield* Reference.Service),
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
Context.make(Shell.Service, yield* Shell.Service),
Context.make(Tools.Service, yield* Tools.Service),
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService),
)
const add = (input: Plugin<Requirements | Scope.Scope>) =>
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
input.effect(context).pipe(Effect.provide(services)),
)
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ApplyPatchTool.Plugin)
yield* add(EditTool.Plugin)
yield* add(GlobTool.Plugin)
yield* add(GrepTool.Plugin)
yield* add(QuestionTool.Plugin)
yield* add(ReadTool.Plugin)
yield* add(ShellTool.Plugin)
yield* add(SkillTool.Plugin)
yield* add(SubagentTool.Plugin)
yield* add(TodoWriteTool.Plugin)
yield* add(WebFetchTool.Plugin)
yield* add(WebSearchTool.Plugin)
yield* add(WriteTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
// Embedder-contributed plugins are added last so they layer over config.
for (const plugin of sdkPlugins.all()) yield* add(plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
)
export const node = makeLocationNode({
name: "plugin-internal",
layer,
deps: [
Catalog.node,
CommandV2.node,
PluginV2.node,
Integration.node,
AgentV2.node,
Config.node,
Location.node,
LocationMutation.node,
FileMutation.node,
Image.node,
ModelsDev.node,
Npm.node,
EventV2.node,
FSUtil.node,
FileSystem.node,
Global.node,
httpClient,
PermissionV2.node,
QuestionV2.node,
ReadToolFileSystem.node,
SessionInstructions.node,
SessionTodo.node,
SkillV2.node,
Reference.node,
Ripgrep.node,
Shell.node,
ToolRegistry.toolsNode,
PluginRuntime.node,
SdkPlugins.node,
WebSearchTool.configNode,
],
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const command = yield* CommandV2.Service
const config = yield* Config.Service
const events = yield* EventV2.Service
const mutation = yield* FileMutation.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const npm = yield* Npm.Service
const permission = yield* PermissionV2.Service
const runtime = yield* PluginRuntime.Service
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
Context.make(Catalog.Service, catalog),
Context.make(CommandV2.Service, command),
Context.make(Config.Service, config),
Context.make(EventV2.Service, events),
Context.make(FileMutation.Service, mutation),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(Integration.Service, integration),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(Npm.Service, npm),
Context.make(PermissionV2.Service, permission),
Context.make(PluginRuntime.Service, runtime),
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
)
})
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
AgentPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
QuestionTool.Plugin,
ReadTool.Plugin,
ShellTool.Plugin,
SkillTool.Plugin,
SubagentTool.Plugin,
TodoWriteTool.Plugin,
WebFetchTool.Plugin,
WebSearchTool.Plugin,
WriteTool.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
const context = yield* services()
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
return {
pre: resolve(pre),
post: resolve(post),
}
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
@@ -197,7 +197,7 @@ function applyModel(
}
export const ModelsDevPlugin = define({
id: "models-dev",
id: "opencode.models-dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
+1 -6
View File
@@ -9,7 +9,7 @@ type Registration = { readonly dispose: () => Promise<void> }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
* loader (`PluginV2` / `PluginSupervisor`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
@@ -93,11 +93,6 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: (input) => run(host.plugin.list(input)),
add: (input) => {
const child = fromPromise(input)
return run(host.plugin.add(child))
},
remove: (id) => run(host.plugin.remove(id)),
},
reference: {
list: (input) => run(host.reference.list(input)),
+1 -2
View File
@@ -31,9 +31,8 @@ import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux"
import type { PluginInternal } from "./internal"
import type { Scope } from "effect"
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({
id: "alibaba",
id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
type MantleSDK = {
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
}
export const AmazonBedrockPlugin = define({
id: "amazon-bedrock",
id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AnthropicPlugin = define({
id: "anthropic",
id: "opencode.provider.anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
+3 -3
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@@ -11,7 +11,7 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
}
export const AzurePlugin = define({
id: "azure",
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -54,7 +54,7 @@ export const AzurePlugin = define({
})
export const AzureCognitiveServicesPlugin = define({
id: "azure-cognitive-services",
id: "opencode.provider.azure-cognitive-services",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CerebrasPlugin = define({
id: "cerebras",
id: "opencode.provider.cerebras",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -1,10 +1,10 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({
id: "cloudflare-ai-gateway",
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,13 +1,13 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai",
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({
id: "cohere",
id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({
id: "deepinfra",
id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,10 +1,10 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
export const DynamicProviderPlugin = define({
id: "dynamic-provider",
id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({
id: "gateway",
id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) {
@@ -12,7 +12,7 @@ function shouldUseResponses(modelID: string) {
}
export const GithubCopilotPlugin = define({
id: "github-copilot",
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
+2 -2
View File
@@ -1,11 +1,11 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({
id: "gitlab",
id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) {
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
}
export const GoogleVertexPlugin = define({
id: "google-vertex",
id: "opencode.provider.google-vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -111,7 +111,7 @@ export const GoogleVertexPlugin = define({
})
export const GoogleVertexAnthropicPlugin = define({
id: "google-vertex-anthropic",
id: "opencode.provider.google-vertex-anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({
id: "google",
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({
id: "groq",
id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const KiloPlugin = define({
id: "kilo",
id: "opencode.provider.kilo",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -1,9 +1,9 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Integration } from "../../integration"
export const LLMGatewayPlugin = define({
id: "llmgateway",
id: "opencode.provider.llmgateway",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({
id: "mistral",
id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const NvidiaPlugin = define({
id: "nvidia",
id: "opencode.provider.nvidia",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({
id: "openai-compatible",
id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -3
View File
@@ -2,7 +2,6 @@ import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version"
@@ -159,7 +158,7 @@ const headless = {
} satisfies IntegrationOAuthMethodRegistration
export const OpenAIPlugin = define({
id: "openai",
id: "opencode.provider.openai",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
@@ -225,7 +224,7 @@ export const OpenAIPlugin = define({
}),
)
}),
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
} satisfies PluginInternal.InternalPlugin)
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
@@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) {
}
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
id: "opencode",
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
@@ -1,9 +1,9 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenRouterPlugin = define({
id: "openrouter",
id: "opencode.provider.openrouter",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({
id: "perplexity",
id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,11 +1,11 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({
id: "sap-ai-core",
id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
}
export const SnowflakeCortexPlugin = define({
id: "snowflake-cortex",
id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({
id: "togetherai",
id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({
id: "venice",
id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VercelPlugin = define({
id: "vercel",
id: "opencode.provider.vercel",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
+2 -2
View File
@@ -1,9 +1,9 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({
id: "xai",
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { define } from "../internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const ZenmuxPlugin = define({
id: "zenmux",
id: "opencode.provider.zenmux",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
+3 -3
View File
@@ -14,9 +14,9 @@ const defaultStore = makeStore()
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginInternal` can add them on every Location boot through the ordinary
* `ctx.plugin.add` seam the same path `ConfigExternalPlugin` uses for plugins
* discovered from config. A plugin registered after a Location has booted only
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. A plugin registered after a Location has booted only
* applies to Locations booted afterward, matching config-plugin timing;
* embedders register at startup before creating Sessions.
*
+2 -2
View File
@@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
@@ -25,7 +25,7 @@ const REPORT_DESCRIPTION =
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
export const Plugin = define({
id: "skill",
id: "opencode.skill",
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics()
yield* ctx.skill.transform((draft) => {
+293
View File
@@ -0,0 +1,293 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event } from "@opencode-ai/schema/config"
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../config"
import { ConfigPlugin } from "../config/plugin"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginPromise } from "../plugin/promise"
import { PluginInternal } from "./internal"
import { SdkPlugins } from "./sdk"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
type Candidate =
| {
readonly type: "definition"
readonly definition: Plugin
}
| {
readonly type: "package"
readonly specifier: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
type ConfiguredPackage = {
readonly operation: Extract<Operation, { type: "add" }>
enabled: boolean
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin[],
post: readonly Plugin[],
operations: readonly Operation[],
) {
const plan = apply(pre, post, operations)
return yield* load(plan)
})
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const plugins = [...pre, ...post]
const enabled = new Set(plugins.map((plugin) => plugin.id))
const packages = new Map<string, ConfiguredPackage>()
for (const operation of operations) {
if (operation.type === "remove") {
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = false
})
continue
}
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
const selectsDefinitions =
matched.length > 0 ||
operation.target === "*" ||
operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.")
if (selectsDefinitions) {
matched.forEach((plugin) => enabled.add(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = true
})
continue
}
packages.set(operation.target, { operation, enabled: true })
}
const definitions: Candidate[] = pre.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
item.enabled
? [
{
type: "package",
specifier: item.operation.target,
options: item.operation.options,
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
},
]
: [],
)
const posts: Candidate[] = post.flatMap((definition) =>
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
)
return [...definitions, ...configured, ...posts]
}
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
return yield* Effect.forEach(plan, (candidate) => {
if (candidate.type === "definition") return Effect.succeed({ plugin: candidate.definition })
return Effect.gen(function* () {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(candidate.specifier)
? pathToFileURL(candidate.specifier).href
: (yield* npm.add(candidate.specifier)).entrypoint
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
candidate.mtime === undefined
? entrypoint
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
plugin: {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
} satisfies Plugin,
...(candidate.mtime === undefined ? {} : { version: String(candidate.mtime) }),
}
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), {
concurrency: "unbounded",
}).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})
export interface Interface {
readonly ready: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* PluginV2.Service
const sdk = yield* SdkPlugins.Service
const config = yield* Config.Service
const events = yield* EventV2.Service
const lock = Semaphore.makeUnsafe(1)
const reload = Effect.fn("PluginSupervisor.reload")(() =>
lock.withPermit(
Effect.gen(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre, ...sdk.all()]
// Read the current layered config before resolving plugin directives and packages.
const entries = yield* config.entries()
const operations = yield* scan(entries)
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, internal.post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
}),
),
)
yield* events.subscribe(Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
const fiber = yield* reload().pipe(
Effect.withSpan("PluginSupervisor.boot"),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ ready: Fiber.join(fiber) })
}),
)
export { layer }
+2 -2
View File
@@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect"
import { define } from "./internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const Plugin = define({
id: "variant",
id: "opencode.variant",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((catalog) => {
for (const record of catalog.provider.list()) {
+33 -23
View File
@@ -43,37 +43,47 @@ export type DeepMutable<T> = T extends string | number | boolean | bigint | symb
* Nominal wrapper for scalar types. The class itself is a valid schema
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
*
* Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type`
* of a field using this newtype resolves to `Self` rather than the underlying
* branded phantom. Without that override, passing a class instance to code
* typed against `Schema.Schema.Type<FieldSchema>` would require a cast even
* though the values are structurally equivalent at runtime.
* The runtime value remains an unwrapped primitive. `Schema.brand` supplies
* the primitive schema behavior and constructor validation, while the class
* supplies the nominal TypeScript identity.
* Apply checks and annotations to the underlying schema before wrapping it;
* schema rebuild operations intentionally return the underlying schema shape.
*
* @example
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
* return this.make(id)
* }
* }
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {}
*
* Schema.decodeEffect(QuestionID)(input)
* const id = QuestionID.make("question-1")
* Schema.decodeUnknownEffect(QuestionID)(input)
*/
type NewtypeSchema<Self, Tag extends string, S extends Schema.Top> = (abstract new (_: never) => {
readonly _newtype: Tag
}) &
Schema.Bottom<
Self,
S["Encoded"],
S["DecodingServices"],
S["EncodingServices"],
S["ast"],
S["Rebuild"],
S["~type.make.in"],
Self,
S["~type.parameters"],
Self,
S["~type.mutability"],
S["~type.optionality"],
S["~type.constructor.default"],
S["~encoded.mutability"],
S["~encoded.optionality"]
> &
Omit<S, keyof Schema.Top>
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S): NewtypeSchema<Self, Tag, S> => {
abstract class Base {
declare readonly _newtype: Tag
static make(value: Schema.Schema.Type<S>): Self {
return value as unknown as Self
}
}
Object.setPrototypeOf(Base, schema)
return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & {
readonly make: (value: Schema.Schema.Type<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "make" | "~type.make"> & {
readonly "~type.make": Self
}
Object.setPrototypeOf(Base, schema.pipe(Schema.brand(tag)))
return Base as unknown as NewtypeSchema<Self, Tag, S>
}
}
+12 -8
View File
@@ -540,7 +540,8 @@ const layer = Layer.effect(
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
}).pipe(Effect.provide(locations.get(session.location)))
})
.pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(
SessionEvent.Shell.Started,
{
@@ -563,8 +564,7 @@ const layer = Layer.effect(
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
})
.pipe(Effect.provide(locations.get(session.location)))
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: completed.shell,
@@ -585,11 +585,15 @@ const layer = Layer.effect(
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(SessionEvent.Skill.Activated, {
sessionID: input.sessionID,
name: skill.name,
text: skill.content,
})
yield* events.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
name: skill.name,
text: skill.content,
},
{ id: input.id ? EventV2.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
if (input.resume !== false)
yield* execution
.resume(input.sessionID)
+23 -9
View File
@@ -8,6 +8,7 @@ export type MemoryState = {
}
export interface Adapter {
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
@@ -29,6 +30,15 @@ export function memory(state: MemoryState): Adapter {
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
return {
getModel() {
return Effect.sync(
() =>
state.messages.findLast(
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
message.type === "model-switched" || message.type === "assistant",
)?.model,
)
},
getCurrentAssistant() {
return Effect.sync(() => {
const index = latestAssistantIndex()
@@ -115,15 +125,19 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous,
time: { created: event.created },
}),
)
})
},
"session.moved": () => Effect.void,
"session.renamed": () => Effect.void,
+13 -1
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeGlobalNode } from "../effect/app-node"
import { ModelV2 } from "../model"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -356,6 +357,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getModel() {
return db
.select({ model: SessionTable.model })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)),
)
},
getCurrentAssistant() {
return Effect.gen(function* () {
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
@@ -570,13 +582,13 @@ const layer = Layer.effectDiscard(
)
yield* events.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
+6 -8
View File
@@ -16,7 +16,6 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
@@ -24,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { SessionContextEntry } from "../context-entry"
import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextCheckpoint } from "../context-checkpoint"
@@ -149,9 +149,8 @@ const layer = Layer.effect(
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const isQuestionCancelled = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.CancelledError)
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
@@ -341,14 +340,13 @@ const layer = Layer.effect(
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
const questionCancelled = settled._tag === "Failure" && isQuestionCancelled(settled.cause)
if (questionDismissed || streamInterrupted || toolsInterrupted) {
if (questionCancelled || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) return yield* Effect.interrupt
if (questionCancelled) return yield* Effect.interrupt
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
+1 -1
View File
@@ -224,7 +224,7 @@ const layer = Layer.effect(
})
return Service.of({ capture, files, diff, preview, restore, checkout })
}),
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({
+20 -9
View File
@@ -26,21 +26,32 @@ export interface Transformable<DraftApi> {
readonly reload: Reload
}
const CurrentBatch = Context.Reference<Set<Reload> | undefined>("@opencode/State/CurrentBatch", {
type Batch = {
active: boolean
readonly reloads: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current) return yield* effect
const reloads = new Set<Reload>()
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads))
yield* Effect.forEach(reloads, (reload) => reload(), { discard: true })
return result
if (current?.active) return yield* effect
const batch: Batch = { active: true, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
}
export const inherit = Effect.fnUntraced(function* () {
const batch = yield* CurrentBatch
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentBatch, batch)
})
export interface Options<State, DraftApi> {
/** Creates the base value for initial state and every scoped-transform reload. */
readonly initial: () => State
@@ -100,8 +111,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch) {
batch.add(reload)
if (batch?.active) {
batch.reloads.add(reload)
return
}
yield* materialize()
@@ -116,7 +127,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch) batch.add(reload)
if (batch?.active) batch.reloads.add(reload)
else yield* reload()
return { dispose }
}),
+2 -1
View File
@@ -28,7 +28,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 })`.
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.
Registrations are scoped:
+1 -1
View File
@@ -55,7 +55,7 @@ type Prepared =
})
export const Plugin = {
id: "core-apply-patch-tool",
id: "opencode.tool.apply-patch",
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service

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