@things-factory/ai-client-base 10.1.168 → 10.1.169

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 (38) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.d.ts +9 -0
  2. package/dist-server/service/agentic-loop/agentic-loop.js +22 -3
  3. package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
  4. package/dist-server/service/assistant/open-subject.d.ts +4 -0
  5. package/dist-server/service/assistant/open-subject.js +3 -0
  6. package/dist-server/service/assistant/open-subject.js.map +1 -1
  7. package/dist-server/service/assistant/registry-assistant.js +16 -2
  8. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  9. package/dist-server/service/providers/anthropic.d.ts +2 -0
  10. package/dist-server/service/providers/anthropic.js +7 -3
  11. package/dist-server/service/providers/anthropic.js.map +1 -1
  12. package/dist-server/service/providers/gemini.js +1 -1
  13. package/dist-server/service/providers/gemini.js.map +1 -1
  14. package/dist-server/service/providers/openai.js +1 -1
  15. package/dist-server/service/providers/openai.js.map +1 -1
  16. package/dist-server/service/tool-registry/call-recorder.d.ts +16 -0
  17. package/dist-server/service/tool-registry/call-recorder.js +24 -0
  18. package/dist-server/service/tool-registry/call-recorder.js.map +1 -0
  19. package/dist-server/service/tool-registry/index.d.ts +2 -0
  20. package/dist-server/service/tool-registry/index.js +5 -1
  21. package/dist-server/service/tool-registry/index.js.map +1 -1
  22. package/dist-server/service/utils/parse-json.d.ts +12 -1
  23. package/dist-server/service/utils/parse-json.js +18 -4
  24. package/dist-server/service/utils/parse-json.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +2 -2
  27. package/server/service/agentic-loop/agentic-loop.test.ts +76 -0
  28. package/server/service/agentic-loop/agentic-loop.ts +22 -3
  29. package/server/service/assistant/open-subject.ts +3 -0
  30. package/server/service/assistant/registry-assistant.test.ts +63 -1
  31. package/server/service/assistant/registry-assistant.ts +15 -2
  32. package/server/service/providers/anthropic.ts +8 -4
  33. package/server/service/providers/gemini.ts +1 -1
  34. package/server/service/providers/json-answer.test.ts +53 -0
  35. package/server/service/providers/openai.ts +1 -1
  36. package/server/service/tool-registry/call-recorder.ts +47 -0
  37. package/server/service/tool-registry/index.ts +3 -0
  38. package/server/service/utils/parse-json.ts +21 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-client-base",
3
- "version": "10.1.168",
3
+ "version": "10.1.169",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,5 +30,5 @@
30
30
  "@things-factory/shell": "^10.1.165",
31
31
  "openai": "^4.0.0"
32
32
  },
33
- "gitHead": "fe067ff11d90effffc2523374453d9b8313b489f"
33
+ "gitHead": "9d71d02b443f3e0080b25de1768b982c51a64111"
34
34
  }
@@ -431,6 +431,82 @@ describe('runAgenticLoop — error boundary (provider 예외)', () => {
431
431
  })
432
432
  })
433
433
 
