@things-factory/ai-client-base 10.1.25 → 10.1.28
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 +22 -8
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/abort-notice.d.ts +3 -1
- package/dist-server/service/assistant/abort-notice.js +20 -6
- package/dist-server/service/assistant/abort-notice.js.map +1 -1
- package/dist-server/service/assistant/assistant-chat.d.ts +6 -0
- package/dist-server/service/assistant/assistant-chat.js +1 -1
- package/dist-server/service/assistant/assistant-chat.js.map +1 -1
- package/dist-server/service/assistant/index.d.ts +1 -0
- package/dist-server/service/assistant/index.js +1 -0
- package/dist-server/service/assistant/index.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.d.ts +12 -10
- package/dist-server/service/assistant/registry-assistant.js +13 -10
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/tool-scope.d.ts +25 -0
- package/dist-server/service/assistant/tool-scope.js +71 -0
- package/dist-server/service/assistant/tool-scope.js.map +1 -0
- package/dist-server/service/capability.d.ts +17 -0
- package/dist-server/service/capability.js +49 -0
- package/dist-server/service/capability.js.map +1 -0
- package/dist-server/service/index.d.ts +2 -0
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/tool-call-context.d.ts +10 -0
- package/dist-server/service/testing/tool-call-context.js +13 -0
- package/dist-server/service/testing/tool-call-context.js.map +1 -0
- package/dist-server/service/tool-registry/index.d.ts +3 -3
- package/dist-server/service/tool-registry/index.js +2 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.d.ts +23 -0
- package/dist-server/service/tool-registry/registry.js +58 -1
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +72 -0
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/docs/grounded-analysis-contract.md +1 -1
- package/package.json +2 -2
- package/server/service/agentic-loop/agentic-loop.ts +21 -8
- package/server/service/agentic-loop/proposal-key.test.ts +52 -0
- package/server/service/analysis-contract.test.ts +0 -1
- package/server/service/assistant/abort-notice.ts +33 -11
- package/server/service/assistant/assistant-chat.test.ts +24 -2
- package/server/service/assistant/assistant-chat.ts +7 -2
- package/server/service/assistant/index.ts +1 -0
- package/server/service/assistant/registry-assistant.test.ts +131 -4
- package/server/service/assistant/registry-assistant.ts +33 -25
- package/server/service/assistant/tool-scope.ts +86 -0
- package/server/service/capability.test.ts +52 -0
- package/server/service/capability.ts +58 -0
- package/server/service/index.ts +3 -0
- package/server/service/testing/tool-call-context.ts +28 -0
- package/server/service/tool-registry/index.ts +7 -3
- package/server/service/tool-registry/registry.test.ts +91 -1
- package/server/service/tool-registry/registry.ts +77 -1
- package/server/service/tool-registry/types.ts +78 -3
|
@@ -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: '
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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. */
|
|
@@ -55,6 +56,8 @@ export interface RegistryAssistantInput {
|
|
|
55
56
|
maxTokens?: number
|
|
56
57
|
/** Make the model call a tool before it may answer. For surfaces that report live state. */
|
|
57
58
|
requireToolOnFirstTurn?: boolean
|
|
59
|
+
/** The request's translator — the stop notice is then in the reader's language. */
|
|
60
|
+
t?: (key: string, params?: Record<string, any>) => string
|
|
58
61
|
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
59
62
|
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
60
63
|
}
|
|
@@ -64,11 +67,6 @@ export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
|
|
|
64
67
|
offeredTools: string[]
|
|
65
68
|
}
|
|
66
69
|
|
|
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
70
|
/**
|
|
73
71
|
* Run one turn of a conversation whose only tools come from the registry.
|
|
74
72
|
*
|
|
@@ -82,8 +80,17 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
82
80
|
throw new Error('[registry-assistant] the configured AI client cannot call tools, so this conversation cannot run')
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
/*
|
|
84
|
+
* Only `read` is offered here. `external` means the tool changes something outside the
|
|
85
|
+
* conversation (board-import's `materializeImportSession` creates a Board); this door answers
|
|
86
|
+
* questions and has no confirm-and-apply step, so a tool with an effect does not go through it
|
|
87
|
+
* even when the caller holds the privilege.
|
|
88
|
+
*/
|
|
89
|
+
const scope = narrowToolScope(
|
|
90
|
+
await resolveToolScope(toolCategories, input.toolAccess),
|
|
91
|
+
spec => spec.kind === 'read'
|
|
92
|
+
)
|
|
93
|
+
const specs = scope.specs
|
|
87
94
|
const byName = new Map(specs.map(spec => [spec.name, spec]))
|
|
88
95
|
|
|
89
96
|
const tools = specs.map(spec => ({
|
|
@@ -96,12 +103,12 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
96
103
|
* The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so
|
|
97
104
|
* a surface never reads instructions for a category it cannot call.
|
|
98
105
|
*/
|
|
99
|
-
const guidance =
|
|
106
|
+
const guidance = toolScopeGuidance(scope)
|
|
100
107
|
const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
|
|
101
108
|
|
|
102
|
-
const toolContext:
|
|
109
|
+
const toolContext: ToolCallContext = { state, host: hostContext ?? {} }
|
|
103
110
|
|
|
104
|
-
const result = await assistantChat<
|
|
111
|
+
const result = await assistantChat<ToolCallContext, unknown>({
|
|
105
112
|
initialConversation: messages,
|
|
106
113
|
tools,
|
|
107
114
|
options: {
|
|
@@ -130,7 +137,8 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
130
137
|
toolCallToOp: () => null,
|
|
131
138
|
summarizeToolResult
|
|
132
139
|
},
|
|
133
|
-
groundingSources: input.groundingSources
|
|
140
|
+
groundingSources: input.groundingSources,
|
|
141
|
+
t: input.t
|
|
134
142
|
})
|
|
135
143
|
|
|
136
144
|
return { ...result, offeredTools: tools.map(tool => tool.name) }
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: a screen can ask whether this deployment has an AI, instead of finding out by
|
|
3
|
+
* pressing a button that fails.
|
|
4
|
+
*
|
|
5
|
+
* The defect this replaces: twin decided the same question from whether a mutation had thrown once.
|
|
6
|
+
* The mutation stopped being called, the flag stayed true, and deployments with no provider showed
|
|
7
|
+
* the button anyway (standard A7-a).
|
|
8
|
+
*/
|
|
9
|
+
const mockConfigGet = jest.fn()
|
|
10
|
+
jest.mock('@things-factory/env', () => ({ config: { get: (...args: any[]) => mockConfigGet(...args) } }))
|
|
11
|
+
|
|
12
|
+
import { aiCapability } from './capability'
|
|
13
|
+
import { registerAIProvider, setDefaultAIClient } from './factory'
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockConfigGet.mockReset()
|
|
17
|
+
setDefaultAIClient(undefined as any)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('★ a configured deployment says so, and names what answers', () => {
|
|
21
|
+
mockConfigGet.mockReturnValue(null)
|
|
22
|
+
setDefaultAIClient({ id: 'anthropic:claude-sonnet-5' } as any)
|
|
23
|
+
|
|
24
|
+
expect(aiCapability()).toEqual({ state: 'configured', provider: 'anthropic', model: 'claude-sonnet-5' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('★ no configuration at all is "not configured", with the reason — never silence', () => {
|
|
28
|
+
mockConfigGet.mockReturnValue(null)
|
|
29
|
+
|
|
30
|
+
expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-config' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a configuration naming a provider nobody registered says which one', () => {
|
|
34
|
+
mockConfigGet.mockReturnValue({ provider: 'nonesuch', model: 'x' })
|
|
35
|
+
|
|
36
|
+
expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'nonesuch', reason: 'unknown-provider' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a configuration with no provider field is its own reason', () => {
|
|
40
|
+
mockConfigGet.mockReturnValue({ model: 'x' })
|
|
41
|
+
|
|
42
|
+
expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-provider' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('★ a provider whose adapter throws is not configured — it does not throw at the caller', () => {
|
|
46
|
+
registerAIProvider('explodes', () => {
|
|
47
|
+
throw new Error('no api key')
|
|
48
|
+
})
|
|
49
|
+
mockConfigGet.mockReturnValue({ provider: 'explodes', model: 'x' })
|
|
50
|
+
|
|
51
|
+
expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'explodes', model: 'x', reason: 'no-adapter' })
|
|
52
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is there an AI on this deployment, and what is missing — asked before anything is tried.
|
|
3
|
+
*
|
|
4
|
+
* The twin had a button that asked the assistant, and whether to show it was decided by whether a
|
|
5
|
+
* now-dead mutation had failed once. When the dock moved to the neutral door nothing called that
|
|
6
|
+
* mutation any more, so the answer was "configured" forever: the button appeared on deployments
|
|
7
|
+
* with no provider at all and failed when pressed (standard A7-a).
|
|
8
|
+
*
|
|
9
|
+
* A capability is read, not discovered by failing (ADR-0042 decision ②). It lives here because the
|
|
10
|
+
* provider configuration does, and the answer has three states — the third is the one a screen
|
|
11
|
+
* must not fold away: **not asked yet** is not "configured".
|
|
12
|
+
*/
|
|
13
|
+
import { config as envConfig } from '@things-factory/env'
|
|
14
|
+
|
|
15
|
+
import { getDefaultAIClient, listRegisteredProviders } from './factory.js'
|
|
16
|
+
|
|
17
|
+
export type AiCapabilityState = 'configured' | 'not-configured'
|
|
18
|
+
|
|
19
|
+
export interface AiCapability {
|
|
20
|
+
state: AiCapabilityState
|
|
21
|
+
/** Which provider answers, when one does — for the person reading a boot log, not for a screen. */
|
|
22
|
+
provider?: string
|
|
23
|
+
/** The default model this deployment would use. */
|
|
24
|
+
model?: string
|
|
25
|
+
/** Why it is not configured, as a code: no-config · no-provider · unknown-provider · no-adapter. */
|
|
26
|
+
reason?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What this server can do, as of now.
|
|
31
|
+
*
|
|
32
|
+
* Creating the client is what proves a provider adapter is loaded and the configuration parses, so
|
|
33
|
+
* this asks for it. It never throws: a deployment with no AI is a normal deployment.
|
|
34
|
+
*/
|
|
35
|
+
export function aiCapability(): AiCapability {
|
|
36
|
+
const client = safeDefaultClient()
|
|
37
|
+
if (client) {
|
|
38
|
+
const [provider, model] = String(client.id ?? '').split(':')
|
|
39
|
+
return { state: 'configured', provider: provider || undefined, model: model || undefined }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cfg = envConfig.get('aiClient', null) as { provider?: string; model?: string } | null
|
|
43
|
+
if (!cfg) return { state: 'not-configured', reason: 'no-config' }
|
|
44
|
+
if (!cfg.provider) return { state: 'not-configured', reason: 'no-provider' }
|
|
45
|
+
if (!listRegisteredProviders().includes(cfg.provider)) {
|
|
46
|
+
return { state: 'not-configured', provider: cfg.provider, reason: 'unknown-provider' }
|
|
47
|
+
}
|
|
48
|
+
return { state: 'not-configured', provider: cfg.provider, model: cfg.model, reason: 'no-adapter' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeDefaultClient() {
|
|
52
|
+
try {
|
|
53
|
+
return getDefaultAIClient()
|
|
54
|
+
} catch {
|
|
55
|
+
/* A broken configuration is "not configured", and it is the reason below that says which. */
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
}
|
package/server/service/index.ts
CHANGED
|
@@ -6,3 +6,6 @@ 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
|
+
/* 「이 배포에 AI 가 있나」 — 시도해서 알아내지 않고 먼저 읽는다(표준 A7-a). */
|
|
10
|
+
export * from './capability.js'
|
|
11
|
+
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'
|