@things-factory/ai-client-base 10.1.20 → 10.1.26

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 (38) hide show
  1. package/dist-server/service/assistant/index.d.ts +1 -0
  2. package/dist-server/service/assistant/index.js +1 -0
  3. package/dist-server/service/assistant/index.js.map +1 -1
  4. package/dist-server/service/assistant/registry-assistant.d.ts +10 -10
  5. package/dist-server/service/assistant/registry-assistant.js +11 -9
  6. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  7. package/dist-server/service/assistant/tool-scope.d.ts +25 -0
  8. package/dist-server/service/assistant/tool-scope.js +71 -0
  9. package/dist-server/service/assistant/tool-scope.js.map +1 -0
  10. package/dist-server/service/index.d.ts +1 -0
  11. package/dist-server/service/index.js +1 -0
  12. package/dist-server/service/index.js.map +1 -1
  13. package/dist-server/service/testing/tool-call-context.d.ts +10 -0
  14. package/dist-server/service/testing/tool-call-context.js +13 -0
  15. package/dist-server/service/testing/tool-call-context.js.map +1 -0
  16. package/dist-server/service/tool-registry/index.d.ts +3 -3
  17. package/dist-server/service/tool-registry/index.js +2 -1
  18. package/dist-server/service/tool-registry/index.js.map +1 -1
  19. package/dist-server/service/tool-registry/registry.d.ts +16 -0
  20. package/dist-server/service/tool-registry/registry.js +36 -0
  21. package/dist-server/service/tool-registry/registry.js.map +1 -1
  22. package/dist-server/service/tool-registry/types.d.ts +52 -4
  23. package/dist-server/service/tool-registry/types.js +4 -4
  24. package/dist-server/service/tool-registry/types.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/docs/grounded-analysis-contract.md +1 -1
  27. package/package.json +2 -2
  28. package/server/service/analysis-contract.test.ts +0 -1
  29. package/server/service/assistant/index.ts +1 -0
  30. package/server/service/assistant/registry-assistant.test.ts +131 -4
  31. package/server/service/assistant/registry-assistant.ts +29 -24
  32. package/server/service/assistant/tool-scope.ts +86 -0
  33. package/server/service/index.ts +1 -0
  34. package/server/service/testing/tool-call-context.ts +28 -0
  35. package/server/service/tool-registry/index.ts +7 -3
  36. package/server/service/tool-registry/registry.test.ts +50 -1
  37. package/server/service/tool-registry/registry.ts +47 -0
  38. package/server/service/tool-registry/types.ts +60 -10
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { registryAssistantChat } from './registry-assistant'
15
15
  import { clearToolRegistry, registerToolCategory } from '../tool-registry/index.js'
16
+ import { toolCallContextFixture } from '../testing/tool-call-context.js'
16
17
  import type { AIToolCall, AIToolChatResult } from '../types.js'
17
18
 
18
19
  const TC = (name: string, args: any = {}, id = 't1'): AIToolCall =>
