@yolk-sdk/codemode 0.1.0-canary.106 → 0.1.0-canary.108

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/src/node.ts CHANGED
@@ -121,7 +121,7 @@ const maxArgumentDepth = 64
121
121
  * proxy (close-match suggestions). Not a security boundary (`globalThis.tools` is unguarded):
122
122
  * arguments are still decoded by the host.
123
123
  */
124
- const toolsGuardSource = `(function (tools, labels) {
124
+ const toolsGuardSource = `(function (tools, labels, unbound) {
125
125
  // Captured before the script runs, so a script that changes built-ins cannot change the checks.
126
126
  const { apply, getPrototypeOf, setPrototypeOf } = Reflect
127
127
  const isFinite = Number.isFinite
@@ -259,8 +259,13 @@ const toolsGuardSource = `(function (tools, labels) {
259
259
  if (!wrappers.has(call)) wrappers.set(call, wrap('tools[' + JSON.stringify(key) + ']', call))
260
260
  guarded[key] = wrappers.get(call)
261
261
  }
262
+ // Unbound keys (stage-only tools) reject with their hint instead of being undefined.
263
+ const rejections = create(null)
264
+ for (const [key, message] of unbound) {
265
+ if (!(key in guarded)) rejections[key] = async () => { throw new TypeErrorCtor(message) }
266
+ }
262
267
  return new Proxy(Object.freeze(guarded), {
263
- get: (target, key) => (key in target ? target[key] : tools[key])
268
+ get: (target, key) => (key in target ? target[key] : key in rejections ? rejections[key] : tools[key])
264
269
  })
265
270
  })`
266
271
 
@@ -275,7 +280,10 @@ const jsonSource = (value: unknown) =>
275
280
  char === '\u2028' ? '\\u2028' : '\\u2029'
276
281
  )
277
282
 
278
- const guardSuffix = (tools: ReadonlyArray<CodeModeExecutorTool>) => {
283
+ const guardSuffix = (
284
+ tools: ReadonlyArray<CodeModeExecutorTool>,
285
+ unbound: CodeModeExecuteOptions['unbound']
286
+ ) => {
279
287
  const names = tools.map(tool => tool.name)
280
288
  const labels = codeModeCallLabels(names)
281
289
 
@@ -285,7 +293,9 @@ const guardSuffix = (tools: ReadonlyArray<CodeModeExecutorTool>) => {
285
293
  tool.callLabel ?? labels[index] ?? `tools[${JSON.stringify(tool.name)}]`
286
294
  ])
287
295
 
288
- return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}), console)`
296
+ const rejected = (unbound ?? []).map(entry => [entry.key, entry.message])
297
+
298
+ return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}, ${jsonSource(rejected)}), console)`
289
299
  }
290
300
 
291
301
  const lineTerminators = /\r\n?|[\n\u2028\u2029]/g
