@opencode/codemode 2.0.4 → 2.0.6

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
@@ -71,7 +71,9 @@ export const make = (options = {}) => {
71
71
  }
72
72
  }
73
73
  return {
74
- catalog: prepared.catalog,
75
- execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
74
+ get catalog() {
75
+ return prepared.catalog;
76
+ },
77
+ execute: (code) => executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
76
78
  };
77
79
  };
@@ -5,7 +5,7 @@ import { formatLocation, PendingThrow, Throw, sourceLocation, typeError } from "
5
5
  import { containsRuntimeReference } from "./references.js";
6
6
  import { createErrorValue, isErrorType } from "./intrinsics.js";
7
7
  import { constructor, methods, prototypeFrom, receiver } from "./native.js";
8
- import { define, get, hidden, Arr, ErrorObj, Obj } from "./objects.js";
8
+ import { define, get, has, hidden, Arr, ErrorObj, Obj } from "./objects.js";
9
9
  import { formatValue } from "../stdlib/console.js";
10
10
  import { coerceToString } from "../stdlib/value.js";
11
11
  export const normalizeError = (error) => {
@@ -128,9 +128,17 @@ export const errorGlobal = (type, ctx) => {
128
128
  const prototype = builtins[type];
129
129
  const construct = (args, newTarget) => {
130
130
  const proto = prototypeFrom(newTarget, prototype);
131
- return type === "AggregateError"
131
+ const created = type === "AggregateError"
132
132
  ? constructAggregateErrorValue(ctx, args, proto)
133
133
  : Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])));
134
+ // ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
135
+ const options = args[type === "AggregateError" ? 2 : 1];
136
+ if (!(options instanceof Obj) || !has(options, "cause"))
137
+ return created;
138
+ return Effect.map(created, (value) => {
139
+ define(value, "cause", get(options, "cause"), hidden);
140
+ return value;
141
+ });
134
142
  };