@@ -60,6 +61,7 @@ beforeEach(() => {
60
61
  kind: 'write',
61
62
  name: 'addComponent',
62
63
  description: 'adds a component to the document',
64
+ privilege: { category: 'board', privilege: 'mutation' },
63
65
  schema: { type: 'object', properties: {} },
64
66
  builder: () => {
65
67
  ran.push({ name: 'addComponent', args: {}, context: null })
@@ -74,7 +76,7 @@ beforeEach(() => {
74
76
  guidance: 'Never state a quantity you did not read.',
75
77
  specs: [
76
78
  {
77
- kind: 'external',
79
+ kind: 'read',
78
80
  name: 'readOrderState',
79
81
  description: 'reads live order state',
80
82
  schema: { type: 'object', properties: {} },
@@ -85,6 +87,40 @@ beforeEach(() => {
85
87
  }
86
88
  ]
87
89
  } as any)
90
+
91
+ /*
92
+ * A category whose tools change something outside the conversation, each behind the same
93
+ * privilege its GraphQL door uses. This is the shape of board-import's `materializeImportSession`,
94
+ * which creates a Board.
95
+ */
96
+ registerToolCategory({
97
+ name: 'board-import',
98
+ guidance: 'Create a board only after the user confirms.',
99
+ specs: [
100
+ {
101
+ kind: 'read',
102
+ name: 'getImportSession',
103
+ description: 'reads an import session',
104
+ privilege: { category: 'board-import', privilege: 'query' },
105
+ schema: { type: 'object', properties: {} },
106
+ builder: (args: any, context: any) => {
107
+ ran.push({ name: 'getImportSession', args, context })
108
+ return { status: 'completed' }
109
+ }
110
+ },
111
+ {
112
+ kind: 'external',
113
+ name: 'materializeImportSession',
114
+ description: 'creates a board from an import session',
115
+ privilege: { category: 'board-import', privilege: 'mutation' },
116
+ schema: { type: 'object', properties: {} },
117
+ builder: (args: any, context: any) => {
118
+ ran.push({ name: 'materializeImportSession', args, context })
119
+ return { boardId: 'b-1' }
120
+ }
121
+ }
122
+ ]
123
+ } as any)
88
124
  })
89
125
 
90
126
  const input = (over: any = {}) => ({
@@ -98,7 +134,7 @@ const input = (over: any = {}) => ({
98
134
  describe('what the model is offered', () => {
99
135
  it('★ only the kinds that answer — a write tool is not offered to a surface with no document', async () => {
100
136
  const { client, seen } = recordingClient()
101
- await registryAssistantChat(input({ client }))
137
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring', 'plant-ops'] }))
102
138
 
103
139
  const offered = seen[0].tools.map(tool => tool.name)
104
140
  expect(offered).toEqual(expect.arrayContaining(['listFigures', 'readOrderState']))
@@ -147,7 +183,7 @@ describe('running a tool', () => {
147
183
  const { client } = recordingClient([okResult(undefined, [TC('listFigures', { q: 'roller' })]), okResult('two of them')])
148
184
 
149
185
  const result = await registryAssistantChat(
150
- input({ client, hostContext: { surface: 'figure-modeller', figureId: 'fig-1' } })
186
+ input({ client, toolCategories: ['figure-authoring'], hostContext: { surface: 'figure-modeller', figureId: 'fig-1' } })
151
187
  )
152
188
 
153
189
  expect(ran).toHaveLength(1)
@@ -158,10 +194,12 @@ describe('running a tool', () => {
158
194
  expect(result.reply).toContain('two of them')
159
195
  })
160
196
 
197
+
161
198
  it('★ a write tool call is not executed — the same set decides offering and dispatch', async () => {
162
199
  const { client } = recordingClient([okResult(undefined, [TC('addComponent')]), okResult('cannot do that')])
163
200
 
164
- await registryAssistantChat(input({ client }))
201
+ /* The category is allowed; the kind is what keeps the write out. */
202
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
165
203
 
166
204
  expect(ran.map(one => one.name)).not.toContain('addComponent')
167
205
  })
@@ -175,6 +213,95 @@ describe('running a tool', () => {
175
213
  })
176
214
  })
177
215
 
216
+ describe('what a tool is handed (A3)', () => {
217
+ it('★ exactly the shared shape — state and host side by side', async () => {
218
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
219
+ const state = { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
220
+
221
+ await registryAssistantChat(input({ client, state, toolCategories: ['figure-authoring'], hostContext: { figureId: 'fig-1' } }))
222
+
223
+ expect(ran[0].context).toEqual(toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', host: { figureId: 'fig-1' } }))
224
+ expect(ran[0].context.state).not.toHaveProperty('host')
225
+ })
226
+
227
+ it('★ host is an empty object when the surface sent none — a tool never reads it off undefined', async () => {
228
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
229
+
230
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
231
+
232
+ expect(ran[0].context.host).toEqual({})
233
+ })
234
+ })
235
+
236
+ describe('who may use a tool (A1)', () => {
237
+ const grantAll = async () => true
238
+ const denyAll = async () => false
239
+
240
+ it('★ omitting the category list offers nothing and runs nothing', async () => {
241
+ const { client, seen } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('done')])
242
+
243
+ await registryAssistantChat(input({ client, toolAccess: grantAll }))
244
+
245
+ expect(seen[0].tools).toEqual([])
246
+ expect(ran).toHaveLength(0)
247
+ })
248
+
249
+ it('★ a tool that changes something outside is not offered here, even with the privilege', async () => {
250
+ const { client, seen } = recordingClient([okResult(undefined, [TC('materializeImportSession')]), okResult('done')])
251
+
252
+ await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: grantAll }))
253
+
254
+ expect(seen[0].tools.map((t: any) => t.name)).not.toContain('materializeImportSession')
255
+ expect(ran.map(one => one.name)).not.toContain('materializeImportSession')
256
+ })
257
+
258
+ it('★ a tool behind a privilege the caller lacks is neither offered nor run when called by name', async () => {
259
+ const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
260
+
261
+ await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: denyAll }))
262
+
263
+ expect(seen[0].tools.map((t: any) => t.name)).not.toContain('getImportSession')
264
+ expect(ran.map(one => one.name)).not.toContain('getImportSession')
265
+ })
266
+
267
+ it('★ with no access check at all, a tool behind a privilege stays closed', async () => {
268
+ const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
269
+
270
+ await registryAssistantChat(input({ client, toolCategories: ['board-import'] }))
271
+
272
+ expect(seen[0].tools.map((t: any) => t.name)).not.toContain('getImportSession')
273
+ expect(ran.map(one => one.name)).not.toContain('getImportSession')
274
+ })
275
+
276
+ it('the access check is asked with the privilege the tool declares, and a grant opens the tool', async () => {
277
+ const asked: any[] = []
278
+ const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
279
+
280
+ await registryAssistantChat(
281
+ input({
282
+ client,
283
+ toolCategories: ['board-import'],
284
+ toolAccess: async (privilege: any) => {
285
+ asked.push(privilege)
286
+ return privilege.privilege === 'query'
287
+ }
288
+ })
289
+ )
290
+
291
+ expect(asked).toContainEqual({ category: 'board-import', privilege: 'query' })
292
+ expect(seen[0].tools.map((t: any) => t.name)).toContain('getImportSession')
293
+ expect(ran.map(one => one.name)).toContain('getImportSession')
294
+ })
295
+
296
+ it('guidance of a category whose every tool is withheld is not read to the model', async () => {
297
+ const { client, seen } = recordingClient()
298
+
299
+ await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: denyAll }))
300
+
301
+ expect(seen[0].options.systemPrompt).not.toContain('Create a board only after the user confirms.')
302
+ })
303
+ })
304
+
178
305
  describe('a client that cannot call tools', () => {
179
306
  it('★ refuses instead of answering from nothing', async () => {
180
307
  await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
@@ -9,28 +9,24 @@
9
9
  * operato-figure hit exactly that: the dock was mounted, the tools were registered, and the only
10
10
  * mutation that could run them lived in a package figure does not load.
11
11
  *
12
- * ── Read and external only ─────────────────────────────────────────────────────
12
+ * ── Read only ──────────────────────────────────────────────────────────────────
13
13
  * A registry category may also carry `write` and `action` tools. Those produce edit ops for a
14
14
  * document the host owns, and a host with no document cannot apply them. Offering them here would
15
15
  * declare a door with no road behind it — the model would call one and the call would go nowhere.
16
- * So this runner offers, and dispatches, `read` and `external` only. A host that owns a document
17
- * keeps assembling its own conversation, as board-ai does.
16
+ * `external` tools change something outside the conversation (board-import's
17
+ * `materializeImportSession` creates a Board) and this door has no confirm-and-apply step, so they
18
+ * are not offered either (A1, 2026-09-17). This runner offers, and dispatches, `read` only.
18
19
  *
19
20
  * The gate answers from the same list the definitions are built from, so what is offered and what
20
21
  * may be dispatched cannot drift apart.
21
22
  */
22
23
  import type { AIClient, AIMessage } from '../types.js'
23
- import { getCategoryGuidance, getToolSpecsFor, type ToolCategoryFilter, type ToolSpec } from '../tool-registry/index.js'
24
+ import { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCheck } from './tool-scope.js'
24
25
  import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
25
26
  import { summarizeToolResult } from './summarize.js'
26
27
  import type { GroundingSources } from '../utils/index.js'
28
+ import type { ToolCallContext } from '../tool-registry/types.js'
27
29
 
28
- /** What a registry tool is handed when it runs. `state` is the caller's koa state. */
29
- export interface RegistryToolCallContext {
30
- state: any
31
- /** What this surface is looking at — the host passes it through; tools read it as `host`. */
32
- host?: any
33
- }
34
30
 
35
31
  export interface RegistryAssistantInput {
36
32
  /** Provider client. Must implement `chatWithTools`. */
@@ -40,12 +36,17 @@ export interface RegistryAssistantInput {
40
36
  /** Who the assistant is and what this surface is. The host owns this prose. */
41
37
  systemPrompt: string
42
38
  /**
43
- * Categories this conversation may use. Omit to allow every registered category.
44
- *
45
- * An empty array is not the same as omitting: it allows nothing, and the conversation runs with
46
- * no tools at all. That is a legitimate surface (plain chat), so it is not corrected here.
39
+ * Categories this conversation may use. **Omitting it allows nothing** — the same as an empty
40
+ * list, a plain conversation with no tools (A1, 2026-09-17). It used to mean every registered
41
+ * category, which is how a Board-creating tool reached any conversation.
47
42
  */
48
43
  toolCategories?: string[]
44
+ /**
45
+ * Answers whether the caller holds a tool's declared privilege. Without it, every tool that
46
+ * declares a privilege stays closed. The host builds it from the request — this package does
47
+ * not know auth.
48
+ */
49
+ toolAccess?: ToolAccessCheck
49
50
  /** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
50
51
  hostContext?: any
51
52
  /** Caller's koa state (domain, user, tx). Tools that read the database need it. */
@@ -64,11 +65,6 @@ export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
64
65
  offeredTools: string[]
65
66
  }
66
67
 
67
- /** `read` and `external` are the two kinds that answer a question without changing a document. */
68
- function answeringSpecs(filter: ToolCategoryFilter): ToolSpec[] {
69
- return getToolSpecsFor(filter).filter(spec => spec.kind === 'read' || spec.kind === 'external')
70
- }
71
-
72
68
  /**
73
69
  * Run one turn of a conversation whose only tools come from the registry.
74
70
  *
@@ -82,8 +78,17 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
82
78
  throw new Error('[registry-assistant] the configured AI client cannot call tools, so this conversation cannot run')
83
79
  }
84
80
 
85
- const filter: ToolCategoryFilter = toolCategories
86
- const specs = answeringSpecs(filter)
81
+ /*
82
+ * Only `read` is offered here. `external` means the tool changes something outside the
83
+ * conversation (board-import's `materializeImportSession` creates a Board); this door answers
84
+ * questions and has no confirm-and-apply step, so a tool with an effect does not go through it
85
+ * even when the caller holds the privilege.
86
+ */
87
+ const scope = narrowToolScope(
88
+ await resolveToolScope(toolCategories, input.toolAccess),
89
+ spec => spec.kind === 'read'
90
+ )
91
+ const specs = scope.specs
87
92
  const byName = new Map(specs.map(spec => [spec.name, spec]))
88
93
 
89
94
  const tools = specs.map(spec => ({
@@ -96,12 +101,12 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
96
101
  * The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so
97
102
  * a surface never reads instructions for a category it cannot call.
98
103
  */
99
- const guidance = getCategoryGuidance(filter)
104
+ const guidance = toolScopeGuidance(scope)
100
105
  const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
101
106
 
102
- const toolContext: RegistryToolCallContext = { state, host: hostContext }
107
+ const toolContext: ToolCallContext = { state, host: hostContext ?? {} }
103
108
 
104
- const result = await assistantChat<RegistryToolCallContext, unknown>({
109
+ const result = await assistantChat<ToolCallContext, unknown>({
105
110
  initialConversation: messages,
106
111
  tools,
107
112
  options: {
@@ -0,0 +1,86 @@
1
+ /*
2
+ * Which registered tools one conversation may use — decided once per request, before anything is
3
+ * offered, so offering and dispatch read the same list.
4
+ *
5
+ * ── Two questions, one list ───────────────────────────────────────────────────────
6
+ * Which categories did this surface ask for? `toolCategories`
7
+ * Which of their tools may this caller use? each spec's `privilege`, asked of `access`
8
+ *
9
+ * **Nothing asked means nothing offered.** Omitting the category list used to mean "every
10
+ * registered category"; that default is the one that opened board-import's Board-creating tool to
11
+ * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
12
+ *
13
+ * **No access check means privileged tools stay closed.** A caller that cannot say who is asking
14
+ * gets only the tools that need no privilege.
15
+ *
16
+ * The permission check itself belongs to auth, which this package does not depend on, so the host
17
+ * hands it in (`ai-assistant` builds it from the request state).
18
+ */
19
+ import { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'
20
+ import type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js'
21
+
22
+ /** Answers "may this caller use a door guarded by this privilege". */
23
+ export type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>
24
+
25
+ export interface ScopedCategory {
26
+ readonly name: string
27
+ readonly guidance: string
28
+ /** The tools of this category this caller may use. Never empty — a category with none is left out. */
29
+ readonly specs: ReadonlyArray<ToolSpec>
30
+ }
31
+
32
+ export interface ToolScope {
33
+ /** Categories with at least one usable tool, in registration order. */
34
+ readonly categories: ReadonlyArray<ScopedCategory>
35
+ /** Every usable tool, flat. Offering and dispatch both read this. */
36
+ readonly specs: ReadonlyArray<ToolSpec>
37
+ }
38
+
39
+ export const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })
40
+
41
+ const privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')
42
+
43
+ export async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {
44
+ if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE
45
+
46
+ /* One check per distinct privilege — a category of ten read tools asks once. */
47
+ const answers = new Map<string, Promise<boolean>>()
48
+ const mayUse = (spec: ToolSpec): Promise<boolean> => {
49
+ const privilege = spec.privilege
50
+ if (!privilege) return Promise.resolve(true)
51
+ if (!access) return Promise.resolve(false)
52
+ const key = privilegeKey(privilege)
53
+ if (!answers.has(key)) answers.set(key, access(privilege).catch(() => false))
54
+ return answers.get(key)!
55
+ }
56
+
57
+ const categories: ScopedCategory[] = []
58
+ for (const category of getToolCategories()) {
59
+ if (!requested.includes(category.name)) continue
60
+ const allowed = await Promise.all(category.specs.map(mayUse))
61
+ const specs = category.specs.filter((_, i) => allowed[i])
62
+ if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })
63
+ }
64
+ return fromCategories(categories)
65
+ }
66
+
67
+ /**
68
+ * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category
69
+ * left with nothing drops out, so its guidance is not read to a model that cannot call it.
70
+ */
71
+ export function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {
72
+ return fromCategories(
73
+ scope.categories
74
+ .map(category => ({ ...category, specs: category.specs.filter(keep) }))
75
+ .filter(category => category.specs.length > 0)
76
+ )
77
+ }
78
+
79
+ /** The rules of the categories in scope, for the system prompt. */
80
+ export function toolScopeGuidance(scope: ToolScope): string[] {
81
+ return scope.categories.map(category => category.guidance).filter(Boolean)
82
+ }
83
+
84
+ function fromCategories(categories: ScopedCategory[]): ToolScope {
85
+ return { categories, specs: categories.flatMap(category => category.specs) }
86
+ }
@@ -6,3 +6,4 @@ export * from './tool-registry/index.js'
6
6
  export * from './agentic-loop/index.js'
7
7
  export * from './assistant/index.js'
8
8
  export * from './analysis-contract.js'
9
+ export * from './testing/tool-call-context.js'
@@ -0,0 +1,28 @@
1
+ /*
2
+ * The one way a test builds what a registered tool is handed (A3, 2026-09-17).
3
+ *
4
+ * The twin's space tools read `ctx.state.host` while the door passed `ctx.host`, and their tests
5
+ * stayed green because each test wrote its own context in the shape the tool expected. A fixture
6
+ * owned by each test agrees with its tool by construction. This one is owned by the package that
7
+ * defines the neutral door, and that door's tests check it hands tools exactly this shape — so a tool
8
+ * test built on it fails when the tool reads a field no door fills.
9
+ */
10
+ import type { ToolCallContext, ToolCallMode } from '../tool-registry/types.js'
11
+
12
+ export interface ToolCallContextFixtureInput {
13
+ domainId?: string
14
+ userId?: string
15
+ /** Extra request-state fields a tool reads (e.g. `tx`). Merged after domain and user. */
16
+ state?: Record<string, any>
17
+ host?: Record<string, any>
18
+ mode?: ToolCallMode
19
+ }
20
+
21
+ export function toolCallContextFixture(input: ToolCallContextFixtureInput = {}): ToolCallContext {
22
+ const context: ToolCallContext = {
23
+ state: { domain: { id: input.domainId ?? 'domain-test' }, user: { id: input.userId ?? 'user-test' }, ...(input.state ?? {}) },
24
+ host: input.host ?? {}
25
+ }
26
+ if (input.mode) context.mode = input.mode
27
+ return context
28
+ }
@@ -13,15 +13,17 @@ export {
13
13
  getToolKind,
14
14
  hasTool,
15
15
  getCategorySpecs,
16
- clearToolRegistry
16
+ clearToolRegistry,
17
+ getToolRegistryWarnings
17
18
  } from './registry'
18
19
 
19
20
  /* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */
20
- export type { ToolCategoryFilter } from './registry'
21
+ export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'
21
22
 
22
23
  export type {
23
24
  ToolSpec,
24
25
  ToolSpecBase,
26
+ ToolPrivilege,
25
27
  ReadToolSpec,
26
28
  WriteToolSpec,
27
29
  ActionToolSpec,
@@ -33,5 +35,7 @@ export type {
33
35
  ExternalToolBuilder,
34
36
  ReadToolContext,
35
37
  WriteToolContext,
36
- ExternalToolContext
38
+ ExternalToolContext,
39
+ ToolCallContext,
40
+ ToolCallMode
37
41
  } from './types'
@@ -14,7 +14,7 @@ import {
14
14
  getToolKind,
15
15
  hasTool,
16
16
  getCategorySpecs,
17
- clearToolRegistry, getCategoryGuidance } from './registry'
17
+ clearToolRegistry, getCategoryGuidance, getToolRegistryWarnings } from './registry'
18
18
  import type { ToolCategory, ToolSpec } from './types'
19
19
 
20
20
  // 테스트 간 격리
@@ -34,6 +34,8 @@ function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
34
34
  description: 'desc',
35
35
  schema: {},
36
36
  builder: () => ({}),
37
+ /* A tool that changes something must name its door (A1). The fixture does, like guidance. */
38
+ ...(s.kind && s.kind !== 'read' ? { privilege: { category: 'fixture', privilege: 'mutation' } } : {}),
37
39
  ...s
38
40
  })) as ToolSpec[]
39
41
  }
@@ -174,6 +176,7 @@ describe('registry — spec 보존', () => {
174
176
  kind: 'write',
175
177
  name: 't1',
176
178
  description: 'main desc',
179
+ privilege: { category: 'fixture', privilege: 'mutation' },
177
180
  themeNote: '⚠ note',
178
181
  schema: { type: 'object' },
179
182
  builder: () => null
@@ -258,3 +261,49 @@ describe('registry — 지침 없는 카테고리는 등록되지 않는다', ()
258
261
  expect(getToolCategories()[0].guidance).toContain('fixture guidance')
259
262
  })
260
263
  })
264
+
265
+ describe('registry — a tool that changes something names its door (A1, stage one: surface)', () => {
266
+ test.each(['write', 'action', 'external'] as const)(
267
+ '★ a %s tool without a privilege registers, and the omission is recorded as a code',
268
+ kind => {
269
+ registerToolCategory({
270
+ name: 'no-door',
271
+ guidance: '- fixture guidance for no-door',
272
+ specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
273
+ })
274
+ expect(getToolKind('changesThings')).toBe(kind)
275
+ expect(getToolRegistryWarnings()).toEqual([
276
+ expect.objectContaining({
277
+ code: 'tool-privilege-undeclared',
278
+ params: { category: 'no-door', tool: 'changesThings', kind }
279
+ })
280
+ ])
281
+ }
282
+ )
283
+
284
+ test('a privilege with no privilege name is recorded the same way', () => {
285
+ registerToolCategory({
286
+ name: 'half-door',
287
+ guidance: '- fixture guidance for half-door',
288
+ specs: [
289
+ { kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
290
+ ]
291
+ })
292
+ expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
293
+ })
294
+
295
+ test('a declared tool and a read tool leave no warning', () => {
296
+ registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }, { name: 'r1', kind: 'read' }]))
297
+ expect(getToolRegistryWarnings()).toEqual([])
298
+ })
299
+
300
+ test('re-registering a category replaces its warnings instead of piling them up', () => {
301
+ const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
302
+ registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
303
+ registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
304
+ expect(getToolRegistryWarnings()).toHaveLength(1)
305
+
306
+ registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
307
+ expect(getToolRegistryWarnings()).toEqual([])
308
+ })
309
+ })
@@ -29,6 +29,24 @@ import type { ToolCategory, ToolSpec } from './types'
29
29
 
30
30
  const categories: Map<string, ToolCategory> = new Map()
31
31
 
32
+ /** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
33
+ export interface ToolRegistryWarning {
34
+ code: 'tool-privilege-undeclared'
35
+ params: { category: string; tool: string; kind: ToolSpec['kind'] }
36
+ /** For a person reading it; the code and params are what callers act on. */
37
+ message: string
38
+ }
39
+
40
+ const warnings: ToolRegistryWarning[] = []
41
+
42
+ /**
43
+ * What registration accepted but the contract does not yet allow. Stage one of A1 records these
44
+ * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
45
+ */
46
+ export function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {
47
+ return warnings.slice()
48
+ }
49
+
32
50
  /**
33
51
  * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.
34
52
  */
@@ -55,6 +73,34 @@ export function registerToolCategory(category: ToolCategory): void {
55
73
  )
56
74
  }
57
75
 
76
+ /*
77
+ * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).
78
+ *
79
+ * The conversation door checks one privilege — may this person talk to the assistant. Without a
80
+ * privilege on the tool, that single check opened every tool behind it: a user holding only
81
+ * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a
82
+ * Board.
83
+ *
84
+ * **This is stage one of two: surface, then refuse** (ADR-0037, "먼저 드러내고, 목록이 비면 막는다").
85
+ * Refusing now would stop every app whose tools predate the `privilege` field from booting, and
86
+ * those apps compile against an installed copy that does not have the field yet. So an undeclared
87
+ * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not
88
+ * a log line — a warning only a log carries is not distinguishable from no warning. When the list
89
+ * is empty across the apps, this becomes a refusal.
90
+ */
91
+ warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
92
+ for (const spec of category.specs) {
93
+ if (spec.kind !== 'read' && !(spec.privilege?.category && spec.privilege?.privilege)) {
94
+ warnings.push({
95
+ code: 'tool-privilege-undeclared',
96
+ params: { category: category.name, tool: spec.name, kind: spec.kind },
97
+ message:
98
+ `tool '${spec.name}' in category '${category.name}' is '${spec.kind}' but declares no privilege — ` +
99
+ 'declare the same { category, privilege } its GraphQL door uses'
100
+ })
101
+ }
102
+ }
103
+
58
104
  if (categories.has(category.name)) {
59
105
  console.warn(
60
106
  `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
@@ -180,4 +226,5 @@ export function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec>
180
226
  */
181
227
  export function clearToolRegistry(): void {
182
228
  categories.clear()
229
+ warnings.length = 0
183
230
  }