@namzu/sdk 5.1.0 → 5.2.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 (158) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts +2 -0
  3. package/dist/agents/__tests__/run-agent-forwarding.test.d.ts.map +1 -0
  4. package/dist/agents/__tests__/run-agent-forwarding.test.js +76 -0
  5. package/dist/agents/__tests__/run-agent-forwarding.test.js.map +1 -0
  6. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts +2 -0
  7. package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts.map +1 -0
  8. package/dist/agents/__tests__/run-completed-stop-reason.test.js +50 -0
  9. package/dist/agents/__tests__/run-completed-stop-reason.test.js.map +1 -0
  10. package/dist/agents/index.d.ts +1 -0
  11. package/dist/agents/index.d.ts.map +1 -1
  12. package/dist/agents/index.js +4 -0
  13. package/dist/agents/index.js.map +1 -1
  14. package/dist/agents/runAgent.d.ts +34 -0
  15. package/dist/agents/runAgent.d.ts.map +1 -1
  16. package/dist/agents/runAgent.js +5 -3
  17. package/dist/agents/runAgent.js.map +1 -1
  18. package/dist/config/runtime.d.ts +30 -30
  19. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts +2 -0
  20. package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts.map +1 -0
  21. package/dist/constants/tools/__tests__/dangerous-patterns.test.js +54 -0
  22. package/dist/constants/tools/__tests__/dangerous-patterns.test.js.map +1 -0
  23. package/dist/constants/tools/index.d.ts.map +1 -1
  24. package/dist/constants/tools/index.js +28 -1
  25. package/dist/constants/tools/index.js.map +1 -1
  26. package/dist/contracts/a2a.d.ts +44 -44
  27. package/dist/directory/__tests__/derive.test.d.ts +2 -0
  28. package/dist/directory/__tests__/derive.test.d.ts.map +1 -0
  29. package/dist/directory/__tests__/derive.test.js +111 -0
  30. package/dist/directory/__tests__/derive.test.js.map +1 -0
  31. package/dist/directory/__tests__/load-diagnostics.test.d.ts +2 -0
  32. package/dist/directory/__tests__/load-diagnostics.test.d.ts.map +1 -0
  33. package/dist/directory/__tests__/load-diagnostics.test.js +106 -0
  34. package/dist/directory/__tests__/load-diagnostics.test.js.map +1 -0
  35. package/dist/directory/__tests__/load-directory.test.d.ts +2 -0
  36. package/dist/directory/__tests__/load-directory.test.d.ts.map +1 -0
  37. package/dist/directory/__tests__/load-directory.test.js +196 -0
  38. package/dist/directory/__tests__/load-directory.test.js.map +1 -0
  39. package/dist/directory/__tests__/subagents.test.d.ts +2 -0
  40. package/dist/directory/__tests__/subagents.test.d.ts.map +1 -0
  41. package/dist/directory/__tests__/subagents.test.js +117 -0
  42. package/dist/directory/__tests__/subagents.test.js.map +1 -0
  43. package/dist/directory/__tests__/supervisor-budget.test.d.ts +2 -0
  44. package/dist/directory/__tests__/supervisor-budget.test.d.ts.map +1 -0
  45. package/dist/directory/__tests__/supervisor-budget.test.js +97 -0
  46. package/dist/directory/__tests__/supervisor-budget.test.js.map +1 -0
  47. package/dist/directory/derive-supervisor.d.ts +58 -0
  48. package/dist/directory/derive-supervisor.d.ts.map +1 -0
  49. package/dist/directory/derive-supervisor.js +88 -0
  50. package/dist/directory/derive-supervisor.js.map +1 -0
  51. package/dist/directory/derive.d.ts +12 -0
  52. package/dist/directory/derive.d.ts.map +1 -0
  53. package/dist/directory/derive.js +58 -0
  54. package/dist/directory/derive.js.map +1 -0
  55. package/dist/directory/index.d.ts +7 -0
  56. package/dist/directory/index.d.ts.map +1 -0
  57. package/dist/directory/index.js +5 -0
  58. package/dist/directory/index.js.map +1 -0
  59. package/dist/directory/load.d.ts +14 -0
  60. package/dist/directory/load.d.ts.map +1 -0
  61. package/dist/directory/load.js +488 -0
  62. package/dist/directory/load.js.map +1 -0
  63. package/dist/directory/scan.d.ts +36 -0
  64. package/dist/directory/scan.d.ts.map +1 -0
  65. package/dist/directory/scan.js +136 -0
  66. package/dist/directory/scan.js.map +1 -0
  67. package/dist/directory/types.d.ts +215 -0
  68. package/dist/directory/types.d.ts.map +1 -0
  69. package/dist/directory/types.js +8 -0
  70. package/dist/directory/types.js.map +1 -0
  71. package/dist/plugin/__tests__/path-containment.test.d.ts +2 -0
  72. package/dist/plugin/__tests__/path-containment.test.d.ts.map +1 -0
  73. package/dist/plugin/__tests__/path-containment.test.js +91 -0
  74. package/dist/plugin/__tests__/path-containment.test.js.map +1 -0
  75. package/dist/plugin/lifecycle.d.ts.map +1 -1
  76. package/dist/plugin/lifecycle.js +3 -3
  77. package/dist/plugin/lifecycle.js.map +1 -1
  78. package/dist/plugin/loader.d.ts.map +1 -1
  79. package/dist/plugin/loader.js +12 -2
  80. package/dist/plugin/loader.js.map +1 -1
  81. package/dist/provider/__tests__/model-version.test.d.ts +2 -0
  82. package/dist/provider/__tests__/model-version.test.d.ts.map +1 -0
  83. package/dist/provider/__tests__/model-version.test.js +80 -0
  84. package/dist/provider/__tests__/model-version.test.js.map +1 -0
  85. package/dist/provider/__tests__/strict-schema.test.d.ts +2 -0
  86. package/dist/provider/__tests__/strict-schema.test.d.ts.map +1 -0
  87. package/dist/provider/__tests__/strict-schema.test.js +109 -0
  88. package/dist/provider/__tests__/strict-schema.test.js.map +1 -0
  89. package/dist/provider/model-version.d.ts +60 -0
  90. package/dist/provider/model-version.d.ts.map +1 -0
  91. package/dist/provider/model-version.js +64 -0
  92. package/dist/provider/model-version.js.map +1 -0
  93. package/dist/provider/strict-schema.d.ts +49 -0
  94. package/dist/provider/strict-schema.d.ts.map +1 -0
  95. package/dist/provider/strict-schema.js +112 -0
  96. package/dist/provider/strict-schema.js.map +1 -0
  97. package/dist/public-runtime.d.ts +6 -1
  98. package/dist/public-runtime.d.ts.map +1 -1
  99. package/dist/public-runtime.js +21 -1
  100. package/dist/public-runtime.js.map +1 -1
  101. package/dist/public-types.d.ts +2 -0
  102. package/dist/public-types.d.ts.map +1 -1
  103. package/dist/registry/tool/__tests__/strict-registration.test.d.ts +2 -0
  104. package/dist/registry/tool/__tests__/strict-registration.test.d.ts.map +1 -0
  105. package/dist/registry/tool/__tests__/strict-registration.test.js +82 -0
  106. package/dist/registry/tool/__tests__/strict-registration.test.js.map +1 -0
  107. package/dist/registry/tool/execute.d.ts.map +1 -1
  108. package/dist/registry/tool/execute.js +16 -0
  109. package/dist/registry/tool/execute.js.map +1 -1
  110. package/dist/runtime/query/result.d.ts.map +1 -1
  111. package/dist/runtime/query/result.js +5 -0
  112. package/dist/runtime/query/result.js.map +1 -1
  113. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +7 -1
  114. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -1
  115. package/dist/tools/builtins/__tests__/edit.test.js +1 -1
  116. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  117. package/dist/tools/builtins/edit.d.ts.map +1 -1
  118. package/dist/tools/builtins/edit.js +20 -2
  119. package/dist/tools/builtins/edit.js.map +1 -1
  120. package/dist/types/plugin/index.d.ts +4 -4
  121. package/dist/types/run/events.d.ts +17 -2
  122. package/dist/types/run/events.d.ts.map +1 -1
  123. package/dist/types/run/events.js.map +1 -1
  124. package/dist/types/verification/index.d.ts +6 -6
  125. package/package.json +1 -1
  126. package/src/agents/__tests__/run-agent-forwarding.test.ts +85 -0
  127. package/src/agents/__tests__/run-completed-stop-reason.test.ts +59 -0
  128. package/src/agents/index.ts +8 -0
  129. package/src/agents/runAgent.ts +30 -4
  130. package/src/constants/tools/__tests__/dangerous-patterns.test.ts +62 -0
  131. package/src/constants/tools/index.ts +28 -1
  132. package/src/directory/__tests__/derive.test.ts +135 -0
  133. package/src/directory/__tests__/load-diagnostics.test.ts +126 -0
  134. package/src/directory/__tests__/load-directory.test.ts +243 -0
  135. package/src/directory/__tests__/subagents.test.ts +155 -0
  136. package/src/directory/__tests__/supervisor-budget.test.ts +117 -0
  137. package/src/directory/derive-supervisor.ts +149 -0
  138. package/src/directory/derive.ts +70 -0
  139. package/src/directory/index.ts +27 -0
  140. package/src/directory/load.ts +556 -0
  141. package/src/directory/scan.ts +163 -0
  142. package/src/directory/types.ts +254 -0
  143. package/src/plugin/__tests__/path-containment.test.ts +107 -0
  144. package/src/plugin/lifecycle.ts +3 -3
  145. package/src/plugin/loader.ts +12 -2
  146. package/src/provider/__tests__/model-version.test.ts +93 -0
  147. package/src/provider/__tests__/strict-schema.test.ts +133 -0
  148. package/src/provider/model-version.ts +98 -0
  149. package/src/provider/strict-schema.ts +125 -0
  150. package/src/public-runtime.ts +31 -0
  151. package/src/public-types.ts +8 -0
  152. package/src/registry/tool/__tests__/strict-registration.test.ts +115 -0
  153. package/src/registry/tool/execute.ts +16 -0
  154. package/src/runtime/query/result.ts +5 -0
  155. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +8 -2
  156. package/src/tools/builtins/__tests__/edit.test.ts +1 -1
  157. package/src/tools/builtins/edit.ts +20 -2
  158. package/src/types/run/events.ts +15 -2
