@things-factory/ai-client-base 10.1.26 → 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/registry-assistant.d.ts +2 -0
- package/dist-server/service/assistant/registry-assistant.js +2 -1
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- 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 +1 -0
- package/dist-server/service/index.js +2 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.d.ts +8 -1
- package/dist-server/service/tool-registry/registry.js +27 -6
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +26 -2
- 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 +21 -8
- package/server/service/agentic-loop/proposal-key.test.ts +52 -0
- 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/registry-assistant.ts +4 -1
- package/server/service/capability.test.ts +52 -0
- package/server/service/capability.ts +58 -0
- package/server/service/index.ts +2 -0
- package/server/service/tool-registry/registry.test.ts +43 -2
- package/server/service/tool-registry/registry.ts +37 -8
- package/server/service/tool-registry/types.ts +27 -2
|
@@ -10,35 +10,57 @@
|
|
|
10
10
|
* and the person acts on half of it. That failure is silent by construction, which is why the
|
|
11
11
|
* notice belongs beside the loop rather than beside each product's prose.
|
|
12
12
|
*
|
|
13
|
-
* ──
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* ── The words are the reader's, and the caller owns the reader (2026-09-18) ────────
|
|
14
|
+
* These lines were Korean literals in a framework package, so every product said them in Korean
|
|
15
|
+
* whatever language the person was reading in (ADR-0049 ② · ADR-0054). The notice now names a key
|
|
16
|
+
* and carries an English default; a caller that has the request's translator hands it in and the
|
|
17
|
+
* person reads the line in their own language.
|
|
18
18
|
*/
|
|
19
19
|
import type { AgenticLoopResult } from '../agentic-loop/index.js'
|
|
20
20
|
import { isTransientAIError } from '../utils/index.js'
|
|
21
21
|
|
|
22
|
+
/** Turns a key and its values into the reader's sentence. `ai-assistant` hands in the request's `context.t`. */
|
|
23
|
+
export type TranslateNotice = (key: string, params?: Record<string, any>) => string
|
|
24
|
+
|
|
25
|
+
/** The English sentence for each reason, used when the caller has no translator. */
|
|
26
|
+
const NOTICE_DEFAULTS: Record<string, string> = {
|
|
27
|
+
'ai-assistant.notice.stopped': 'the request stopped partway, so only part of it was done.',
|
|
28
|
+
'ai-assistant.notice.provider-busy': 'the ai service did not answer just now. try again in a moment.',
|
|
29
|
+
'ai-assistant.notice.provider-error': 'something went wrong while handling this. tell an administrator if it keeps happening.',
|
|
30
|
+
'ai-assistant.notice.too-long': 'this ran long and stopped at step {iter}. try asking in smaller pieces.',
|
|
31
|
+
'ai-assistant.notice.tool-kept-failing': '{tool} failed {count} times in a row, so it stopped. try saying it differently.'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function line(key: string, params: Record<string, any>, t?: TranslateNotice): string {
|
|
35
|
+
const said = t
|
|
36
|
+
? t(key, { defaultValue: NOTICE_DEFAULTS[key], ...params })
|
|
37
|
+
: Object.entries(params).reduce((text, [k, v]) => text.replaceAll(`{${k}}`, String(v)), NOTICE_DEFAULTS[key])
|
|
38
|
+
return `\n\n_(${said})_`
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
/**
|
|
23
42
|
* 중단 이유 → 답에 덧붙일 한 줄. 정상 종료면 빈 문자열이다.
|
|
24
43
|
*
|
|
25
44
|
* 답을 고치지 않고 **뒤에 붙인다** — 모델이 한 말을 우리가 다시 쓰지 않는다.
|
|
26
45
|
*/
|
|
27
|
-
export function formatAbortNotice(
|
|
46
|
+
export function formatAbortNotice(
|
|
47
|
+
abortReason: AgenticLoopResult<any>['abortReason'],
|
|
48
|
+
t?: TranslateNotice
|
|
49
|
+
): string {
|
|
28
50
|
if (!abortReason) return ''
|
|
29
51
|
switch (abortReason.type) {
|
|
30
52
|
case 'aborted':
|
|
31
|
-
return '
|
|
53
|
+
return line('ai-assistant.notice.stopped', {}, t)
|
|
32
54
|
case 'provider_error':
|
|
33
55
|
/* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다 — "Error fetching from undefined: [503 …]" 은
|
|
34
56
|
* 사용자가 할 수 있는 일을 알려주지 않는다. 일시 장애는 이미 몇 번 다시 시도한 뒤이므로,
|
|
35
57
|
* 지금 필요한 안내는 "잠시 후 다시" 하나다. 원문은 서버 로그에 남는다. */
|
|
36
58
|
return isTransientAIError({ message: abortReason.message })
|
|
37
|
-
? '
|
|
38
|
-
: '
|
|
59
|
+
? line('ai-assistant.notice.provider-busy', {}, t)
|
|
60
|
+
: line('ai-assistant.notice.provider-error', {}, t)
|
|
39
61
|
case 'max_iterations':
|
|
40
|
-
return
|
|
62
|
+
return line('ai-assistant.notice.too-long', { iter: abortReason.iter }, t)
|
|
41
63
|
case 'repeated_validation_failure':
|
|
42
|
-
return
|
|
64
|
+
return line('ai-assistant.notice.tool-kept-failing', { tool: abortReason.toolName, count: abortReason.consecutive }, t)
|
|
43
65
|
}
|
|
44
66
|
}
|
|
@@ -76,7 +76,7 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
|
|
|
76
76
|
})
|
|
77
77
|
)
|
|
78
78
|
expect(r.loop.abortReason?.type).toBe('provider_error')
|
|
79
|
-
expect(r.reply).toMatch(
|
|
79
|
+
expect(r.reply).toMatch(/try again in a moment/)
|
|
80
80
|
/* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다. */
|
|
81
81
|
expect(r.reply).not.toMatch(/503/)
|
|
82
82
|
expect(r.reply).not.toMatch(/Error fetching/)
|
|
@@ -94,7 +94,29 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
|
|
|
94
94
|
)
|
|
95
95
|
)
|
|
96
96
|
expect(r.loop.abortReason?.type).toBe('max_iterations')
|
|
97
|
-
|
|
97
|
+
/* No translator was handed in, so the line falls back to English. */
|
|
98
|
+
expect(r.reply).toMatch(/stopped at step 2/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('★ 통지의 말은 읽는 사람의 것이다 — 번역기를 주면 그것이 쓴 문장이 붙는다', async () => {
|
|
102
|
+
const asked: any[] = []
|
|
103
|
+
const r = await assistantChat(
|
|
104
|
+
input(
|
|
105
|
+
{
|
|
106
|
+
options: { systemPrompt: 's', maxIterations: 2 },
|
|
107
|
+
chat: async () => okResult(undefined, [TC('read1')]),
|
|
108
|
+
t: (key: string, params: any) => {
|
|
109
|
+
asked.push({ key, params })
|
|
110
|
+
return `멈췄습니다(${params.iter}단계)`
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{ isReadTool: () => true }
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
expect(asked[0].key).toBe('ai-assistant.notice.too-long')
|
|
117
|
+
expect(asked[0].params.iter).toBe(2)
|
|
118
|
+
expect(asked[0].params.defaultValue).toMatch(/stopped at step/)
|
|
119
|
+
expect(r.reply).toContain('멈췄습니다(2단계)')
|
|
98
120
|
})
|
|
99
121
|
|
|
100
122
|
it('통지는 답을 고치지 않고 뒤에 붙는다', async () => {
|
|
@@ -36,9 +36,14 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { runAgenticLoop, type AgenticLoopInput, type AgenticLoopResult } from '../agentic-loop/index.js'
|
|
38
38
|
import { buildGroundedCorpus, groundingWarnings, type GroundingSources } from '../utils/index.js'
|
|
39
|
-
import { formatAbortNotice } from './abort-notice.js'
|
|
39
|
+
import { formatAbortNotice, type TranslateNotice } from './abort-notice.js'
|
|
40
40
|
|
|
41
41
|
export interface AssistantChatInput<TCtx = unknown, TOp = unknown> extends AgenticLoopInput<TCtx, TOp> {
|
|
42
|
+
/**
|
|
43
|
+
* The request's translator, so a notice this function appends is in the reader's language
|
|
44
|
+
* (ADR-0054). Without it the notice falls back to English.
|
|
45
|
+
*/
|
|
46
|
+
t?: TranslateNotice
|
|
42
47
|
/**
|
|
43
48
|
* 이 턴에 모델이 본 것 — **도구 결과 말고** 전부. 도구 결과는 이 함수가 루프에서 받아 더한다.
|
|
44
49
|
*
|
|
@@ -86,7 +91,7 @@ export async function assistantChat<TCtx = unknown, TOp = unknown>(
|
|
|
86
91
|
const loop = await runAgenticLoop<TCtx, TOp>(loopInput)
|
|
87
92
|
|
|
88
93
|
const baseText = loop.lastText ?? ''
|
|
89
|
-
const reply = baseText + formatAbortNotice(loop.abortReason)
|
|
94
|
+
const reply = baseText + formatAbortNotice(loop.abortReason, input.t)
|
|
90
95
|
|
|
91
96
|
let warnings: string[] | undefined
|
|
92
97
|
if (!skipGrounding && baseText) {
|
|
@@ -56,6 +56,8 @@ export interface RegistryAssistantInput {
|
|
|
56
56
|
maxTokens?: number
|
|
57
57
|
/** Make the model call a tool before it may answer. For surfaces that report live state. */
|
|
58
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
|
|
59
61
|
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
60
62
|
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
61
63
|
}
|
|
@@ -135,7 +137,8 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
135
137
|
toolCallToOp: () => null,
|
|
136
138
|
summarizeToolResult
|
|
137
139
|
},
|
|
138
|
-
groundingSources: input.groundingSources
|
|
140
|
+
groundingSources: input.groundingSources,
|
|
141
|
+
t: input.t
|
|
139
142
|
})
|
|
140
143
|
|
|
141
144
|
return { ...result, offeredTools: tools.map(tool => tool.name) }
|
|
@@ -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,4 +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'
|
|
9
11
|
export * from './testing/tool-call-context.js'
|
|
@@ -292,11 +292,52 @@ describe('registry — a tool that changes something names its door (A1, stage o
|
|
|
292
292
|
expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
-
test('a declared tool
|
|
296
|
-
registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }
|
|
295
|
+
test('a declared tool leaves no warning', () => {
|
|
296
|
+
registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
|
|
297
297
|
expect(getToolRegistryWarnings()).toEqual([])
|
|
298
298
|
})
|
|
299
299
|
|
|
300
|
+
/*
|
|
301
|
+
* 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
|
|
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' }]))
|
|
306
|
+
|
|
307
|
+
expect(getToolRegistryWarnings()).toEqual([
|
|
308
|
+
expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
|
|
309
|
+
])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
|
|
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
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(getToolRegistryWarnings()).toEqual([])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
|
|
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
|
+
})
|
|
332
|
+
|
|
333
|
+
expect(getToolRegistryWarnings()).toEqual([
|
|
334
|
+
expect.objectContaining({
|
|
335
|
+
code: 'tool-door-has-no-privilege',
|
|
336
|
+
params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
|
|
337
|
+
})
|
|
338
|
+
])
|
|
339
|
+
})
|
|
340
|
+
|
|
300
341
|
test('re-registering a category replaces its warnings instead of piling them up', () => {
|
|
301
342
|
const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
|
|
302
343
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
|
|
@@ -31,8 +31,14 @@ const categories: Map<string, ToolCategory> = new Map()
|
|
|
31
31
|
|
|
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
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `tool-privilege-undeclared` — nothing said which door this tool reaches.
|
|
36
|
+
* `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet
|
|
37
|
+
* (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
|
|
38
|
+
* this is a different fact from saying nothing, and the report keeps the two apart.
|
|
39
|
+
*/
|
|
40
|
+
code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege'
|
|
41
|
+
params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }
|
|
36
42
|
/** For a person reading it; the code and params are what callers act on. */
|
|
37
43
|
message: string
|
|
38
44
|
}
|
|
@@ -90,21 +96,44 @@ export function registerToolCategory(category: ToolCategory): void {
|
|
|
90
96
|
*/
|
|
91
97
|
warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
|
|
92
98
|
for (const spec of category.specs) {
|
|
93
|
-
|
|
99
|
+
const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
|
|
100
|
+
if (declared) continue
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* An empty list says this tool reaches no door — the ones that hand back what the surface sent
|
|
104
|
+
* are that. Nothing to gate, nothing to report.
|
|
105
|
+
*/
|
|
106
|
+
if (Array.isArray(spec.doors) && spec.doors.length === 0) continue
|
|
107
|
+
|
|
108
|
+
if (spec.doors?.length) {
|
|
109
|
+
/*
|
|
110
|
+
* The door is named and carries no privilege yet. The tool cannot be stricter than the door
|
|
111
|
+
* it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
|
|
112
|
+
* (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
|
|
113
|
+
*/
|
|
94
114
|
warnings.push({
|
|
95
|
-
code: 'tool-privilege
|
|
96
|
-
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
115
|
+
code: 'tool-door-has-no-privilege',
|
|
116
|
+
params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
|
|
97
117
|
message:
|
|
98
|
-
`tool '${spec.name}'
|
|
99
|
-
'
|
|
118
|
+
`tool '${spec.name}' reaches ${spec.doors.join(' + ')}, which declare no privilege — ` +
|
|
119
|
+
'the tool is as open as the doors it reaches until they declare one'
|
|
100
120
|
})
|
|
121
|
+
continue
|
|
101
122
|
}
|
|
123
|
+
|
|
124
|
+
warnings.push({
|
|
125
|
+
code: 'tool-privilege-undeclared',
|
|
126
|
+
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
127
|
+
message:
|
|
128
|
+
`tool '${spec.name}' in category '${category.name}' says nothing about the doors it reaches — ` +
|
|
129
|
+
"declare `doors` with the same { category, privilege } those doors use, or `doors: []` when it reaches none"
|
|
130
|
+
})
|
|
102
131
|
}
|
|
103
132
|
|
|
104
133
|
if (categories.has(category.name)) {
|
|
105
134
|
console.warn(
|
|
106
135
|
`[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
|
|
107
|
-
|
|
136
|
+
'(normal on a dev hot reload; in production it means the same category is registered twice)'
|
|
108
137
|
)
|
|
109
138
|
}
|
|
110
139
|
|
|
@@ -155,10 +155,35 @@ export interface ToolSpecBase {
|
|
|
155
155
|
* The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
|
|
156
156
|
*
|
|
157
157
|
* Expected for `write`, `action` and `external` — registration records a
|
|
158
|
-
* `tool-privilege-undeclared` warning for one without it (A1 stage one).
|
|
159
|
-
* offered on domain membership alone, and its builder must check what it reads.
|
|
158
|
+
* `tool-privilege-undeclared` warning for one without it (A1 stage one).
|
|
160
159
|
*/
|
|
161
160
|
privilege?: ToolPrivilege
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Which GraphQL doors this tool reaches — `['Query.twinNow']`,
|
|
164
|
+
* `['Query.productionSummary', 'Query.pendingSummary']`.
|
|
165
|
+
*
|
|
166
|
+
* **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,
|
|
167
|
+
* only plant's tools asked whether the caller may read what they read; twin's and figure's
|
|
168
|
+
* checked the tenant and nothing else, so holding the conversation's own privilege was enough to
|
|
169
|
+
* reach their business data.
|
|
170
|
+
*
|
|
171
|
+
* **Always a list, even for one** — one shape to read. A tool that answers from several doors
|
|
172
|
+
* needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):
|
|
173
|
+
* plant's factory overview answers from two, and asking for only one of them would hand back
|
|
174
|
+
* half of what the caller may not see.
|
|
175
|
+
*
|
|
176
|
+
* Three states, and the boot report keeps them apart:
|
|
177
|
+
* - doors and a privilege — the gate checks it
|
|
178
|
+
* - `doors: []` — this tool reaches no door at all. The ones that hand back what the surface
|
|
179
|
+
* sent (`getOpenScene`, `getStationScreen`) are this: there is nothing behind them to gate.
|
|
180
|
+
* - doors with no privilege — the doors themselves declare none yet (twin's queries, waiting
|
|
181
|
+
* on AUTH stage two). A tool cannot be stricter than the door it reaches.
|
|
182
|
+
*
|
|
183
|
+
* The privilege is **the door's**, copied, and a test in each app compares the copy with the
|
|
184
|
+
* directive on that field. When the schema can be read at boot, the copy goes away.
|
|
185
|
+
*/
|
|
186
|
+
doors?: string[]
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
export interface ReadToolSpec extends ToolSpecBase {
|