@things-factory/ai-client-base 10.1.176 → 10.1.178
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/agentic-loop/agentic-loop.js +5 -5
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.js +3 -2
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/summarize.d.ts +13 -7
- package/dist-server/service/assistant/summarize.js +30 -59
- package/dist-server/service/assistant/summarize.js.map +1 -1
- package/dist-server/service/tool-registry/call-recorder.d.ts +34 -6
- package/dist-server/service/tool-registry/call-recorder.js +65 -7
- package/dist-server/service/tool-registry/call-recorder.js.map +1 -1
- package/dist-server/service/tool-registry/index.d.ts +2 -2
- package/dist-server/service/tool-registry/index.js +2 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/agentic-loop/agentic-loop.ts +5 -5
- package/server/service/assistant/registry-assistant.test.ts +53 -16
- package/server/service/assistant/registry-assistant.ts +4 -3
- package/server/service/assistant/summarize.ts +39 -53
- package/server/service/tool-registry/call-recorder.ts +87 -18
- package/server/service/tool-registry/index.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-client-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.178",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@anthropic-ai/sdk": "^0.40.0",
|
|
26
26
|
"@google/generative-ai": "^0.24.0",
|
|
27
|
-
"@things-factory/auth-base": "^10.1.
|
|
27
|
+
"@things-factory/auth-base": "^10.1.178",
|
|
28
28
|
"@things-factory/env": "^10.1.149",
|
|
29
|
-
"@things-factory/setting-base": "^10.1.
|
|
30
|
-
"@things-factory/shell": "^10.1.
|
|
29
|
+
"@things-factory/setting-base": "^10.1.178",
|
|
30
|
+
"@things-factory/shell": "^10.1.178",
|
|
31
31
|
"openai": "^4.0.0"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "d57448d3f3045f72a9f24b877a3e306eb34cbaf1"
|
|
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,
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
expect(
|
|
483
|
-
expect(
|
|
484
|
-
expect(
|
|
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
|
|
488
|
-
|
|
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
|
|
491
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
2
|
+
* A tool result as it is kept - its shape, never its values (ADR-0061 decision 8, ruled 2026-10-17).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
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,
|
|
5
|
-
* came back or why it failed - whoever registered the tool: its own tools, a read target's list query, a
|
|
6
|
-
* target's proposal. Wrapping each tool in the application left out the tools other packages declare,
|
|
7
|
-
* missed the reads that mattered most (2026-10-16). The registry conversation runs every tool at one
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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 ${
|
|
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'
|