@@ -0,0 +1,126 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { loadDirectory } from '../load.js'
7
+
8
+ /**
9
+ * Three refusals this loader claimed to make and did not.
10
+ *
11
+ * Each is the same shape: a check that existed, read the wrong thing, and
12
+ * therefore reported the wrong answer while looking correct in review. A
13
+ * loader whose entire job is to say what is wrong with a directory has to be
14
+ * held to the diagnostics it actually emits, not the ones its code appears to
15
+ * emit.
16
+ */
17
+
18
+ function project(files: Record<string, string>): string {
19
+ const root = mkdtempSync(join(tmpdir(), 'namzu-diag-'))
20
+ for (const [relative, body] of Object.entries(files)) {
21
+ const full = join(root, relative)
22
+ mkdirSync(join(full, '..'), { recursive: true })
23
+ writeFileSync(full, body)
24
+ }
25
+ return root
26
+ }
27
+
28
+ describe('a tool that would crash later is refused now', () => {
29
+ it('rejects a default export with no inputSchema', async () => {
30
+ // `name` and `execute` alone used to pass. The object then registered
31
+ // clean and died in `toLLMTools()` on `inputSchema._def` — a TypeError
32
+ // naming neither this file nor this loader.
33
+ const root = project({
34
+ 'tools/broken.js': `
35
+ export default {
36
+ name: 'broken',
37
+ description: 'no schema',
38
+ execute: async () => ({ success: true }),
39
+ }
40
+ `,
41
+ })
42
+
43
+ const { manifest, diagnostics, ok } = await loadDirectory(root)
44
+
45
+ expect(ok).toBe(false)
46
+ expect(diagnostics.map((d) => d.code)).toContain('not_a_tool')
47
+ expect(manifest.tools).toHaveLength(0)
48
+ })
49
+
50
+ it('still accepts a hand-written tool that satisfies the published type', async () => {
51
+ // The required set and no more: demanding `defineTool`'s extras would
52
+ // make this loader refuse an object the SDK's own type accepts.
53
+ const root = project({
54
+ 'tools/fine.js': `
55
+ export default {
56
+ name: 'fine',
57
+ description: 'a tool',
58
+ inputSchema: { parse: (v) => v },
59
+ execute: async () => ({ success: true }),
60
+ }
61
+ `,
62
+ })
63
+
64
+ const { manifest, ok } = await loadDirectory(root)
65
+
66
+ expect(ok).toBe(true)
67
+ expect(manifest.tools).toHaveLength(1)
68
+ })
69
+ })
70
+
71
+ describe('an import failure explains itself', () => {
72
+ it('gives the extension hint for a module it cannot resolve', async () => {
73
+ // The hint is chosen from Node's error CODE. It used to be matched
74
+ // against the message, which never contains the code, so every hint in
75
+ // the function was unreachable and every author got the bare error.
76
+ const root = project({
77
+ 'tools/importer.js': `
78
+ import './nowhere-at-all.js'
79
+ export default { name: 'x', description: 'x', inputSchema: {}, execute: async () => ({}) }
80
+ `,
81
+ })
82
+
83
+ const { diagnostics } = await loadDirectory(root)
84
+
85
+ const failed = diagnostics.find((d) => d.code === 'module_load_failed')
86
+ expect(failed).toBeDefined()
87
+ expect(failed?.cause).toContain('real extension')
88
+ })
89
+ })
90
+
91
+ describe('agent.ts metadata is checked, not assumed', () => {
92
+ it('refuses metadata values that are not strings', async () => {
93
+ // Typed `Record<string, string>` and admitted on `typeof === 'object'`,
94
+ // so the values were never looked at and the type was a promise this
95
+ // loader did not keep.
96
+ const root = project({
97
+ 'agent.js': 'export default { model: "m", metadata: { count: 1 } }',
98
+ })
99
+
100
+ const { diagnostics, ok } = await loadDirectory(root)
101
+
102
+ expect(ok).toBe(false)
103
+ expect(diagnostics.some((d) => d.code === 'invalid_config')).toBe(true)
104
+ })
105
+
106
+ it('refuses an array, which typeof calls an object', async () => {
107
+ const root = project({
108
+ 'agent.js': 'export default { model: "m", metadata: ["a", "b"] }',
109
+ })
110
+
111
+ const { ok } = await loadDirectory(root)
112
+
113
+ expect(ok).toBe(false)
114
+ })
115
+
116
+ it('accepts a metadata object of strings', async () => {
117
+ const root = project({
118
+ 'agent.js': 'export default { model: "m", metadata: { team: "core" } }',
119
+ })
120
+
121
+ const { manifest, ok } = await loadDirectory(root)
122
+
123
+ expect(ok).toBe(true)
124
+ expect(manifest.config.metadata).toEqual({ team: 'core' })
125
+ })
126
+ })
@@ -0,0 +1,243 @@
1
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { loadDirectory } from '../load.js'
7
+
8
+ /**
9
+ * Every case builds a real directory and reads it. A fixture object standing
10
+ * in for the filesystem would pass while the scan, the import and the
11
+ * containment check all did the wrong thing — and the whole package is those
12
+ * three things.
13
+ *
14
+ * `.js` is used for tool files rather than `.ts`. Node's type stripping is
15
+ * real and the loader accepts both, but a test that depends on it would be
16
+ * testing Node's version rather than this code.
17
+ */
18
+
19
+ const CAN_SYMLINK = (() => {
20
+ try {
21
+ const probe = mkdtempSync(join(tmpdir(), 'namzu-proj-sym-'))
22
+ symlinkSync(probe, join(probe, 'self'), 'dir')
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ })()
28
+
29
+ function project(files: Record<string, string>): string {
30
+ const root = mkdtempSync(join(tmpdir(), 'namzu-proj-'))
31
+ for (const [relative, body] of Object.entries(files)) {
32
+ const full = join(root, relative)
33
+ mkdirSync(join(full, '..'), { recursive: true })
34
+ writeFileSync(full, body)
35
+ }
36
+ return root
37
+ }
38
+
39
+ const TOOL = (name: string) => `
40
+ export default {
41
+ name: ${JSON.stringify(name)},
42
+ description: 'a tool',
43
+ inputSchema: { parse: (v) => v, safeParse: (v) => ({ success: true, data: v }) },
44
+ execute: async () => ({ success: true, output: 'ok' }),
45
+ }
46
+ `
47
+
48
+ describe('reading a project directory', () => {
49
+ it('loads instructions, config and tools', async () => {
50
+ const root = project({
51
+ 'instructions.md': 'You are helpful.\n',
52
+ 'agent.js': 'export default { model: "m", temperature: 0.2 }',
53
+ 'tools/search.js': TOOL('search'),
54
+ })
55
+
56
+ const { manifest, ok, diagnostics } = await loadDirectory(root)
57
+
58
+ expect(ok, JSON.stringify(diagnostics)).toBe(true)
59
+ expect(manifest.instructions).toBe('You are helpful.')
60
+ expect(manifest.config).toMatchObject({ model: 'm', temperature: 0.2 })
61
+ expect(manifest.tools.map((t) => t.definition.name)).toEqual(['search'])
62
+ })
63
+
64
+ it('reports a broken tool instead of dropping it', async () => {
65
+ const root = project({
66
+ 'instructions.md': 'hi',
67
+ 'tools/good.js': TOOL('good'),
68
+ 'tools/broken.js': 'this is not valid javascript !!!',
69
+ })
70
+
71
+ const { manifest, ok, diagnostics } = await loadDirectory(root)
72
+
73
+ expect(ok).toBe(false)
74
+ // The good one still loaded — one bad file does not lose the rest.
75
+ expect(manifest.tools.map((t) => t.definition.name)).toEqual(['good'])
76
+ const failure = diagnostics.find((d) => d.code === 'module_load_failed')
77
+ expect(failure?.path).toContain('broken')
78
+ expect(manifest.sources.find((s) => s.id === 'broken')?.outcome).toBe('failed')
79
+ })
80
+
81
+ it('refuses both files when two tools claim one name', async () => {
82
+ const root = project({
83
+ 'instructions.md': 'hi',
84
+ 'tools/a.js': TOOL('search'),
85
+ 'tools/b.js': TOOL('search'),
86
+ })
87
+
88
+ const { manifest, ok, diagnostics } = await loadDirectory(root)
89
+
90
+ expect(ok).toBe(false)
91
+ expect(diagnostics.some((d) => d.code === 'duplicate_tool_name')).toBe(true)
92
+ // Picking a winner silently is what hides the mistake.
93
+ expect(manifest.tools).toHaveLength(1)
94
+ })
95
+
96
+ it('rejects a default export that is not a tool', async () => {
97
+ const root = project({
98
+ 'instructions.md': 'hi',
99
+ 'tools/x.js': 'export default { hello: 1 }',
100
+ })
101
+
102
+ const { ok, diagnostics } = await loadDirectory(root)
103
+
104
+ expect(ok).toBe(false)
105
+ expect(diagnostics.some((d) => d.code === 'not_a_tool')).toBe(true)
106
+ })
107
+
108
+ it('warns rather than errors when instructions are absent', async () => {
109
+ // `runAgent` treats instructions as optional; this package does not get
110
+ // to overrule the kernel about what a valid agent is.
111
+ const root = project({ 'tools/a.js': TOOL('a') })
112
+
113
+ const { ok, diagnostics } = await loadDirectory(root)
114
+
115
+ expect(ok).toBe(true)
116
+ expect(diagnostics.find((d) => d.code === 'instructions_missing')?.severity).toBe('warning')
117
+ })
118
+
119
+ it('errors on an instructions file that says nothing', async () => {
120
+ const root = project({ 'instructions.md': ' \n' })
121
+
122
+ const { ok, diagnostics } = await loadDirectory(root)
123
+
124
+ expect(ok).toBe(false)
125
+ expect(diagnostics.find((d) => d.code === 'instructions_empty')?.severity).toBe('error')
126
+ })
127
+
128
+ it('reports a malformed config field by name', async () => {
129
+ const root = project({
130
+ 'instructions.md': 'hi',
131
+ 'agent.js': 'export default { model: 42 }',
132
+ })
133
+
134
+ const { ok, diagnostics } = await loadDirectory(root)
135
+
136
+ expect(ok).toBe(false)
137
+ expect(diagnostics.find((d) => d.code === 'invalid_config')?.message).toContain('"model"')
138
+ })
139
+
140
+ it('ignores dot- and underscore-prefixed entries silently', async () => {
141
+ const root = project({
142
+ 'instructions.md': 'hi',
143
+ 'tools/real.js': TOOL('real'),
144
+ 'tools/_helpers/util.js': 'export const x = 1',
145
+ 'tools/.hidden.js': TOOL('hidden'),
146
+ })
147
+
148
+ const { manifest, ok, diagnostics } = await loadDirectory(root)
149
+
150
+ expect(ok).toBe(true)
151
+ expect(manifest.tools.map((t) => t.definition.name)).toEqual(['real'])
152
+ expect(diagnostics.some((d) => d.code === 'unscanned_directory')).toBe(false)
153
+ })
154
+
155
+ it('warns about a subdirectory it did not scan', async () => {
156
+ const root = project({
157
+ 'instructions.md': 'hi',
158
+ 'tools/nested/deep.js': TOOL('deep'),
159
+ })
160
+
161
+ const { diagnostics } = await loadDirectory(root)
162
+
163
+ expect(diagnostics.find((d) => d.code === 'unscanned_directory')?.message).toContain('nested')
164
+ })
165
+
166
+ it('reports a missing directory rather than throwing', async () => {
167
+ const { ok, diagnostics } = await loadDirectory(join(tmpdir(), 'namzu-does-not-exist-xyz'))
168
+
169
+ expect(ok).toBe(false)
170
+ expect(diagnostics[0]?.code).toBe('root_missing')
171
+ })
172
+
173
+ it('throws only on programmer error', async () => {
174
+ await expect(loadDirectory('' as string)).rejects.toThrow(TypeError)
175
+ })
176
+ })
177
+
178
+ describe('modules: skip', () => {
179
+ it('gives full structure without importing anything', async () => {
180
+ // The proof is the side effect: a module that would throw on import is
181
+ // listed, and nothing runs.
182
+ const root = project({
183
+ 'instructions.md': 'You are helpful.',
184
+ 'agent.js': 'throw new Error("agent.js executed")',
185
+ 'tools/a.js': 'throw new Error("tool executed")',
186
+ })
187
+
188
+ const { manifest, ok } = await loadDirectory(root, { modules: 'skip' })
189
+
190
+ expect(ok).toBe(true)
191
+ expect(manifest.instructions).toBe('You are helpful.')
192
+ expect(manifest.sources.map((s) => s.id).sort()).toEqual(['a', 'agent', 'instructions'])
193
+ expect(
194
+ manifest.sources
195
+ .filter((s) => s.slot !== 'instructions')
196
+ .every((s) => s.outcome === 'not_loaded'),
197
+ ).toBe(true)
198
+ expect(manifest.tools).toHaveLength(0)
199
+ })
200
+ })
201
+
202
+ describe('include is an allow-list', () => {
203
+ it('scans only what was asked for', async () => {
204
+ const root = project({ 'instructions.md': 'hi', 'tools/a.js': TOOL('a') })
205
+
206
+ const { manifest } = await loadDirectory(root, { include: ['tools'] })
207
+
208
+ expect(manifest.included).toEqual(['tools'])
209
+ expect(manifest.instructions).toBe('')
210
+ expect(manifest.tools).toHaveLength(1)
211
+ })
212
+
213
+ it('scans nothing when the list is empty', async () => {
214
+ // An allow-list that admits everything when empty is the fail-open
215
+ // shape this estate removed elsewhere.
216
+ const root = project({ 'instructions.md': 'hi', 'tools/a.js': TOOL('a') })
217
+
218
+ const { manifest } = await loadDirectory(root, {
219
+ include: [] as unknown as ['tools'],
220
+ })
221
+
222
+ expect(manifest.sources).toHaveLength(0)
223
+ expect(manifest.tools).toHaveLength(0)
224
+ })
225
+ })
226
+
227
+ describe('symlinks are refused, not followed', () => {
228
+ it.skipIf(!CAN_SYMLINK)('does not import a linked tool file', async () => {
229
+ const outside = mkdtempSync(join(tmpdir(), 'namzu-outside-'))
230
+ writeFileSync(join(outside, 'evil.js'), TOOL('evil'))
231
+ const root = project({
232
+ 'instructions.md': 'hi',
233
+ 'tools/real.js': TOOL('real'),
234
+ })
235
+ symlinkSync(join(outside, 'evil.js'), join(root, 'tools', 'linked.js'), 'file')
236
+
237
+ const { manifest, diagnostics } = await loadDirectory(root)
238
+
239
+ // The file that would be imported is not the file that was listed.
240
+ expect(manifest.tools.map((t) => t.definition.name)).toEqual(['real'])
241
+ expect(diagnostics.some((d) => d.code === 'symlink_refused')).toBe(true)
242
+ })
243
+ })
@@ -0,0 +1,155 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { deriveSupervisorOptions } from '../derive-supervisor.js'
7
+ import { loadDirectory } from '../load.js'
8
+
9
+ /**
10
+ * `SupervisorAgent` needs an `agentIds` roster and a manager that can spawn
11
+ * them. Nothing led from a directory to either, so a multi-agent system could
12
+ * be described on disk and not run.
13
+ *
14
+ * Delegates are loaded by recursion — a delegate directory has the same shape
15
+ * as its parent — capped at one level, because how deep a system may fan out
16
+ * is a topology decision and a directory layout should not make it by default.
17
+ */
18
+
19
+ const provider = { id: 'mock', name: 'Mock' } as never
20
+ const agentManager = { sendMessage: async () => ({}) } as never
21
+
22
+ function tree(files: Record<string, string>): string {
23
+ const root = mkdtempSync(join(tmpdir(), 'namzu-sub-'))
24
+ for (const [relative, body] of Object.entries(files)) {
25
+ const full = join(root, relative)
26
+ mkdirSync(join(full, '..'), { recursive: true })
27
+ writeFileSync(full, body)
28
+ }
29
+ return root
30
+ }
31
+
32
+ const TOOL = (name: string) => `
33
+ export default {
34
+ name: ${JSON.stringify(name)},
35
+ description: 'a tool',
36
+ inputSchema: { parse: (v) => v, safeParse: (v) => ({ success: true, data: v }) },
37
+ execute: async () => ({ success: true, output: 'ok' }),
38
+ }
39
+ `
40
+
41
+ const SYSTEM = {
42
+ 'instructions.md': 'You coordinate.',
43
+ 'agent.js': 'export default { model: "coordinator-model" }',
44
+ 'agents/researcher/instructions.md': 'You research.',
45
+ 'agents/researcher/agent.js': 'export default { model: "cheap-model" }',
46
+ 'agents/researcher/tools/search.js': TOOL('search'),
47
+ 'agents/writer/instructions.md': 'You write.',
48
+ }
49
+
50
+ describe('a project that declares delegates', () => {
51
+ it('loads each one as a project in its own right', async () => {
52
+ const { manifest, ok, diagnostics } = await loadDirectory(tree(SYSTEM))
53
+
54
+ expect(ok, JSON.stringify(diagnostics)).toBe(true)
55
+ expect(manifest.agents.map((a) => a.id).sort()).toEqual(['researcher', 'writer'])
56
+
57
+ const researcher = manifest.agents.find((a) => a.id === 'researcher')
58
+ expect(researcher?.manifest.instructions).toBe('You research.')
59
+ expect(researcher?.manifest.tools.map((t) => t.definition.name)).toEqual(['search'])
60
+ expect(researcher?.manifest.config.model).toBe('cheap-model')
61
+ })
62
+
63
+ it('surfaces a delegate failure in the parent list', async () => {
64
+ // A delegate that could not load is a fact about THIS project. A caller
65
+ // reading one list should not have to walk the tree to discover the run
66
+ // will be short a specialist.
67
+ const { manifest, ok, diagnostics } = await loadDirectory(
68
+ tree({ ...SYSTEM, 'agents/writer/tools/bad.js': 'this is not valid !!!' }),
69
+ )
70
+
71
+ expect(ok).toBe(false)
72
+ expect(diagnostics.some((d) => d.message.startsWith('agents/writer:'))).toBe(true)
73
+ // The broken one is not offered as a delegate.
74
+ expect(manifest.agents.map((a) => a.id)).toEqual(['researcher'])
75
+ })
76
+
77
+ it('refuses to read a second level of delegates', async () => {
78
+ const { manifest, diagnostics } = await loadDirectory(
79
+ tree({
80
+ ...SYSTEM,
81
+ 'agents/researcher/agents/deeper/instructions.md': 'You are too deep.',
82
+ }),
83
+ )
84
+
85
+ const researcher = manifest.agents.find((a) => a.id === 'researcher')
86
+ expect(researcher?.manifest.agents).toHaveLength(0)
87
+ expect(diagnostics.some((d) => d.code === 'subagent_too_deep')).toBe(true)
88
+ })
89
+
90
+ it('imports nothing under modules: skip, delegates included', async () => {
91
+ const { manifest, ok } = await loadDirectory(
92
+ tree({
93
+ 'instructions.md': 'hi',
94
+ 'agents/x/instructions.md': 'hi',
95
+ 'agents/x/tools/boom.js': 'throw new Error("executed")',
96
+ }),
97
+ { modules: 'skip' },
98
+ )
99
+
100
+ expect(ok).toBe(true)
101
+ expect(manifest.sources.some((s) => s.slot === 'agents')).toBe(true)
102
+ })
103
+ })
104
+
105
+ describe('deriving supervisor options', () => {
106
+ it('builds the roster from the directory', async () => {
107
+ const { manifest } = await loadDirectory(tree(SYSTEM))
108
+
109
+ const { config, delegates } = deriveSupervisorOptions(manifest, { provider, agentManager })
110
+
111
+ expect(config.agentIds.sort()).toEqual(['researcher', 'writer'])
112
+ expect(config.systemPrompt).toBe('You coordinate.')
113
+ expect(config.model).toBe('coordinator-model')
114
+ expect(delegates).toHaveLength(2)
115
+ })
116
+
117
+ it('lets a delegate keep its own model and inherit when it has none', async () => {
118
+ // A cheap model for a narrow job is the common case; inheriting
119
+ // unconditionally would bill every specialist at the coordinator rate.
120
+ const { manifest } = await loadDirectory(tree(SYSTEM))
121
+ const { delegates } = deriveSupervisorOptions(manifest, { provider, agentManager })
122
+
123
+ expect(delegates.find((d) => d.id === 'researcher')?.model).toBe('cheap-model')
124
+ expect(delegates.find((d) => d.id === 'writer')?.model).toBe('coordinator-model')
125
+ })
126
+
127
+ it('carries each delegate its own instructions and tools', async () => {
128
+ const { manifest } = await loadDirectory(tree(SYSTEM))
129
+ const { delegates } = deriveSupervisorOptions(manifest, { provider, agentManager })
130
+
131
+ const researcher = delegates.find((d) => d.id === 'researcher')
132
+ expect(researcher?.systemPrompt).toBe('You research.')
133
+ expect(researcher?.tools.has('search')).toBe(true)
134
+ })
135
+
136
+ it('refuses a project with no delegates', async () => {
137
+ // An empty roster makes create_task unmountable, so the result would be
138
+ // a coordinator that cannot coordinate — and the caller asked for one.
139
+ const { manifest } = await loadDirectory(
140
+ tree({ 'instructions.md': 'hi', 'agent.js': 'export default { model: "m" }' }),
141
+ )
142
+
143
+ expect(() => deriveSupervisorOptions(manifest, { provider, agentManager })).toThrow(
144
+ /declares no delegates/,
145
+ )
146
+ })
147
+
148
+ it('refuses a manifest whose modules were never loaded', async () => {
149
+ const { manifest } = await loadDirectory(tree(SYSTEM), { modules: 'skip' })
150
+
151
+ expect(() => deriveSupervisorOptions(manifest, { provider, agentManager })).toThrow(
152
+ /modules: "skip"/,
153
+ )
154
+ })
155
+ })
@@ -0,0 +1,117 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { deriveSupervisorOptions } from '../derive-supervisor.js'
7
+ import { loadDirectory } from '../load.js'
8
+
9
+ /**
10
+ * `BaseAgentConfig` declares `tokenBudget` and `timeoutMs` as REQUIRED, and
11
+ * `deriveSupervisorOptions` supplied them only when `agent.ts` happened to
12
+ * name them — which is the uncommon case. An `as SupervisorAgentConfig` made
13
+ * that compile, so the returned object was typed `tokenBudget: number` while
14
+ * holding `undefined`.
15
+ *
16
+ * The consequence is not a type-level nicety. `buildLimitConfig` defaults only
17
+ * `maxIterations`, so an undefined budget and timeout disable BOTH hard stops
18
+ * — a project-derived supervisor ran with no token cap and no wall clock. And
19
+ * the child-spawn guard computes a delegate's allocation from the parent
20
+ * budget, so `undefined` became `NaN`, and `NaN <= 0` is false: the refusal
21
+ * that exists to stop an unfunded child let it through.
22
+ *
23
+ * The cast is now `satisfies`, which is what keeps this from recurring — but
24
+ * a type check is not a test, so the values are asserted here too.
25
+ */
26
+
27
+ const provider = { id: 'mock', name: 'Mock' } as never
28
+ const agentManager = { sendMessage: async () => ({}) } as never
29
+
30
+ function tree(files: Record<string, string>): string {
31
+ const root = mkdtempSync(join(tmpdir(), 'namzu-budget-'))
32
+ for (const [relative, body] of Object.entries(files)) {
33
+ const full = join(root, relative)
34
+ mkdirSync(join(full, '..'), { recursive: true })
35
+ writeFileSync(full, body)
36
+ }
37
+ return root
38
+ }
39
+
40
+ const MINIMAL = {
41
+ 'agent.js': 'export default { model: "m" }',
42
+ 'agents/researcher/agent.js': 'export default { model: "m2" }',
43
+ 'agents/researcher/instructions.md': 'Research things.',
44
+ }
45
+
46
+ describe('a derived supervisor always has a budget and a clock', () => {
47
+ it('defaults both when agent.ts names neither', async () => {
48
+ const { manifest } = await loadDirectory(tree(MINIMAL))
49
+ const { config } = deriveSupervisorOptions(manifest, { provider, agentManager })
50
+
51
+ // The exact numbers matter less than that they are finite: a NaN or an
52
+ // undefined here is what silently disabled the limits.
53
+ expect(Number.isFinite(config.tokenBudget)).toBe(true)
54
+ expect(Number.isFinite(config.timeoutMs)).toBe(true)
55
+ expect(config.tokenBudget).toBeGreaterThan(0)
56
+ expect(config.timeoutMs).toBeGreaterThan(0)
57
+ })
58
+
59
+ it('uses the same defaults the SDK front door uses', async () => {
60
+ // Imported from the module that owns them rather than through the
61
+ // package entry: this file now lives inside that package, and a
62
+ // self-import resolves to the built `dist`, not to the source under
63
+ // test — which is how a stale build reads as a passing test.
64
+ const { DEFAULT_TIMEOUT_MS, DEFAULT_TOKEN_BUDGET } = await import('../../agents/runAgent.js')
65
+ const { manifest } = await loadDirectory(tree(MINIMAL))
66
+ const { config } = deriveSupervisorOptions(manifest, { provider, agentManager })
67
+
68
+ // Two front doors that default differently is a difference nobody would
69
+ // find until a run behaved unlike its sibling.
70
+ expect(config.tokenBudget).toBe(DEFAULT_TOKEN_BUDGET)
71
+ expect(config.timeoutMs).toBe(DEFAULT_TIMEOUT_MS)
72
+ })
73
+
74
+ it('still honours what agent.ts declares', async () => {
75
+ const { manifest } = await loadDirectory(
76
+ tree({
77
+ ...MINIMAL,
78
+ 'agent.js': 'export default { model: "m", tokenBudget: 1234, timeoutMs: 5678 }',
79
+ }),
80
+ )
81
+ const { config } = deriveSupervisorOptions(manifest, { provider, agentManager })
82
+
83
+ expect(config.tokenBudget).toBe(1234)
84
+ expect(config.timeoutMs).toBe(5678)
85
+ })
86
+
87
+ it('lets an override win over both', async () => {
88
+ const { manifest } = await loadDirectory(tree(MINIMAL))
89
+ const { config } = deriveSupervisorOptions(manifest, {
90
+ provider,
91
+ agentManager,
92
+ overrides: { tokenBudget: 42 },
93
+ })
94
+
95
+ expect(config.tokenBudget).toBe(42)
96
+ })
97
+ })
98
+
99
+ describe('a derived supervisor carries the skills it loaded', () => {
100
+ it('passes skills through instead of dropping them', async () => {
101
+ const { manifest } = await loadDirectory(
102
+ tree({
103
+ ...MINIMAL,
104
+ 'skills/plan-a-trip/SKILL.md':
105
+ '---\nname: plan-a-trip\ndescription: Plan a trip\n---\n\nAsk for dates.',
106
+ }),
107
+ )
108
+ expect(manifest.skills).toHaveLength(1)
109
+
110
+ const { config } = deriveSupervisorOptions(manifest, { provider, agentManager })
111
+
112
+ // Loaded from disk, put on the manifest, and then — before this — left
113
+ // out of the config the supervisor actually runs with.
114
+ expect(config.skills).toHaveLength(1)
115
+ expect(config.skills?.[0]?.metadata.name).toBe('plan-a-trip')
116
+ })
117
+ })