@things-factory/ai-client-base 10.1.25 → 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 (37) 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 +48 -0
  23. package/dist-server/service/tool-registry/types.js.map +1 -1
  24. package/dist-server/tsconfig.tsbuildinfo +1 -0
  25. package/docs/grounded-analysis-contract.md +1 -1
  26. package/package.json +2 -2
  27. package/server/service/analysis-contract.test.ts +0 -1
  28. package/server/service/assistant/index.ts +1 -0
  29. package/server/service/assistant/registry-assistant.test.ts +131 -4
  30. package/server/service/assistant/registry-assistant.ts +29 -24
  31. package/server/service/assistant/tool-scope.ts +86 -0
  32. package/server/service/index.ts +1 -0
  33. package/server/service/testing/tool-call-context.ts +28 -0
  34. package/server/service/tool-registry/index.ts +7 -3
  35. package/server/service/tool-registry/registry.test.ts +50 -1
  36. package/server/service/tool-registry/registry.ts +47 -0
  37. package/server/service/tool-registry/types.ts +53 -3
@@ -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
  }
@@ -27,6 +27,34 @@
27
27
  // 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면
28
28
  // 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.
29
29
 
30
+ // ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──
31
+
32
+ /**
33
+ * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a
34
+ * registered tool when it runs.
35
+ *
36
+ * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door
37
+ * passed `ctx.host`, so they never saw the space; the two are never merged into one object.
38
+ *
39
+ * The board product's door (board-ai) passes its own shape and is not part of this contract.
40
+ */
41
+ export interface ToolCallContext {
42
+ /** The request state the server built — domain, user, tx. The only basis for authorization. */
43
+ state: any
44
+ /**
45
+ * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads
46
+ * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.
47
+ */
48
+ host: Record<string, any>
49
+ /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */
50
+ mode?: ToolCallMode
51
+ }
52
+
53
+ export interface ToolCallMode {
54
+ kind: string
55
+ target?: string
56
+ }
57
+
30
58
  // ── 카테고리별 builder 시그니처 ──────────────────────────────────────
31
59
 
32
60
  /**
@@ -48,7 +76,6 @@ export interface ReadToolContext {
48
76
  /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */
49
77
  [k: string]: any
50
78
  }
51
-
52
79
  /**
53
80
  * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.
54
81
  * 예: board-ai 의 addComponent / setFill / modifyBoard.
@@ -65,7 +92,6 @@ export interface WriteToolContext {
65
92
  components?: ReadonlyArray<any>
66
93
  [k: string]: any
67
94
  }
68
-
69
95
  /**
70
96
  * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).
71
97
  * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.
@@ -91,9 +117,25 @@ export interface ExternalToolContext {
91
117
  state?: any
92
118
  [k: string]: any
93
119
  }
94
-
95
120
  // ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────
96
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
+
97
139
  /**
98
140
  * 모든 ToolSpec 공통 메타데이터.
99
141
  */
@@ -109,6 +151,14 @@ export interface ToolSpecBase {
109
151
  themeNote?: string
110
152
  /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
111
153
  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). A `read` tool without it is
159
+ * offered on domain membership alone, and its builder must check what it reads.
160
+ */
161
+ privilege?: ToolPrivilege
112
162
  }
113
163
 
114
164
  export interface ReadToolSpec extends ToolSpecBase {