@things-factory/ai-client-base 10.1.26 → 10.1.29
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 +34 -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 +13 -0
- package/dist-server/service/assistant/registry-assistant.js +7 -2
- 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 +50 -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 +34 -8
- package/server/service/agentic-loop/proposal-key.test.ts +83 -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.test.ts +43 -0
- package/server/service/assistant/registry-assistant.ts +17 -2
- 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 +88 -7
- package/server/service/tool-registry/registry.ts +63 -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) {
|
|
@@ -307,3 +307,46 @@ describe('a client that cannot call tools', () => {
|
|
|
307
307
|
await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
|
|
308
308
|
})
|
|
309
309
|
})
|
|
310
|
+
|
|
311
|
+
/*
|
|
312
|
+
* 도구가 「어느 대화에서 불렸나」를 안다 — ADR-0061 결정 3.
|
|
313
|
+
*
|
|
314
|
+
* 이 칸은 A2(모드를 서버가 등록한다)가 서기 전까지 비어 있었고 주석만 「A2 가 서면」이라고 적혀 있었다.
|
|
315
|
+
* A2 가 선 뒤에도 채우는 쪽이 없어서 도구는 계속 `host` 만 봤다 — 그것은 클라이언트가 채우는 칸이다.
|
|
316
|
+
*/
|
|
317
|
+
describe('the tool context carries the conversation', () => {
|
|
318
|
+
it('★ 부르는 쪽이 준 mode 가 도구에게 간다 — 서버의 값이다', async () => {
|
|
319
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
320
|
+
|
|
321
|
+
await registryAssistantChat(
|
|
322
|
+
input({ client, toolCategories: ['figure-authoring'], mode: { kind: 'twin.space', target: 'SITE-1' } })
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(ran[0].context.mode).toEqual({ kind: 'twin.space', target: 'SITE-1' })
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('★ 그 값은 `host` 와 섞이지 않는다 — 하나는 서버가, 하나는 클라이언트가 채운다', async () => {
|
|
329
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
330
|
+
|
|
331
|
+
await registryAssistantChat(
|
|
332
|
+
input({
|
|
333
|
+
client,
|
|
334
|
+
toolCategories: ['figure-authoring'],
|
|
335
|
+
hostContext: { figureId: 'fig-1' },
|
|
336
|
+
mode: { kind: 'figure.modeller' }
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
expect(ran[0].context).toEqual(
|
|
341
|
+
toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', host: { figureId: 'fig-1' }, mode: { kind: 'figure.modeller' } })
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('mode 를 안 주면 그 칸은 없다 — 빈 값을 지어내지 않는다', async () => {
|
|
346
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
347
|
+
|
|
348
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
349
|
+
|
|
350
|
+
expect(ran[0].context).not.toHaveProperty('mode')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
@@ -49,6 +49,14 @@ export interface RegistryAssistantInput {
|
|
|
49
49
|
toolAccess?: ToolAccessCheck
|
|
50
50
|
/** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
|
|
51
51
|
hostContext?: any
|
|
52
|
+
/**
|
|
53
|
+
* Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).
|
|
54
|
+
*
|
|
55
|
+
* Tools see it as `context.mode`. It is the server's value, resolved from the registry before the
|
|
56
|
+
* turn runs, so a tool that behaves differently in two conversations reads it here and not from
|
|
57
|
+
* `host`, which the client fills.
|
|
58
|
+
*/
|
|
59
|
+
mode?: { kind: string; target?: string }
|
|
52
60
|
/** Caller's koa state (domain, user, tx). Tools that read the database need it. */
|
|
53
61
|
state: any
|
|
54
62
|
model?: string
|
|
@@ -56,6 +64,8 @@ export interface RegistryAssistantInput {
|
|
|
56
64
|
maxTokens?: number
|
|
57
65
|
/** Make the model call a tool before it may answer. For surfaces that report live state. */
|
|
58
66
|
requireToolOnFirstTurn?: boolean
|
|
67
|
+
/** The request's translator — the stop notice is then in the reader's language. */
|
|
68
|
+
t?: (key: string, params?: Record<string, any>) => string
|
|
59
69
|
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
60
70
|
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
61
71
|
}
|
|
@@ -104,7 +114,11 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
104
114
|
const guidance = toolScopeGuidance(scope)
|
|
105
115
|
const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
|
|
106
116
|
|
|
107
|
-
|
|
117
|
+
/*
|
|
118
|
+
* `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
|
|
119
|
+
* 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
|
|
120
|
+
*/
|
|
121
|
+
const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }
|
|
108
122
|
|
|
109
123
|
const result = await assistantChat<ToolCallContext, unknown>({
|
|
110
124
|
initialConversation: messages,
|
|
@@ -135,7 +149,8 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
135
149
|
toolCallToOp: () => null,
|
|
136
150
|
summarizeToolResult
|
|
137
151
|
},
|
|
138
|
-
groundingSources: input.groundingSources
|
|
152
|
+
groundingSources: input.groundingSources,
|
|
153
|
+
t: input.t
|
|
139
154
|
})
|
|
140
155
|
|
|
141
156
|
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'
|
|
@@ -23,6 +23,16 @@ 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
|
+
|
|
26
36
|
function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
|
|
27
37
|
return {
|
|
28
38
|
name,
|
|
@@ -272,7 +282,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
|
|
|
272
282
|
specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
|
|
273
283
|
})
|
|
274
284
|
expect(getToolKind('changesThings')).toBe(kind)
|
|
275
|
-
expect(
|
|
285
|
+
expect(privilegeWarnings()).toEqual([
|
|
276
286
|
expect.objectContaining({
|
|
277
287
|
code: 'tool-privilege-undeclared',
|
|
278
288
|
params: { category: 'no-door', tool: 'changesThings', kind }
|
|
@@ -289,21 +299,92 @@ describe('registry — a tool that changes something names its door (A1, stage o
|
|
|
289
299
|
{ kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
|
|
290
300
|
]
|
|
291
301
|
})
|
|
292
|
-
expect(
|
|
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
|
+
|
|
310
|
+
/*
|
|
311
|
+
* 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
|
|
312
|
+
* plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
|
|
313
|
+
*/
|
|
314
|
+
test('★ a read tool that says nothing about its door is recorded too', () => {
|
|
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
|
+
])
|
|
293
320
|
})
|
|
294
321
|
|
|
295
|
-
test('
|
|
296
|
-
registerToolCategory(
|
|
297
|
-
|
|
322
|
+
test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
|
|
323
|
+
registerToolCategory({
|
|
324
|
+
name: 'surface-only',
|
|
325
|
+
guidance: '- fixture guidance for surface-only',
|
|
326
|
+
specs: [
|
|
327
|
+
{ kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
|
|
328
|
+
]
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(privilegeWarnings()).toEqual([])
|
|
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([
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
code: 'tool-door-has-no-privilege',
|
|
346
|
+
params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
|
|
347
|
+
})
|
|
348
|
+
])
|
|
349
|
+
})
|
|
350
|
+
|
|
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
|
+
)
|
|
367
|
+
|
|
368
|
+
const doors = getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')
|
|
369
|
+
expect(doors.map(w => w.params.tool)).toEqual(['proposeSceneEdit'])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
|
|
373
|
+
registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
|
|
374
|
+
|
|
375
|
+
expect(getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')).toEqual([])
|
|
298
376
|
})
|
|
299
377
|
|
|
300
378
|
test('re-registering a category replaces its warnings instead of piling them up', () => {
|
|
301
379
|
const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
|
|
302
380
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
|
|
381
|
+
const once = getToolRegistryWarnings().map(w => w.code).sort()
|
|
382
|
+
|
|
303
383
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
|
|
304
|
-
|
|
384
|
+
/* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
|
|
385
|
+
expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
|
|
305
386
|
|
|
306
387
|
registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
|
|
307
|
-
expect(
|
|
388
|
+
expect(privilegeWarnings()).toEqual([])
|
|
308
389
|
})
|
|
309
390
|
})
|
|
@@ -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' | 'tool-doors-undeclared'
|
|
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
|
}
|
|
@@ -89,22 +95,71 @@ export function registerToolCategory(category: ToolCategory): void {
|
|
|
89
95
|
* is empty across the apps, this becomes a refusal.
|
|
90
96
|
*/
|
|
91
97
|
warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────
|
|
101
|
+
*
|
|
102
|
+
* ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는
|
|
103
|
+
* `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
|
|
104
|
+
* 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
|
|
105
|
+
*
|
|
106
|
+
* 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,
|
|
107
|
+
* 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —
|
|
108
|
+
* 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
|
|
109
|
+
*/
|
|
92
110
|
for (const spec of category.specs) {
|
|
93
|
-
if (
|
|
111
|
+
if (Array.isArray(spec.doors)) continue
|
|
112
|
+
|
|
113
|
+
warnings.push({
|
|
114
|
+
code: 'tool-doors-undeclared',
|
|
115
|
+
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
116
|
+
message:
|
|
117
|
+
`tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +
|
|
118
|
+
'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'
|
|
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
|
+
*/
|
|
94
138
|
warnings.push({
|
|
95
|
-
code: 'tool-privilege
|
|
96
|
-
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
139
|
+
code: 'tool-door-has-no-privilege',
|
|
140
|
+
params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
|
|
97
141
|
message:
|
|
98
|
-
`tool '${spec.name}'
|
|
99
|
-
'
|
|
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)'
|
|
100
145
|
})
|
|
146
|
+
continue
|
|
101
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`)."
|
|
156
|
+
})
|
|
102
157
|
}
|
|
103
158
|
|
|
104
159
|
if (categories.has(category.name)) {
|
|
105
160
|
console.warn(
|
|
106
161
|
`[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
|
|
107
|
-
|
|
162
|
+
'(normal on a dev hot reload; in production it means the same category is registered twice)'
|
|
108
163
|
)
|
|
109
164
|
}
|
|
110
165
|
|
|
@@ -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 {
|