@things-factory/ai-client-base 10.1.29 → 10.1.39
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/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 +16 -51
- 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/assistant/registry-assistant.test.ts +22 -15
- 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 +31 -102
- package/server/service/tool-registry/registry.ts +24 -61
- package/server/service/tool-registry/types.ts +16 -33
|
@@ -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[] = []
|
|
@@ -23,16 +23,6 @@ afterEach(() => {
|
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
// 헬퍼 — 카테고리 fixture
|
|
26
|
-
/**
|
|
27
|
-
* 권한 축의 경고만 — 문 축(`tool-doors-undeclared`)은 자기 시험이 따로 본다.
|
|
28
|
-
*
|
|
29
|
-
* 한 시험이 두 축을 함께 세면, 한 축에 코드가 하나 늘 때마다 다른 축의 시험이 빨개진다. 그러면 고치는
|
|
30
|
-
* 사람은 자기가 건드리지 않은 시험을 고치게 되고, 그 손질이 시험을 무디게 만든다.
|
|
31
|
-
*/
|
|
32
|
-
function privilegeWarnings() {
|
|
33
|
-
return getToolRegistryWarnings().filter(w => w.code !== 'tool-doors-undeclared')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
|
|
37
27
|
return {
|
|
38
28
|
name,
|
|
@@ -44,8 +34,6 @@ function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
|
|
|
44
34
|
description: 'desc',
|
|
45
35
|
schema: {},
|
|
46
36
|
builder: () => ({}),
|
|
47
|
-
/* A tool that changes something must name its door (A1). The fixture does, like guidance. */
|
|
48
|
-
...(s.kind && s.kind !== 'read' ? { privilege: { category: 'fixture', privilege: 'mutation' } } : {}),
|
|
49
37
|
...s
|
|
50
38
|
})) as ToolSpec[]
|
|
51
39
|
}
|
|
@@ -186,7 +174,7 @@ describe('registry — spec 보존', () => {
|
|
|
186
174
|
kind: 'write',
|
|
187
175
|
name: 't1',
|
|
188
176
|
description: 'main desc',
|
|
189
|
-
|
|
177
|
+
doors: ['Mutation.t1'],
|
|
190
178
|
themeNote: '⚠ note',
|
|
191
179
|
schema: { type: 'object' },
|
|
192
180
|
builder: () => null
|
|
@@ -272,107 +260,48 @@ describe('registry — 지침 없는 카테고리는 등록되지 않는다', ()
|
|
|
272
260
|
})
|
|
273
261
|
})
|
|
274
262
|
|
|
275
|
-
describe('registry — a tool
|
|
276
|
-
test.each(['write', 'action', 'external'] as const)(
|
|
277
|
-
'★ a %s tool without a privilege registers, and the omission is recorded as a code',
|
|
278
|
-
kind => {
|
|
279
|
-
registerToolCategory({
|
|
280
|
-
name: 'no-door',
|
|
281
|
-
guidance: '- fixture guidance for no-door',
|
|
282
|
-
specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
|
|
283
|
-
})
|
|
284
|
-
expect(getToolKind('changesThings')).toBe(kind)
|
|
285
|
-
expect(privilegeWarnings()).toEqual([
|
|
286
|
-
expect.objectContaining({
|
|
287
|
-
code: 'tool-privilege-undeclared',
|
|
288
|
-
params: { category: 'no-door', tool: 'changesThings', kind }
|
|
289
|
-
})
|
|
290
|
-
])
|
|
291
|
-
}
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
test('a privilege with no privilege name is recorded the same way', () => {
|
|
295
|
-
registerToolCategory({
|
|
296
|
-
name: 'half-door',
|
|
297
|
-
guidance: '- fixture guidance for half-door',
|
|
298
|
-
specs: [
|
|
299
|
-
{ kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
|
|
300
|
-
]
|
|
301
|
-
})
|
|
302
|
-
expect(privilegeWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
test('a declared tool leaves no warning', () => {
|
|
306
|
-
registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
|
|
307
|
-
expect(privilegeWarnings()).toEqual([])
|
|
308
|
-
})
|
|
309
|
-
|
|
263
|
+
describe('registry — a tool names the doors it reaches (ADR-0071 decision 2)', () => {
|
|
310
264
|
/*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
265
|
+
* A tool that names no doors makes its answers **unreadable to everyone** — its author included.
|
|
266
|
+
*
|
|
267
|
+
* Since ADR-0073 an answer row carries the doors its tools passed, and an undeclared tool is written
|
|
268
|
+
* as `unresolved:<tool>`, which nobody passes. On 2026-09-18 in the twin modeller, an authoring
|
|
269
|
+
* answer showed its own author "you do not have permission to see this answer" after a refresh.
|
|
270
|
+
* From 2026-09-18 the gate also leaves such a tool out — this list is the only place that says what
|
|
271
|
+
* is missing.
|
|
313
272
|
*/
|
|
314
|
-
test('★ a
|
|
315
|
-
registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
|
|
316
|
-
|
|
317
|
-
expect(privilegeWarnings()).toEqual([
|
|
318
|
-
expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
|
|
319
|
-
])
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
|
|
273
|
+
test.each(['read', 'write', 'action', 'external'] as const)('★ a %s tool that declares no doors is reported by name', kind => {
|
|
323
274
|
registerToolCategory({
|
|
324
|
-
name: '
|
|
325
|
-
guidance: '- fixture guidance for
|
|
326
|
-
specs: [
|
|
327
|
-
{ kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
|
|
328
|
-
]
|
|
275
|
+
name: 'no-door',
|
|
276
|
+
guidance: '- fixture guidance for no-door',
|
|
277
|
+
specs: [{ kind, name: 'reachesSomething', description: 'd', schema: {}, builder: () => null } as any]
|
|
329
278
|
})
|
|
330
279
|
|
|
331
|
-
expect(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
|
|
335
|
-
registerToolCategory({
|
|
336
|
-
name: 'waiting',
|
|
337
|
-
guidance: '- fixture guidance for waiting',
|
|
338
|
-
specs: [
|
|
339
|
-
{ kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
|
|
340
|
-
]
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
expect(privilegeWarnings()).toEqual([
|
|
280
|
+
expect(getToolKind('reachesSomething')).toBe(kind)
|
|
281
|
+
expect(getToolRegistryWarnings()).toEqual([
|
|
344
282
|
expect.objectContaining({
|
|
345
|
-
code: 'tool-
|
|
346
|
-
params: { category: '
|
|
283
|
+
code: 'tool-doors-undeclared',
|
|
284
|
+
params: { category: 'no-door', tool: 'reachesSomething', kind }
|
|
347
285
|
})
|
|
348
286
|
])
|
|
349
287
|
})
|
|
350
288
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
*
|
|
354
|
-
* ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
|
|
355
|
-
* 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
|
|
356
|
-
* 「이 답을 볼 권한이 없습니다」로 보였다.
|
|
357
|
-
*
|
|
358
|
-
* 부팅은 그때 아무 말도 안 했다. 권한 검사가 `privilege` 가 있으면 건너뛰는 자리에서 문 검사까지
|
|
359
|
-
* 함께 건너뛰었기 때문이다 — 트윈의 저작 도구 넷이 정확히 그 모양이었다(권한 있음, 문 없음).
|
|
360
|
-
*/
|
|
361
|
-
test('★ a tool that declares no doors is reported even when it declares a privilege', () => {
|
|
362
|
-
registerToolCategory(
|
|
363
|
-
makeCategory('authoring', [
|
|
364
|
-
{ name: 'proposeSceneEdit', kind: 'write', privilege: { category: 'board', privilege: 'mutation' } }
|
|
365
|
-
])
|
|
366
|
-
)
|
|
289
|
+
test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
|
|
290
|
+
registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
|
|
367
291
|
|
|
368
|
-
|
|
369
|
-
expect(doors.map(w => w.params.tool)).toEqual(['proposeSceneEdit'])
|
|
292
|
+
expect(getToolRegistryWarnings()).toEqual([])
|
|
370
293
|
})
|
|
371
294
|
|
|
372
|
-
test('
|
|
373
|
-
registerToolCategory(makeCategory('
|
|
295
|
+
test('a named door leaves no warning — what that door requires is asked at boot, not here', () => {
|
|
296
|
+
registerToolCategory(makeCategory('waiting', [{ name: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }]))
|
|
297
|
+
|
|
298
|
+
expect(getToolRegistryWarnings()).toEqual([])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('the message says what to write, so the person reading it does not have to look it up', () => {
|
|
302
|
+
registerToolCategory(makeCategory('no-door', [{ name: 'silent', kind: 'read' }]))
|
|
374
303
|
|
|
375
|
-
expect(getToolRegistryWarnings().
|
|
304
|
+
expect(getToolRegistryWarnings()[0].message).toMatch(/doors: \[\]/)
|
|
376
305
|
})
|
|
377
306
|
|
|
378
307
|
test('re-registering a category replaces its warnings instead of piling them up', () => {
|
|
@@ -384,7 +313,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
|
|
|
384
313
|
/* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
|
|
385
314
|
expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
|
|
386
315
|
|
|
387
|
-
registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared,
|
|
388
|
-
expect(
|
|
316
|
+
registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, doors: [] }] })
|
|
317
|
+
expect(getToolRegistryWarnings()).toEqual([])
|
|
389
318
|
})
|
|
390
319
|
})
|
|
@@ -32,13 +32,15 @@ const categories: Map<string, ToolCategory> = new Map()
|
|
|
32
32
|
/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
|
|
33
33
|
export interface ToolRegistryWarning {
|
|
34
34
|
/**
|
|
35
|
-
* `tool-
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* `tool-doors-undeclared` — nothing said which doors this tool reaches, so the gate leaves it out
|
|
36
|
+
* of every conversation (`resolveToolScope`) and this names it.
|
|
37
|
+
*
|
|
38
|
+
* What those doors require is **not** asked here. Registration runs while modules load, before the
|
|
39
|
+
* schema stands, so AUTH's field table is still empty; `ai-assistant`'s boot report asks the
|
|
40
|
+
* schema after it is built (`tool-door-not-in-schema` · `tool-door-has-no-privilege`).
|
|
39
41
|
*/
|
|
40
|
-
code: 'tool-
|
|
41
|
-
params: { category: string; tool: string; kind: ToolSpec['kind']
|
|
42
|
+
code: 'tool-doors-undeclared'
|
|
43
|
+
params: { category: string; tool: string; kind: ToolSpec['kind'] }
|
|
42
44
|
/** For a person reading it; the code and params are what callers act on. */
|
|
43
45
|
message: string
|
|
44
46
|
}
|
|
@@ -46,8 +48,8 @@ export interface ToolRegistryWarning {
|
|
|
46
48
|
const warnings: ToolRegistryWarning[] = []
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
* What
|
|
50
|
-
*
|
|
51
|
+
* What registered without saying which doors it reaches. A host prints this at boot; the gate has
|
|
52
|
+
* already left each of these tools out of every conversation.
|
|
51
53
|
*/
|
|
52
54
|
export function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {
|
|
53
55
|
return warnings.slice()
|
|
@@ -80,19 +82,17 @@ export function registerToolCategory(category: ToolCategory): void {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/*
|
|
83
|
-
* A tool
|
|
85
|
+
* A tool says which doors it reaches (ADR-0071 decision 2).
|
|
84
86
|
*
|
|
85
|
-
* The conversation door checks one privilege — may this person talk to the assistant.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* Board.
|
|
87
|
+
* The conversation door checks one privilege — may this person talk to the assistant. On its own
|
|
88
|
+
* that single check opened every tool behind it: a user holding only `ai-assistant:mutation` could
|
|
89
|
+
* reach board-import's `materializeImportSession` and create a Board.
|
|
89
90
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* tool
|
|
94
|
-
*
|
|
95
|
-
* is empty across the apps, this becomes a refusal.
|
|
91
|
+
* Registration does not refuse a tool that says nothing; it records the omission as a code
|
|
92
|
+
* (`getToolRegistryWarnings`), not a log line — a warning only a log carries is not
|
|
93
|
+
* distinguishable from no warning. The refusal happens where it costs nothing to be late: the gate
|
|
94
|
+
* leaves an undeclared tool out of every conversation, so booting stays possible while the list is
|
|
95
|
+
* worked down, and no undeclared tool is reachable in the meantime.
|
|
96
96
|
*/
|
|
97
97
|
warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
|
|
98
98
|
|
|
@@ -103,9 +103,8 @@ export function registerToolCategory(category: ToolCategory): void {
|
|
|
103
103
|
* `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
|
|
104
104
|
* 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
|
|
106
|
+
* From 2026-09-18 this declaration is also the gate (ADR-0071 decision 2, addendum). A tool that names
|
|
107
|
+
* no doors is offered to no conversation, so this list is the only place that says what is missing.
|
|
109
108
|
*/
|
|
110
109
|
for (const spec of category.specs) {
|
|
111
110
|
if (Array.isArray(spec.doors)) continue
|
|
@@ -114,45 +113,9 @@ export function registerToolCategory(category: ToolCategory): void {
|
|
|
114
113
|
code: 'tool-doors-undeclared',
|
|
115
114
|
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
116
115
|
message:
|
|
117
|
-
`tool '${spec.name}' in category '${category.name}' declares no doors, so
|
|
118
|
-
'
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const spec of category.specs) {
|
|
123
|
-
const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
|
|
124
|
-
if (declared) continue
|
|
125
|
-
|
|
126
|
-
/*
|
|
127
|
-
* An empty list says this tool reaches no door — the ones that hand back what the surface sent
|
|
128
|
-
* are that. Nothing to gate, nothing to report.
|
|
129
|
-
*/
|
|
130
|
-
if (Array.isArray(spec.doors) && spec.doors.length === 0) continue
|
|
131
|
-
|
|
132
|
-
if (spec.doors?.length) {
|
|
133
|
-
/*
|
|
134
|
-
* The door is named and carries no privilege yet. The tool cannot be stricter than the door
|
|
135
|
-
* it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
|
|
136
|
-
* (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
|
|
137
|
-
*/
|
|
138
|
-
warnings.push({
|
|
139
|
-
code: 'tool-door-has-no-privilege',
|
|
140
|
-
params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
|
|
141
|
-
message:
|
|
142
|
-
`tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +
|
|
143
|
-
'whether those doors require anything is asked at boot, against the schema, not copied here ' +
|
|
144
|
-
'(ADR-0072 decision 5)'
|
|
145
|
-
})
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
warnings.push({
|
|
150
|
-
code: 'tool-privilege-undeclared',
|
|
151
|
-
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
152
|
-
message:
|
|
153
|
-
`tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +
|
|
154
|
-
'a tool that changes something says who may run it. What the doors it reaches require is a ' +
|
|
155
|
-
"separate question, answered at boot against the schema (`tool-door-not-in-schema`)."
|
|
116
|
+
`tool '${spec.name}' in category '${category.name}' declares no doors, so it is offered to no ` +
|
|
117
|
+
'conversation and answers built with it would be hidden from every reader including their ' +
|
|
118
|
+
'author — declare `doors`, or `doors: []` when it reaches none'
|
|
156
119
|
})
|
|
157
120
|
}
|
|
158
121
|
|
|
@@ -119,23 +119,6 @@ export interface ExternalToolContext {
|
|
|
119
119
|
}
|
|
120
120
|
// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* The privilege a tool requires — the same `category`/`privilege` the tool's GraphQL door declares
|
|
124
|
-
* (`@privilege(category, privilege, owner?, super?)`), so the fields mirror `PrivilegeObject`.
|
|
125
|
-
*
|
|
126
|
-
* A tool is not a new door; it is another way into a door that already exists. A tool that creates
|
|
127
|
-
* a Board goes through the privilege that creating a Board already needs. Minting an AI-only
|
|
128
|
-
* privilege name would put two doors on one effect, and one of them would end up looser.
|
|
129
|
-
*/
|
|
130
|
-
export interface ToolPrivilege {
|
|
131
|
-
category: string
|
|
132
|
-
privilege: string
|
|
133
|
-
/** Domain owners pass without the privilege — mirror the door's `domainOwnerGranted`. */
|
|
134
|
-
owner?: boolean
|
|
135
|
-
/** Super users pass without the privilege — mirror the door's `superUserGranted`. */
|
|
136
|
-
super?: boolean
|
|
137
|
-
}
|
|
138
|
-
|
|
139
122
|
/**
|
|
140
123
|
* 모든 ToolSpec 공통 메타데이터.
|
|
141
124
|
*/
|
|
@@ -151,14 +134,6 @@ export interface ToolSpecBase {
|
|
|
151
134
|
themeNote?: string
|
|
152
135
|
/** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
|
|
153
136
|
schema: any
|
|
154
|
-
/**
|
|
155
|
-
* The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
|
|
156
|
-
*
|
|
157
|
-
* Expected for `write`, `action` and `external` — registration records a
|
|
158
|
-
* `tool-privilege-undeclared` warning for one without it (A1 stage one).
|
|
159
|
-
*/
|
|
160
|
-
privilege?: ToolPrivilege
|
|
161
|
-
|
|
162
137
|
/**
|
|
163
138
|
* Which GraphQL doors this tool reaches — `['Query.twinNow']`,
|
|
164
139
|
* `['Query.productionSummary', 'Query.pendingSummary']`.
|
|
@@ -173,15 +148,23 @@ export interface ToolSpecBase {
|
|
|
173
148
|
* plant's factory overview answers from two, and asking for only one of them would hand back
|
|
174
149
|
* half of what the caller may not see.
|
|
175
150
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
151
|
+
* **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its
|
|
152
|
+
* own copy of what its door asks; the copy went stale the day a door changed its declaration, and
|
|
153
|
+
* the looser of the two won. Now the gate reads the door's own declaration from AUTH's field
|
|
154
|
+
* table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same
|
|
155
|
+
* answer, one declaration.
|
|
156
|
+
*
|
|
157
|
+
* Three states, and they are not the same thing:
|
|
158
|
+
* - **doors named** — the caller passes each of them, or the tool is neither offered nor run.
|
|
159
|
+
* - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface
|
|
160
|
+
* sent (`getOpenScene`) are this: there is nothing behind them to gate.
|
|
161
|
+
* - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every
|
|
162
|
+
* conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it
|
|
163
|
+
* would be hidden from every reader including its author (ADR-0073), so opening it would
|
|
164
|
+
* produce a tool whose answers no one can read.
|
|
182
165
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
166
|
+
* A door that declares nothing of its own opens as far as that door opens — a tool is never
|
|
167
|
+
* stricter than the door it reaches, and never softer.
|
|
185
168
|
*/
|
|
186
169
|
doors?: string[]
|
|
187
170
|
}
|