@namzu/sdk 3.0.0 → 3.1.0

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/public-runtime.d.ts +1 -1
  3. package/dist/public-runtime.d.ts.map +1 -1
  4. package/dist/public-runtime.js +6 -1
  5. package/dist/public-runtime.js.map +1 -1
  6. package/dist/runtime/query/__tests__/capability-negotiation.test.js +59 -0
  7. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  8. package/dist/runtime/query/executor.d.ts +1 -0
  9. package/dist/runtime/query/executor.d.ts.map +1 -1
  10. package/dist/runtime/query/executor.js +10 -1
  11. package/dist/runtime/query/executor.js.map +1 -1
  12. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  13. package/dist/runtime/query/iteration/index.js +22 -0
  14. package/dist/runtime/query/iteration/index.js.map +1 -1
  15. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts +2 -0
  16. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts.map +1 -0
  17. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js +78 -0
  18. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js.map +1 -0
  19. package/dist/tools/builtins/__tests__/edit.test.js +242 -1
  20. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  21. package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts +2 -0
  22. package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts.map +1 -0
  23. package/dist/tools/builtins/__tests__/stale-file-refusal.test.js +187 -0
  24. package/dist/tools/builtins/__tests__/stale-file-refusal.test.js.map +1 -0
  25. package/dist/tools/builtins/atomic-write-file.d.ts +1 -1
  26. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -1
  27. package/dist/tools/builtins/atomic-write-file.js +31 -2
  28. package/dist/tools/builtins/atomic-write-file.js.map +1 -1
  29. package/dist/tools/builtins/content-fingerprint.d.ts +23 -0
  30. package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -0
  31. package/dist/tools/builtins/content-fingerprint.js +30 -0
  32. package/dist/tools/builtins/content-fingerprint.js.map +1 -0
  33. package/dist/tools/builtins/edit.d.ts.map +1 -1
  34. package/dist/tools/builtins/edit.js +172 -31
  35. package/dist/tools/builtins/edit.js.map +1 -1
  36. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  37. package/dist/tools/builtins/read-file.js +5 -2
  38. package/dist/tools/builtins/read-file.js.map +1 -1
  39. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  40. package/dist/tools/builtins/write-file.js +75 -27
  41. package/dist/tools/builtins/write-file.js.map +1 -1
  42. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +55 -0
  43. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
  44. package/dist/tools/coordinator/index.d.ts.map +1 -1
  45. package/dist/tools/coordinator/index.js +62 -4
  46. package/dist/tools/coordinator/index.js.map +1 -1
  47. package/dist/types/tool/index.d.ts +17 -1
  48. package/dist/types/tool/index.d.ts.map +1 -1
  49. package/dist/types/tool/index.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/public-runtime.ts +8 -1
  52. package/src/runtime/query/__tests__/capability-negotiation.test.ts +69 -0
  53. package/src/runtime/query/executor.ts +9 -1
  54. package/src/runtime/query/iteration/index.ts +27 -0
  55. package/src/tools/builtins/__tests__/edit-commits-atomically.test.ts +103 -0
  56. package/src/tools/builtins/__tests__/edit.test.ts +324 -1
  57. package/src/tools/builtins/__tests__/stale-file-refusal.test.ts +258 -0
  58. package/src/tools/builtins/atomic-write-file.ts +30 -2
  59. package/src/tools/builtins/content-fingerprint.ts +33 -0
  60. package/src/tools/builtins/edit.ts +188 -36
  61. package/src/tools/builtins/read-file.ts +5 -2
  62. package/src/tools/builtins/write-file.ts +79 -28
  63. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +68 -0
  64. package/src/tools/coordinator/index.ts +102 -41
  65. package/src/types/tool/index.ts +17 -1
@@ -1,8 +1,10 @@
1
- import { access, mkdir, writeFile } from 'node:fs/promises'
1
+ import { access, mkdir } from 'node:fs/promises'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import type { ToolContext } from '../../types/tool/index.js'
5
5
  import { defineTool } from '../defineTool.js'
6
+ import { atomicWriteFile } from './atomic-write-file.js'
7
+ import { withFileMutationLock } from './file-mutation-lock.js'
6
8
 
7
9
  const inputSchema = z
