@things-factory/ai-client-base 10.1.3 → 10.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist-server/service/assistant/abort-notice.d.ts +7 -0
  2. package/dist-server/service/assistant/abort-notice.js +29 -0
  3. package/dist-server/service/assistant/abort-notice.js.map +1 -0
  4. package/dist-server/service/assistant/assistant-chat.d.ts +41 -0
  5. package/dist-server/service/assistant/assistant-chat.js +63 -0
  6. package/dist-server/service/assistant/assistant-chat.js.map +1 -0
  7. package/dist-server/service/assistant/index.d.ts +4 -0
  8. package/dist-server/service/assistant/index.js +13 -0
  9. package/dist-server/service/assistant/index.js.map +1 -0
  10. package/dist-server/service/assistant/registry-gate.d.ts +31 -0
  11. package/dist-server/service/assistant/registry-gate.js +61 -0
  12. package/dist-server/service/assistant/registry-gate.js.map +1 -0
  13. package/dist-server/service/assistant/summarize.d.ts +7 -0
  14. package/dist-server/service/assistant/summarize.js +67 -0
  15. package/dist-server/service/assistant/summarize.js.map +1 -0
  16. package/dist-server/service/index.d.ts +1 -0
  17. package/dist-server/service/index.js +1 -0
  18. package/dist-server/service/index.js.map +1 -1
  19. package/dist-server/service/tool-registry/registry.js +19 -0
  20. package/dist-server/service/tool-registry/registry.js.map +1 -1
  21. package/dist-server/service/tool-registry/types.d.ts +11 -1
  22. package/dist-server/service/tool-registry/types.js.map +1 -1
  23. package/dist-server/service/utils/grounding.d.ts +25 -2
  24. package/dist-server/service/utils/grounding.js +44 -8
  25. package/dist-server/service/utils/grounding.js.map +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +2 -2
  28. package/server/service/assistant/abort-notice.ts +44 -0
  29. package/server/service/assistant/assistant-chat.test.ts +218 -0
  30. package/server/service/assistant/assistant-chat.ts +99 -0
  31. package/server/service/assistant/index.ts +9 -0
  32. package/server/service/assistant/registry-gate.ts +76 -0
  33. package/server/service/assistant/summarize.ts +61 -0
  34. package/server/service/index.ts +1 -0
  35. package/server/service/tool-registry/registry.test.ts +59 -4
  36. package/server/service/tool-registry/registry.ts +22 -0
  37. package/server/service/tool-registry/types.ts +11 -1
  38. package/server/service/utils/grounding.test.ts +49 -0
  39. 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,9 @@
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'
@@ -0,0 +1,76 @@
1
+ /*
2
+ * Which tools this conversation may call — the registry half.
3
+ *
4
+ * ── Why a gate at all ───────────────────────────────────────────────────────────
5
+ * One product grows several conversation surfaces (a board authoring dock, an operations dock, a
6
+ * header assistant). Board edits can be reverted; an operations command reaches a real factory.
7
+ * If both sets hang in one conversation, the boundary is a label on the screen.
8
+ *
9
+ * **Hiding the definitions is not enough.** A model can guess a name, and an older conversation
10
+ * can be replayed with tool calls still in it. So the same gate has to decide *both* what is
11
+ * offered and what may be dispatched. A name that fails the gate is reported back to the model as
12
+ * unregistered — never a silent no-op, which the model would read as success.
13
+ *
14
+ * ── Why the registry half is here and the rest is not (2026-09-10) ──────────────
15
+ * This came out of `board-ai`, where the gate answered from two sources at once: the tool registry
16
+ * *and* board's own core tool-name sets. The first is neutral — any host asking "what kind of tool
17
+ * is this name, and may this conversation see it" wants the same answer. The second is board's.
18
+ *
19
+ * So the registry half lives here and a host **extends** it with whatever it owns
20
+ * (`extendToolGate`). Splitting it this way is what lets a product with no board of its own get
21
+ * the same dispatch discipline, instead of each product writing its own gate and diverging — which
22
+ * is what happened with the grounding guard: the twin's header route had it and the dock route did
23
+ * not, so the same question was verified on one side and not the other.
24
+ */
25
+ import { findToolSpec, getToolKind, type ToolCategoryFilter, type ToolSpec } from '../tool-registry/index.js'
26
+
27
+ /** 이름 하나에 대한 판정. 정의 조립과 dispatch 가 **같은 것**을 쓴다. */
28
+ export interface ToolGate {
29
+ /** 이 대화에 허용된 카테고리 목록. 없음 = 전부. */
30
+ filter: ToolCategoryFilter
31
+ isRead(name: string): boolean
32
+ isWrite(name: string): boolean
33
+ isAction(name: string): boolean
34
+ spec(name: string): ToolSpec | undefined
35
+ }
36
+
37
+ /** 호스트가 자기 도구를 더할 때 주는 것. 준 것만 더해지고, 안 준 축은 레지스트리 판정 그대로다. */
38
+ export interface ToolGateExtension {
39
+ isRead?(name: string): boolean
40
+ isWrite?(name: string): boolean
41
+ isAction?(name: string): boolean
42
+ spec?(name: string): ToolSpec | undefined
43
+ }
44
+
45
+ /**
46
+ * 레지스트리만 보고 답하는 게이트.
47
+ *
48
+ * `external` 은 읽기로 본다 — 등록한 쪽이 「밖에서 값을 가져온다」고 말한 것이고, 그것은 쓰지 않는다.
49
+ */
50
+ export function registryToolGate(filter?: ToolCategoryFilter): ToolGate {
51
+ const kind = (name: string) => getToolKind(name, filter)
52
+ return {
53
+ filter,
54
+ isRead: name => kind(name) === 'read' || kind(name) === 'external',
55
+ isWrite: name => kind(name) === 'write',
56
+ isAction: name => kind(name) === 'action',
57
+ spec: name => findToolSpec(name, filter)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 호스트의 판정을 **더한다.** 겹치면 어느 쪽이든 참이면 참이다 — 게이트는 「이 이름을 다룰 수 있나」를
63
+ * 묻고, 두 출처 중 하나가 안다고 하면 다룰 수 있다.
64
+ *
65
+ * 한쪽으로 덮지 않는 이유: 덮으면 호스트가 자기 이름집합에 없는 등록 도구를 **조용히 잠근다.** 그러면
66
+ * 대화면이 카테고리를 허용했는데 호출이 미등록으로 회신되고, 원인이 두 자리에 나뉘어 있어 못 찾는다.
67
+ */
68
+ export function extendToolGate(base: ToolGate, extension: ToolGateExtension): ToolGate {
69
+ return {
70
+ filter: base.filter,
71
+ isRead: name => base.isRead(name) || !!extension.isRead?.(name),
72
+ isWrite: name => base.isWrite(name) || !!extension.isWrite?.(name),
73
+ isAction: name => base.isAction(name) || !!extension.isAction?.(name),
74
+ spec: name => base.spec(name) ?? extension.spec?.(name)
75
+ }
76
+ }
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Tool results, cut down to what a screen can hold.
3
+ *
4
+ * ── Why this is here and not in a product (2026-09-10) ───────────────────────────
5
+ * This came out of `board-ai`, and measuring the other consumer is what settled it: `twin-ai`
6
+ * passes `summarizeToolResult: v => v` — the identity function. So one product compressed its
7
+ * tool results before persisting them and the other did not, for no stated reason. That is the
8
+ * shape of a layer living in the wrong package: the second product does not decide differently,
9
+ * it just never gets the decision.
10
+ *
11
+ * ── ⚠ This is for the screen, never for a judgement ─────────────────────────────
12
+ * The output is depth- and length-limited, so it is **not evidence of what the model received**.
13
+ * A check that reads these summaries can pass while the model was handed `{}` — an un-awaited
14
+ * async builder did exactly that, and the screen still showed a successful call.
15
+ *
16
+ * What the model actually received is `AgenticLoopResult.groundedTexts`. Grounding already uses
17
+ * it, and anything that judges correctness must use it too.
18
+ */
19
+
20
+ /** 문자열은 이 길이에서 자른다. 자른 사실과 잘린 양을 남긴다 — 조용히 짧아지지 않는다. */
21
+ const MAX_STRING = 200
22
+ /** 배열은 이 개수까지 보이고 나머지는 수로 말한다. */
23
+ const MAX_ITEMS = 5
24
+ /** 배열 안의 배열은 이 깊이에서 모양만 남긴다. */
25
+ const MAX_ARRAY_DEPTH = 2
26
+ /** 객체는 이 깊이에서 키 이름만 남긴다. */
27
+ const MAX_OBJECT_DEPTH = 3
28
+ /** 깊이 한계에서 남길 키 개수. */
29
+ const MAX_KEYS = 6
30
+
31
+ /**
32
+ * 도구 결과를 화면·영속에 쓸 크기로 줄인다.
33
+ *
34
+ * 줄인 자리마다 **줄였다는 표시**를 남긴다(`…[+N chars]` · `…[+N items]` · `[Array(N)]`).
35
+ * 표시가 없으면 읽는 사람이 그것을 원본으로 믿는다.
36
+ */
37
+ export function summarizeToolResult(value: any, depth = 0): any {
38
+ if (value === null || value === undefined) return value
39
+ if (typeof value === 'string') {
40
+ return value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…[+${value.length - MAX_STRING} chars]` : value
41
+ }
42
+ if (typeof value === 'number' || typeof value === 'boolean') return value
43
+ if (Array.isArray(value)) {
44
+ if (depth >= MAX_ARRAY_DEPTH) return `[Array(${value.length})]`
45
+ const out = value.slice(0, MAX_ITEMS).map(v => summarizeToolResult(v, depth + 1))
46
+ if (value.length > MAX_ITEMS) out.push(`…[+${value.length - MAX_ITEMS} items]`)
47
+ return out
48
+ }
49
+ if (typeof value === 'object') {
50
+ if (depth >= MAX_OBJECT_DEPTH) {
51
+ const keys = Object.keys(value).slice(0, MAX_KEYS).join(',')
52
+ return `{${keys}${Object.keys(value).length > MAX_KEYS ? ',…' : ''}}`
53
+ }
54
+ const out: any = {}
55
+ for (const key of Object.keys(value)) {
56
+ out[key] = summarizeToolResult(value[key], depth + 1)
57
+ }
58
+ return out
59
+ }
60
+ return undefined
61
+ }
@@ -4,3 +4,4 @@ export * from './providers/index.js'
4
4
  export * from './utils/index.js'
5
5
  export * from './tool-registry/index.js'
6
6
  export * from './agentic-loop/index.js'
7
+ export * from './assistant/index.js'
@@ -26,6 +26,8 @@ afterEach(() => {
26
26
  function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
27
27
  return {
28
28
  name,
29
+ /* 지침은 필수다(2026-09-09). 픽스처가 그 강제를 우회하지 않도록 여기서도 채운다. */
30
+ guidance: `- fixture guidance for ${name}`,
29
31
  specs: specs.map(s => ({
30
32
  kind: 'read' as const,
31
33
  name: 'unnamed',
@@ -141,6 +143,7 @@ describe('registry — spec 보존', () => {
141
143
  const builder = jest.fn(() => ({ ok: true }))
142
144
  registerToolCategory({
143
145
  name: 'cat1',
146
+ guidance: '- fixture guidance for cat1',
144
147
  specs: [
145
148
  {
146
149
  kind: 'read',
@@ -165,6 +168,7 @@ describe('registry — spec 보존', () => {
165
168
  test('description / themeNote / schema 보존', () => {
166
169
  registerToolCategory({
167
170
  name: 'cat1',
171
+ guidance: '- fixture guidance for cat1',
168
172
  specs: [
169
173
  {
170
174
  kind: 'write',
@@ -196,10 +200,61 @@ describe('getCategoryGuidance — 도구와 함께 가는 사용 규율', () =>
196
200
  expect(getCategoryGuidance(undefined)).toEqual(['RULE-A'])
197
201
  })
198
202
 
199
- it('규율이 없는 카테고리는 건너뛴다 — 빈 줄을 프롬프트에 넣지 않는다', () => {
200
- registerToolCategory({ name: 'a', specs: [] })
201
- registerToolCategory({ name: 'b', guidance: ' ', specs: [] })
203
+ /*
204
+ * This test used to register two categories with no guidance and assert they were skipped, so
205
+ * that no blank line reached the prompt. Registration now refuses them (see the enforcement
206
+ * block below), which means the skip branch inside `getCategoryGuidance` can no longer be
207
+ * reached through the registry — it is left in place only so a mutated entry cannot emit a
208
+ * blank line.
209
+ *
210
+ * What is still worth asserting is the outcome that test was protecting: whatever a caller
211
+ * asks for, the collected guidance never contains an empty string.
212
+ */
213
+ it('collected guidance never contains a blank entry', () => {
214
+ registerToolCategory({ name: 'a', guidance: 'RULE-A', specs: [] })
202
215
  registerToolCategory({ name: 'c', guidance: 'RULE-C', specs: [] })
203
- expect(getCategoryGuidance(undefined)).toEqual(['RULE-C'])
216
+
217
+ for (const collected of [getCategoryGuidance(undefined), getCategoryGuidance(['a']), getCategoryGuidance(['a', 'c'])]) {
218
+ expect(collected.length).toBeGreaterThan(0)
219
+ for (const line of collected) expect(line.trim()).not.toBe('')
220
+ }
221
+ })
222
+ })
223
+
224
+ // ── 지침 강제 ────────────────────────────────────────────────────────
225
+
226
+ describe('registry — 지침 없는 카테고리는 등록되지 않는다', () => {
227
+ /*
228
+ * 왜 이 검사가 있나: 도구 목록만 받은 모델이 「언제 반드시 불러야 하는지」를 못 맞힌 사고가 있었다 —
229
+ * 조치 요청에 `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해 사용자에게 없는 버튼을
230
+ * 누르라고 안내했다. 규율을 적을 자리는 그 뒤에 생겼지만 **선택이었고**, 그래서 그 사고를 아는
231
+ * 사람만 채웠다.
232
+ *
233
+ * 여기서 재는 것은 「빠지면 거절하는가」다. 거절하지 않으면 이 규율은 다음 새 카테고리까지만 간다.
234
+ */
235
+ test('지침이 없으면 던진다', () => {
236
+ const withoutGuidance = { name: 'no-guidance', specs: [] } as unknown as ToolCategory
237
+ expect(() => registerToolCategory(withoutGuidance)).toThrow(/no guidance/)
238
+ expect(getToolCategories()).toEqual([])
239
+ })
240
+
241
+ test('빈 문자열·공백도 거절한다 — 「없음」을 「적었다」로 위장하지 못한다', () => {
242
+ for (const blank of ['', ' ', '\n\t']) {
243
+ const category = { name: 'blank', specs: [], guidance: blank } as ToolCategory
244
+ expect(() => registerToolCategory(category)).toThrow(/no guidance/)
245
+ }
246
+ expect(getToolCategories()).toEqual([])
247
+ })
248
+
249
+ test('거절 문장이 무엇을 하라고 말한다 — 이름만 대면 사람이 다시 찾는다', () => {
250
+ const withoutGuidance = { name: 'no-guidance', specs: [] } as unknown as ToolCategory
251
+ expect(() => registerToolCategory(withoutGuidance)).toThrow(/when the model must call/)
252
+ expect(() => registerToolCategory(withoutGuidance)).toThrow(/no-guidance/)
253
+ })
254
+
255
+ test('지침이 있으면 그대로 등록된다', () => {
256
+ registerToolCategory(makeCategory('ok', [{ name: 'tool1' }]))
257
+ expect(getToolCategories()).toHaveLength(1)
258
+ expect(getToolCategories()[0].guidance).toContain('fixture guidance')
204
259
  })
205
260
  })
@@ -33,6 +33,28 @@ const categories: Map<string, ToolCategory> = new Map()
33
33
  * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.
34
34
  */
35
35
  export function registerToolCategory(category: ToolCategory): void {
36
+ /*
37
+ * 지침 없는 카테고리는 **등록하지 않는다.**
38
+ *
39
+ * 도구 목록만 보낸 모델이 「불러야 할 때 부른다」를 맞히지 못한 사고가 있었다 — 조치 요청에
40
+ * `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해, 사용자에게 없는 버튼을 누르라고
41
+ * 안내했다. 화면에는 아무 일도 없었고 오류도 나지 않았다(§`ToolCategory.guidance`).
42
+ *
43
+ * 그 뒤로 규율을 적는 자리를 만들었지만 **선택이었다.** 선택인 규율은 그 사고를 아는 사람만
44
+ * 채운다. 여기서 거절하는 이유가 그것이다 — 빠진 것을 부팅에서 만든 사람이 보고, 모델이 헛말을
45
+ * 한 뒤에 사용자가 보지 않는다.
46
+ *
47
+ * 빈 문자열·공백도 거절한다. 통과시키면 이 검사가 형식이 되고, 형식이 된 검사는 없는 것과 같다.
48
+ */
49
+ if (!String(category.guidance ?? '').trim()) {
50
+ throw new Error(
51
+ `[ai-client-base/tool-registry] category '${category.name}' has no guidance. ` +
52
+ 'Tool descriptions say what a tool does; guidance says when the model must call one. ' +
53
+ 'Without it a model answers as though it called a tool it did not, and nothing errors. ' +
54
+ 'Write the rules for these tools in `guidance` — the side that registers them owns them.'
55
+ )
56
+ }
57
+
36
58
  if (categories.has(category.name)) {
37
59
  console.warn(
38
60
  `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
@@ -155,6 +155,16 @@ export interface ToolCategory {
155
155
  *
156
156
  * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의
157
157
  * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.
158
+ *
159
+ * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────
160
+ * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기
161
+ * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.
162
+ *
163
+ * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸
164
+ * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만
165
+ * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).
166
+ *
167
+ * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.
158
168
  */
159
- guidance?: string
169
+ guidance: string
160
170
  }
@@ -68,3 +68,52 @@ describe('groundingWarnings — 대화면 진입점', () => {
68
68
  expect(groundingWarnings('지게차들이 바쁘게 움직이고 있습니다.', corpus)).toEqual([])
69
69
  })
70
70
  })
71
+
72
+ /*
73
+ * ── 대문자 식별자 (2026-09-10) ──────────────────────────────────────────────
74
+ *
75
+ * `ID_TOKEN` 이 `[a-z]…` 이던 동안, 대문자 식별자를 쓰는 제품에는 이 가드가 **아예 닿지
76
+ * 않았다.** plant 의 실제 오더 이름이 `WO-ECU-SET-2609B` 이고, 그것에서 추출되는 토큰이 0 개였다.
77
+ *
78
+ * 그 상태의 무서운 점은 조용하다는 것이 아니라 **깨끗해 보인다**는 것이다 — 경고 0 건이 「지어낸
79
+ * 식별자가 없다」로 읽혔다. 없는 것과 안 보는 것이 같은 그림이었다.
80
+ *
81
+ * 이 블록은 그 구멍을 다시 열면 실패한다. 패턴을 소문자로 되돌리고 돌려 확인했다.
82
+ */
83
+ describe('식별자는 대문자도 본다 — 소문자만 보면 두 제품이 통째로 빠진다', () => {
84
+ it('근거에 없는 대문자 오더 번호를 경고한다', () => {
85
+ const warnings = groundingWarnings('WO-ECU-SET-2609B 가 밀렸습니다', '주문 목록: WO-ECU-SET-2601A')
86
+ expect(warnings.join(' ')).toContain('WO-ECU-SET-2609B')
87
+ })
88
+
89
+ it('근거에 있으면 경고하지 않는다', () => {
90
+ expect(groundingWarnings('WO-ECU-SET-2609B 가 밀렸습니다', '{"orders":["WO-ECU-SET-2609B"]}')).toEqual([])
91
+ })
92
+
93
+ it('표기만 다른 것은 다른 대상이 아니다 — 대소문자를 무시하고 대조한다', () => {
94
+ /* 모델이 오더 번호를 소문자로 적는 것은 형식이지 창작이 아니다. 그것을 경고로 만들면
95
+ * 경고가 소음이 되고, 소음이 되면 사람이 경고 자체를 안 본다. */
96
+ expect(groundingWarnings('wo-ecu-set-2609b 가 밀렸습니다', '{"orders":["WO-ECU-SET-2609B"]}')).toEqual([])
97
+ expect(groundingWarnings('WO-ECU-SET-2609B 가 밀렸습니다', '{"orders":["wo-ecu-set-2609b"]}')).toEqual([])
98
+ })
99
+
100
+ it('트윈의 태그도 잡힌다', () => {
101
+ expect(groundingWarnings('TAG-FUNC-AGING 을 적용했습니다', '가용 태그: TAG-FUNC-WELD').join(' ')).toContain(
102
+ 'TAG-FUNC-AGING'
103
+ )
104
+ })
105
+
106
+ it('★ 대문자 영어 합성어는 경고가 아니다 — 넓힌 대가로 소음을 사지 않는다', () => {
107
+ /*
108
+ * 넓히면서 걱정한 것이 이것이다. `filterEntityLike` 가 뒤에서 거른다 — 숫자도 없고 근거에
109
+ * 같은 계열도 없다. 이 줄이 깨지면 넓힌 것이 손해다.
110
+ */
111
+ for (const reply of ['This is Read-Only for now', 'Hand-Off complete', 'a Follow-Up is needed']) {
112
+ expect(groundingWarnings(reply, '보드 상태: 컴포넌트 3개')).toEqual([])
113
+ }
114
+ })
115
+
116
+ it('근거에 같은 계열이 있으면 대문자 합성어도 잡는다 — 같은 계열의 없는 대상', () => {
117
+ expect(groundingWarnings('DOCK-North 로 보냈습니다', '{"docks":["DOCK-South"]}').join(' ')).toContain('DOCK-North')
118
+ })
119
+ })