@opencode/codemode 0.0.0-dev-19673 → 0.0.0-dev-19676

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
88
88
  runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
89
89
  ```
90
90
 
91
- The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
92
- input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
91
+ The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
92
+ host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
93
+ receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
94
+ `failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
95
+ failure as a thrown error.
93
96
 
94
97
  ### `Values`
95
98
 
@@ -3,7 +3,7 @@ import type { Extension } from "./extension.js";
3
3
  import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js";
4
4
  import type { Tools } from "./tools.js";
5
5
  /** A tool call admitted during an execution. */
6
- export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js";
6
+ export type { CallResult, ExtensionInvocation, Hooks, ToolCall, ToolDescription, ToolInvocation, } from "./tool-runtime.js";
7
7
  /** Signature-construction helpers for host-owned catalog instructions. */
8
8
  export { searchSignature, toolExpression } from "./tool-runtime.js";
9
9
  /** Resource budgets enforced independently during each CodeMode program execution. */
@@ -27,9 +27,11 @@ export type ResolvedExecutionLimits = {
27
27
  readonly maxOutputBytes: number | undefined;
28
28
  };
29
29
  /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
30
- export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
30
+ export type Options<Provided extends Record<string, unknown> = {}> = {
31
31
  /** Explicit tools exposed to the program as `tools`. */
32
32
  tools?: Provided & Tools<Services<Provided>>;
33
+ /** Hooks around every tool and extension call the program makes; see `Hooks`. */
34
+ hooks?: ToolRuntime.Hooks<Services<Provided>>;
33
35
  /** Host functions exposed as globals; see `Extension.make`. */
34
36
  extensions?: ReadonlyArray<Extension>;
35
37
  /** Resource limits enforced on each execution. */
package/dist/codemode.js CHANGED
@@ -72,6 +72,6 @@ export const make = (options = {}) => {
72
72
  }
73
73
  return {
74
74
  catalog: prepared.catalog,
75
- execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
75
+ execute: (code) => executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
76
76
  };
77
77
  };
@@ -2,4 +2,4 @@ import { Effect } from "effect";
2
2
  import type { ResolvedExecutionLimits, Result } from "../codemode.js";
3
3
  import { ToolRuntime } from "../tool-runtime.js";
4
4
  import { Interpreter } from "./interpreter.js";
5
- export declare const executeProgram: <R>(code: string, prepared: ToolRuntime.Prepared<R>, limits: ResolvedExecutionLimits, hooks: ToolRuntime.ToolCallHooks<R>, globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>) => Effect.Effect<Result, never, R>;
5
+ export declare const executeProgram: <R>(code: string, prepared: ToolRuntime.Prepared<R>, limits: ResolvedExecutionLimits, hooks: ToolRuntime.Hooks<R>, globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>) => Effect.Effect<Result, never, R>;
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { coerceToString } from "../stdlib/value.js";
3
+ import { hooked } from "../tool-runtime.js";
3
4
  import { createErrorValue, isErrorType } from "./intrinsics.js";
4
5
  import { MAX_VALUE_DEPTH } from "./limits.js";
5
6
  import { Throw, typeError } from "./model.js";
@@ -115,23 +116,27 @@ export const extensionGlobals = (ctx, extensions) => {
115
116
  }