8
10
  .object({
@@ -25,17 +27,46 @@ const inputSchema = z
25
27
  'Alias for content. Useful for hosts that expose create/write operations as newStr. Self-budget this payload under 12000 characters before calling.',
26
28
  ),
27
29
  })
30
+ .strict()
28
31
  .refine((value) => typeof value.content === 'string' || typeof value.newStr === 'string', {
29
32
  message: 'Either content or newStr is required.',
30
33
  })
31
34
 
32
35
  type WriteInput = z.infer<typeof inputSchema>
33
36
 
37
+ /**
38
+ * The single shape a model is constrained to emit.
39
+ *
40
+ * `newStr` is a host affordance and deliberately absent here: a model given
41
+ * two names for the body has to pick, and picking is what produces the
42
+ * half-filled calls this schema exists to prevent.
43
+ */
44
+ const modelInputSchema: Record<string, unknown> = {
45
+ type: 'object',
46
+ properties: {
47
+ path: {
48
+ type: 'string',
49
+ description: 'Relative path to the file to write. Must not be empty.',
50
+ },
51
+ content: {
52
+ type: 'string',
53
+ description:
54
+ 'Complete file body. Use "" only for an intentionally empty file. Keep under 12000 characters.',
55
+ },
56
+ },
57
+ required: ['path', 'content'],
58
+ additionalProperties: false,
59
+ }
60
+
34
61
  export const WriteFileTool = defineTool({
35
62
  name: 'write',
36
63
  description:
37
64
  'Writes a file to the local filesystem. Overwrites the existing file at the path if there is one.\n\n- If the file already exists, you must use the `read` tool on it first in this conversation, or this call will fail.\n- Prefer the `edit` tool for modifying existing files — it only sends the diff and preserves the rest of the file byte-for-byte.\n- Use `write` to create a new file or to perform a deliberate full rewrite of a file you have already read.\n- Self-budget content/newStr under 12000 characters before emitting the tool call. For long content, write a smaller opening section, then use `edit` with insertLine: "end" to extend the file section by section. Do not chain multiple `write` calls — each one overwrites the previous.',
38
65
  inputSchema,
66
+ modelInputSchema,
67
+ enforceModelInput: true,
68
+ validationErrorHint:
69
+ 'Required shape: {"path":"file.md","content":"complete file body"}. Pass the whole body, not a diff.',
39
70
  category: 'filesystem',
40
71
  permissions: ['file_write'],
41
72
  readOnly: false,
@@ -43,40 +74,60 @@ export const WriteFileTool = defineTool({
43
74
  concurrencySafe: false,
44
75
 
45
76
  async execute(input: WriteInput, context) {
46
- const content = input.content ?? input.newStr ?? ''
47
- // Sandbox-aware: route through sandbox.writeFile() when available
48
- if (context.sandbox) {
49
- const sandboxExists = await sandboxFileExists(context, input.path)
50
- if (sandboxExists) {
51
- const guard = enforceReadBeforeOverwrite(context, input.path)
52
- if (guard) return guard
53
- }
54
- await context.sandbox.writeFile(input.path, content)
55
- context.fileReadTracker?.recordRead(input.path)
77
+ // `execute` is reachable without going through the registry, so the
78
+ // closed contract has to be enforced on this path too or it is not
79
+ // closed at all.
80
+ const parsed = inputSchema.safeParse(input)
81
+ if (!parsed.success) {
56
82
  return {
57
- success: true,
58
- output: `File written successfully: ${input.path} (${content.length} chars) [sandboxed]`,
59
- data: { path: input.path, size: content.length, sandboxed: true },
83
+ success: false,
84
+ output: '',
85
+ error: `Invalid write input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
60
86
  }
61
87
  }
88
+ const valid = parsed.data
89
+ const content = valid.content ?? valid.newStr ?? ''
90
+ const filePath = resolve(context.workingDirectory, valid.path)
91
+ // The exists-check and the write are a check-then-act pair. Unlocked,
92
+ // two writers both see "absent", both skip the read-before-overwrite
93
+ // guard, and the second silently discards the first.
94
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
62
95
 
63
- const filePath = resolve(context.workingDirectory, input.path)
96
+ return withFileMutationLock(lockKey, async () => {
97
+ if (context.sandbox) {
98
+ const sandboxExists = await sandboxFileExists(context, valid.path)
99
+ if (sandboxExists) {
100
+ const guard = enforceReadBeforeOverwrite(context, valid.path)
101
+ if (guard) return guard
102
+ }
103
+ await context.sandbox.writeFile(valid.path, content)
104
+ context.fileReadTracker?.recordRead(valid.path)
105
+ return {
106
+ success: true as const,
107
+ output: `File written successfully: ${valid.path} (${content.length} chars) [sandboxed]`,
108
+ data: { path: valid.path, size: content.length, sandboxed: true },
109
+ }
110
+ }
64
111
 
65
- const localExists = await pathExists(filePath)
66
- if (localExists) {
67
- const guard = enforceReadBeforeOverwrite(context, filePath)
68
- if (guard) return guard
69
- }
112
+ const localExists = await pathExists(filePath)
113
+ if (localExists) {
114
+ const guard = enforceReadBeforeOverwrite(context, filePath)
115
+ if (guard) return guard
116
+ }
70
117
 
71
- await mkdir(dirname(filePath), { recursive: true })
72
- await writeFile(filePath, content, 'utf-8')
73
- context.fileReadTracker?.recordRead(filePath)
118
+ await mkdir(dirname(filePath), { recursive: true })
119
+ // Temp file, fsync, rename. A plain write that fails partway
120
+ // leaves the destination truncated — and this tool overwrites a
121
+ // whole file, so the truncation is the user's previous work.
122
+ await atomicWriteFile(filePath, content)
123
+ context.fileReadTracker?.recordRead(filePath)
74
124
 
75
- return {
76
- success: true,
77
- output: `File written successfully: ${filePath} (${content.length} chars)`,
78
- data: { path: filePath, size: content.length },
79
- }
125
+ return {
126
+ success: true as const,
127
+ output: `File written successfully: ${filePath} (${content.length} chars)`,
128
+ data: { path: filePath, size: content.length },
129
+ }
130
+ })
80
131
  },
81
132
  })
82
133
 
@@ -368,3 +368,71 @@ describe('coordinator ask_user_question decision -> output mapping', () => {
368
368
  expect(result.output).toBe('User answered "Who is the audience?": "Board"')
369
369
  })
370
370
  })
371
+
372
+ describe('the question contract is closed, not merely shaped', () => {
373
+ const noopHandler: ResumeHandler = async () => ({ action: 'continue' })
374
+
375
+ it('publishes one closed model-facing schema', () => {
376
+ const tool = askTool(noopHandler)
377
+
378
+ expect(tool.modelInputSchema).toMatchObject({
379
+ type: 'object',
380
+ required: ['question', 'options'],
381
+ additionalProperties: false,
382
+ })
383
+ // The whole point of the array declaration: a model that serializes
384
+ // its options once tends to keep doing it, and a closed schema makes
385
+ // a capable provider refuse at generation time instead of after.
386
+ const properties = (tool.modelInputSchema as { properties: Record<string, { type: string }> })
387
+ .properties
388
+ expect(properties.options?.type).toBe('array')
389
+ expect(tool.enforceModelInput).toBe(true)
390
+ })
391
+
392
+ it('rejects a serialized options string and other malformed option shapes', () => {
393
+ const tool = askTool(noopHandler)
394
+
395
+ for (const options of [
396
+ '<options><option><label>Board</label></option></options>',
397
+ '[{"label":"Board"},{"label":"Engineering"}]',
398
+ [42, { label: 'Engineering' }],
399
+ [{ description: 'Missing label' }, { label: 'Engineering' }],
400
+ [{ label: 'Board', description: 42 }, { label: 'Engineering' }],
401
+ ]) {
402
+ expect(
403
+ tool.inputSchema.safeParse({ question: 'Who is the audience?', options }).success,
404
+ JSON.stringify(options),
405
+ ).toBe(false)
406
+ }
407
+ })
408
+
409
+ it('rejects a field it does not declare, rather than dropping it', () => {
410
+ const tool = askTool(noopHandler)
411
+ const valid = {
412
+ question: 'Who is the audience?',
413
+ options: [{ label: 'Board' }, { label: 'Engineering' }],
414
+ }
415
+
416
+ expect(tool.inputSchema.safeParse(valid).success).toBe(true)
417
+
418
+ // Without `.strict()` zod strips these, so the call proceeds as if the
419
+ // caller had never written them — a misspelling becomes a silent no-op.
420
+ for (const extra of [
421
+ { ...valid, multiSelct: true },
422
+ { ...valid, choices: ['a', 'b'] },
423
+ {
424
+ ...valid,
425
+ options: [{ label: 'Board', reason: 'why' }, { label: 'Engineering' }],
426
+ },
427
+ ]) {
428
+ expect(tool.inputSchema.safeParse(extra).success, JSON.stringify(extra)).toBe(false)
429
+ }
430
+ })
431
+
432
+ it('carries a recovery hint that names the shape to retry with', () => {
433
+ const tool = askTool(noopHandler)
434
+
435
+ expect(tool.validationErrorHint).toContain('"options" must be a JSON array')
436
+ expect(tool.validationErrorHint).toContain('never a string')
437
+ })
438
+ })
@@ -78,6 +78,59 @@ const approvePlanStepSchema = z.object({
78
78
  depends_on: z.array(z.string()).optional().describe('Step descriptions this depends on'),
79
79
  })
80
80
 
81
+ /**
82
+ * The single closed shape a capable provider constrains this call to.
83
+ *
84
+ * `options` arriving as a STRING is the failure this exists for: a model that
85
+ * serializes the array once tends to keep doing it, and the parse error it
86
+ * gets back never says the array was the problem.
87
+ * `additionalProperties: false` turns that into a refusal at generation time
88
+ * rather than a rejection after the fact.
89
+ */
90
+ const askUserQuestionModelInputSchema: Record<string, unknown> = {
91
+ type: 'object',
92
+ properties: {
93
+ question: {
94
+ type: 'string',
95
+ description: 'Full question text — clear, specific, and ending with a question mark.',
96
+ },
97
+ header: {
98
+ type: 'string',
99
+ description: 'Optional very short topic label, no more than 24 characters.',
100
+ },
101
+ options: {
102
+ type: 'array',
103
+ description: 'A JSON array of 2-4 genuinely distinct, context-derived option objects.',
104
+ items: {
105
+ type: 'object',
106
+ properties: {
107
+ label: {
108
+ type: 'string',
109
+ description:
110
+ 'Concise option label. Put the recommended option first and append " (Recommended)".',
111
+ },
112
+ description: {
113
+ type: 'string',
114
+ description: 'Optional one-line explanation of what changes if selected.',
115
+ },
116
+ },
117
+ required: ['label'],
118
+ additionalProperties: false,
119
+ },
120
+ },
121
+ multiSelect: {
122
+ type: 'boolean',
123
+ description: 'True only when several options can apply at once.',
124
+ },
125
+ allowFreeText: {
126
+ type: 'boolean',
127
+ description: 'Whether the user may answer in their own words.',
128
+ },
129
+ },
130
+ required: ['question', 'options'],
131
+ additionalProperties: false,
132
+ }
133
+
81
134
  function normalizeApprovePlanSteps(value: unknown): unknown {
82
135
  if (typeof value !== 'string') return value
83
136
 
@@ -437,47 +490,55 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
437
490
  name: 'ask_user_question',
438
491
  description:
439
492
  'Ask the user ONE question ONLY when you are blocked on a decision that is genuinely theirs to make — one you cannot resolve from their request, your tools, the files you can read, or sensible defaults. The question must be the genuinely undecidable thing in THIS task. Never ask for information a tool can discover (do not ask what you can read, list, or search), never re-ask what the conversation already answers, and never ask meta-questions like "Shall I proceed?" — plan ratification goes through approve_plan. Provide 2-4 genuinely distinct options derived from the actual context — concrete paths, never generic placeholders (for example, asked to prepare a presentation, ask "Who is the audience?" with options like Board / Engineering team / Customer); keep labels short (1-5 words) and give each option a one-line description of what practically changes if it is chosen. Put your recommended option FIRST and append " (Recommended)" to its label. Set multiSelect: true only when several options can apply at once. A free-text "Something else" escape hatch is always shown automatically — do not add your own "Other" option. Ask ONE question per call and prefer at most one question per assistant turn; if several decisions block you, ask only the ones that materially change your next actions, in sequence — most work needs at most 2-3 questions, so prefer proceeding on stated defaults over interrogating the user. Never invent answers or synthetic content on the user\'s behalf unless they explicitly asked for a random/test scenario. The answer arrives as this tool\'s result; if the result says the user did not answer, do not ask this or any other question again — proceed on your best judgment without assuming consent.',
440
- inputSchema: z.object({
441
- question: z
442
- .string()
443
- .min(1)
444
- .describe('Full question text — clear, specific, ends with a question mark.'),
445
- header: z
446
- .string()
447
- .max(24)
448
- .optional()
449
- .describe('Very short topic label for the question (e.g. "Audience", "Auth method").'),
450
- options: z
451
- .array(
452
- z.object({
453
- label: z
454
- .string()
455
- .min(1)
456
- .max(80)
457
- .describe(
458
- 'Concise option label (1-5 words). Recommended option goes first with " (Recommended)" appended.',
459
- ),
460
- description: z
461
- .string()
462
- .max(300)
463
- .optional()
464
- .describe('One line on what practically changes if this option is chosen.'),
465
- }),
466
- )
467
- .min(2)
468
- .max(4)
469
- .describe('2-4 genuinely distinct, context-derived options.'),
470
- multiSelect: z
471
- .boolean()
472
- .optional()
473
- .default(false)
474
- .describe('True only when several options can apply at once.'),
475
- allowFreeText: z
476
- .boolean()
477
- .optional()
478
- .default(true)
479
- .describe('Whether the user may answer in their own words.'),
480
- }),
493
+ inputSchema: z
494
+ .object({
495
+ question: z
496
+ .string()
497
+ .min(1)
498
+ .describe('Full question text — clear, specific, ends with a question mark.'),
499
+ header: z
500
+ .string()
501
+ .max(24)
502
+ .optional()
503
+ .describe('Very short topic label for the question (e.g. "Audience", "Auth method").'),
504
+ options: z
505
+ .array(
506
+ z
507
+ .object({
508
+ label: z
509
+ .string()
510
+ .min(1)
511
+ .max(80)
512
+ .describe(
513
+ 'Concise option label (1-5 words). Recommended option goes first with " (Recommended)" appended.',
514
+ ),
515
+ description: z
516
+ .string()
517
+ .max(300)
518
+ .optional()
519
+ .describe('One line on what practically changes if this option is chosen.'),
520
+ })
521
+ .strict(),
522
+ )
523
+ .min(2)
524
+ .max(4)
525
+ .describe('2-4 genuinely distinct, context-derived options.'),
526
+ multiSelect: z
527
+ .boolean()
528
+ .optional()
529
+ .default(false)
530
+ .describe('True only when several options can apply at once.'),
531
+ allowFreeText: z
532
+ .boolean()
533
+ .optional()
534
+ .default(true)
535
+ .describe('Whether the user may answer in their own words.'),
536
+ })
537
+ .strict(),
538
+ modelInputSchema: structuredClone(askUserQuestionModelInputSchema),
539
+ enforceModelInput: true,
540
+ validationErrorHint:
541
+ 'Required shape: {"question":"...?","options":[{"label":"First (Recommended)","description":"What changes"},{"label":"Second","description":"What changes"}]}. "options" must be a JSON array of 2-4 objects, never a string.',
481
542
  category: 'custom',
482
543
  permissions: [],
483
544
  readOnly: true,
@@ -19,8 +19,24 @@ export interface ToolRegistryRef {
19
19
  * is active, absolute (`workingDirectory`-resolved) otherwise.
20
20
  */
21
21
  export interface FileReadTracker {
22
- recordRead(key: string): void
22
+ /**
23
+ * `content` lets the tracker fingerprint what was read, which is what
24
+ * makes drift detectable later. Optional so a host that only needs the
25
+ * read-before-overwrite guard can keep its existing implementation.
26
+ */
27
+ recordRead(key: string, content?: string): void
23
28
  hasRead(key: string): boolean
29
+ /**
30
+ * Fingerprint of the body captured at the last read, when one was.
31
+ *
32
+ * A file mutation is computed against what the agent READ, and between
33
+ * that read and the write the file may have moved under it — a person
34
+ * editing in an editor, another process, a second agent. The in-process
35
+ * lock cannot see any of those. Comparing this against the body actually
36
+ * on disk at mutation time is what turns a silent lost update into a
37
+ * refusal the agent can act on by re-reading.
38
+ */
39
+ fingerprint?(key: string): string | undefined
24
40
  }
25
41
 
26
42
  export interface ToolPauseOption {