@things-factory/ai-client-base 10.1.168 → 10.1.170
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.d.ts +9 -0
- package/dist-server/service/agentic-loop/agentic-loop.js +22 -3
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/open-subject.d.ts +4 -0
- package/dist-server/service/assistant/open-subject.js +3 -0
- package/dist-server/service/assistant/open-subject.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.js +16 -2
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/providers/anthropic.d.ts +2 -0
- package/dist-server/service/providers/anthropic.js +7 -3
- package/dist-server/service/providers/anthropic.js.map +1 -1
- package/dist-server/service/providers/gemini.js +1 -1
- package/dist-server/service/providers/gemini.js.map +1 -1
- package/dist-server/service/providers/openai.js +1 -1
- package/dist-server/service/providers/openai.js.map +1 -1
- package/dist-server/service/tool-registry/call-recorder.d.ts +16 -0
- package/dist-server/service/tool-registry/call-recorder.js +24 -0
- package/dist-server/service/tool-registry/call-recorder.js.map +1 -0
- package/dist-server/service/tool-registry/index.d.ts +2 -0
- package/dist-server/service/tool-registry/index.js +5 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/utils/parse-json.d.ts +12 -1
- package/dist-server/service/utils/parse-json.js +18 -4
- package/dist-server/service/utils/parse-json.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/agentic-loop/agentic-loop.test.ts +76 -0
- package/server/service/agentic-loop/agentic-loop.ts +22 -3
- package/server/service/assistant/open-subject.ts +3 -0
- package/server/service/assistant/registry-assistant.test.ts +63 -1
- package/server/service/assistant/registry-assistant.ts +15 -2
- package/server/service/providers/anthropic.ts +8 -4
- package/server/service/providers/gemini.ts +1 -1
- package/server/service/providers/json-answer.test.ts +53 -0
- package/server/service/providers/openai.ts +1 -1
- package/server/service/tool-registry/call-recorder.ts +47 -0
- package/server/service/tool-registry/index.ts +3 -0
- 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.
|
|
3
|
+
"version": "10.1.170",
|
|
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": "
|
|
33
|
+
"gitHead": "1956b73789eace6376f1d79443306e1bb8e61908"
|
|
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 =>
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
return parseJSONResponse<T>(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|