135
143
  const ctor = constructor(builtins, prototype, {
136
144
  name: type,
@@ -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,10 +1,11 @@
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
- import { Throw, typeError } from "./model.js";
6
+ import { PendingThrow, Throw, typeError } from "./model.js";
6
7
  import { fn } from "./native.js";
7
- import { Callable, define, entries, get, Arr, Bytes, DateObj, ErrorObj, GeneratorObj, MapObj, Obj, PromiseObj, RegExpObj, SetObj, URLObj, URLSearchParamsObj, } from "./objects.js";
8
+ import { Callable, define, entries, get, has, hidden, keys, Arr, Bytes, DateObj, ErrorObj, GeneratorObj, MapObj, Obj, PromiseObj, RegExpObj, SetObj, URLObj, URLSearchParamsObj, } from "./objects.js";
8
9
  import { describeValue } from "./references.js";
9
10
  /**
10
11
  * The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
@@ -42,15 +43,30 @@ export const extensionGlobals = (ctx, extensions) => {
42
43
  value instanceof PromiseObj) {
43
44
  throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`);
44
45
  }
46
+ if (seen.has(value))
47
+ throw typeError(`${label} contains a circular value.`);
48
+ seen.add(value);
45
49
  if (value instanceof ErrorObj) {
46
50
  const name = coerceToString(get(value, "name"));
47
51
  const message = get(value, "message");
48
52
  const text = message === undefined ? "" : coerceToString(message);
49
- return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text);
53
+ const copied = name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text);
54
+ for (const key of new Set(["cause", ...keys(value)])) {
55
+ if (uncrossed.has(key) || !has(value, key))
56
+ continue;
57
+ const item = crossing(() => next(get(value, key)));
58
+ if (item === left)
59
+ continue;
60
+ Object.defineProperty(copied, key, {
61
+ value: item,
62
+ writable: true,
63
+ configurable: true,
64
+ enumerable: key !== "cause",
65
+ });
66
+ }
67
+ seen.delete(value);
68
+ return copied;
50
69
  }
51
- if (seen.has(value))
52
- throw typeError(`${label} contains a circular value.`);
53
- seen.add(value);
54
70
  const copied = value instanceof Arr
55
71
  ? value.items.map(next)
56
72
  : Object.fromEntries(entries(value)
@@ -67,6 +83,7 @@ export const extensionGlobals = (ctx, extensions) => {
67
83
  if (typeof value === "function")
68
84
  return wrap(value, label);
69
85
  if (value !== null && typeof value === "object") {
86
+ const next = (item, path) => fromHost(item, path, depth + 1, seen);
70
87
  if (value instanceof Date)
71
88
  return new DateObj(builtins.Date, value.getTime());
72
89
  if (value instanceof RegExp)
@@ -76,14 +93,27 @@ export const extensionGlobals = (ctx, extensions) => {
76
93
  if (value instanceof ArrayBuffer)
77
94
  return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)));
78
95
  if (value instanceof Error) {
79
- return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message);
96
+ if (seen.has(value))
97
+ throw typeError(`${label} produced a circular value.`);
98
+ seen.add(value);
99
+ const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message);
100
+ const fields = value;
101
+ for (const key of new Set(["cause", ...Object.keys(value)])) {
102
+ if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function")
103
+ continue;
104
+ const item = crossing(() => next(fields[key], `${label}.${key}`));
105
+ if (item === left)
106
+ continue;
107
+ define(copied, key, item, key === "cause" ? hidden : undefined);
108
+ }
109
+ seen.delete(value);
110
+ return copied;
80
111
  }
81
112
  if (value instanceof URL)
82
113
  return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href));
83
114
  if (value instanceof URLSearchParams) {
84
115
  return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value));
85
116
  }
86
- const next = (item, path) => fromHost(item, path, depth + 1, seen);
87
117
  if (value instanceof Map) {
88
118
  const wrapped = new MapObj(builtins.Map);
89
119
  for (const [key, item] of value)
@@ -115,23 +145,44 @@ export const extensionGlobals = (ctx, extensions) => {
115
145
  }
116
146
  throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`);
117
147
  };
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));
148
+ // A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
149
+ // hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
150
+ // program catches a copy. Functions inside results are part of a value's API and skip the hooks.
151
+ const wrap = (value, label, describe) => fn(builtins, value.name, value.length, (_, values) => {
152
+ const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`));
153
+ const hooks = ctx.tools.hooks;
154
+ const settle = (run) => (describe === undefined
155
+ ? run
156
+ : 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
157
  let result;
124
158
  try {
125
- result = value.apply(undefined, converted);
159
+ result = value.apply(undefined, args);
126
160
  }
127
161
  catch (reason) {
128
- return Effect.fail(thrown(reason));
162
+ return settle(Effect.fail(reason));
129
163
  }
130
164
  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)));
165
+ return settle(Effect.succeed(result));
166
+ return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })));
133
167
  });
134
- return extensions.flatMap((extension) => Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)]));
168
+ return extensions.flatMap((extension) => Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))]));
169
+ };
170
+ /**
171
+ * An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
172
+ * `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
173
+ * cross (a socket, a handle, a function) is left behind so the error itself always arrives.
174
+ */
175
+ const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"]);
176
+ const left = Symbol("left behind");
177
+ const crossing = (convert) => {
178
+ try {
179
+ return convert();
180
+ }
181
+ catch (reason) {
182
+ if (reason instanceof PendingThrow)
183
+ return left;
184
+ throw reason;
185
+ }
135
186
  };
136
187
  const hostErrors = new Map([
137
188
  ["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;
@@ -47,7 +58,7 @@ type ToolNode<R> = {
47
58
  namespace?: Namespace<R>;
48
59
  readonly children: Map<string, ToolNode<R>>;
49
60
  };
50
- /** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
61
+ /** Tools indexed once per runtime, with discovery materialized on demand. */
51
62
  export type Prepared<R = never> = {
52
63
  readonly root: ToolNode<R>;
53
64
  readonly catalog: ReadonlyArray<ToolDescription>;
@@ -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));
@@ -67,13 +80,22 @@ const flattenTools = (node, path = [], namespaces = []) => {
67
80
  ...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
68
81
  ];
69
82
  };
70
- const describeTool = (visible) => ({
71
- path: visible.path,
72
- description: visible.tool.description,
73
- signature: isEmptyInput(visible.tool)
74
- ? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
75
- : `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
76
- });
83
+ const describeTool = (visible) => {
84
+ let signature;
85
+ return {
86
+ path: visible.path,
87
+ description: visible.tool.description,
88
+ get signature() {
89
+ // Search ranks paths and descriptions first; only returned matches need their schemas rendered.
90
+ // Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
91
+ return (signature ??= [
92
+ toolExpression(visible.path),
93
+ isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
94
+ `: Promise<${outputTypeScript(visible.tool, true)}>`,
95
+ ].join(""));
96
+ },
97
+ };
98
+ };
77
99
  const tokenize = (query) => query
78
100
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
79
101
  .toLowerCase()
@@ -152,12 +174,20 @@ const toSearchEntry = (visible) => ({
152
174
  });
153
175
  export const prepare = (tools) => {
154
176
  const root = toolTrie(tools);
155
- // Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
156
- const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path));
177
+ let searchIndex;
178
+ let catalog;
157
179
  return {
158
180
  root,
159
- catalog: visible.map(describeTool),
160
- searchIndex: visible.map(toSearchEntry),
181
+ get catalog() {
182
+ return (catalog ??= this.searchIndex.map((entry) => entry.description));
183
+ },
184
+ get searchIndex() {
185
+ // Executing known tools only needs the trie. Render discovery when it is actually read,
186
+ // ordering after canonical-path collisions settle so instruction bytes stay deterministic.
187
+ return (searchIndex ??= flattenTools(root)
188
+ .sort((left, right) => compareText(left.path, right.path))
189
+ .map(toSearchEntry));
190
+ },
161
191
  };
162
192
  };
163
193
  const lookup = (root, segments) => segments.reduce((node, segment) => node?.children.get(segment), root);
@@ -193,26 +223,9 @@ export class ToolRuntimeError extends Error {
193
223
  }
194
224
  }
195
225
  /** Per-execution call state over tools prepared once for the runtime. */
196
- export const make = (prepared, maxToolCalls, hooks) => {
226
+ export const make = (prepared, maxToolCalls, hooks = {}) => {
197
227
  const calls = [];
198
228
  const root = prepared.root;
199
- 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
229
  const recordCall = (call) => {
217
230
  if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
218
231
  throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`);
@@ -227,14 +240,8 @@ export const make = (prepared, maxToolCalls, hooks) => {
227
240
  try: () => decodeToolInput(tool, normalized[0]),
228
241
  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
242
  });
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);
243
+ yield* Effect.sync(() => recordCall({ name }));
244
+ return yield* hooked({ name, input }, hooks["tool.before"], hooks["tool.after"], Effect.gen(function* () {
238
245
  const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(Effect.catchCause((cause) => {
239
246
  if (Cause.hasInterruptsOnly(cause))
240
247
  return Effect.interrupt;
@@ -250,17 +257,18 @@ export const make = (prepared, maxToolCalls, hooks) => {
250
257
  },
251
258
  catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
252
259
  });
253
- }), call);
260
+ }));
254
261
  });
255
262
  return {
256
263
  calls,
264
+ hooks,
257
265
  keys: (path) => namespaceKeys(root, path),
258
- search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
266
+ search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
259
267
  execute: (path, args) => Effect.suspend(() => {
260
268
  const segments = canonicalSegments(path);
261
269
  // Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
262
270
  if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
263
- return executeTool("search", searchTool, args);
271
+ return executeTool("search", makeSearchTool(prepared.searchIndex), args);
264
272
  return executeTool(segments.join("."), resolve(root, path), args);
265
273
  }),
266
274
  };
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": "2.0.4",
4
+ "version": "2.0.6",
5
5
  "description": "Effect-native confined code execution over schema-described tools",
6
6
  "type": "module",
7
7
  "license": "MIT",