@things-factory/ai-client-base 10.1.3 → 10.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/assistant/abort-notice.d.ts +7 -0
- package/dist-server/service/assistant/abort-notice.js +29 -0
- package/dist-server/service/assistant/abort-notice.js.map +1 -0
- package/dist-server/service/assistant/assistant-chat.d.ts +41 -0
- package/dist-server/service/assistant/assistant-chat.js +63 -0
- package/dist-server/service/assistant/assistant-chat.js.map +1 -0
- package/dist-server/service/assistant/index.d.ts +5 -0
- package/dist-server/service/assistant/index.js +14 -0
- package/dist-server/service/assistant/index.js.map +1 -0
- package/dist-server/service/assistant/registry-assistant.d.ts +46 -0
- package/dist-server/service/assistant/registry-assistant.js +71 -0
- package/dist-server/service/assistant/registry-assistant.js.map +1 -0
- package/dist-server/service/assistant/registry-gate.d.ts +31 -0
- package/dist-server/service/assistant/registry-gate.js +61 -0
- package/dist-server/service/assistant/registry-gate.js.map +1 -0
- package/dist-server/service/assistant/summarize.d.ts +7 -0
- package/dist-server/service/assistant/summarize.js +67 -0
- package/dist-server/service/assistant/summarize.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +1 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.js +19 -0
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +11 -1
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/service/utils/grounding.d.ts +25 -2
- package/dist-server/service/utils/grounding.js +44 -8
- package/dist-server/service/utils/grounding.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant/abort-notice.ts +44 -0
- package/server/service/assistant/assistant-chat.test.ts +218 -0
- package/server/service/assistant/assistant-chat.ts +99 -0
- package/server/service/assistant/index.ts +10 -0
- package/server/service/assistant/registry-assistant.test.ts +182 -0
- package/server/service/assistant/registry-assistant.ts +137 -0
- package/server/service/assistant/registry-gate.ts +76 -0
- package/server/service/assistant/summarize.ts +61 -0
- package/server/service/index.ts +1 -0
- package/server/service/tool-registry/registry.test.ts +59 -4
- package/server/service/tool-registry/registry.ts +22 -0
- package/server/service/tool-registry/types.ts +11 -1
- package/server/service/utils/grounding.test.ts +49 -0
- package/server/service/utils/grounding.ts +74 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: **the two things a product must not be able to skip.**
|
|
3
|
+
*
|
|
4
|
+
* The loop came out of board-ai on 2026-09-05 and the guards around it did not, so for four days
|
|
5
|
+
* "run the loop" and "run the loop safely" were two different calls and only one of them was
|
|
6
|
+
* reachable from a product without a board. The failure that made this urgent is recorded in
|
|
7
|
+
* board-ai's grounding file: the twin's header route had the grounding guard and its dock route
|
|
8
|
+
* did not, so the same question was verified on one side and not the other.
|
|
9
|
+
*
|
|
10
|
+
* So these tests are not about the loop's control flow — `agentic-loop.test.ts` covers that. They
|
|
11
|
+
* assert that a caller who does nothing special still gets:
|
|
12
|
+
*
|
|
13
|
+
* ① the reason the loop stopped, in the reply
|
|
14
|
+
* ② the reply checked against what the model was actually handed
|
|
15
|
+
*
|
|
16
|
+
* and that ② reads `groundedTexts`, never the display summaries.
|
|
17
|
+
*/
|
|
18
|
+
import { assistantChat } from './assistant-chat'
|
|
19
|
+
import type { AIToolCall, AIToolChatResult } from '../types.js'
|
|
20
|
+
import type { ToolDispatchHelpers } from '../agentic-loop/index.js'
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* ⚠ 식별자 픽스처가 **소문자**인 이유: 접지 가드의 `ID_TOKEN` 이
|
|
24
|
+
* `[a-z][a-z0-9]*(-[a-z0-9]+)+` 다. 대문자 식별자(`WO-99871`)는 애초에 추출되지 않아 경고가 나올
|
|
25
|
+
* 수 없고, 그것으로 시험을 쓰면 **구현이 무엇을 하든 초록**이 된다(처음에 그렇게 썼다).
|
|
26
|
+
*
|
|
27
|
+
* 그 사정거리 자체가 별 문제다 — plant 의 오더(`WO-…`)와 트윈의 태그(`TAG-FUNC-…`)가 대문자라
|
|
28
|
+
* **이 가드가 그 제품들에는 닿지 않는다.** 이 파일이 고칠 것은 아니고(9개 앱이 이 규칙을 쓴다),
|
|
29
|
+
* `utils/grounding.ts` 의 `ID_TOKEN` 을 넓히는 별 판단이다.
|
|
30
|
+
*/
|
|
31
|
+
const TC = (name: string, args: any = {}, id = 't1'): AIToolCall =>
|
|
32
|
+
({ id, name, arguments: args, type: 'tool_use' } as any)
|
|
33
|
+
|
|
34
|
+
const okResult = (text?: string, toolCalls: AIToolCall[] = []): AIToolChatResult =>
|
|
35
|
+
({
|
|
36
|
+
text,
|
|
37
|
+
toolCalls,
|
|
38
|
+
stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn',
|
|
39
|
+
usage: undefined
|
|
40
|
+
} as any)
|
|
41
|
+
|
|
42
|
+
const dispatch = (overrides: Partial<ToolDispatchHelpers> = {}): ToolDispatchHelpers => ({
|
|
43
|
+
isReadTool: () => false,
|
|
44
|
+
isWriteTool: () => false,
|
|
45
|
+
isActionTool: () => false,
|
|
46
|
+
executeReadTool: () => ({ stub: true }),
|
|
47
|
+
validateWriteToolCall: () => ({ valid: true }),
|
|
48
|
+
toolCallToOp: () => null,
|
|
49
|
+
summarizeToolResult: v => v,
|
|
50
|
+
...overrides
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const input = (overrides: any = {}, dispatchOverrides: Partial<ToolDispatchHelpers> = {}) => ({
|
|
54
|
+
initialConversation: [{ role: 'user' as const, content: 'hi' }],
|
|
55
|
+
tools: [],
|
|
56
|
+
options: { systemPrompt: 'you are a bot' },
|
|
57
|
+
chat: async () => okResult('done'),
|
|
58
|
+
dispatch: dispatch(dispatchOverrides),
|
|
59
|
+
context: {},
|
|
60
|
+
...overrides
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
|
|
64
|
+
it('정상 종료면 답이 모델의 말 그대로다 — 없는 통지를 붙이지 않는다', async () => {
|
|
65
|
+
const r = await assistantChat(input({ chat: async () => okResult('열두 대입니다') }))
|
|
66
|
+
expect(r.reply).toBe('열두 대입니다')
|
|
67
|
+
expect(r.baseText).toBe('열두 대입니다')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('공급자 오류로 멈추면 그 사실이 답에 붙는다 — 잘린 답이 완결로 보이지 않는다', async () => {
|
|
71
|
+
const r = await assistantChat(
|
|
72
|
+
input({
|
|
73
|
+
chat: async () => {
|
|
74
|
+
throw new Error('Error fetching from undefined: [503 Service Unavailable]')
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
expect(r.loop.abortReason?.type).toBe('provider_error')
|
|
79
|
+
expect(r.reply).toMatch(/다시/)
|
|
80
|
+
/* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다. */
|
|
81
|
+
expect(r.reply).not.toMatch(/503/)
|
|
82
|
+
expect(r.reply).not.toMatch(/Error fetching/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('반복 한계로 멈추면 답이 그것을 말한다', async () => {
|
|
86
|
+
/* 도구를 계속 부르게 해서 max_iterations 로 몰아넣는다. */
|
|
87
|
+
const r = await assistantChat(
|
|
88
|
+
input(
|
|
89
|
+
{
|
|
90
|
+
options: { systemPrompt: 's', maxIterations: 2 },
|
|
91
|
+
chat: async () => okResult(undefined, [TC('read1')])
|
|
92
|
+
},
|
|
93
|
+
{ isReadTool: () => true }
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
expect(r.loop.abortReason?.type).toBe('max_iterations')
|
|
97
|
+
expect(r.reply).toMatch(/중단/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('통지는 답을 고치지 않고 뒤에 붙는다', async () => {
|
|
101
|
+
const r = await assistantChat(
|
|
102
|
+
input(
|
|
103
|
+
{
|
|
104
|
+
options: { systemPrompt: 's', maxIterations: 1 },
|
|
105
|
+
chat: async () => okResult('부분 결과', [TC('read1')])
|
|
106
|
+
},
|
|
107
|
+
{ isReadTool: () => true }
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
expect(r.baseText).toBe('부분 결과')
|
|
111
|
+
expect(r.reply.startsWith('부분 결과')).toBe(true)
|
|
112
|
+
expect(r.reply.length).toBeGreaterThan(r.baseText.length)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('assistantChat — ② 접지 검사가 그냥 돈다', () => {
|
|
117
|
+
it('아무 설정 없이도 근거 밖 식별자가 잡힌다 — 켜는 것을 잊을 수 없다', async () => {
|
|
118
|
+
const r = await assistantChat(input({ chat: async () => okResult('wo-99871 이 밀렸습니다') }))
|
|
119
|
+
expect(r.groundingWarnings).toBeDefined()
|
|
120
|
+
expect(r.groundingWarnings!.join(' ')).toMatch(/wo-99871/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('도구가 준 원문 안의 식별자는 경고가 아니다', async () => {
|
|
124
|
+
const r = await assistantChat(
|
|
125
|
+
input(
|
|
126
|
+
{
|
|
127
|
+
chat: async (msgs: any[]) =>
|
|
128
|
+
msgs.some(m => JSON.stringify(m.content).includes('wo-99871'))
|
|
129
|
+
? okResult('wo-99871 이 밀렸습니다')
|
|
130
|
+
: okResult(undefined, [TC('findOrders')])
|
|
131
|
+
},
|
|
132
|
+
{ isReadTool: () => true, executeReadTool: () => ({ orders: ['wo-99871'] }) }
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
expect(r.baseText).toBe('wo-99871 이 밀렸습니다')
|
|
136
|
+
expect(r.groundingWarnings).toBeUndefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('★ 근거는 groundedTexts 다 — 화면용 요약이 잘라낸 식별자를 「근거 없음」으로 오판하지 않는다', async () => {
|
|
140
|
+
/*
|
|
141
|
+
* 이 시험이 이 파일의 이유다. `summarizeToolResult` 를 「전부 버린다」로 두면, 화면 요약을 근거로
|
|
142
|
+
* 읽는 구현은 정상 답을 경고로 만든다. 모델은 원문을 받았기 때문이다.
|
|
143
|
+
*/
|
|
144
|
+
const r = await assistantChat(
|
|
145
|
+
input(
|
|
146
|
+
{
|
|
147
|
+
chat: async (msgs: any[]) =>
|
|
148
|
+
msgs.some(m => JSON.stringify(m.content).includes('wo-99871'))
|
|
149
|
+
? okResult('wo-99871 이 밀렸습니다')
|
|
150
|
+
: okResult(undefined, [TC('findOrders')])
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
isReadTool: () => true,
|
|
154
|
+
executeReadTool: () => ({ orders: ['wo-99871'] }),
|
|
155
|
+
summarizeToolResult: () => '…(생략)'
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
expect(r.loop.toolUsages[0].result).toBe('…(생략)')
|
|
160
|
+
expect(r.loop.groundedTexts.join(' ')).toMatch(/wo-99871/)
|
|
161
|
+
expect(r.groundingWarnings).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('호스트가 준 문맥도 근거다 — 안 주면 정상 답이 경고를 받는다', async () => {
|
|
165
|
+
const reply = 'board-77 을 고쳤습니다'
|
|
166
|
+
|
|
167
|
+
const without = await assistantChat(input({ chat: async () => okResult(reply) }))
|
|
168
|
+
expect(without.groundingWarnings?.join(' ')).toMatch(/board-77/)
|
|
169
|
+
|
|
170
|
+
const withContext = await assistantChat(
|
|
171
|
+
input({ chat: async () => okResult(reply), groundingSources: { hostContext: 'current board: board-77' } })
|
|
172
|
+
)
|
|
173
|
+
expect(withContext.groundingWarnings).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('명시로만 끌 수 있다 — 그리고 끈 것은 경고를 버린 것과 구별된다', async () => {
|
|
177
|
+
const r = await assistantChat(
|
|
178
|
+
input({ chat: async () => okResult('wo-99871 이 밀렸습니다'), skipGrounding: true })
|
|
179
|
+
)
|
|
180
|
+
expect(r.groundingWarnings).toBeUndefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('경고가 없으면 undefined 다 — 빈 배열을 주지 않는다', async () => {
|
|
184
|
+
const r = await assistantChat(input({ chat: async () => okResult('네') }))
|
|
185
|
+
expect(r.groundingWarnings).toBeUndefined()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('assistantChat — 말을 지어내지 않는다', () => {
|
|
190
|
+
it('모델이 말을 안 하고 끝나면 답이 비어 있다 — 도메인을 모르는 자리에서 서술하지 않는다', async () => {
|
|
191
|
+
const r = await assistantChat(
|
|
192
|
+
input({ chat: async () => okResult(undefined) })
|
|
193
|
+
)
|
|
194
|
+
expect(r.baseText).toBe('')
|
|
195
|
+
expect(r.reply).toBe('')
|
|
196
|
+
/* 말이 없으면 검사할 것도 없다 — 빈 답에 대고 경고를 만들지 않는다. */
|
|
197
|
+
expect(r.groundingWarnings).toBeUndefined()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('루프 결과는 그대로 넘어온다 — 호스트가 자기 뜻으로 해석한다', async () => {
|
|
201
|
+
/* 도구를 한 번만 부르게 한다 — 매번 같은 호출을 돌려주면 루프가 반복 한계까지 돈다. */
|
|
202
|
+
let called = false
|
|
203
|
+
const r = await assistantChat(
|
|
204
|
+
input(
|
|
205
|
+
{
|
|
206
|
+
chat: async () => {
|
|
207
|
+
if (called) return okResult('했습니다')
|
|
208
|
+
called = true
|
|
209
|
+
return okResult(undefined, [TC('write1')])
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{ isWriteTool: () => true }
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
expect(r.loop.accumulatedWriteCalls.map(c => c.name)).toEqual(['write1'])
|
|
216
|
+
expect(r.baseText).toBe('했습니다')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One turn of an assistant — the loop **with the guards that must not be optional.**
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists (2026-09-10) ────────────────────────────────────────────────
|
|
5
|
+
* `runAgenticLoop` is neutral and has been since 2026-09-05, when the same loop running in two
|
|
6
|
+
* places was consolidated. The note on that change is the reason this file exists: it was "not a
|
|
7
|
+
* duplication, a defect" — the two copies had **their safety measures set opposite ways**.
|
|
8
|
+
*
|
|
9
|
+
* The loop came out. The guards around it did not. They stayed in `board-ai`, wrapped around
|
|
10
|
+
* `DefaultBoardAIAssistant.chat`, which meant a product with no board of its own could only get
|
|
11
|
+
* them by going through the board editor. The plant lane read that correctly — it uses none of
|
|
12
|
+
* board's session machinery — and asked whether it could call `runAgenticLoop` directly instead.
|
|
13
|
+
*
|
|
14
|
+
* The answer was no, and the reason is written in `board-ai`'s own grounding file: the twin's
|
|
15
|
+
* header route had a grounding guard and its dock route did not, so **the same question was
|
|
16
|
+
* verified on one side and not the other.** A per-product door makes that three ways. This file is
|
|
17
|
+
* the door instead.
|
|
18
|
+
*
|
|
19
|
+
* ── What is guaranteed here, and what the host still owns ───────────────────────
|
|
20
|
+
* Guaranteed, because a caller cannot skip it:
|
|
21
|
+
* · the loop stops for a reason and the reason reaches the reply (`formatAbortNotice`)
|
|
22
|
+
* · the reply is checked against what the model was actually given (`groundingWarnings`)
|
|
23
|
+
*
|
|
24
|
+
* Still the host's, because only the host knows the domain:
|
|
25
|
+
* · which tools exist and what a write call means → `gate`, `dispatch`
|
|
26
|
+
* · what counts as evidence beyond the tool results → `groundingSources`
|
|
27
|
+
*
|
|
28
|
+
* ── Grounding sources: leaving one out produces false warnings ──────────────────
|
|
29
|
+
* The corpus is everything the model saw this turn. The tool results are added here, from
|
|
30
|
+
* `groundedTexts` — the raw strings sent back as `tool_result`, never `toolUsages[].result`, which
|
|
31
|
+
* is depth-limited for display. A check reading the display copy can pass while the model was
|
|
32
|
+
* handed `{}`; an un-awaited async builder did exactly that.
|
|
33
|
+
*
|
|
34
|
+
* Anything else the host put in front of the model — a system prompt, a state summary, the user's
|
|
35
|
+
* own words — has to be declared, or a correct answer quoting it gets warned about.
|
|
36
|
+
*/
|
|
37
|
+
import { runAgenticLoop, type AgenticLoopInput, type AgenticLoopResult } from '../agentic-loop/index.js'
|
|
38
|
+
import { buildGroundedCorpus, groundingWarnings, type GroundingSources } from '../utils/index.js'
|
|
39
|
+
import { formatAbortNotice } from './abort-notice.js'
|
|
40
|
+
|
|
41
|
+
export interface AssistantChatInput<TCtx = unknown, TOp = unknown> extends AgenticLoopInput<TCtx, TOp> {
|
|
42
|
+
/**
|
|
43
|
+
* 이 턴에 모델이 본 것 — **도구 결과 말고** 전부. 도구 결과는 이 함수가 루프에서 받아 더한다.
|
|
44
|
+
*
|
|
45
|
+
* 빠뜨리면 정상 답이 경고를 받는다(오탐). 시스템 프롬프트에 적어 보낸 타입 이름을 모델이 말하는
|
|
46
|
+
* 것은 근거 있는 발언이다.
|
|
47
|
+
*/
|
|
48
|
+
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
49
|
+
/**
|
|
50
|
+
* 접지 검사를 끈다 — **기본은 켜짐.**
|
|
51
|
+
*
|
|
52
|
+
* 끄는 것이 맞는 자리가 있다: 식별자를 다루지 않는 대화(문서 상담·개념 질문)는 근거집합에 식별자가
|
|
53
|
+
* 없어 모든 고유명사가 경고로 나온다. 그때는 끄는 것이 정직하고, **켠 채로 경고를 버리는 것**이
|
|
54
|
+
* 정직하지 않다.
|
|
55
|
+
*/
|
|
56
|
+
skipGrounding?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AssistantChatResult<TOp = unknown> {
|
|
60
|
+
/**
|
|
61
|
+
* 사용자에게 보일 답 — 모델의 마지막 말 + 중단 통지.
|
|
62
|
+
*
|
|
63
|
+
* 모델의 말을 고치지 않는다. 통지는 **뒤에 붙는다.**
|
|
64
|
+
*/
|
|
65
|
+
reply: string
|
|
66
|
+
/** 통지가 붙기 전의 답. 접지 검사가 본 것이고, 호스트가 다시 판정할 때 쓴다. */
|
|
67
|
+
baseText: string
|
|
68
|
+
/** 근거 밖의 식별자를 지목한 자리들. 없으면 undefined — 빈 배열을 주지 않는다. */
|
|
69
|
+
groundingWarnings?: string[]
|
|
70
|
+
/** 루프 결과 원본 — 쓰기 호출·조치·제안·도구 흔적. 호스트가 자기 뜻으로 해석한다. */
|
|
71
|
+
loop: AgenticLoopResult<TOp>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 한 턴을 돌린다.
|
|
76
|
+
*
|
|
77
|
+
* `lastText` 가 없을 수 있다(도구만 부르고 말을 안 한 턴). 그 경우 `baseText` 는 빈 문자열이고,
|
|
78
|
+
* 무엇을 대신 말할지는 호스트가 정한다 — 여기서 「변경했습니다」 같은 말을 지어내지 않는다.
|
|
79
|
+
* 도메인을 모르는 자리에서 결과를 서술하면 그것이 곧 지어낸 원인이 된다.
|
|
80
|
+
*/
|
|
81
|
+
export async function assistantChat<TCtx = unknown, TOp = unknown>(
|
|
82
|
+
input: AssistantChatInput<TCtx, TOp>
|
|
83
|
+
): Promise<AssistantChatResult<TOp>> {
|
|
84
|
+
const { groundingSources, skipGrounding, ...loopInput } = input
|
|
85
|
+
|
|
86
|
+
const loop = await runAgenticLoop<TCtx, TOp>(loopInput)
|
|
87
|
+
|
|
88
|
+
const baseText = loop.lastText ?? ''
|
|
89
|
+
const reply = baseText + formatAbortNotice(loop.abortReason)
|
|
90
|
+
|
|
91
|
+
let warnings: string[] | undefined
|
|
92
|
+
if (!skipGrounding && baseText) {
|
|
93
|
+
const corpus = buildGroundedCorpus({ ...(groundingSources ?? {}), toolResults: loop.groundedTexts })
|
|
94
|
+
const found = groundingWarnings(baseText, corpus)
|
|
95
|
+
warnings = found.length > 0 ? found : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { reply, baseText, groundingWarnings: warnings, loop }
|
|
99
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The neutral assistant layer — the agentic loop plus the guards that must not be optional.
|
|
3
|
+
*
|
|
4
|
+
* See `assistant-chat.ts` for why this is not in a product package.
|
|
5
|
+
*/
|
|
6
|
+
export * from './abort-notice.js'
|
|
7
|
+
export * from './summarize.js'
|
|
8
|
+
export * from './registry-gate.js'
|
|
9
|
+
export * from './assistant-chat.js'
|
|
10
|
+
export * from './registry-assistant.js'
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: **a product with no document of its own gets a working conversation, and only
|
|
3
|
+
* the tools it can actually run.**
|
|
4
|
+
*
|
|
5
|
+
* The gap this closes: the registry half of building a conversation lived inside board-ai, so
|
|
6
|
+
* operato-figure mounted the dock, registered four tools, and had nothing to call — the only
|
|
7
|
+
* mutation that could run them was in a package figure does not load.
|
|
8
|
+
*
|
|
9
|
+
* The trap on the other side is subtler. A registered category can carry `write` and `action`
|
|
10
|
+
* tools, which produce edit ops for a document the host owns. A host with no document cannot
|
|
11
|
+
* apply them, so offering them would put a name in front of the model that leads nowhere. These
|
|
12
|
+
* tests fix that: offered and dispatchable are the same set, and it is the answering kinds only.
|
|
13
|
+
*/
|
|
14
|
+
import { registryAssistantChat } from './registry-assistant'
|
|
15
|
+
import { clearToolRegistry, registerToolCategory } from '../tool-registry/index.js'
|
|
16
|
+
import type { AIToolCall, AIToolChatResult } from '../types.js'
|
|
17
|
+
|
|
18
|
+
const TC = (name: string, args: any = {}, id = 't1'): AIToolCall =>
|
|
19
|
+
({ id, name, arguments: args, type: 'tool_use' } as any)
|
|
20
|
+
|
|
21
|
+
const okResult = (text?: string, toolCalls: AIToolCall[] = []): AIToolChatResult =>
|
|
22
|
+
({ text, toolCalls, stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn', usage: undefined } as any)
|
|
23
|
+
|
|
24
|
+
/** Records what the provider was handed, so the tests can read the offer rather than infer it. */
|
|
25
|
+
function recordingClient(replies: AIToolChatResult[] = [okResult('answered')]) {
|
|
26
|
+
const seen: Array<{ tools: any[]; options: any }> = []
|
|
27
|
+
let turn = 0
|
|
28
|
+
return {
|
|
29
|
+
seen,
|
|
30
|
+
client: {
|
|
31
|
+
chatWithTools: async (_msgs: any, tools: any[], options: any) => {
|
|
32
|
+
seen.push({ tools, options })
|
|
33
|
+
return replies[Math.min(turn++, replies.length - 1)]
|
|
34
|
+
}
|
|
35
|
+
} as any
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ran: Array<{ name: string; args: any; context: any }> = []
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
clearToolRegistry()
|
|
43
|
+
ran.length = 0
|
|
44
|
+
|
|
45
|
+
registerToolCategory({
|
|
46
|
+
name: 'figure-authoring',
|
|
47
|
+
guidance: 'Always look at the figure before describing it.',
|
|
48
|
+
specs: [
|
|
49
|
+
{
|
|
50
|
+
kind: 'read',
|
|
51
|
+
name: 'listFigures',
|
|
52
|
+
description: 'lists figures',
|
|
53
|
+
schema: { type: 'object', properties: {} },
|
|
54
|
+
builder: (args: any, context: any) => {
|
|
55
|
+
ran.push({ name: 'listFigures', args, context })
|
|
56
|
+
return { figures: ['roller-conveyor'] }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: 'write',
|
|
61
|
+
name: 'addComponent',
|
|
62
|
+
description: 'adds a component to the document',
|
|
63
|
+
schema: { type: 'object', properties: {} },
|
|
64
|
+
builder: () => {
|
|
65
|
+
ran.push({ name: 'addComponent', args: {}, context: null })
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
} as any)
|
|
71
|
+
|
|
72
|
+
registerToolCategory({
|
|
73
|
+
name: 'plant-ops',
|
|
74
|
+
guidance: 'Never state a quantity you did not read.',
|
|
75
|
+
specs: [
|
|
76
|
+
{
|
|
77
|
+
kind: 'external',
|
|
78
|
+
name: 'readOrderState',
|
|
79
|
+
description: 'reads live order state',
|
|
80
|
+
schema: { type: 'object', properties: {} },
|
|
81
|
+
builder: (args: any, context: any) => {
|
|
82
|
+
ran.push({ name: 'readOrderState', args, context })
|
|
83
|
+
return { orders: 3 }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
} as any)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const input = (over: any = {}) => ({
|
|
91
|
+
client: recordingClient().client,
|
|
92
|
+
messages: [{ role: 'user' as const, content: 'what figures do we have' }],
|
|
93
|
+
systemPrompt: 'you help someone draw a figure',
|
|
94
|
+
state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
|
|
95
|
+
...over
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('what the model is offered', () => {
|
|
99
|
+
it('★ only the kinds that answer — a write tool is not offered to a surface with no document', async () => {
|
|
100
|
+
const { client, seen } = recordingClient()
|
|
101
|
+
await registryAssistantChat(input({ client }))
|
|
102
|
+
|
|
103
|
+
const offered = seen[0].tools.map(tool => tool.name)
|
|
104
|
+
expect(offered).toEqual(expect.arrayContaining(['listFigures', 'readOrderState']))
|
|
105
|
+
expect(offered).not.toContain('addComponent')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('the category filter narrows the offer', async () => {
|
|
109
|
+
const { client, seen } = recordingClient()
|
|
110
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
111
|
+
|
|
112
|
+
expect(seen[0].tools.map(tool => tool.name)).toEqual(['listFigures'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('an empty category list offers nothing — a plain conversation is a legitimate surface', async () => {
|
|
116
|
+
const { client, seen } = recordingClient()
|
|
117
|
+
await registryAssistantChat(input({ client, toolCategories: [] }))
|
|
118
|
+
|
|
119
|
+
expect(seen[0].tools).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('the result reports what was offered, so the caller can log it without guessing', async () => {
|
|
123
|
+
const result = await registryAssistantChat(input({ toolCategories: ['plant-ops'] }))
|
|
124
|
+
expect(result.offeredTools).toEqual(['readOrderState'])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('the rules for using a tool travel with it', () => {
|
|
129
|
+
it('★ guidance of the exposed categories reaches the prompt', async () => {
|
|
130
|
+
const { client, seen } = recordingClient()
|
|
131
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
132
|
+
|
|
133
|
+
expect(seen[0].options.systemPrompt).toContain('you help someone draw a figure')
|
|
134
|
+
expect(seen[0].options.systemPrompt).toContain('Always look at the figure before describing it.')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('guidance of a category this conversation cannot call is not read to the model', async () => {
|
|
138
|
+
const { client, seen } = recordingClient()
|
|
139
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
140
|
+
|
|
141
|
+
expect(seen[0].options.systemPrompt).not.toContain('Never state a quantity you did not read.')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('running a tool', () => {
|
|
146
|
+
it('★ the registered builder runs and is handed the caller state and the host context', async () => {
|
|
147
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures', { q: 'roller' })]), okResult('two of them')])
|
|
148
|
+
|
|
149
|
+
const result = await registryAssistantChat(
|
|
150
|
+
input({ client, hostContext: { surface: 'figure-modeller', figureId: 'fig-1' } })
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
expect(ran).toHaveLength(1)
|
|
154
|
+
expect(ran[0].name).toBe('listFigures')
|
|
155
|
+
expect(ran[0].args).toEqual({ q: 'roller' })
|
|
156
|
+
expect(ran[0].context.state.domain.id).toBe('dom-1')
|
|
157
|
+
expect(ran[0].context.host).toEqual({ surface: 'figure-modeller', figureId: 'fig-1' })
|
|
158
|
+
expect(result.reply).toContain('two of them')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('★ a write tool call is not executed — the same set decides offering and dispatch', async () => {
|
|
162
|
+
const { client } = recordingClient([okResult(undefined, [TC('addComponent')]), okResult('cannot do that')])
|
|
163
|
+
|
|
164
|
+
await registryAssistantChat(input({ client }))
|
|
165
|
+
|
|
166
|
+
expect(ran.map(one => one.name)).not.toContain('addComponent')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('★ a tool outside the allowed categories does not run', async () => {
|
|
170
|
+
const { client } = recordingClient([okResult(undefined, [TC('readOrderState')]), okResult('done')])
|
|
171
|
+
|
|
172
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
173
|
+
|
|
174
|
+
expect(ran.map(one => one.name)).not.toContain('readOrderState')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('a client that cannot call tools', () => {
|
|
179
|
+
it('★ refuses instead of answering from nothing', async () => {
|
|
180
|
+
await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A conversation for a product that has no editor of its own.
|
|
3
|
+
*
|
|
4
|
+
* ── What this is for ───────────────────────────────────────────────────────────
|
|
5
|
+
* `board-ai` assembles its conversation from two sources: its own board editing tools, and
|
|
6
|
+
* whatever other packages registered in the tool registry. The second half is the same work for
|
|
7
|
+
* every product — read the specs, build the definitions, dispatch by name — but it only existed
|
|
8
|
+
* inside the board assistant, so a product without a board had nowhere to ask a question from.
|
|
9
|
+
* operato-figure hit exactly that: the dock was mounted, the tools were registered, and the only
|
|
10
|
+
* mutation that could run them lived in a package figure does not load.
|
|
11
|
+
*
|
|
12
|
+
* ── Read and external only ─────────────────────────────────────────────────────
|
|
13
|
+
* A registry category may also carry `write` and `action` tools. Those produce edit ops for a
|
|
14
|
+
* document the host owns, and a host with no document cannot apply them. Offering them here would
|
|
15
|
+
* declare a door with no road behind it — the model would call one and the call would go nowhere.
|
|
16
|
+
* So this runner offers, and dispatches, `read` and `external` only. A host that owns a document
|
|
17
|
+
* keeps assembling its own conversation, as board-ai does.
|
|
18
|
+
*
|
|
19
|
+
* The gate answers from the same list the definitions are built from, so what is offered and what
|
|
20
|
+
* may be dispatched cannot drift apart.
|
|
21
|
+
*/
|
|
22
|
+
import type { AIClient, AIMessage } from '../types.js'
|
|
23
|
+
import { getCategoryGuidance, getToolSpecsFor, type ToolCategoryFilter, type ToolSpec } from '../tool-registry/index.js'
|
|
24
|
+
import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
|
|
25
|
+
import { summarizeToolResult } from './summarize.js'
|
|
26
|
+
import type { GroundingSources } from '../utils/index.js'
|
|
27
|
+
|
|
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
|
+
|
|
35
|
+
export interface RegistryAssistantInput {
|
|
36
|
+
/** Provider client. Must implement `chatWithTools`. */
|
|
37
|
+
client: AIClient
|
|
38
|
+
/** The conversation so far, newest last. The caller owns history and truncation. */
|
|
39
|
+
messages: AIMessage[]
|
|
40
|
+
/** Who the assistant is and what this surface is. The host owns this prose. */
|
|
41
|
+
systemPrompt: string
|
|
42
|
+
/**
|
|
43
|
+
* Categories this conversation may use. Omit to allow every registered category.
|
|
44
|
+
*
|
|
45
|
+
* An empty array is not the same as omitting: it allows nothing, and the conversation runs with
|
|
46
|
+
* no tools at all. That is a legitimate surface (plain chat), so it is not corrected here.
|
|
47
|
+
*/
|
|
48
|
+
toolCategories?: string[]
|
|
49
|
+
/** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
|
|
50
|
+
hostContext?: any
|
|
51
|
+
/** Caller's koa state (domain, user, tx). Tools that read the database need it. */
|
|
52
|
+
state: any
|
|
53
|
+
model?: string
|
|
54
|
+
temperature?: number
|
|
55
|
+
maxTokens?: number
|
|
56
|
+
/** Make the model call a tool before it may answer. For surfaces that report live state. */
|
|
57
|
+
requireToolOnFirstTurn?: boolean
|
|
58
|
+
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
59
|
+
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
|
|
63
|
+
/** Tool names actually offered to the model this turn. The caller may log or return it. */
|
|
64
|
+
offeredTools: string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
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
|
+
/**
|
|
73
|
+
* Run one turn of a conversation whose only tools come from the registry.
|
|
74
|
+
*
|
|
75
|
+
* Throws when the client cannot use tools at all — a surface built on registered tools that
|
|
76
|
+
* silently ran without them would answer from nothing and look like it had checked.
|
|
77
|
+
*/
|
|
78
|
+
export async function registryAssistantChat(input: RegistryAssistantInput): Promise<RegistryAssistantResult> {
|
|
79
|
+
const { client, messages, systemPrompt, toolCategories, hostContext, state } = input
|
|
80
|
+
|
|
81
|
+
if (!client.chatWithTools) {
|
|
82
|
+
throw new Error('[registry-assistant] the configured AI client cannot call tools, so this conversation cannot run')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filter: ToolCategoryFilter = toolCategories
|
|
86
|
+
const specs = answeringSpecs(filter)
|
|
87
|
+
const byName = new Map(specs.map(spec => [spec.name, spec]))
|
|
88
|
+
|
|
89
|
+
const tools = specs.map(spec => ({
|
|
90
|
+
name: spec.name,
|
|
91
|
+
description: spec.themeNote ? `${spec.description} ${spec.themeNote}` : spec.description,
|
|
92
|
+
parameters: spec.schema
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
/*
|
|
96
|
+
* The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so
|
|
97
|
+
* a surface never reads instructions for a category it cannot call.
|
|
98
|
+
*/
|
|
99
|
+
const guidance = getCategoryGuidance(filter)
|
|
100
|
+
const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
|
|
101
|
+
|
|
102
|
+
const toolContext: RegistryToolCallContext = { state, host: hostContext }
|
|
103
|
+
|
|
104
|
+
const result = await assistantChat<RegistryToolCallContext, unknown>({
|
|
105
|
+
initialConversation: messages,
|
|
106
|
+
tools,
|
|
107
|
+
options: {
|
|
108
|
+
systemPrompt: prompt,
|
|
109
|
+
model: input.model,
|
|
110
|
+
temperature: input.temperature,
|
|
111
|
+
maxTokens: input.maxTokens,
|
|
112
|
+
requireToolOnFirstTurn: input.requireToolOnFirstTurn
|
|
113
|
+
},
|
|
114
|
+
chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),
|
|
115
|
+
context: toolContext,
|
|
116
|
+
dispatch: {
|
|
117
|
+
isReadTool: name => byName.has(name),
|
|
118
|
+
/*
|
|
119
|
+
* This surface has no document, so nothing here can write. Saying so plainly makes the loop
|
|
120
|
+
* report an unknown name back to the model instead of accepting a call and dropping it.
|
|
121
|
+
*/
|
|
122
|
+
isWriteTool: () => false,
|
|
123
|
+
isActionTool: () => false,
|
|
124
|
+
executeReadTool: (call, context) => {
|
|
125
|
+
const spec = byName.get(call.name)
|
|
126
|
+
if (!spec) return undefined
|
|
127
|
+
return (spec.builder as any)(call.arguments ?? {}, context)
|
|
128
|
+
},
|
|
129
|
+
validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),
|
|
130
|
+
toolCallToOp: () => null,
|
|
131
|
+
summarizeToolResult
|
|
132
|
+
},
|
|
133
|
+
groundingSources: input.groundingSources
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return { ...result, offeredTools: tools.map(tool => tool.name) }
|
|
137
|
+
}
|