116
117
  throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`);
117
118
  };
118
- // A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
119
- // rejects with crosses out, so the program catches what the author threw.
120
- const wrap = (value, label) => fn(builtins, value.name, value.length, (_, values) => {
121
- const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`));
122
- const thrown = (reason) => new Throw(fromHost(reason, label));
119
+ // A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
120
+ // hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
121
+ // program catches a copy. Functions inside results are part of a value's API and skip the hooks.
122
+ const wrap = (value, label, describe) => fn(builtins, value.name, value.length, (_, values) => {
123
+ const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`));
124
+ const hooks = ctx.tools.hooks;
125
+ const settle = (run) => (describe === undefined
126
+ ? run
127
+ : hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)).pipe(Effect.mapError((reason) => new Throw(fromHost(reason, label))), Effect.map((settled) => fromHost(settled, label)));
123
128
  let result;
124
129
  try {
125
- result = value.apply(undefined, converted);
130
+ result = value.apply(undefined, args);
126
131
  }
127
132
  catch (reason) {
128
- return Effect.fail(thrown(reason));
133
+ return settle(Effect.fail(reason));
129
134
  }
130
135
  if (!(result instanceof Promise))
131
- return fromHost(result, label);
132
- return ctx.pending.create(Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)));
136
+ return settle(Effect.succeed(result));
137
+ return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })));
133
138
  });
134
- return extensions.flatMap((extension) => Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)]));
139
+ return extensions.flatMap((extension) => Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))]));
135
140
  };
136
141
  const hostErrors = new Map([
137
142
  ["TypeError", TypeError],
@@ -13,25 +13,36 @@ type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] exten
13
13
  export type ToolCall = {
14
14
  readonly name: string;
15
15
  };
16
- export type ToolCallStarted = {
17
- readonly index: number;
16
+ /** A tool call the program is making, with its decoded input. */
17
+ export type ToolInvocation = {
18
18
  readonly name: string;
19
19
  readonly input: unknown;
20
20
  };
21
- export type ToolCallEnded = {
22
- readonly index: number;
21
+ /** A call the program is making to an extension global, with its arguments. */
22
+ export type ExtensionInvocation = {
23
+ readonly extension: string;
23
24
  readonly name: string;
24
- readonly input: unknown;
25
- readonly durationMs: number;
26
- readonly outcome: "success" | "failure" | "interrupted";
27
- readonly message?: string;
25
+ readonly args: ReadonlyArray<unknown>;
26
+ };
27
+ /** How a call ended; `after` hooks observe it and cannot change it. */
28
+ export type CallResult = {
29
+ readonly status: "success";
30
+ readonly value: unknown;
31
+ } | {
32
+ readonly status: "failure";
33
+ readonly error: unknown;
34
+ } | {
35
+ readonly status: "interrupted";
28
36
  };
29
- export type ToolCallHooks<R = never> = {
30
- /** Observes decoded tool input immediately before tool execution. */
31
- readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined;
32
- /** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
33
- readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined;
37
+ /** Hooks around every call the program makes into the host. A failing `before` denies the call. */
38
+ export type Hooks<R = never> = {
39
+ readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined;
40
+ readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined;
41
+ readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined;
42
+ readonly "extension.after"?: ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined;
34
43
  };
44
+ /** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
45
+ export declare const hooked: <Call, A, R>(call: Call, before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined, after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined, run: Effect.Effect<A, unknown, R>) => Effect.Effect<A, unknown, R>;
35
46
  export type ToolDescription = {
36
47
  readonly path: string;
37
48
  readonly description: string;
@@ -68,10 +79,11 @@ export declare class ToolRuntimeError extends Error {
68
79
  /** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
69
80
  export type ToolRuntime<R = never> = {
70
81
  readonly calls: Array<ToolCall>;
82
+ readonly hooks: Hooks<R>;
71
83
  readonly execute: (path: ReadonlyArray<string>, args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>;
72
84
  readonly search: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>;
73
85
  readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>;
74
86
  };
75
87
  /** Per-execution call state over tools prepared once for the runtime. */
76
- export declare const make: <R>(prepared: Prepared<R>, maxToolCalls: number | undefined, hooks?: ToolCallHooks<R>) => ToolRuntime<R>;
88
+ export declare const make: <R>(prepared: Prepared<R>, maxToolCalls: number | undefined, hooks?: Hooks<R>) => ToolRuntime<R>;
77
89
  export * as ToolRuntime from "./tool-runtime.js";
@@ -4,6 +4,19 @@ import { decodeInput as decodeToolInput, decodeOutput as decodeToolOutput, ident
4
4
  import { isNamespace } from "./namespace.js";
5
5
  import { isTool } from "./tool.js";
6
6
  export const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
7
+ /** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
8
+ export const hooked = (call, before, after, run) => {
9
+ const observed = after === undefined
10
+ ? run
11
+ : Effect.onExit(run, (exit) => {
12
+ if (Exit.isSuccess(exit))
13
+ return after(call, { status: "success", value: exit.value });
14
+ if (Cause.hasInterruptsOnly(exit.cause))
15
+ return after(call, { status: "interrupted" });
16
+ return after(call, { status: "failure", error: Cause.squash(exit.cause) });
17
+ });
18
+ return before === undefined ? observed : Effect.andThen(before(call), observed);
19
+ };
7
20
  const defaultSearchLimit = 10;
8
21
  const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
9
22
  const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
@@ -193,26 +206,10 @@ export class ToolRuntimeError extends Error {
193
206
  }
194
207
  }
195
208
  /** Per-execution call state over tools prepared once for the runtime. */
196
- export const make = (prepared, maxToolCalls, hooks) => {
209
+ export const make = (prepared, maxToolCalls, hooks = {}) => {
197
210
  const calls = [];
198
211
  const root = prepared.root;
199
212
  const searchTool = makeSearchTool(prepared.searchIndex);
200
- const observeEnd = (effect, call) => {
201
- const onEnd = hooks?.onToolCallEnd;
202
- if (onEnd === undefined)
203
- return effect;
204
- const startedAt = Date.now();
205
- return effect.pipe(Effect.onExit((exit) => {
206
- const durationMs = Date.now() - startedAt;
207
- if (Exit.isSuccess(exit))
208
- return onEnd({ ...call, durationMs, outcome: "success" });
209
- if (Cause.hasInterruptsOnly(exit.cause))
210
- return onEnd({ ...call, durationMs, outcome: "interrupted" });
211
- const error = Cause.squash(exit.cause);
212
- const message = error instanceof Error ? error.message : Cause.pretty(exit.cause);
213
- return onEnd({ ...call, durationMs, outcome: "failure", message });
214
- }));
215
- };
216
213
  const recordCall = (call) => {
217
214
  if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
218
215
  throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`);
@@ -227,14 +224,8 @@ export const make = (prepared, maxToolCalls, hooks) => {
227
224
  try: () => decodeToolInput(tool, normalized[0]),
228
225
  catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`, name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."]),
229
226
  });
230
- const index = yield* Effect.sync(() => {
231
- recordCall({ name });
232
- return calls.length - 1;
233
- });
234
- const call = { index, name, input };
235
- return yield* observeEnd(Effect.gen(function* () {
236
- if (hooks?.onToolCallStart !== undefined)
237
- yield* hooks.onToolCallStart(call);
227
+ yield* Effect.sync(() => recordCall({ name }));
228
+ return yield* hooked({ name, input }, hooks["tool.before"], hooks["tool.after"], Effect.gen(function* () {
238
229
  const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(Effect.catchCause((cause) => {
239
230
  if (Cause.hasInterruptsOnly(cause))
240
231
  return Effect.interrupt;
@@ -250,10 +241,11 @@ export const make = (prepared, maxToolCalls, hooks) => {
250
241
  },
251
242
  catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
252
243
  });
253
- }), call);
244
+ }));
254
245
  });
255
246
  return {
256
247
  calls,
248
+ hooks,
257
249
  keys: (path) => namespaceKeys(root, path),
258
250
  search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
259
251
  execute: (path, args) => Effect.suspend(() => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode/codemode",
4
- "version": "0.0.0-dev-19673",
4
+ "version": "0.0.0-dev-19676",
5
5
  "description": "Effect-native confined code execution over schema-described tools",
6
6
  "type": "module",
7
7
  "license": "MIT",