@neurocode-ai/codemode 1.18.8

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.
@@ -0,0 +1,806 @@
1
+ import { Cause, Effect, Schema } from "effect"
2
+ import { ToolError, toolError } from "./tool-error.js"
3
+ import {
4
+ decodeInput as decodeToolInput,
5
+ decodeOutput as decodeToolOutput,
6
+ identifierSegment,
7
+ inputProperties,
8
+ inputTypeScript,
9
+ outputTypeScript,
10
+ } from "./tool-schema.js"
11
+ import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
12
+ import {
13
+ SandboxDate,
14
+ SandboxMap,
15
+ SandboxPromise,
16
+ SandboxRegExp,
17
+ SandboxSet,
18
+ SandboxURL,
19
+ SandboxURLSearchParams,
20
+ } from "./values.js"
21
+
22
+ const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
23
+
24
+ export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
25
+
26
+ export type HostTools<R = never> = {
27
+ [name: string]: HostTool<R> | Definition<R> | HostTools<R>
28
+ }
29
+
30
+ export type Services<Tools> = ServicesOf<Tools, []>
31
+
32
+ type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
33
+ ? never
34
+ : Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
35
+ ? R
36
+ : Tools extends {
37
+ readonly _tag: "CodeModeTool"
38
+ readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
39
+ }
40
+ ? R
41
+ : Tools extends object
42
+ ? string extends keyof Tools
43
+ ? ServicesOf<Tools[string], [...Depth, unknown]>
44
+ : ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
45
+ : never
46
+
47
+ /** Minimal audit record retained for each admitted tool call. */
48
+ export type ToolCall = {
49
+ readonly name: string
50
+ }
51
+
52
+ /** Decoded tool call observed immediately before tool execution. */
53
+ export type ToolCallStarted = {
54
+ readonly index: number
55
+ readonly name: string
56
+ readonly input: unknown
57
+ }
58
+
59
+ /** Completed tool call observed immediately after tool execution settles. */
60
+ export type ToolCallEnded = {
61
+ readonly index: number
62
+ readonly name: string
63
+ readonly input: unknown
64
+ readonly durationMs: number
65
+ readonly outcome: "success" | "failure"
66
+ /** Model-safe failure message; present only when `outcome` is `"failure"`. */
67
+ readonly message?: string
68
+ }
69
+
70
+ /** Non-throwing observation hooks fired around each admitted tool call. */
71
+ export type ToolCallHooks<R = never> = {
72
+ readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
73
+ readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
74
+ }
75
+
76
+ /** Model-visible description of one schema-backed tool. */
77
+ export type ToolDescription = {
78
+ readonly path: string
79
+ readonly description: string
80
+ readonly signature: string
81
+ }
82
+
83
+ export type SafeObject = Record<string, unknown>
84
+
85
+ const reservedNamespace = "$codemode"
86
+ const defaultCatalogBudget = 2_000
87
+ const defaultSearchLimit = 10
88
+ const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
89
+ const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
90
+ const SearchInput = Schema.Struct({
91
+ query: Schema.optionalKey(Schema.String),
92
+ namespace: Schema.optionalKey(Schema.String),
93
+ limit: Schema.optionalKey(PositiveInt),
94
+ offset: Schema.optionalKey(NonNegativeInt),
95
+ })
96
+ const SearchItem = Schema.Struct({
97
+ path: Schema.String,
98
+ description: Schema.String,
99
+ signature: Schema.String,
100
+ })
101
+ const SearchOutput = Schema.Struct({
102
+ items: Schema.Array(SearchItem),
103
+ remaining: NonNegativeInt,
104
+ next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
105
+ })
106
+ const toolExpression = (path: string) =>
107
+ "tools" +
108
+ path
109
+ .split(".")
110
+ .map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`))
111
+ .join("")
112
+
113
+ export class ToolReference {
114
+ constructor(readonly path: ReadonlyArray<string>) {}
115
+ }
116
+
117
+ /**
118
+ * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
119
+ * limit) purely because it produces a clearer diagnostic than a native stack-overflow
120
+ * RangeError would.
121
+ */
122
+ const MAX_VALUE_DEPTH = 32
123
+
124
+ export class ToolRuntimeError extends Error {
125
+ constructor(
126
+ readonly kind:
127
+ | "UnknownTool"
128
+ | "InvalidToolInput"
129
+ | "InvalidToolOutput"
130
+ | "InvalidDataValue"
131
+ | "ToolCallLimitExceeded",
132
+ message: string,
133
+ readonly suggestions: ReadonlyArray<string> = [],
134
+ ) {
135
+ super(message)
136
+ this.name = "ToolRuntimeError"
137
+ }
138
+ }
139
+
140
+ const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
141
+ isToolDefinition<R>(value)
142
+
143
+ const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
144
+ effect.pipe(
145
+ Effect.catchCause((cause) => {
146
+ if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
147
+ const error = Cause.squash(cause)
148
+ return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
149
+ }),
150
+ )
151
+
152
+ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
153
+
154
+ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
155
+
156
+ /**
157
+ * Validates and copies a value against the plain-data contract (depth, circularity, plain
158
+ * objects only, blocked properties, data-only leaves).
159
+ *
160
+ * Two modes share the walk:
161
+ * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
162
+ * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
163
+ * exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
164
+ * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
165
+ * codemode.ts): standard-library value instances pass through untouched (treated as leaves,
166
+ * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
167
+ * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
168
+ *
169
+ * Both modes reject un-awaited promises with an await-hinting diagnostic.
170
+ */
171
+ export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
172
+ copyBounded(value, label, 0, new Set(), preserveSandboxValues)
173
+
174
+ const copyBounded = (
175
+ value: unknown,
176
+ label: string,
177
+ depth: number,
178
+ seen: Set<object>,
179
+ preserveSandboxValues: boolean,
180
+ ): unknown => {
181
+ if (depth > MAX_VALUE_DEPTH) {
182
+ throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
183
+ }
184
+ if (
185
+ value === null ||
186
+ value === undefined ||
187
+ typeof value === "string" ||
188
+ typeof value === "boolean" ||
189
+ // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
190
+ // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
191
+ // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
192
+ // JSON.stringify already does at any tool boundary.
193
+ typeof value === "number"
194
+ ) {
195
+ return value
196
+ }
197
+
198
+ if (typeof value !== "object") {
199
+ throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
200
+ }
201
+
202
+ // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
203
+ // model exactly how to fix the program instead.
204
+ if (value instanceof SandboxPromise) {
205
+ throw new ToolRuntimeError(
206
+ "InvalidDataValue",
207
+ `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
208
+ )
209
+ }
210
+
211
+ if (preserveSandboxValues) {
212
+ // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
213
+ // are never walked here (Map/Set members are validated where mutation happens, and the
214
+ // real boundary still serializes them below).
215
+ if (
216
+ value instanceof SandboxDate ||
217
+ value instanceof SandboxRegExp ||
218
+ value instanceof SandboxMap ||
219
+ value instanceof SandboxSet ||
220
+ value instanceof SandboxURL ||
221
+ value instanceof SandboxURLSearchParams
222
+ ) {
223
+ return value
224
+ }
225
+ // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
226
+ // the boundary first), but wrap them defensively rather than degrading to JSON forms.
227
+ if (value instanceof Date) return new SandboxDate(value.getTime())
228
+ if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
229
+ if (value instanceof Map) {
230
+ const wrapped = new SandboxMap()
231
+ for (const [key, item] of value.entries()) {
232
+ wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
233
+ }
234
+ return wrapped
235
+ }
236
+ if (value instanceof Set) {
237
+ const wrapped = new SandboxSet()
238
+ for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
239
+ return wrapped
240
+ }
241
+ if (value instanceof URL) return new SandboxURL(new URL(value.href))
242
+ if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
243
+ }
244
+
245
+ // Sandbox value types (and their host counterparts, which a host tool may legitimately
246
+ // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
247
+ // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
248
+ if (value instanceof SandboxDate) {
249
+ return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
250
+ }
251
+ if (value instanceof Date) {
252
+ return Number.isFinite(value.getTime()) ? value.toISOString() : null
253
+ }
254
+ if (value instanceof SandboxURL) return value.url.href
255
+ if (value instanceof URL) return value.href
256
+ if (
257
+ value instanceof SandboxRegExp ||
258
+ value instanceof SandboxMap ||
259
+ value instanceof SandboxSet ||
260
+ value instanceof SandboxURLSearchParams ||
261
+ value instanceof RegExp ||
262
+ value instanceof Map ||
263
+ value instanceof Set ||
264
+ value instanceof URLSearchParams
265
+ ) {
266
+ return Object.create(null) as SafeObject
267
+ }
268
+
269
+ if (seen.has(value)) {
270
+ throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
271
+ }
272
+
273
+ seen.add(value)
274
+
275
+ if (Array.isArray(value)) {
276
+ const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
277
+ seen.delete(value)
278
+ return copied
279
+ }
280
+
281
+ const prototype = Object.getPrototypeOf(value)
282
+ if (prototype !== Object.prototype && prototype !== null) {
283
+ throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
284
+ }
285
+
286
+ const copied: SafeObject = Object.create(null) as SafeObject
287
+ for (const [key, item] of Object.entries(value)) {
288
+ if (isBlockedMember(key)) {
289
+ throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
290
+ }
291
+ copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
292
+ }
293
+ seen.delete(value)
294
+ return copied
295
+ }
296
+
297
+ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
298
+ if (value === undefined && undefinedAsNull) return null
299
+ // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
300
+ // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
301
+ // have no JSON representation, so JSON.stringify would produce null anyway.
302
+ if (typeof value === "number" && !Number.isFinite(value)) {
303
+ return null
304
+ }
305
+ if (Array.isArray(value)) {
306
+ return value.map((item) => copyOut(item, undefinedAsNull))
307
+ }
308
+
309
+ if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
310
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
311
+ }
312
+
313
+ return value
314
+ }
315
+
316
+ const definitions = <R>(
317
+ tools: HostTools<R>,
318
+ path: ReadonlyArray<string> = [],
319
+ ): Array<{ path: string; definition: Definition<R> }> => {
320
+ const entries: Array<{ path: string; definition: Definition<R> }> = []
321
+ for (const [name, value] of Object.entries(tools)) {
322
+ const next = [...path, name]
323
+ if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
324
+ else if (typeof value !== "function") entries.push(...definitions(value, next))
325
+ }
326
+ return entries
327
+ }
328
+
329
+ const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
330
+ path,
331
+ description: definition.description,
332
+ signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
333
+ })
334
+
335
+ const visibleDefinitions = <R>(tools: HostTools<R>) =>
336
+ definitions(tools).map(({ path, definition }) => ({
337
+ path,
338
+ definition,
339
+ description: describeDefinition(path, definition),
340
+ }))
341
+
342
+ export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
343
+ visibleDefinitions(tools).map(({ description }) => description)
344
+
345
+ export type DiscoveryPlan = {
346
+ readonly catalog: ReadonlyArray<ToolDescription>
347
+ readonly instructions: string
348
+ readonly searchIndex: ReadonlyArray<SearchEntry>
349
+ }
350
+
351
+ export type SearchEntry = {
352
+ readonly description: ToolDescription
353
+ /** Top-level namespace (first path segment), matched by the search `namespace` option. */
354
+ readonly namespace: string
355
+ /** Lowercased path + description + input property names/descriptions, for substring matching. */
356
+ readonly searchText: string
357
+ }
358
+
359
+ /**
360
+ * Split a query into lowercased search terms. camelCase boundaries are split
361
+ * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
362
+ * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
363
+ * tokenize alike. Empties and the `*` wildcard are dropped.
364
+ */
365
+ const tokenize = (query: string): Array<string> =>
366
+ query
367
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
368
+ .toLowerCase()
369
+ .split(/[^a-z0-9]+/)
370
+ .filter((term) => term.length > 0 && term !== "*")
371
+
372
+ /**
373
+ * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
374
+ * query term ("issues") still matches indexed text that only carries the singular
375
+ * ("issue"). Matching is one-directional substring containment, so the variants are
376
+ * needed only on the query side; scoring weights are unchanged - each field check
377
+ * passes when ANY form matches.
378
+ */
379
+ const termForms = (term: string): Array<string> => {
380
+ const forms = [term]
381
+ if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
382
+ if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1))
383
+ return forms
384
+ }
385
+
386
+ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
387
+ _tag: "CodeModeTool",
388
+ description: "Search available Code Mode tools",
389
+ input: SearchInput,
390
+ output: SearchOutput,
391
+ run: (input) =>
392
+ Effect.sync(() => {
393
+ const request = input as typeof SearchInput.Type
394
+ const query = request.query ?? ""
395
+ const offset = request.offset ?? 0
396
+ const scoped =
397
+ request.namespace === undefined
398
+ ? searchIndex
399
+ : searchIndex.filter((entry) => entry.namespace === request.namespace)
400
+ // A query that names one tool path exactly (canonical path or rendered JavaScript
401
+ // expression) is a lookup, not a search: return that tool alone.
402
+ const trimmed = query.trim()
403
+ const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
404
+ const exact =
405
+ pathQuery === ""
406
+ ? undefined
407
+ : scoped.find(
408
+ (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
409
+ )
410
+ const terms = tokenize(query).map(termForms)
411
+ // Additive field-weighted scoring, summed across terms: exact path or path segment
412
+ // (20) > path substring (8) > description substring (4) > any searchable text,
413
+ // including input parameter names and descriptions (2).
414
+ const ranked =
415
+ exact !== undefined
416
+ ? [exact]
417
+ : scoped
418
+ .map((entry) => {
419
+ const path = entry.description.path.toLowerCase()
420
+ const description = entry.description.description.toLowerCase()
421
+ const score = terms.reduce(
422
+ (total, forms) =>
423
+ total +
424
+ (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
425
+ (forms.some((form) => path.includes(form)) ? 8 : 0) +
426
+ (forms.some((form) => description.includes(form)) ? 4 : 0) +
427
+ (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
428
+ 0,
429
+ )
430
+ return { entry, score }
431
+ })
432
+ .filter(({ score }) => terms.length === 0 || score > 0)
433
+ .sort(
434
+ (left, right) =>
435
+ right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
436
+ )
437
+ .map(({ entry }) => entry)
438
+ const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
439
+ ...description,
440
+ path: toolExpression(description.path),
441
+ }))
442
+ const remaining = Math.max(0, ranked.length - offset - items.length)
443
+ return {
444
+ items,
445
+ remaining,
446
+ next: remaining > 0 ? { offset: offset + items.length } : null,
447
+ }
448
+ }),
449
+ })
450
+
451
+ const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
452
+
453
+ const catalogLine = (tool: ToolDescription) => {
454
+ // Keep the tool description concise; the full schema documentation remains in the signature.
455
+ const line = tool.description.split("\n", 1)[0]!.trim()
456
+ const description = line.length > 120 ? line.slice(0, 119) + "..." : line
457
+ return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
458
+ }
459
+
460
+ const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
461
+ description,
462
+ namespace: path.split(".", 1)[0]!,
463
+ searchText: [
464
+ path,
465
+ definition.description,
466
+ ...inputProperties(definition).flatMap(({ name, description: property }) =>
467
+ property === undefined ? [name] : [name, property],
468
+ ),
469
+ ]
470
+ .join("\n")
471
+ .toLowerCase(),
472
+ })
473
+
474
+ /** The runtime search index over every described tool. Search is always registered. */
475
+ export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
476
+ visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
477
+
478
+ export const assertValidTools = <R>(tools: HostTools<R>): void => {
479
+ if (Object.hasOwn(tools, reservedNamespace)) {
480
+ throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Budgeted catalog: every namespace is always listed with its tool count; full call
486
+ * signatures are inlined against the `catalogBudget` (estimated tokens,
487
+ * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
488
+ * namespace still holding un-inlined tools attempts to place its next-cheapest line, and
489
+ * a namespace whose next line does not fit is done while the others keep going - so every
490
+ * namespace gets some representation before any namespace gets everything. The section
491
+ * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
492
+ * namespace. Namespace stub lines are never budgeted: every namespace appears with its
493
+ * tool count even at budget 0.
494
+ */
495
+ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
496
+ if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
497
+ throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
498
+ }
499
+ const visible = visibleDefinitions(tools)
500
+ const described = visible.map(({ description }) => description)
501
+
502
+ const namespaces = new Map<string, Array<ToolDescription>>()
503
+ for (const tool of described) {
504
+ const [namespace = tool.path] = tool.path.split(".")
505
+ const group = namespaces.get(namespace) ?? []
506
+ group.push(tool)
507
+ namespaces.set(namespace, group)
508
+ }
509
+ const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
510
+
511
+ // Select which signatures fit the budget before emitting, so the list can state
512
+ // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
513
+ // alphabetical), every namespace still holding un-inlined tools tries to place its
514
+ // next-cheapest line against the shared budget; a namespace whose next line does not
515
+ // fit is done - the others keep going - so every namespace gets some representation
516
+ // before any namespace gets everything.
517
+ const selections = ordered.map(([namespace, group]) => ({
518
+ namespace,
519
+ picked: new Set<ToolDescription>(),
520
+ queue: [...group].sort(
521
+ (left, right) =>
522
+ estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
523
+ ),
524
+ }))
525
+ let used = 0
526
+ let active = selections.filter((selection) => selection.queue.length > 0)
527
+ while (active.length > 0) {
528
+ const stillActive: typeof active = []
529
+ for (const selection of active) {
530
+ const tool = selection.queue[0]!
531
+ const cost = estimateTokens(catalogLine(tool))
532
+ if (used + cost > catalogBudget) continue
533
+ selection.queue.shift()
534
+ selection.picked.add(tool)
535
+ used += cost
536
+ if (selection.queue.length > 0) stillActive.push(selection)
537
+ }
538
+ active = stillActive
539
+ }
540
+ const shown = new Map<string, ReadonlySet<ToolDescription>>(
541
+ selections.map(({ namespace, picked }) => [namespace, picked]),
542
+ )
543
+ const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
544
+ const complete = totalShown === described.length
545
+
546
+ const empty = described.length === 0
547
+
548
+ // Section order is deliberate: workflow first (the top is the least likely part of a long
549
+ // description to be truncated or skimmed away), then rules, then syntax, with the budgeted
550
+ // catalog at the bottom. Example call forms use placeholders - never a real or fabricated
551
+ // tool name - and show both dot and bracket notation so non-identifier names are not normalized.
552
+ const intro = [
553
+ empty
554
+ ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
555
+ : complete
556
+ ? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
557
+ : "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
558
+ ...(empty
559
+ ? []
560
+ : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
561
+ ]
562
+
563
+ // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
564
+ // catalog already shows every signature, so step 1 picks from the list instead.
565
+ const workflow = empty
566
+ ? []
567
+ : [
568
+ "",
569
+ "## Workflow",
570
+ "",
571
+ ...(complete
572
+ ? [
573
+ "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
574
+ "2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
575
+ "3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
576
+ ]
577
+ : [
578
+ '1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
579
+ "2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
580
+ ]),
581
+ ]
582
+
583
+ const rules = empty
584
+ ? []
585
+ : [
586
+ "",
587
+ "## Rules",
588
+ "",
589
+ complete
590
+ ? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
591
+ : "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
592
+ "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
593
+ "- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
594
+ '- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
595
+ "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
596
+ ...(complete
597
+ ? []
598
+ : [
599
+ '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
600
+ "- If search returns `next`, repeat the same search with `offset: next.offset`.",
601
+ ]),
602
+ ]
603
+
604
+ const language = [
605
+ "",
606
+ "## Language",
607
+ "",
608
+ "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
609
+ "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
610
+ "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
611
+ ]
612
+
613
+ const toolSection: Array<string> = [""]
614
+ if (empty) {
615
+ toolSection.push("## Available tools", "", "No tools are currently available.")
616
+ } else {
617
+ toolSection.push(
618
+ complete
619
+ ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
620
+ : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
621
+ "",
622
+ )
623
+ for (const [namespace, group] of ordered) {
624
+ const picked = shown.get(namespace)!
625
+ const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
626
+ // Annotate only when a namespace is not fully shown, so a comprehensive
627
+ // namespace reads cleanly and a truncated one is unambiguous.
628
+ const label =
629
+ picked.size === group.length
630
+ ? count
631
+ : picked.size === 0
632
+ ? `${count}, none shown`
633
+ : `${count}, ${picked.size} shown`
634
+ toolSection.push(`- ${namespace} (${label})`)
635
+ for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
636
+ }
637
+ if (!complete) {
638
+ toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
639
+ }
640
+ }
641
+
642
+ const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
643
+ return {
644
+ catalog: described,
645
+ instructions: lines.join("\n"),
646
+ searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
647
+ }
648
+ }
649
+
650
+ /**
651
+ * The enumerable names at one node of the callable tool tree - namespace names at the root,
652
+ * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
653
+ * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
654
+ * function in JS). An unknown path is an `UnknownTool` error pointing at the working
655
+ * discovery idioms, mirroring how calling an unknown tool fails.
656
+ */
657
+ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
658
+ let value: HostTool<R> | Definition<R> | HostTools<R> = tools
659
+ for (const segment of path) {
660
+ if (
661
+ isBlockedMember(segment) ||
662
+ typeof value === "function" ||
663
+ isDefinition(value) ||
664
+ !Object.hasOwn(value, segment)
665
+ ) {
666
+ throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
667
+ "Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
668
+ ])
669
+ }
670
+ value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
671
+ }
672
+ if (typeof value === "function" || isDefinition(value)) return []
673
+ return Object.keys(value)
674
+ }
675
+
676
+ const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
677
+ let value: HostTool<R> | Definition<R> | HostTools<R> = tools
678
+
679
+ for (const segment of path) {
680
+ if (
681
+ isBlockedMember(segment) ||
682
+ typeof value === "function" ||
683
+ isDefinition(value) ||
684
+ !Object.hasOwn(value, segment)
685
+ ) {
686
+ throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
687
+ "Use tools.$codemode.search({ query }) to find available described tools.",
688
+ ])
689
+ }
690
+ value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
691
+ }
692
+
693
+ if (typeof value !== "function" && !isDefinition(value)) {
694
+ throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
695
+ }
696
+
697
+ return value
698
+ }
699
+
700
+ export type ToolRuntime<R = never> = {
701
+ readonly root: ToolReference
702
+ readonly calls: Array<ToolCall>
703
+ readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
704
+ /** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
705
+ readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
706
+ }
707
+
708
+ export const make = <R>(
709
+ tools: HostTools<R>,
710
+ /** Undefined means unlimited tool calls. */
711
+ maxToolCalls: number | undefined,
712
+ searchIndex: ReadonlyArray<SearchEntry>,
713
+ hooks?: ToolCallHooks<R>,
714
+ ): ToolRuntime<R> => {
715
+ const calls: Array<ToolCall> = []
716
+ const callableTools = {
717
+ ...tools,
718
+ [reservedNamespace]: { search: makeSearchTool(searchIndex) },
719
+ }
720
+
721
+ // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
722
+ // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
723
+ const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
724
+ const onEnd = hooks?.onToolCallEnd
725
+ if (onEnd === undefined) return effect
726
+ const startedAt = Date.now()
727
+ return effect.pipe(
728
+ Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
729
+ Effect.tapError((error) => {
730
+ const message =
731
+ error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
732
+ return onEnd({
733
+ ...call,
734
+ durationMs: Date.now() - startedAt,
735
+ outcome: "failure",
736
+ message,
737
+ })
738
+ }),
739
+ )
740
+ }
741
+
742
+ const decodeOutput = (value: unknown, name: string) =>
743
+ Effect.try({
744
+ try: () => copyIn(value, `Result from tool '${name}'`),
745
+ catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
746
+ })
747
+
748
+ const recordCall = (call: ToolCall): void => {
749
+ if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
750
+ throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
751
+ }
752
+ calls.push(call)
753
+ }
754
+
755
+ return {
756
+ root: new ToolReference([]),
757
+ calls,
758
+ keys: (path) => namespaceKeys(callableTools, path),
759
+ invoke: (path, args) =>
760
+ Effect.gen(function* () {
761
+ const name = path.join(".")
762
+ const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
763
+ const call = { name }
764
+ const recordAndObserve = (input: unknown) =>
765
+ Effect.sync(() => {
766
+ recordCall(call)
767
+ return calls.length - 1
768
+ }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
769
+ const tool = resolve(callableTools, path)
770
+ let describedInput: unknown
771
+ if (isDefinition(tool)) {
772
+ if (externalArgs.length !== 1)
773
+ throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
774
+ describedInput = yield* Effect.try({
775
+ try: () => decodeToolInput(tool, externalArgs[0]),
776
+ catch: (cause) =>
777
+ new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
778
+ })
779
+ }
780
+ const input = isDefinition(tool) ? describedInput : externalArgs
781
+ const index = yield* recordAndObserve(input)
782
+ const currentCall = { index, name, input }
783
+ if (isDefinition(tool)) {
784
+ return yield* observeEnd(
785
+ Effect.gen(function* () {
786
+ const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
787
+ const result = yield* Effect.try({
788
+ try: () => decodeToolOutput(tool, raw),
789
+ catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
790
+ })
791
+ return yield* decodeOutput(result, name)
792
+ }),
793
+ currentCall,
794
+ )
795
+ }
796
+ return yield* observeEnd(
797
+ Effect.gen(function* () {
798
+ return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
799
+ }),
800
+ currentCall,
801
+ )
802
+ }),
803
+ }
804
+ }
805
+
806
+ export * as ToolRuntime from "./tool-runtime.js"