@@ -510,11 +520,14 @@ export const makePiCodeModeExecutor = (
510
520
 
511
521
  try {
512
522
  return fromPiResult(
513
- await sandbox.execute(`${guardPrefix}${stripped.code}${guardSuffix(execution.tools)}`, {
514
- signal: execution.signal,
515
- store: execution.store,
516
- timeoutMs
517
- }),
523
+ await sandbox.execute(
524
+ `${guardPrefix}${stripped.code}${guardSuffix(execution.tools, execution.unbound)}`,
525
+ {
526
+ signal: execution.signal,
527
+ store: execution.store,
528
+ timeoutMs
529
+ }
530
+ ),
518
531
  scriptLineCount(stripped.code)
519
532
  )
520
533
  } finally {
package/src/search.ts CHANGED
@@ -1,113 +1,59 @@
1
+ import { rankToolSearchDocuments, toolSearchTokens } from '@yolk-sdk/agent/tools'
1
2
  import type { CodeModeCatalogTool } from './catalog.ts'
2
3
 
3
4
  /** Default number of `searchTools` results. */
4
5
  export const defaultCodeModeSearchLimit = 8
5
6
 
6
- const k1 = 1.2
7
-
8
- const b = 0.75
9
-
10
- /** Lowercase word tokens; splits on non-alphanumerics and camelCase boundaries. */
11
- export const codeModeSearchTokens = (text: string): ReadonlyArray<string> =>
12
- text
13
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
14
- .toLowerCase()
15
- .split(/[^a-z0-9]+/)
16
- .filter(token => token.length > 0)
17
-
18
- type IndexedTool = {
19
- readonly tool: CodeModeCatalogTool
20
- readonly frequencies: ReadonlyMap<string, number>
21
- readonly length: number
22
- }
23
-
24
- const indexTool = (tool: CodeModeCatalogTool): IndexedTool => {
25
- const tokens = codeModeSearchTokens(
26
- [
27
- tool.identifier,
28
- tool.name,
29
- tool.description,
30
- tool.namespace,
31
- tool.namespaceDescription ?? ''
32
- ].join(' ')
33
- )
34
-
35
- const frequencies = new Map<string, number>()
36
-
37
- for (const token of tokens) {
38
- frequencies.set(token, (frequencies.get(token) ?? 0) + 1)
39
- }
40
-
41
- return { tool, frequencies, length: tokens.length }
42
- }
43
-
44
7
  export type CodeModeSearchOptions = {
45
8
  readonly limit?: number
46
9
  readonly namespace?: string
47
10
  }
48
11
 
49
12
  export type CodeModeSearchHit = {
50
- /** The identifier scripts call as `tools.<name>(args)`. */
13
+ /** The identifier scripts call as `tools.<name>(args)`, or for a stage-only tool the name
14
+ * scripts pass to `stage(name, args)`.
15
+ */
51
16
  readonly name: string
52
17
  readonly description: string
18
+ /** A stage-only tool (ADR 0007): no `tools` binding, only `stage()` it. */
19
+ readonly stageOnly?: true
53
20
  }
54
21
 
55
22
  /**
56
- * BM25 over each tool's identifier, raw name, description, namespace, and namespace description. Ties keep catalog order.
57
- * A query without words lists the (namespace-filtered) tools in catalog order.
23
+ * BM25 (the shared `rankToolSearchDocuments`) over each tool's identifier, raw name, description,
24
+ * namespace, and namespace description. Ties keep catalog order. A query without words lists the
25
+ * (namespace-filtered) tools in catalog order.
58
26
  */
59
27
  export const searchCodeModeTools = (
60
28
  catalog: ReadonlyArray<CodeModeCatalogTool>,
61
29
  query: string,
62
30
  options: CodeModeSearchOptions = {}
63
31
  ): ReadonlyArray<CodeModeSearchHit> => {
64
- const limit = Math.max(0, Math.floor(options.limit ?? defaultCodeModeSearchLimit))
65
-
66
32
  const scoped =
67
33
  options.namespace === undefined
68
34
  ? catalog
69
35
  : catalog.filter(tool => tool.namespace === options.namespace)
70
36
 
71
- const documents = scoped.map(indexTool)
72
- const terms = [...new Set(codeModeSearchTokens(query))]
73
-
74
- const hit = (tool: CodeModeCatalogTool): CodeModeSearchHit => ({
75
- name: tool.identifier,
76
- description: tool.description
77
- })
78
-
79
- if (terms.length === 0) {
80
- return documents.slice(0, limit).map(document => hit(document.tool))
81
- }
82
-
83
- const averageLength =
84
- documents.reduce((total, document) => total + document.length, 0) /
85
- Math.max(1, documents.length)
86
-
87
- const inverse = new Map(
88
- terms.map((term): readonly [string, number] => {
89
- const containing = documents.filter(document => document.frequencies.has(term)).length
90
-
91
- return [term, Math.log(1 + (documents.length - containing + 0.5) / (containing + 0.5))]
92
- })
93
- )
94
-
95
- return documents
96
- .map((document, index) => {
97
- const score = terms.reduce((total, term) => {
98
- const frequency = document.frequencies.get(term) ?? 0
99
-
100
- if (frequency === 0) return total
101
-
102
- const norm = k1 * (1 - b + (b * document.length) / Math.max(1, averageLength))
103
-
104
- return total + ((inverse.get(term) ?? 0) * frequency * (k1 + 1)) / (frequency + norm)
105
- }, 0)
106
-
107
- return { document, index, score }
108
- })
109
- .filter(entry => entry.score > 0)
110
- .sort((left, right) => right.score - left.score || left.index - right.index)
111
- .slice(0, limit)
112
- .map(entry => hit(entry.document.tool))
37
+ const hit = (tool: CodeModeCatalogTool): CodeModeSearchHit =>
38
+ tool.exposure === 'stage'
39
+ ? { name: tool.name, description: tool.description, stageOnly: true }
40
+ : { name: tool.identifier, description: tool.description }
41
+
42
+ return rankToolSearchDocuments(
43
+ scoped.map(tool => ({
44
+ value: tool,
45
+ text: [
46
+ tool.identifier,
47
+ tool.name,
48
+ tool.description,
49
+ tool.namespace,
50
+ tool.namespaceDescription ?? ''
51
+ ].join(' ')
52
+ })),
53
+ query,
54
+ options.limit ?? defaultCodeModeSearchLimit
55
+ ).map(hit)
113
56
  }
57
+
58
+ /** Lowercase word tokens of code mode search (the shared `toolSearchTokens`). */
59
+ export const codeModeSearchTokens = toolSearchTokens
package/src/tool.ts CHANGED
@@ -30,9 +30,12 @@ import {
30
30
  type NestedToolCallInput,
31
31
  type NestedToolCallStatus
32
32
  } from '@yolk-sdk/agent/protocol'
33
+ import { toCodemodeIdentifier } from '@earendil-works/pi-codemode/declarations'
33
34
  import {
34
35
  makeTool,
36
+ type NestedTool,
35
37
  type NestedToolExecutor,
38
+ type NestedToolInstructor,
36
39
  type StagedCallReceipt,
37
40
  type ToolExecutionInput,
38
41
  type ToolLedgerAbandonedInput,
@@ -43,11 +46,15 @@ import {
43
46
  } from '@yolk-sdk/agent/tools'
44
47
  import {
45
48
  codeModeCatalog,
49
+ codeModeCatalogInstruction,
50
+ codeModeStageOnlyCatalog,
46
51
  defaultCodeModeInlineBudget,
47
52
  describeCodeModeTool,
48
53
  findCodeModeTool,
49
54
  renderCodeModeDescription,
50
- type CodeModeCatalogTool
55
+ renderCodeModeInstructedDescription,
56
+ type CodeModeCatalogTool,
57
+ type CodeModeStagingDescription
51
58
  } from './catalog.ts'
52
59
  import type {
53
60
  CodeModeExecutionResult,
@@ -156,6 +163,15 @@ export type MakeCodeModeToolOptions<Context> = {
156
163
  * resolution's cap.
157
164
  */
158
165
  readonly staging?: CodeModeStagingOptions | false
166
+ /**
167
+ * Where the nested tool catalog goes (ADR 0007). `description` (default): in this tool's
168
+ * description, which changes with the catalog. `instructions`: the description stays invariant
169
+ * and the catalog (every namespace with its tool count, listed tools within `inlineBudget`) is
170
+ * the resolution's `instructions` entry; hosts pass `ResolvedToolSet.instructions` to
171
+ * `RunConfig.instructions`, so a change is appended to the conversation instead of rewriting the
172
+ * cached request prefix.
173
+ */
174
+ readonly catalog?: 'description' | 'instructions'
159
175
  }
160
176
 
161
177
  export type CodeModeStagingOptions = {
@@ -267,7 +283,11 @@ const hookOutcome = (exit: Exit.Exit<ToolResult>): CodeModeNestedCallOutcome =>
267
283
  type CodeModeNamespaceDescription = {
268
284
  readonly name: string
269
285
  description?: string
270
- readonly tools: ReadonlyArray<{ readonly name: string; readonly description: string }>
286
+ readonly tools: ReadonlyArray<{
287
+ readonly name: string
288
+ readonly description: string
289
+ readonly stageOnly?: true
290
+ }>
271
291
  }
272
292
 
273
293
  const SearchOptions = Schema.Struct({
@@ -277,7 +297,8 @@ const SearchOptions = Schema.Struct({
277
297
 
278
298
  const decodeSearchOptions = Schema.decodeUnknownOption(SearchOptions)
279
299
 
280
- /** `searchTools`, `describeTool`, and `describeNamespace` over every nested tool. */
300
+ /** `searchTools`, `describeTool`, and `describeNamespace` over every nested tool and, while the
301
+ * script may stage, every stage-only tool. */
281
302
  const discoveryGlobals = (
282
303
  catalog: ReadonlyArray<CodeModeCatalogTool>
283
304
  ): ReadonlyArray<CodeModeExecutorGlobal> => [
@@ -317,7 +338,11 @@ const discoveryGlobals = (
317
338
 
318
339
  const namespace: CodeModeNamespaceDescription = {
319
340
  name,
320
- tools: tools.map(tool => ({ name: tool.identifier, description: tool.description }))
341
+ tools: tools.map(tool =>
342
+ tool.exposure === 'stage'
343
+ ? { name: tool.name, description: tool.description, stageOnly: true }
344
+ : { name: tool.identifier, description: tool.description }
345
+ )
321
346
  }
322
347
 
323
348
  if (description !== undefined) namespace.description = description
@@ -332,8 +357,15 @@ type StageFork = (
332
357
  options: { readonly signal: AbortSignal }
333
358
  ) => Fiber.Fiber<StagedCallReceipt, ToolPlanStageError>
334
359
 
335
- /** `stage(name, args)`: records one call into the script's plan (never runs it). */
336
- const stageGlobal = (plan: ToolPlanBuilder, fork: StageFork): CodeModeExecutorGlobal => ({
360
+ /**
361
+ * `stage(name, args)`: records one call into the script's plan (never runs it). `name` is the raw
362
+ * tool name or its catalog identifier (what `searchTools` returns for callable tools).
363
+ */
364
+ const stageGlobal = (
365
+ plan: ToolPlanBuilder,
366
+ fork: StageFork,
367
+ stageable: ReadonlyArray<NestedTool>
368
+ ): CodeModeExecutorGlobal => ({
337
369
  name: 'stage',
338
370
  spread: true,
339
371
  signature: '(name: string, args: object): Promise<{ staged: true; key: string; index: number }>',
@@ -347,8 +379,13 @@ const stageGlobal = (plan: ToolPlanBuilder, fork: StageFork): CodeModeExecutorGl
347
379
  if (!Predicate.isString(name))
348
380
  return Promise.reject(new Error('stage: the first argument must be a tool name.'))
349
381
 
382
+ const toolName =
383
+ stageable.find(({ def }) => def.name === name)?.def.name ??
384
+ stageable.find(({ def }) => toCodemodeIdentifier(def.name) === name)?.def.name ??
385
+ name
386
+
350
387
  return new Promise((resolve, reject) => {
351
- fork(plan.stage({ name, params: params === undefined ? {} : params }), {
388
+ fork(plan.stage({ name: toolName, params: params === undefined ? {} : params }), {
352
389
  signal
353
390
  }).addObserver(exit => {
354
391
  if (Exit.isSuccess(exit)) {
@@ -373,6 +410,19 @@ const stageGlobal = (plan: ToolPlanBuilder, fork: StageFork): CodeModeExecutorGl
373
410
  }
374
411
  })
375
412
 
413
+ /** A direct call of a stage-only tool rejects with a hint to stage it (ADR 0007). */
414
+ const stageOnlyRejections = (stageOnly: ReadonlyArray<CodeModeCatalogTool>) =>
415
+ stageOnly.flatMap(tool => {
416
+ const message = `${tool.name} is approval-gated: record it with stage(${JSON.stringify(tool.name)}, args) instead of calling it.`
417
+
418
+ return tool.identifier === tool.name
419
+ ? [{ key: tool.name, message }]
420
+ : [
421
+ { key: tool.identifier, message },
422
+ { key: tool.name, message }
423
+ ]
424
+ })
425
+
376
426
  const planNotice = (plan: CodeModeStagedPlan) =>
377
427
  `\n\nStaged ${plan.count} call${plan.count === 1 ? '' : 's'} as plan ${plan.id}; nothing was applied yet. To apply them, call ${plan.reviewToolName}(${JSON.stringify({ planId: plan.id, planDigest: plan.digest })}): a person reviews the plan and selects the calls to apply.`
378
428
 
@@ -600,14 +650,23 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
600
650
  execute: callTool(tool)
601
651
  }))
602
652
 
653
+ const stageOnly =
654
+ plan === undefined || staging === undefined
655
+ ? []
656
+ : codeModeStageOnlyCatalog(staging.tools)
657
+
603
658
  const result = yield* Effect.tryPromise({
604
659
  try: signal =>
605
660
  options.executor.execute(input.code, {
606
661
  tools,
607
662
  globals:
608
- plan === undefined
663
+ plan === undefined || staging === undefined
609
664
  ? discoveryGlobals(catalog)
610
- : [...discoveryGlobals(catalog), stageGlobal(plan, stageFork)],
665
+ : [
666
+ ...discoveryGlobals([...catalog, ...stageOnly]),
667
+ stageGlobal(plan, stageFork, staging.tools)
668
+ ],
669
+ unbound: stageOnlyRejections(stageOnly),
611
670
  timeoutMs,
612
671
  memoryLimitBytes: limits.memoryLimitBytes,
613
672
  store,
@@ -893,18 +952,41 @@ export const makeCodeModeTool = <Context>(
893
952
  const inlineBudget = options.inlineBudget ?? defaultCodeModeInlineBudget
894
953
  const store = options.loadStore !== undefined
895
954
 
896
- return makeTool<Context, typeof CodeModeParams>({
955
+ const instructed = options.catalog === 'instructions'
956
+
957
+ // In description mode `stage()` is documented once a stageable tool exists (the description
958
+ // lists them anyway). In instructions mode the description must not depend on tools: staging
959
+ // availability alone decides.
960
+ const offered = (staging: CodeModeStagingDescription | undefined) =>
961
+ options.staging === false ||
962
+ staging === undefined ||
963
+ (!instructed && staging.tools.length === 0)
964
+ ? undefined
965
+ : staging
966
+
967
+ const registration = makeTool<Context, typeof CodeModeParams>({
897
968
  name,
898
- description: renderCodeModeDescription({ tools: [], inlineBudget, store }),
969
+ description: instructed
970
+ ? renderCodeModeInstructedDescription({ store })
971
+ : renderCodeModeDescription({ tools: [], inlineBudget, store }),
899
972
  parameters: CodeModeParams,
900
973
  access: 'write',
901
974
  nestedToolAccess: true,
902
- describe: ({ tools, staging }) =>
903
- renderCodeModeDescription(
904
- staging === undefined || options.staging === false
975
+ describe: ({ tools, staging }) => {
976
+ const offeredStaging = offered(staging)
977
+
978
+ if (instructed)
979
+ return renderCodeModeInstructedDescription(
980
+ offeredStaging === undefined ? { store } : { store, staging: offeredStaging }
981
+ )
982
+
983
+ return renderCodeModeDescription(
984
+ offeredStaging === undefined
905
985
  ? { tools, inlineBudget, store }
906
- : { tools, inlineBudget, store, staging }
907
- ),
986
+ : { tools, inlineBudget, store, staging: offeredStaging }
987
+ )
988
+ },
989
+
908
990
  abandonedResult: abandonedScriptResult(limits),
909
991
  execute: ({
910
992
  call,
@@ -922,4 +1004,18 @@ export const makeCodeModeTool = <Context>(
922
1004
  )
923
1005
  : runScript({ options, limits, call, context, code: params.code, nested })
924
1006
  })
1007
+
1008
+ if (!instructed) return registration
1009
+
1010
+ const instruct: NestedToolInstructor = ({ tools, staging }) => {
1011
+ const offeredStaging = offered(staging)
1012
+
1013
+ return codeModeCatalogInstruction(
1014
+ offeredStaging === undefined
1015
+ ? { tools, inlineBudget }
1016
+ : { tools, inlineBudget, staging: offeredStaging }
1017
+ )
1018
+ }
1019
+
1020
+ return { ...registration, instruct }
925
1021
  }