@things-factory/ai-client-base 10.1.176 → 10.1.177

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-client-base",
3
- "version": "10.1.176",
3
+ "version": "10.1.177",
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": "24928f127d73de0e9946423bd4c3295f54cce03d"
33
+ "gitHead": "91da09c945ec5e77ab960074db8f53b9ea8ac534"
34
34
  }
@@ -556,7 +556,7 @@ async function processToolCalls<TCtx, TOp>(
556
556
  } catch (e: any) {
557
557
  const failure = { error: TOOL_FAILED, message: String(e?.message ?? e) }
558
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' })
559
+ toolUsages.push({ name: tc.name, arguments: tc.arguments ?? {}, result: dispatch.summarizeToolResult(failure), kind: 'read', iter, outcome: 'error' })
560
560
  continue
561
561
  }
562
562
  /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).
@@ -628,7 +628,7 @@ async function processToolCalls<TCtx, TOp>(
628
628
  toolUsages.push({
629
629
  name: tc.name,
630
630
  arguments: tc.arguments ?? {},
631
- result: errorResult,
631
+ result: dispatch.summarizeToolResult(errorResult),
632
632
  kind: 'write',
633
633
  iter,
634
634
  outcome: 'rejected'
@@ -645,7 +645,7 @@ async function processToolCalls<TCtx, TOp>(
645
645
  toolUsages.push({
646
646
  name: tc.name,
647
647
  arguments: tc.arguments ?? {},
648
- result: queuedResult,
648
+ result: dispatch.summarizeToolResult(queuedResult),
649
649
  kind: 'write',
650
650
  iter,
651
651
  outcome: 'queued'
@@ -667,7 +667,7 @@ async function processToolCalls<TCtx, TOp>(
667
667
  toolUsages.push({
668
668
  name: tc.name,
669
669
  arguments: tc.arguments ?? {},
670
- result: queuedResult,
670
+ result: dispatch.summarizeToolResult(queuedResult),
671
671
  kind: 'write',
672
672
  iter,
673
673
  outcome: 'queued'
@@ -683,7 +683,7 @@ async function processToolCalls<TCtx, TOp>(
683
683
  toolUsages.push({
684
684
  name: tc.name,
685
685
  arguments: tc.arguments ?? {},
686
- result: errResult,
686
+ result: dispatch.summarizeToolResult(errResult),
687
687
  kind: 'unknown',
688
688
  iter,
689
689
  outcome: 'error'
@@ -447,14 +447,14 @@ describe('the request translator and the checked attachments (ADR-0061, 2026-10-
447
447
  })
448
448
  })
449
449
 
450
- describe('every call is handed to the recorders', () => {
450
+ describe('every call is handed to the recorders - as a digest (ADR-0061 decision 8)', () => {
451
451
  const recorded: Array<{ record: ToolCallRecord; context: any }> = []
452
+ const keep = (record: ToolCallRecord, context: any) => {
453
+ recorded.push({ record, context })
454
+ }
452
455
  beforeEach(() => {
453
456
  clearToolCallRecorders()
454
457
  recorded.length = 0
455
- registerToolCallRecorder((record, context) => {
456
- recorded.push({ record, context })
457
- })
458
458
  registerToolCategory({
459
459
  name: 'ledger',
460
460
  guidance: 'Read the ledger before saying a balance.',
@@ -466,29 +466,64 @@ describe('every call is handed to the recorders', () => {
466
466
  doors: ['Query.ledger'],
467
467
  schema: { type: 'object', properties: {} },
468
468
  builder: () => {
469
- throw new Error('the ledger could not be read')
469
+ throw new Error('the ledger of 김급여 could not be read')
470
470
  }
471
+ },
472
+ {
473
+ kind: 'read',
474
+ name: 'readPay',
475
+ description: 'pay lines',
476
+ doors: ['Query.pay'],
477
+ schema: { type: 'object', properties: {} },
478
+ builder: (args: any) => (args.refuse ? { rejected: true, error: 'query-failed', message: 'unauthorized' } : { read: 2, rows: [{ name: '김급여', net: 3_100_000 }, { name: '이급여', net: 2_900_000 }] })
471
479
  }
472
480
  ]
473
481
  } as any)
474
482
  })
475
483
  afterAll(() => clearToolCallRecorders())
476
484
 
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' } }))
485
+ it('★ the tool, its category, the ids it names, the size and hash of what came back - and the conversation it ran in', async () => {
486
+ registerToolCallRecorder(keep)
487
+ const { client } = recordingClient([okResult(undefined, [TC('readPay', { employeeId: 'e-7', month: '2026-09' })]), okResult('two people')])
488
+ await registryAssistantChat(input({ client, toolCategories: ['ledger'], mode: { kind: 'office.finance' } }))
480
489
  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')
490
+ const { record, context } = recorded[0]
491
+ expect(record).toMatchObject({ tool: 'readPay', category: 'ledger', targetIds: ['e-7'], resultCount: 2, ok: true })
492
+ expect(record.argsHash).toMatch(/^[0-9a-f]{64}$/)
493
+ expect(record.resultHash).toMatch(/^[0-9a-f]{64}$/)
494
+ expect(record.resultSize).toBeGreaterThan(0)
495
+ expect(context.mode).toEqual({ kind: 'office.finance' })
496
+ expect(context.state.domain.id).toBe('dom-1')
497
+ })
498
+
499
+ it('★ pay and names are not in the record by default - only a category the recorder names carries them', async () => {
500
+ registerToolCallRecorder(keep)
501
+ const { client } = recordingClient([okResult(undefined, [TC('readPay', { month: '2026-09' })]), okResult('two people')])
502
+ const turn: any = await registryAssistantChat(input({ client, toolCategories: ['ledger'] }))
503
+ expect(recorded[0].record.raw).toBeUndefined()
504
+ /* The conversation row keeps the same shape - the dock's tool panel shows how many, never whose pay. */
505
+ expect(turn.loop.toolUsages[0].result).toEqual({ size: expect.any(Number), count: 2, keys: ['read', 'rows'] })
506
+ expect(JSON.stringify(turn.loop.toolUsages)).not.toMatch(/김급여|3100000/)
507
+ expect(JSON.stringify(recorded[0].record)).not.toMatch(/김급여|3100000|2026-09/)
508
+
509
+ clearToolCallRecorders()
510
+ recorded.length = 0
511
+ registerToolCallRecorder(keep, { raw: ['ledger'] })
512
+ const again = recordingClient([okResult(undefined, [TC('readPay', { month: '2026-09' })]), okResult('two people')])
513
+ await registryAssistantChat(input({ client: again.client, toolCategories: ['ledger'] }))
514
+ expect(recorded[0].record.raw).toEqual({ args: { month: '2026-09' }, result: { read: 2, rows: [{ name: '김급여', net: 3_100_000 }, { name: '이급여', net: 2_900_000 }] } })
485
515
  })
486
516
 
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')])
517
+ it('★ a tool that throws or refuses is recorded by a code, never by its message - and the model is still told', async () => {
518
+ registerToolCallRecorder(keep)
519
+ const { client } = recordingClient([okResult(undefined, [TC('readBrokenLedger'), TC('readPay', { refuse: true }, 't2')]), okResult('could not read it')])
489
520
  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()
521
+ expect(recorded.map(one => [one.record.tool, one.record.errorCode, one.record.ok])).toEqual([
522
+ ['readBrokenLedger', 'tool-threw', false],
523
+ ['readPay', 'query-failed', false]
524
+ ])
525
+ expect(recorded[0].record.resultSize).toBe(0)
526
+ expect(JSON.stringify(recorded[0].record)).not.toMatch(/김급여/)
492
527
  expect(result.reply).toContain('could not read it')
493
528
  })
494
529
 
@@ -496,6 +531,7 @@ describe('every call is handed to the recorders', () => {
496
531
  registerToolCallRecorder(() => {
497
532
  throw new Error('audit table missing')
498
533
  })
534
+ registerToolCallRecorder(keep)
499
535
  const { client } = recordingClient([okResult(undefined, [TC('readOrderState')]), okResult('three orders')])
500
536
  const result = await registryAssistantChat(input({ client, toolCategories: ['plant-ops'] }))
501
537
  expect(result.reply).toContain('three orders')
@@ -503,6 +539,7 @@ describe('every call is handed to the recorders', () => {
503
539
  })
504
540
 
505
541
  it('a tool that was not offered is not run, so nothing is recorded', async () => {
542
+ registerToolCallRecorder(keep)
506
543
  const { client } = recordingClient([okResult(undefined, [TC('readOrderState')]), okResult('done')])
507
544
  await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
508
545
  expect(recorded).toHaveLength(0)
@@ -23,7 +23,7 @@
23
23
  import type { AIClient, AIMessage } from '../types.js'
24
24
  import { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCheck } from './tool-scope.js'
25
25
  import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
26
- import { summarizeToolResult } from './summarize.js'
26
+ import { resultShapeOf } 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'
@@ -180,13 +180,14 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
180
180
  await recordToolCall({ ...record, result, durationMs: Date.now() - started }, context)
181
181
  return result
182
182
  } catch (e) {
183
- await recordToolCall({ ...record, error: String((e as Error)?.message ?? e), durationMs: Date.now() - started }, context)
183
+ await recordToolCall({ ...record, error: e, durationMs: Date.now() - started }, context)
184
184
  throw e
185
185
  }
186
186
  },
187
187
  validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),
188
188
  toolCallToOp: () => null,
189
- summarizeToolResult
189
+ /* The conversation row keeps the shape of each answer, as the audit does - never its values (ADR-0061 decision 8). */
190
+ summarizeToolResult: resultShapeOf
190
191
  },
191
192
  groundingSources: input.groundingSources,
192
193
  t: input.t
@@ -1,61 +1,47 @@
1
1
  /*
2
- * Tool results, cut down to what a screen can hold.
2
+ * A tool result as it is kept - its shape, never its values (ADR-0061 decision 8, ruled 2026-10-17).
3
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.
4
+ * Two records keep what a tool answered: the conversation row (`toolUsages`, the dock's tool panel) and the AI audit
5
+ * (the call recorder's digest). Both keep the same thing, through this one function: how big the answer was, how many
6
+ * rows it carried, the names of its fields, and the code it failed with. The values - pay, shareholdings, a person's
7
+ * name - stay in their own tables; copying them into a conversation row puts them in its backups and exports too,
8
+ * whoever may read the row.
10
9
  *
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.
10
+ * ── ⚠ This is for the panel and the audit, never for a judgement ─────────────────
11
+ * What the model actually received is `AgenticLoopResult.groundedTexts`. Grounding uses it, and anything that judges
12
+ * correctness must use it too.
18
13
  */
19
14
 
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
15
+ /** Field names kept of an object answer - its first ones; an answer with more says how many. */
16
+ const MAX_KEYS = 20
30
17
 
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
18
+ export interface ResultShape {
19
+ /** Characters of the answer as JSON. */
20
+ size: number
21
+ /** Rows it carried - `read` of a list read, else the length of the answer or of its first list. */
22
+ count?: number
23
+ /** The field names of an object answer - names only. */
24
+ keys?: string[]
25
+ /** More field names than `keys` holds. */
26
+ moreKeys?: number
27
+ /** The code it failed with - a refusal's `error`, or the `error` code of a failure. */
28
+ errorCode?: string
29
+ }
30
+
31
+ export function resultShapeOf(value: unknown): ResultShape {
32
+ const size = JSON.stringify(value ?? null).length
33
+ if (Array.isArray(value)) return { size, count: value.length }
34
+ if (!value || typeof value !== 'object') return { size }
35
+ const answer = value as Record<string, any>
36
+ const names = Object.keys(answer)
37
+ const lists = Object.values(answer).filter(Array.isArray)
38
+ const count = typeof answer.read === 'number' ? answer.read : lists.length ? lists[0].length : undefined
39
+ const errorCode = answer.rejected === true ? String(answer.error ?? 'rejected') : typeof answer.error === 'string' ? answer.error : undefined
40
+ return {
41
+ size,
42
+ ...(count === undefined ? {} : { count }),
43
+ keys: names.slice(0, MAX_KEYS),
44
+ ...(names.length > MAX_KEYS ? { moreKeys: names.length - MAX_KEYS } : {}),
45
+ ...(errorCode ? { errorCode } : {})
59
46
  }
60
- return undefined
61
47
  }
@@ -1,47 +1,116 @@
1
1
  /*
2
- * Every tool call of a registry conversation, handed to whoever writes them down.
2
+ * Every tool call of a registry conversation, handed to whoever writes them down (ADR-0061 decision 8).
3
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.
4
+ * An application that keeps an AI audit (operato-office's AI 감사 기록) needs each call - which tool, about what,
5
+ * how much came back or why it failed - whoever registered the tool: its own tools, a read target's list query, a
6
+ * data entry target's proposal. Wrapping each tool in the application left out the tools other packages declare,
7
+ * so the audit missed the reads that mattered most (2026-10-16). The registry conversation runs every tool at one
8
+ * place and calls the recorders there.
9
+ *
10
+ * A recorder is handed a digest: the tool, its category, the ids its arguments name, the size and hash of what came
11
+ * back, an error code. The arguments and the result themselves - pay, shareholdings - are not copied into an audit
12
+ * by default; a recorder that must keep them names the categories it keeps them for (`raw`).
9
13
  *
10
14
  * A recorder that fails is said in the log and the call goes on - an audit that cannot be written never stops an
11
15
  * answer. A recorder decides for itself which conversations it writes down (`context.mode.kind`).
12
16
  */
17
+ import { createHash } from 'crypto'
18
+
19
+ import { resultShapeOf } from '../assistant/summarize'
13
20
  import type { ToolCallContext } from './types'
14
21
 
15
22
  export interface ToolCallRecord {
16
23
  tool: string
17
24
  /** The category the tool was offered from. */
18
25
  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
26
+ /** The ids the arguments name - `id`, `…Id`, `ids`, `…Ids` - and nothing else of them. */
27
+ targetIds: string[]
28
+ /** sha256 of the arguments - the same call is the same hash. */
29
+ argsHash: string
30
+ /** Characters of the result as JSON; 0 when the tool threw. */
31
+ resultSize: number
32
+ /** How many rows it carried - `read` of a list read, else the length of its first list; absent when it says none. */
33
+ resultCount?: number
34
+ resultHash?: string
35
+ /** Why it failed: the code a refusal returned (`{ rejected: true, error }`), the thrown error's code, or `tool-threw`. */
36
+ errorCode?: string
37
+ ok: boolean
24
38
  durationMs: number
39
+ /** The arguments and the result as they were - only for a category the recorder named in `raw`. */
40
+ raw?: { args: unknown; result?: unknown; error?: string }
25
41
  }
26
42
 
27
43
  export type ToolCallRecorder = (record: ToolCallRecord, context: ToolCallContext) => void | Promise<void>
28
44
 
29
- const recorders: ToolCallRecorder[] = []
45
+ export interface ToolCallRecorderOptions {
46
+ /** Categories whose calls the recorder is handed with their arguments and result as they were. */
47
+ raw?: string[]
48
+ }
49
+
50
+ /** A call as it ran - the registry door's side, before it is cut down for a recorder. */
51
+ export interface ToolCallRun {
52
+ tool: string
53
+ category: string
54
+ args: unknown
55
+ result?: unknown
56
+ error?: unknown
57
+ durationMs: number
58
+ }
59
+
60
+ const recorders: { recorder: ToolCallRecorder; raw: Set<string> }[] = []
30
61
 
31
- export function registerToolCallRecorder(recorder: ToolCallRecorder): void {
32
- if (!recorders.includes(recorder)) recorders.push(recorder)
62
+ export function registerToolCallRecorder(recorder: ToolCallRecorder, options: ToolCallRecorderOptions = {}): void {
63
+ if (recorders.some(one => one.recorder === recorder)) return
64
+ recorders.push({ recorder, raw: new Set(options.raw ?? []) })
33
65
  }
34
66
 
35
67
  export function clearToolCallRecorders(): void {
36
68
  recorders.length = 0
37
69
  }
38
70
 
39
- export async function recordToolCall(record: ToolCallRecord, context: ToolCallContext): Promise<void> {
40
- for (const recorder of recorders) {
71
+ const hash = (value: unknown) => createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex')
72
+
73
+ /** The ids an argument object names, by its keys - a value under any other key is not carried. */
74
+ function idsOf(args: unknown): string[] {
75
+ if (!args || typeof args !== 'object') return []
76
+ const ids: string[] = []
77
+ for (const [key, value] of Object.entries(args as Record<string, unknown>)) {
78
+ if (!/^(id|ids)$|[a-z](Id|Ids)$/.test(key)) continue
79
+ for (const one of Array.isArray(value) ? value : [value]) if (typeof one === 'string' || typeof one === 'number') ids.push(String(one))
80
+ }
81
+ return ids
82
+ }
83
+
84
+ /** A run as a recorder sees it - the digest, and the raw text when its category was asked for. */
85
+ export function digestOf(run: ToolCallRun, raw = false): ToolCallRecord {
86
+ const threw = run.error !== undefined
87
+ const result: any = run.result
88
+ /* The same shape the conversation row keeps (resultShapeOf) - one reading of an answer. */
89
+ const shape = threw ? undefined : resultShapeOf(result)
90
+ const errorCode = threw ? String((run.error as any)?.code ?? 'tool-threw') : shape!.errorCode
91
+ const resultSize = shape?.size ?? 0
92
+ const resultCount = shape?.count
93
+ return {
94
+ tool: run.tool,
95
+ category: run.category,
96
+ targetIds: idsOf(run.args),
97
+ argsHash: hash(run.args),
98
+ resultSize,
99
+ ...(resultCount === undefined ? {} : { resultCount }),
100
+ ...(threw ? {} : { resultHash: hash(result) }),
101
+ ...(errorCode ? { errorCode } : {}),
102
+ ok: !errorCode,
103
+ durationMs: run.durationMs,
104
+ ...(raw ? { raw: { args: run.args, ...(threw ? { error: String((run.error as any)?.message ?? run.error) } : { result }) } } : {})
105
+ }
106
+ }
107
+
108
+ export async function recordToolCall(run: ToolCallRun, context: ToolCallContext): Promise<void> {
109
+ for (const { recorder, raw } of recorders) {
41
110
  try {
42
- await recorder(record, context)
111
+ await recorder(digestOf(run, raw.has(run.category)), context)
43
112
  } catch (e) {
44
- console.warn(`[ai-client-base] a tool call recorder failed for ${record.tool}:`, (e as Error)?.message ?? e)
113
+ console.warn(`[ai-client-base] a tool call recorder failed for ${run.tool}:`, (e as Error)?.message ?? e)
45
114
  }
46
115
  }
47
116
  }
@@ -19,8 +19,8 @@ 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'
22
+ export { registerToolCallRecorder, clearToolCallRecorders, recordToolCall, digestOf } from './call-recorder'
23
+ export type { ToolCallRecord, ToolCallRecorder, ToolCallRecorderOptions, ToolCallRun } from './call-recorder'
24
24
 
25
25
  /* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */
26
26
  export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'