@things-factory/ai-client-base 10.1.28 → 10.1.31
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/dist-server/service/agentic-loop/agentic-loop.js +13 -1
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.d.ts +11 -0
- package/dist-server/service/assistant/registry-assistant.js +5 -1
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/tool-scope.d.ts +3 -3
- package/dist-server/service/assistant/tool-scope.js +32 -15
- package/dist-server/service/assistant/tool-scope.js.map +1 -1
- package/dist-server/service/tool-registry/index.d.ts +1 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.d.ts +9 -8
- package/dist-server/service/tool-registry/registry.js +26 -38
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +16 -31
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/agentic-loop/agentic-loop.ts +14 -1
- package/server/service/agentic-loop/proposal-key.test.ts +31 -0
- package/server/service/assistant/registry-assistant.test.ts +65 -15
- package/server/service/assistant/registry-assistant.ts +13 -1
- package/server/service/assistant/tool-scope.test.ts +123 -0
- package/server/service/assistant/tool-scope.ts +34 -17
- package/server/service/tool-registry/index.ts +0 -1
- package/server/service/tool-registry/registry.test.ts +34 -66
- package/server/service/tool-registry/registry.ts +35 -46
- package/server/service/tool-registry/types.ts +16 -33
|
@@ -51,6 +51,7 @@ beforeEach(() => {
|
|
|
51
51
|
kind: 'read',
|
|
52
52
|
name: 'listFigures',
|
|
53
53
|
description: 'lists figures',
|
|
54
|
+
doors: ['Query.figures'],
|
|
54
55
|
schema: { type: 'object', properties: {} },
|
|
55
56
|
builder: (args: any, context: any) => {
|
|
56
57
|
ran.push({ name: 'listFigures', args, context })
|
|
@@ -61,7 +62,7 @@ beforeEach(() => {
|
|
|
61
62
|
kind: 'write',
|
|
62
63
|
name: 'addComponent',
|
|
63
64
|
description: 'adds a component to the document',
|
|
64
|
-
|
|
65
|
+
doors: ['Mutation.updateBoard'],
|
|
65
66
|
schema: { type: 'object', properties: {} },
|
|
66
67
|
builder: () => {
|
|
67
68
|
ran.push({ name: 'addComponent', args: {}, context: null })
|
|
@@ -79,6 +80,7 @@ beforeEach(() => {
|
|
|
79
80
|
kind: 'read',
|
|
80
81
|
name: 'readOrderState',
|
|
81
82
|
description: 'reads live order state',
|
|
83
|
+
doors: ['Query.jobOrders'],
|
|
82
84
|
schema: { type: 'object', properties: {} },
|
|
83
85
|
builder: (args: any, context: any) => {
|
|
84
86
|
ran.push({ name: 'readOrderState', args, context })
|
|
@@ -89,9 +91,8 @@ beforeEach(() => {
|
|
|
89
91
|
} as any)
|
|
90
92
|
|
|
91
93
|
/*
|
|
92
|
-
* A category whose tools change something outside the conversation, each
|
|
93
|
-
*
|
|
94
|
-
* which creates a Board.
|
|
94
|
+
* A category whose tools change something outside the conversation, each naming the GraphQL door
|
|
95
|
+
* it reaches. This is the shape of board-import's `materializeImportSession`, which creates a Board.
|
|
95
96
|
*/
|
|
96
97
|
registerToolCategory({
|
|
97
98
|
name: 'board-import',
|
|
@@ -101,7 +102,7 @@ beforeEach(() => {
|
|
|
101
102
|
kind: 'read',
|
|
102
103
|
name: 'getImportSession',
|
|
103
104
|
description: 'reads an import session',
|
|
104
|
-
|
|
105
|
+
doors: ['Query.importSession'],
|
|
105
106
|
schema: { type: 'object', properties: {} },
|
|
106
107
|
builder: (args: any, context: any) => {
|
|
107
108
|
ran.push({ name: 'getImportSession', args, context })
|
|
@@ -112,7 +113,7 @@ beforeEach(() => {
|
|
|
112
113
|
kind: 'external',
|
|
113
114
|
name: 'materializeImportSession',
|
|
114
115
|
description: 'creates a board from an import session',
|
|
115
|
-
|
|
116
|
+
doors: ['Mutation.materializeImportSession'],
|
|
116
117
|
schema: { type: 'object', properties: {} },
|
|
117
118
|
builder: (args: any, context: any) => {
|
|
118
119
|
ran.push({ name: 'materializeImportSession', args, context })
|
|
@@ -128,6 +129,12 @@ const input = (over: any = {}) => ({
|
|
|
128
129
|
messages: [{ role: 'user' as const, content: 'what figures do we have' }],
|
|
129
130
|
systemPrompt: 'you help someone draw a figure',
|
|
130
131
|
state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
|
|
132
|
+
/*
|
|
133
|
+
* Every tool here names a door, and a door is asked of the host (ADR-0071 decision 2). A caller
|
|
134
|
+
* who passes them all is the uninteresting case, so it is the default; the tests about the gate
|
|
135
|
+
* itself pass their own check, and one passes `toolAccess: undefined` on purpose.
|
|
136
|
+
*/
|
|
137
|
+
toolAccess: async () => true,
|
|
131
138
|
...over
|
|
132
139
|
})
|
|
133
140
|
|
|
@@ -246,7 +253,7 @@ describe('who may use a tool (A1)', () => {
|
|
|
246
253
|
expect(ran).toHaveLength(0)
|
|
247
254
|
})
|
|
248
255
|
|
|
249
|
-
it('★ a tool that changes something outside is not offered here, even
|
|
256
|
+
it('★ a tool that changes something outside is not offered here, even when its door is open', async () => {
|
|
250
257
|
const { client, seen } = recordingClient([okResult(undefined, [TC('materializeImportSession')]), okResult('done')])
|
|
251
258
|
|
|
252
259
|
await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: grantAll }))
|
|
@@ -255,7 +262,7 @@ describe('who may use a tool (A1)', () => {
|
|
|
255
262
|
expect(ran.map(one => one.name)).not.toContain('materializeImportSession')
|
|
256
263
|
})
|
|
257
264
|
|
|
258
|
-
it('★ a tool
|
|
265
|
+
it('★ a tool whose door the caller cannot pass is neither offered nor run when called by name', async () => {
|
|
259
266
|
const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
|
|
260
267
|
|
|
261
268
|
await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: denyAll }))
|
|
@@ -264,16 +271,16 @@ describe('who may use a tool (A1)', () => {
|
|
|
264
271
|
expect(ran.map(one => one.name)).not.toContain('getImportSession')
|
|
265
272
|
})
|
|
266
273
|
|
|
267
|
-
it('★ with no access check at all, a tool
|
|
274
|
+
it('★ with no access check at all, a tool that reaches a door stays closed', async () => {
|
|
268
275
|
const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
|
|
269
276
|
|
|
270
|
-
await registryAssistantChat(input({ client, toolCategories: ['board-import'] }))
|
|
277
|
+
await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: undefined }))
|
|
271
278
|
|
|
272
279
|
expect(seen[0].tools.map((t: any) => t.name)).not.toContain('getImportSession')
|
|
273
280
|
expect(ran.map(one => one.name)).not.toContain('getImportSession')
|
|
274
281
|
})
|
|
275
282
|
|
|
276
|
-
it('the access check is asked
|
|
283
|
+
it('the access check is asked for the doors the tool names, and a caller holding them opens it', async () => {
|
|
277
284
|
const asked: any[] = []
|
|
278
285
|
const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
|
|
279
286
|
|
|
@@ -281,14 +288,14 @@ describe('who may use a tool (A1)', () => {
|
|
|
281
288
|
input({
|
|
282
289
|
client,
|
|
283
290
|
toolCategories: ['board-import'],
|
|
284
|
-
toolAccess: async (
|
|
285
|
-
asked.push(
|
|
286
|
-
return
|
|
291
|
+
toolAccess: async (door: string) => {
|
|
292
|
+
asked.push(door)
|
|
293
|
+
return door.startsWith('Query.')
|
|
287
294
|
}
|
|
288
295
|
})
|
|
289
296
|
)
|
|
290
297
|
|
|
291
|
-
expect(asked).
|
|
298
|
+
expect(asked).toContain('Query.importSession')
|
|
292
299
|
expect(seen[0].tools.map((t: any) => t.name)).toContain('getImportSession')
|
|
293
300
|
expect(ran.map(one => one.name)).toContain('getImportSession')
|
|
294
301
|
})
|
|
@@ -307,3 +314,46 @@ describe('a client that cannot call tools', () => {
|
|
|
307
314
|
await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
|
|
308
315
|
})
|
|
309
316
|
})
|
|
317
|
+
|
|
318
|
+
/*
|
|
319
|
+
* 도구가 「어느 대화에서 불렸나」를 안다 — ADR-0061 결정 3.
|
|
320
|
+
*
|
|
321
|
+
* 이 칸은 A2(모드를 서버가 등록한다)가 서기 전까지 비어 있었고 주석만 「A2 가 서면」이라고 적혀 있었다.
|
|
322
|
+
* A2 가 선 뒤에도 채우는 쪽이 없어서 도구는 계속 `host` 만 봤다 — 그것은 클라이언트가 채우는 칸이다.
|
|
323
|
+
*/
|
|
324
|
+
describe('the tool context carries the conversation', () => {
|
|
325
|
+
it('★ 부르는 쪽이 준 mode 가 도구에게 간다 — 서버의 값이다', async () => {
|
|
326
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
327
|
+
|
|
328
|
+
await registryAssistantChat(
|
|
329
|
+
input({ client, toolCategories: ['figure-authoring'], mode: { kind: 'twin.space', target: 'SITE-1' } })
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
expect(ran[0].context.mode).toEqual({ kind: 'twin.space', target: 'SITE-1' })
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('★ 그 값은 `host` 와 섞이지 않는다 — 하나는 서버가, 하나는 클라이언트가 채운다', async () => {
|
|
336
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
337
|
+
|
|
338
|
+
await registryAssistantChat(
|
|
339
|
+
input({
|
|
340
|
+
client,
|
|
341
|
+
toolCategories: ['figure-authoring'],
|
|
342
|
+
hostContext: { figureId: 'fig-1' },
|
|
343
|
+
mode: { kind: 'figure.modeller' }
|
|
344
|
+
})
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
expect(ran[0].context).toEqual(
|
|
348
|
+
toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', host: { figureId: 'fig-1' }, mode: { kind: 'figure.modeller' } })
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('mode 를 안 주면 그 칸은 없다 — 빈 값을 지어내지 않는다', async () => {
|
|
353
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
354
|
+
|
|
355
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
356
|
+
|
|
357
|
+
expect(ran[0].context).not.toHaveProperty('mode')
|
|
358
|
+
})
|
|
359
|
+
})
|
|
@@ -49,6 +49,14 @@ export interface RegistryAssistantInput {
|
|
|
49
49
|
toolAccess?: ToolAccessCheck
|
|
50
50
|
/** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
|
|
51
51
|
hostContext?: any
|
|
52
|
+
/**
|
|
53
|
+
* Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).
|
|
54
|
+
*
|
|
55
|
+
* Tools see it as `context.mode`. It is the server's value, resolved from the registry before the
|
|
56
|
+
* turn runs, so a tool that behaves differently in two conversations reads it here and not from
|
|
57
|
+
* `host`, which the client fills.
|
|
58
|
+
*/
|
|
59
|
+
mode?: { kind: string; target?: string }
|
|
52
60
|
/** Caller's koa state (domain, user, tx). Tools that read the database need it. */
|
|
53
61
|
state: any
|
|
54
62
|
model?: string
|
|
@@ -106,7 +114,11 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
106
114
|
const guidance = toolScopeGuidance(scope)
|
|
107
115
|
const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
/*
|
|
118
|
+
* `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
|
|
119
|
+
* 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
|
|
120
|
+
*/
|
|
121
|
+
const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }
|
|
110
122
|
|
|
111
123
|
const result = await assistantChat<ToolCallContext, unknown>({
|
|
112
124
|
initialConversation: messages,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this guards: which tools one conversation is offered, now that the **doors** decide it
|
|
3
|
+
* (ADR-0071 decision 2, added to 2026-09-18).
|
|
4
|
+
*
|
|
5
|
+
* The three states of `doors` are three different answers, and the interesting one is the third:
|
|
6
|
+
* a tool that says nothing is **left out**, not opened. Reading an absent declaration as "nothing
|
|
7
|
+
* to check" is how the board-import tool reached every conversation once already.
|
|
8
|
+
*/
|
|
9
|
+
import { registerToolCategory, clearToolRegistry } from '../tool-registry/registry'
|
|
10
|
+
import type { ToolCategory, ToolSpec } from '../tool-registry/types'
|
|
11
|
+
import { resolveToolScope, narrowToolScope, EMPTY_TOOL_SCOPE } from './tool-scope'
|
|
12
|
+
|
|
13
|
+
afterEach(() => clearToolRegistry())
|
|
14
|
+
|
|
15
|
+
function category(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
guidance: `- fixture guidance for ${name}`,
|
|
19
|
+
specs: specs.map(s => ({
|
|
20
|
+
kind: 'read' as const,
|
|
21
|
+
name: 'unnamed',
|
|
22
|
+
description: 'desc',
|
|
23
|
+
schema: {},
|
|
24
|
+
builder: () => ({}),
|
|
25
|
+
...s
|
|
26
|
+
})) as ToolSpec[]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const names = (scope: { specs: ReadonlyArray<ToolSpec> }) => scope.specs.map(s => s.name)
|
|
31
|
+
|
|
32
|
+
/** A caller holding these doors and no others. */
|
|
33
|
+
const holding = (...doors: string[]) => jest.fn(async (door: string) => doors.includes(door))
|
|
34
|
+
|
|
35
|
+
describe('resolveToolScope — the doors decide', () => {
|
|
36
|
+
test('★ a tool that names no doors at all is left out, not let through', async () => {
|
|
37
|
+
registerToolCategory(category('mixed', [{ name: 'undeclared' }, { name: 'declared', doors: ['Query.a'] }]))
|
|
38
|
+
|
|
39
|
+
const scope = await resolveToolScope(['mixed'], holding('Query.a'))
|
|
40
|
+
|
|
41
|
+
expect(names(scope)).toEqual(['declared'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('★ `doors: []` passes without asking — it reaches nothing to ask about', async () => {
|
|
45
|
+
const access = holding()
|
|
46
|
+
registerToolCategory(category('surface', [{ name: 'getOpenScene', doors: [] }]))
|
|
47
|
+
|
|
48
|
+
expect(names(await resolveToolScope(['surface'], access))).toEqual(['getOpenScene'])
|
|
49
|
+
expect(access).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('★ several doors are AND — half of them is not enough', async () => {
|
|
53
|
+
registerToolCategory(
|
|
54
|
+
category('plant', [
|
|
55
|
+
{ name: 'overview', doors: ['Query.jobOrders', 'Query.productionReports'] },
|
|
56
|
+
{ name: 'findJobOrders', doors: ['Query.jobOrders'] }
|
|
57
|
+
])
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const scope = await resolveToolScope(['plant'], holding('Query.jobOrders'))
|
|
61
|
+
|
|
62
|
+
expect(names(scope)).toEqual(['findJobOrders'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('each door is asked once however many tools reach it', async () => {
|
|
66
|
+
const access = holding('Query.jobOrders', 'Query.productionReports')
|
|
67
|
+
registerToolCategory(
|
|
68
|
+
category('plant', [
|
|
69
|
+
{ name: 'overview', doors: ['Query.jobOrders', 'Query.productionReports'] },
|
|
70
|
+
{ name: 'findJobOrders', doors: ['Query.jobOrders'] },
|
|
71
|
+
{ name: 'attention', doors: ['Query.jobOrders', 'Query.productionReports'] }
|
|
72
|
+
])
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
await resolveToolScope(['plant'], access)
|
|
76
|
+
|
|
77
|
+
expect(access.mock.calls.map(c => c[0]).sort()).toEqual(['Query.jobOrders', 'Query.productionReports'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('★ no access check closes every tool that reaches a door', async () => {
|
|
81
|
+
registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }, { name: 'getOpenScene', doors: [] }]))
|
|
82
|
+
|
|
83
|
+
expect(names(await resolveToolScope(['plant']))).toEqual(['getOpenScene'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a door check that throws closes that door', async () => {
|
|
87
|
+
const access = jest.fn(async () => {
|
|
88
|
+
throw new Error('auth is down')
|
|
89
|
+
})
|
|
90
|
+
registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }]))
|
|
91
|
+
|
|
92
|
+
expect(await resolveToolScope(['plant'], access)).toEqual(EMPTY_TOOL_SCOPE)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a category left with no usable tool drops out, so its guidance is not read to the model', async () => {
|
|
96
|
+
registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }]))
|
|
97
|
+
registerToolCategory(category('twin', [{ name: 'queryState', doors: ['Query.twinReplay'] }]))
|
|
98
|
+
|
|
99
|
+
const scope = await resolveToolScope(['plant', 'twin'], holding('Query.twinReplay'))
|
|
100
|
+
|
|
101
|
+
expect(scope.categories.map(c => c.name)).toEqual(['twin'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('nothing asked for is nothing offered', async () => {
|
|
105
|
+
registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: [] }]))
|
|
106
|
+
|
|
107
|
+
expect(await resolveToolScope([], holding())).toEqual(EMPTY_TOOL_SCOPE)
|
|
108
|
+
expect(await resolveToolScope(undefined as any, holding())).toEqual(EMPTY_TOOL_SCOPE)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('narrowing keeps the same reading — a surface that runs only reads', async () => {
|
|
112
|
+
registerToolCategory(
|
|
113
|
+
category('twin', [
|
|
114
|
+
{ name: 'queryState', doors: ['Query.twinReplay'] },
|
|
115
|
+
{ name: 'bindEntity', kind: 'write', doors: ['Query.twinReplay'] }
|
|
116
|
+
])
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const scope = await resolveToolScope(['twin'], holding('Query.twinReplay'))
|
|
120
|
+
|
|
121
|
+
expect(names(narrowToolScope(scope, spec => spec.kind === 'read'))).toEqual(['queryState'])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -4,23 +4,32 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ── Two questions, one list ───────────────────────────────────────────────────────
|
|
6
6
|
* Which categories did this surface ask for? `toolCategories`
|
|
7
|
-
* Which of their tools may this caller use? each spec's `
|
|
7
|
+
* Which of their tools may this caller use? each spec's `doors`, asked of `access`
|
|
8
8
|
*
|
|
9
9
|
* **Nothing asked means nothing offered.** Omitting the category list used to mean "every
|
|
10
10
|
* registered category"; that default is the one that opened board-import's Board-creating tool to
|
|
11
11
|
* any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
|
|
12
12
|
*
|
|
13
|
-
* **
|
|
14
|
-
*
|
|
13
|
+
* **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its
|
|
14
|
+
* door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;
|
|
15
|
+
* plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it
|
|
16
|
+
* reaches and the host resolves each one against the door's own declaration, so what passes here is
|
|
17
|
+
* what passes at the door.
|
|
18
|
+
*
|
|
19
|
+
* **All the doors, not one of them.** A tool that answers from two doors hands back what is behind
|
|
20
|
+
* both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).
|
|
21
|
+
*
|
|
22
|
+
* **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is
|
|
23
|
+
* asking gets only the tools that reach nothing.
|
|
15
24
|
*
|
|
16
25
|
* 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).
|
|
26
|
+
* hands it in (`ai-assistant` builds it from the request state and AUTH's field table).
|
|
18
27
|
*/
|
|
19
28
|
import { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'
|
|
20
|
-
import type {
|
|
29
|
+
import type { ToolSpec } from '../tool-registry/types.js'
|
|
21
30
|
|
|
22
|
-
/** Answers "may this caller
|
|
23
|
-
export type ToolAccessCheck = (
|
|
31
|
+
/** Answers "may this caller pass this door" — `'Query.jobOrders'`. */
|
|
32
|
+
export type ToolAccessCheck = (door: string) => Promise<boolean>
|
|
24
33
|
|
|
25
34
|
export interface ScopedCategory {
|
|
26
35
|
readonly name: string
|
|
@@ -38,20 +47,28 @@ export interface ToolScope {
|
|
|
38
47
|
|
|
39
48
|
export const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })
|
|
40
49
|
|
|
41
|
-
const privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')
|
|
42
|
-
|
|
43
50
|
export async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {
|
|
44
51
|
if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE
|
|
45
52
|
|
|
46
|
-
/* One check per distinct
|
|
53
|
+
/* One check per distinct door — five tools behind `Query.jobOrders` ask once. */
|
|
47
54
|
const answers = new Map<string, Promise<boolean>>()
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const passes = (door: string): Promise<boolean> => {
|
|
56
|
+
if (!answers.has(door)) answers.set(door, access!(door).catch(() => false))
|
|
57
|
+
return answers.get(door)!
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mayUse = async (spec: ToolSpec): Promise<boolean> => {
|
|
61
|
+
/*
|
|
62
|
+
* Said nothing. Not open — left out, and the boot report names it. An answer this tool built
|
|
63
|
+
* would be readable by no one (ADR-0073), so offering it would only produce dead answers.
|
|
64
|
+
*/
|
|
65
|
+
if (!Array.isArray(spec.doors)) return false
|
|
66
|
+
/* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */
|
|
67
|
+
if (spec.doors.length === 0) return true
|
|
68
|
+
if (!access) return false
|
|
69
|
+
|
|
70
|
+
const held = await Promise.all(spec.doors.map(passes))
|
|
71
|
+
return held.every(Boolean)
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
const categories: ScopedCategory[] = []
|
|
@@ -34,8 +34,6 @@ 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' } } : {}),
|
|
39
37
|
...s
|
|
40
38
|
})) as ToolSpec[]
|
|
41
39
|
}
|
|
@@ -176,7 +174,7 @@ describe('registry — spec 보존', () => {
|
|
|
176
174
|
kind: 'write',
|
|
177
175
|
name: 't1',
|
|
178
176
|
description: 'main desc',
|
|
179
|
-
|
|
177
|
+
doors: ['Mutation.t1'],
|
|
180
178
|
themeNote: '⚠ note',
|
|
181
179
|
schema: { type: 'object' },
|
|
182
180
|
builder: () => null
|
|
@@ -262,89 +260,59 @@ describe('registry — 지침 없는 카테고리는 등록되지 않는다', ()
|
|
|
262
260
|
})
|
|
263
261
|
})
|
|
264
262
|
|
|
265
|
-
describe('registry — a tool
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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', () => {
|
|
263
|
+
describe('registry — a tool names the doors it reaches (ADR-0071 decision 2)', () => {
|
|
264
|
+
/*
|
|
265
|
+
* 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** — 쓴 사람 본인까지.
|
|
266
|
+
*
|
|
267
|
+
* ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
|
|
268
|
+
* 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
|
|
269
|
+
* 「이 답을 볼 권한이 없습니다」로 보였다. 2026-09-18 부터는 관문도 그 도구를 내보내지 않는다 —
|
|
270
|
+
* 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
|
|
271
|
+
*/
|
|
272
|
+
test.each(['read', 'write', 'action', 'external'] as const)('★ a %s tool that declares no doors is reported by name', kind => {
|
|
285
273
|
registerToolCategory({
|
|
286
|
-
name: '
|
|
287
|
-
guidance: '- fixture guidance for
|
|
288
|
-
specs: [
|
|
289
|
-
{ kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
|
|
290
|
-
]
|
|
274
|
+
name: 'no-door',
|
|
275
|
+
guidance: '- fixture guidance for no-door',
|
|
276
|
+
specs: [{ kind, name: 'reachesSomething', description: 'd', schema: {}, builder: () => null } as any]
|
|
291
277
|
})
|
|
292
|
-
expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
|
|
293
|
-
})
|
|
294
278
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
279
|
+
expect(getToolKind('reachesSomething')).toBe(kind)
|
|
280
|
+
expect(getToolRegistryWarnings()).toEqual([
|
|
281
|
+
expect.objectContaining({
|
|
282
|
+
code: 'tool-doors-undeclared',
|
|
283
|
+
params: { category: 'no-door', tool: 'reachesSomething', kind }
|
|
284
|
+
})
|
|
285
|
+
])
|
|
298
286
|
})
|
|
299
287
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
* plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
|
|
303
|
-
*/
|
|
304
|
-
test('★ a read tool that says nothing about its door is recorded too', () => {
|
|
305
|
-
registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
|
|
288
|
+
test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
|
|
289
|
+
registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
|
|
306
290
|
|
|
307
|
-
expect(getToolRegistryWarnings()).toEqual([
|
|
308
|
-
expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
|
|
309
|
-
])
|
|
291
|
+
expect(getToolRegistryWarnings()).toEqual([])
|
|
310
292
|
})
|
|
311
293
|
|
|
312
|
-
test('
|
|
313
|
-
registerToolCategory({
|
|
314
|
-
name: 'surface-only',
|
|
315
|
-
guidance: '- fixture guidance for surface-only',
|
|
316
|
-
specs: [
|
|
317
|
-
{ kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
|
|
318
|
-
]
|
|
319
|
-
})
|
|
294
|
+
test('a named door leaves no warning — what that door requires is asked at boot, not here', () => {
|
|
295
|
+
registerToolCategory(makeCategory('waiting', [{ name: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }]))
|
|
320
296
|
|
|
321
297
|
expect(getToolRegistryWarnings()).toEqual([])
|
|
322
298
|
})
|
|
323
299
|
|
|
324
|
-
test('
|
|
325
|
-
registerToolCategory({
|
|
326
|
-
name: 'waiting',
|
|
327
|
-
guidance: '- fixture guidance for waiting',
|
|
328
|
-
specs: [
|
|
329
|
-
{ kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
|
|
330
|
-
]
|
|
331
|
-
})
|
|
300
|
+
test('the message says what to write, so the person reading it does not have to look it up', () => {
|
|
301
|
+
registerToolCategory(makeCategory('no-door', [{ name: 'silent', kind: 'read' }]))
|
|
332
302
|
|
|
333
|
-
expect(getToolRegistryWarnings()).
|
|
334
|
-
expect.objectContaining({
|
|
335
|
-
code: 'tool-door-has-no-privilege',
|
|
336
|
-
params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
|
|
337
|
-
})
|
|
338
|
-
])
|
|
303
|
+
expect(getToolRegistryWarnings()[0].message).toMatch(/doors: \[\]/)
|
|
339
304
|
})
|
|
340
305
|
|
|
341
306
|
test('re-registering a category replaces its warnings instead of piling them up', () => {
|
|
342
307
|
const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
|
|
343
308
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
|
|
309
|
+
const once = getToolRegistryWarnings().map(w => w.code).sort()
|
|
310
|
+
|
|
344
311
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
|
|
345
|
-
|
|
312
|
+
/* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
|
|
313
|
+
expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
|
|
346
314
|
|
|
347
|
-
registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared,
|
|
315
|
+
registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, doors: [] }] })
|
|
348
316
|
expect(getToolRegistryWarnings()).toEqual([])
|
|
349
317
|
})
|
|
350
318
|
})
|