434
+ /*
435
+ A read tool that throws is the tool's answer, not the end of the turn (figure audit 2026-10-16 N1): the model reads why
436
+ and says it, or tries another way. Before, the exception left the loop and the whole turn failed with the raw message.
437
+ */
438
+ describe('runAgenticLoop — a read tool that throws', () => {
439
+ test('the reason goes back to the model as an error result, and the turn goes on', async () => {
440
+ const sent: any[][] = []
441
+ let calls = 0
442
+ const input = mkInput(
443
+ {
444
+ chat: async (messages: any[]) => {
445
+ sent.push(JSON.parse(JSON.stringify(messages)))
446
+ calls++
447
+ return calls === 1 ? okResult(undefined, [TC('proposeThing')]) : okResult('It could not be made: the face is wrong.')
448
+ }
449
+ },
450
+ {
451
+ isReadTool: () => true,
452
+ executeReadTool: async () => {
453
+ throw new Error('The figure could not be made.\nATTACH_FACE: steps[4]')
454
+ }
455
+ }
456
+ )
457
+ const r = await runAgenticLoop(input)
458
+ expect(r.abortReason).toBeUndefined()
459
+ expect(r.lastText).toBe('It could not be made: the face is wrong.')
460
+ const result = sent[1]!.at(-1)!.content.find((part: any) => part.type === 'tool_result')
461
+ expect(result.isError).toBe(true)
462
+ expect(JSON.parse(result.content)).toEqual({ error: 'TOOL_FAILED', message: 'The figure could not be made.\nATTACH_FACE: steps[4]' })
463
+ expect(r.toolUsages).toMatchObject([{ name: 'proposeThing', kind: 'read', outcome: 'error' }])
464
+ })
465
+
466
+ test('the same tool failing turn after turn stops at the limit, as a write that keeps failing does', async () => {
467
+ let calls = 0
468
+ const input = mkInput(
469
+ {
470
+ options: { systemPrompt: 'x', repeatedFailureLimit: 3 },
471
+ chat: async () => {
472
+ calls++
473
+ return okResult(undefined, [TC('proposeThing', {}, `t${calls}`)])
474
+ }
475
+ },
476
+ {
477
+ isReadTool: () => true,
478
+ executeReadTool: () => {
479
+ throw new Error('no agent')
480
+ }
481
+ }
482
+ )
483
+ const r = await runAgenticLoop(input)
484
+ expect(r.abortReason).toMatchObject({ type: 'repeated_validation_failure', toolName: 'proposeThing', consecutive: 3 })
485
+ expect(calls).toBe(3)
486
+ })
487
+
488
+ test('a stop the person asked for while the tool ran is still a stop', async () => {
489
+ const ctrl = new AbortController()
490
+ const input = mkInput(
491
+ {
492
+ options: { systemPrompt: 'x', signal: ctrl.signal },
493
+ chat: async () => okResult(undefined, [TC('proposeThing')])
494
+ },
495
+ {
496
+ isReadTool: () => true,
497
+ executeReadTool: () => {
498
+ ctrl.abort()
499
+ const err = new Error('aborted')
500
+ ;(err as any).name = 'AbortError'
501
+ throw err
502
+ }
503
+ }
504
+ )
505
+ const r = await runAgenticLoop(input)
506
+ expect(r.abortReason).toMatchObject({ type: 'aborted' })
507
+ })
508
+ })
509
+
434
510
  describe('runAgenticLoop — AbortSignal', () => {
435
511
  test('이미 abort 된 신호 — 첫 iteration 도 안 돔', async () => {
436
512
  const ctrl = new AbortController()
@@ -472,6 +472,9 @@ function stableStringify(value: any): string {
472
472
  * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).
473
473
  * tool 이 succeed 하면 카운터 reset.
474
474
  */
475
+ /** What a read tool that threw answers, in place of its result. */
476
+ export const TOOL_FAILED = 'TOOL_FAILED'
477
+
475
478
  function detectRepeatedValidationFailure(
476
479
  newUsages: ToolUsage[],
477
480
  lastFailedToolName: string | undefined,
@@ -483,9 +486,11 @@ function detectRepeatedValidationFailure(
483
486
  consecutive: number
484
487
  lastFailedToolName: string | undefined
485
488
  } {
486
- // 이번 iteration 에서 실패한 write tool 들 (validation 거절)
489
+ // 이번 iteration 에서 실패한 write tool 들 (validation 거절), and read tools that threw
487
490
  const failed = newUsages.filter(
488
- u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'
491
+ u =>
492
+ (u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation') ||
493
+ (u.kind === 'read' && (u.result as any)?.error === TOOL_FAILED)
489
494
  )
490
495
 
491
496
  if (failed.length === 0) {
@@ -540,11 +545,25 @@ async function processToolCalls<TCtx, TOp>(
540
545
 
541
546
  for (const tc of toolCalls) {
542
547
  if (dispatch.isReadTool(tc.name)) {
548
+ /*
549
+ * A tool that throws has answered: the model reads why, and says it or tries another way (figure audit
550
+ * 2026-10-16 N1). Left to rise, the exception ended the whole turn and the person saw its raw message instead of
551
+ * an answer. A stop asked for while the tool ran is still seen at the top of the next iteration.
552
+ */
553
+ let executed: any
554
+ try {
555
+ executed = await dispatch.executeReadTool(tc, context)
556
+ } catch (e: any) {
557
+ const failure = { error: TOOL_FAILED, message: String(e?.message ?? e) }
558
+ toolResultParts.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify(failure), isError: true })
559
+ toolUsages.push({ name: tc.name, arguments: tc.arguments ?? {}, result: failure, kind: 'read', iter, outcome: 'error' })
560
+ continue
561
+ }
543
562
  /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).
544
563
  * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
545
564
  * (문서 가져오기·트윈 구조 조회 등 — 무엇인지는 부르는 쪽이 안다). */
546
565
  /* Pictures a read tool hands back ride as image parts after the results - never inside the JSON. */
547
- const { value: given, images } = takeToolImages(await dispatch.executeReadTool(tc, context))
566
+ const { value: given, images } = takeToolImages(executed)
548
567
  toolResultImages.push(...images)
549
568
  /* What only the card needs (a table of a thousand rows) goes to the screen, never to the model. */
550
569
  const { value, forScreen } = takeForScreen(given)
@@ -96,6 +96,9 @@ function quoted(name: string): string {
96
96
  return `"${plain(name, NAME_LIMIT).replace(/"/g, "'")}"`
97
97
  }
98
98
 
99
+ /** A name put before the model as data: in quotes, on one line, without control characters, cut short (above). */
100
+ export const quotedName = quoted
101
+
99
102
  /**
100
103
  * The line for this turn, or `undefined` when the conversation is about nothing in particular (`none`, or no
101
104
  * anchor). A subject that cannot be read is said to be unreadable - the model must not fill in a name.
@@ -12,7 +12,7 @@
12
12
  * tests fix that: offered and dispatchable are the same set, and it is the answering kinds only.
13
13
  */
14
14
  import { registryAssistantChat, REGISTRY_MAX_ITERATIONS } from './registry-assistant'
15
- import { clearToolRegistry, registerToolCategory } from '../tool-registry/index.js'
15
+ import { clearToolCallRecorders, clearToolRegistry, registerToolCallRecorder, registerToolCategory, type ToolCallRecord } from '../tool-registry/index.js'
16
16
  import { toolCallContextFixture } from '../testing/tool-call-context.js'
17
17
  import type { AIToolCall, AIToolChatResult } from '../types.js'
18
18
 
@@ -446,3 +446,65 @@ describe('the request translator and the checked attachments (ADR-0061, 2026-10-
446
446
  expect(ran[0].context).toEqual(toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', t, attachments: [PNG] }))
447
447
  })
448
448
  })
449
+
450
+ describe('every call is handed to the recorders', () => {
451
+ const recorded: Array<{ record: ToolCallRecord; context: any }> = []
452
+ beforeEach(() => {
453
+ clearToolCallRecorders()
454
+ recorded.length = 0
455
+ registerToolCallRecorder((record, context) => {
456
+ recorded.push({ record, context })
457
+ })
458
+ registerToolCategory({
459
+ name: 'ledger',
460
+ guidance: 'Read the ledger before saying a balance.',
461
+ specs: [
462
+ {
463
+ kind: 'read',
464
+ name: 'readBrokenLedger',
465
+ description: 'a list whose query fails',
466
+ doors: ['Query.ledger'],
467
+ schema: { type: 'object', properties: {} },
468
+ builder: () => {
469
+ throw new Error('the ledger could not be read')
470
+ }
471
+ }
472
+ ]
473
+ } as any)
474
+ })
475
+ afterAll(() => clearToolCallRecorders())
476
+
477
+ it('★ the tool, its category, what it was called with and what came back - with the conversation it ran in', async () => {
478
+ const { client } = recordingClient([okResult(undefined, [TC('readOrderState', { line: 'L1' })]), okResult('three orders')])
479
+ await registryAssistantChat(input({ client, toolCategories: ['plant-ops'], mode: { kind: 'plant.station' } }))
480
+ expect(recorded).toHaveLength(1)
481
+ expect(recorded[0].record).toMatchObject({ tool: 'readOrderState', category: 'plant-ops', args: { line: 'L1' }, result: { orders: 3 } })
482
+ expect(recorded[0].record.error).toBeUndefined()
483
+ expect(recorded[0].context.mode).toEqual({ kind: 'plant.station' })
484
+ expect(recorded[0].context.state.domain.id).toBe('dom-1')
485
+ })
486
+
487
+ it('★ a tool that throws is recorded with why, and the model is still told', async () => {
488
+ const { client } = recordingClient([okResult(undefined, [TC('readBrokenLedger')]), okResult('could not read it')])
489
+ const result = await registryAssistantChat(input({ client, toolCategories: ['ledger'] }))
490
+ expect(recorded.map(one => one.record)).toEqual([expect.objectContaining({ tool: 'readBrokenLedger', category: 'ledger', error: 'the ledger could not be read' })])
491
+ expect(recorded[0].record.result).toBeUndefined()
492
+ expect(result.reply).toContain('could not read it')
493
+ })
494
+
495
+ it('a recorder that fails does not stop the answer', async () => {
496
+ registerToolCallRecorder(() => {
497
+ throw new Error('audit table missing')
498
+ })
499
+ const { client } = recordingClient([okResult(undefined, [TC('readOrderState')]), okResult('three orders')])
500
+ const result = await registryAssistantChat(input({ client, toolCategories: ['plant-ops'] }))
501
+ expect(result.reply).toContain('three orders')
502
+ expect(recorded).toHaveLength(1)
503
+ })
504
+
505
+ it('a tool that was not offered is not run, so nothing is recorded', async () => {
506
+ const { client } = recordingClient([okResult(undefined, [TC('readOrderState')]), okResult('done')])
507
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
508
+ expect(recorded).toHaveLength(0)
509
+ })
510
+ })
@@ -27,6 +27,7 @@ import { summarizeToolResult } from './summarize.js'
27
27
  import type { GroundingSources } from '../utils/index.js'
28
28
  import type { ToolAttachment, ToolCallContext } from '../tool-registry/types.js'
29
29
  import { defaultValueTranslate } from '../tool-registry/translate.js'
30
+ import { recordToolCall } from '../tool-registry/call-recorder.js'
30
31
 
31
32
 
32
33
  /** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
@@ -113,6 +114,7 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
113
114
  )
114
115
  const specs = scope.specs
115
116
  const byName = new Map(specs.map(spec => [spec.name, spec]))
117
+ const categoryOf = new Map(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category.name] as [string, string])))
116
118
 
117
119
  const tools = specs.map(spec => ({
118
120
  name: spec.name,
@@ -166,10 +168,21 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
166
168
  */
167
169
  isWriteTool: () => false,
168
170
  isActionTool: () => false,
169
- executeReadTool: (call, context) => {
171
+ /* Every call is handed to the recorders - an application's audit sees the tools other packages declared too. */
172
+ executeReadTool: async (call, context) => {
170
173
  const spec = byName.get(call.name)
171
174
  if (!spec) return undefined
172
- return (spec.builder as any)(call.arguments ?? {}, context)
175
+ const args = call.arguments ?? {}
176
+ const started = Date.now()
177
+ const record = { tool: spec.name, category: categoryOf.get(spec.name) ?? '', args }
178
+ try {
179
+ const result = await (spec.builder as any)(args, context)
180
+ await recordToolCall({ ...record, result, durationMs: Date.now() - started }, context)
181
+ return result
182
+ } catch (e) {
183
+ await recordToolCall({ ...record, error: String((e as Error)?.message ?? e), durationMs: Date.now() - started }, context)
184
+ throw e
185
+ }
173
186
  },
174
187
  validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),
175
188
  toolCallToOp: () => null,
@@ -39,6 +39,11 @@ export class AnthropicAIClient implements AIClient {
39
39
  }
40
40
 
41
41
  async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
42
+ return extractResponseText(await this.send(messages, options))
43
+ }
44
+
45
+ /** One request, the provider's response as it came: `generateJSON` reads whether it stopped at the output limit. */
46
+ private async send(messages: AIMessage[], options: AIChatOptions): Promise<any> {
42
47
  const systemFromMessages = messages
43
48
  .filter(m => m.role === 'system')
44
49
  .map(extractTextContent)
@@ -59,8 +64,7 @@ export class AnthropicAIClient implements AIClient {
59
64
  messages: conversation
60
65
  } as any)
61
66
  reportUsage(response, options, options.model ?? this.defaultModel)
62
-
63
- return extractResponseText(response)
67
+ return response
64
68
  }
65
69
 
66
70
  async generateJSON<T = unknown>(
@@ -78,8 +82,8 @@ export class AnthropicAIClient implements AIClient {
78
82
  'Return only valid JSON. No markdown code fences, no commentary.' +
79
83
  schemaHint
80
84
 
81
- const text = await this.chat(messages, { ...options, systemPrompt: enforcedSystem })
82
- return parseJSONResponse<T>(text)
85
+ const response = await this.send(messages, { ...options, systemPrompt: enforcedSystem })
86
+ return parseJSONResponse<T>(extractResponseText(response), { cutShort: response?.stop_reason === 'max_tokens' })
83
87
  }
84
88
 
85
89
  async vision(
@@ -73,7 +73,7 @@ export class GeminiAIClient implements AIClient {
73
73
  const result = await model.generateContent({ contents } as any)
74
74
  reportUsage(result, options, options.model ?? this.defaultModel)
75
75
  const text = result.response.text()
76
- return parseJSONResponse<T>(text)
76
+ return parseJSONResponse<T>(text, { cutShort: result.response.candidates?.[0]?.finishReason === 'MAX_TOKENS' })
77
77
  }
78
78
 
79
79
  async vision(
@@ -0,0 +1,53 @@
1
+ /*
2
+ * What this guards: an answer that is not JSON is told apart from a request that failed, and an answer cut off at the
3
+ * output limit says so (figure audit 2026-10-16 N2). A caller asks the model again only for the first -- a rejected key,
4
+ * a rate limit or a dropped connection asked again three times is three failures, said as "not valid JSON".
5
+ */
6
+ import { AnthropicAIClient } from './anthropic'
7
+ import { OpenAIAIClient } from './openai'
8
+ import { GeminiAIClient } from './gemini'
9
+ import { AIAnswerNotJSON } from '../utils/parse-json'
10
+
11
+ const CUT = '{"steps":[{"kind":"add-part","part":{"id":"ro'
12
+
13
+ function anthropic(text: string, stop: string) {
14
+ const client = new AnthropicAIClient({ apiKey: 'test' } as any)
15
+ ;(client as any).sdk = { messages: { create: async () => ({ content: [{ type: 'text', text }], stop_reason: stop, usage: { input_tokens: 1, output_tokens: 1 } }) } }
16
+ return client
17
+ }
18
+ function openai(text: string, finish: string) {
19
+ const client = new OpenAIAIClient({ apiKey: 'test' } as any)
20
+ ;(client as any).sdk = { chat: { completions: { create: async () => ({ choices: [{ message: { content: text }, finish_reason: finish }], usage: { prompt_tokens: 1, completion_tokens: 1 } }) } } }
21
+ return client
22
+ }
23
+ function gemini(text: string, finish: string) {
24
+ const client = new GeminiAIClient({ apiKey: 'test' } as any)
25
+ ;(client as any).sdk = {
26
+ getGenerativeModel: () => ({ generateContent: async () => ({ response: { text: () => text, candidates: [{ finishReason: finish }], usageMetadata: {} } }) })
27
+ }
28
+ return client
29
+ }
30
+
31
+ const providers = [
32
+ ['anthropic', anthropic, 'max_tokens', 'end_turn'],
33
+ ['openai', openai, 'length', 'stop'],
34
+ ['gemini', gemini, 'MAX_TOKENS', 'STOP']
35
+ ] as const
36
+
37
+ describe.each(providers)('%s generateJSON', (_name, make, cut, done) => {
38
+ it('an answer cut off at the output limit is not JSON, and says it was cut off', async () => {
39
+ const failure = await make(CUT, cut).generateJSON('x').catch(e => e)
40
+ expect(failure).toBeInstanceOf(AIAnswerNotJSON)
41
+ expect(failure.cutShort).toBe(true)
42
+ })
43
+
44
+ it('an answer that ended and is not JSON is not JSON, not cut off', async () => {
45
+ const failure = await make('I would rather explain.', done).generateJSON('x').catch(e => e)
46
+ expect(failure).toBeInstanceOf(AIAnswerNotJSON)
47
+ expect(failure.cutShort).toBe(false)
48
+ })
49
+
50
+ it('a whole answer is read', async () => {
51
+ await expect(make('{"steps":[]}', done).generateJSON('x')).resolves.toEqual({ steps: [] })
52
+ })
53
+ })
@@ -81,7 +81,7 @@ export class OpenAIAIClient implements AIClient {
81
81
  })
82
82
  reportUsage(response, options, options.model ?? this.defaultModel)
83
83
  const text = response.choices[0]?.message?.content ?? '{}'
84
- return parseJSONResponse<T>(text)
84
+ return parseJSONResponse<T>(text, { cutShort: response.choices[0]?.finish_reason === 'length' })
85
85
  }
86
86
 
87
87
  async vision(
@@ -0,0 +1,47 @@
1
+ /*
2
+ * Every tool call of a registry conversation, handed to whoever writes them down.
3
+ *
4
+ * An application that keeps an AI audit (operato-office's AI 감사 기록) needs each call - which tool, with what, what
5
+ * came back or why it failed - whoever registered the tool: its own tools, a read target's list query, a data entry
6
+ * target's proposal. Wrapping each tool in the application left out the tools other packages declare, so the audit
7
+ * missed the reads that mattered most (2026-10-16). The registry conversation runs every tool at one place and calls
8
+ * the recorders there.
9
+ *
10
+ * A recorder that fails is said in the log and the call goes on - an audit that cannot be written never stops an
11
+ * answer. A recorder decides for itself which conversations it writes down (`context.mode.kind`).
12
+ */
13
+ import type { ToolCallContext } from './types'
14
+
15
+ export interface ToolCallRecord {
16
+ tool: string
17
+ /** The category the tool was offered from. */
18
+ category: string
19
+ args: unknown
20
+ /** What the tool returned - absent when it threw. A refusal it returned (`{ rejected: true }`) is here. */
21
+ result?: unknown
22
+ /** Why it threw. */
23
+ error?: string
24
+ durationMs: number
25
+ }
26
+
27
+ export type ToolCallRecorder = (record: ToolCallRecord, context: ToolCallContext) => void | Promise<void>
28
+
29
+ const recorders: ToolCallRecorder[] = []
30
+
31
+ export function registerToolCallRecorder(recorder: ToolCallRecorder): void {
32
+ if (!recorders.includes(recorder)) recorders.push(recorder)
33
+ }
34
+
35
+ export function clearToolCallRecorders(): void {
36
+ recorders.length = 0
37
+ }
38
+
39
+ export async function recordToolCall(record: ToolCallRecord, context: ToolCallContext): Promise<void> {
40
+ for (const recorder of recorders) {
41
+ try {
42
+ await recorder(record, context)
43
+ } catch (e) {
44
+ console.warn(`[ai-client-base] a tool call recorder failed for ${record.tool}:`, (e as Error)?.message ?? e)
45
+ }
46
+ }
47
+ }
@@ -19,6 +19,9 @@ export {
19
19
 
20
20
  export { defaultValueTranslate } from './translate'
21
21
 
22
+ export { registerToolCallRecorder, clearToolCallRecorders, recordToolCall } from './call-recorder'
23
+ export type { ToolCallRecord, ToolCallRecorder } from './call-recorder'
24
+
22
25
  /* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */
23
26
  export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'
24
27
 
@@ -13,9 +13,24 @@
13
13
  * 3) 그래도 실패하면 raw 출력 포함한 명확한 에러
14
14
  */
15
15
 
16
- export function parseJSONResponse<T = unknown>(text: string): T {
16
+ /**
17
+ * The model answered, and the answer is not JSON (figure audit 2026-10-16 N2). Told apart from a request that failed --
18
+ * a rejected key, a rate limit, a dropped connection -- because only this is worth asking again, with why it was not.
19
+ * `cutShort`: the answer stopped at the output limit before it ended, so asking again for the same is cut off again.
20
+ */
21
+ export class AIAnswerNotJSON extends Error {
22
+ constructor(
23
+ message: string,
24
+ readonly cutShort = false
25
+ ) {
26
+ super(message)
27
+ this.name = 'AIAnswerNotJSON'
28
+ }
29
+ }
30
+
31
+ export function parseJSONResponse<T = unknown>(text: string, { cutShort = false }: { cutShort?: boolean } = {}): T {
17
32
  if (typeof text !== 'string') {
18
- throw new Error(`AI response is not a string (got ${typeof text})`)
33
+ throw new AIAnswerNotJSON(`AI response is not a string (got ${typeof text})`, cutShort)
19
34
  }
20
35
 
21
36
  const stripped = stripCodeFences(text).trim()
@@ -33,13 +48,14 @@ export function parseJSONResponse<T = unknown>(text: string): T {
33
48
  try {
34
49
  return JSON.parse(extracted) as T
35
50
  } catch (e) {
36
- throw new Error(
37
- `AI response is not valid JSON (after extraction): ${(e as Error).message}\n----- raw -----\n${text}`
51
+ throw new AIAnswerNotJSON(
52
+ `AI response is not valid JSON (after extraction): ${(e as Error).message}\n----- raw -----\n${text}`,
53
+ cutShort
38
54
  )
39
55
  }
40
56
  }
41
57
 
42
- throw new Error(`AI response is not valid JSON: no balanced JSON found.\n----- raw -----\n${text}`)
58
+ throw new AIAnswerNotJSON(`AI response is not valid JSON: no balanced JSON found.\n----- raw -----\n${text}`, cutShort)
43
59
  }
44
60
 
45
61
  function stripCodeFences(text: string): string {