@things-factory/ai-client-base 10.1.167 → 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.
- 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.d.ts +11 -1
- package/dist-server/service/assistant/registry-assistant.js +25 -3
- 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/testing/tool-call-context.d.ts +5 -1
- package/dist-server/service/testing/tool-call-context.js +4 -1
- package/dist-server/service/testing/tool-call-context.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 +4 -1
- package/dist-server/service/tool-registry/index.js +7 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/translate.d.ts +2 -0
- package/dist-server/service/tool-registry/translate.js +6 -0
- package/dist-server/service/tool-registry/translate.js.map +1 -0
- package/dist-server/service/tool-registry/types.d.ts +50 -0
- package/dist-server/service/tool-registry/types.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 +133 -1
- package/server/service/assistant/registry-assistant.ts +35 -5
- 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/testing/tool-call-context.ts +9 -2
- package/server/service/tool-registry/call-recorder.ts +47 -0
- package/server/service/tool-registry/index.ts +10 -1
- package/server/service/tool-registry/translate.ts +12 -0
- package/server/service/tool-registry/types.ts +54 -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.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": "
|
|
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 =>
|
|
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
|
|
|
@@ -376,3 +376,135 @@ describe('a question that needs many lookups', () => {
|
|
|
376
376
|
expect(result.reply).toBe('found two; the third is missing')
|
|
377
377
|
})
|
|
378
378
|
})
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
* 도구가 사람이 읽는 글을 쓸 번역기와, 서버가 확인한 이번 턴의 파일 — ADR-0061 2026-10-16 판정 1·2.
|
|
382
|
+
*
|
|
383
|
+
* 둘 다 없던 칸이다. 그래서 도구는 카드 제목을 한국어로 박았고(`figure-tools.ts`), 데이터 읽기의
|
|
384
|
+
* `wordsOf` 는 `context.t` 를 읽었지만 채우는 문이 없어 늘 폴백을 냈다. 첨부는 바깥 대화에만 붙고
|
|
385
|
+
* 도구에는 `host` 만 갔으므로, 도형 생성기는 사람이 올린 그림을 받지 못했다.
|
|
386
|
+
*/
|
|
387
|
+
describe('the request translator and the checked attachments (ADR-0061, 2026-10-16)', () => {
|
|
388
|
+
const PNG = { id: 'att-1', name: 'sketch.png', kind: 'image' as const, mediaType: 'image/png', size: 4, data: Buffer.from([1, 2, 3, 4]) }
|
|
389
|
+
|
|
390
|
+
it('★ the request translator reaches the tool as context.t — the one the caller built', async () => {
|
|
391
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
392
|
+
const t = (key: string) => `ko:${key}`
|
|
393
|
+
|
|
394
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'], t }))
|
|
395
|
+
|
|
396
|
+
expect(ran[0].context.t).toBe(t)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('★ without one, the tool still has a translator — it writes the English default value, placeholders filled', async () => {
|
|
400
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
401
|
+
|
|
402
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
403
|
+
|
|
404
|
+
expect(ran[0].context.t('figure.label', { name: 'Mixer', defaultValue: 'Make {name}' })).toBe('Make Mixer')
|
|
405
|
+
/* No default value: the key comes back, so a reader such as `wordsOf` knows nothing was translated. */
|
|
406
|
+
expect(ran[0].context.t('figure.label')).toBe('figure.label')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('★ a translator in the host context is not the one a tool gets — the server builds it', async () => {
|
|
410
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
411
|
+
const fromClient = () => 'from the client'
|
|
412
|
+
|
|
413
|
+
await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'], hostContext: { t: fromClient } }))
|
|
414
|
+
|
|
415
|
+
expect(ran[0].context.t).not.toBe(fromClient)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('★ the checked attachments reach the tool in their own field, never mixed into host', async () => {
|
|
419
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
420
|
+
|
|
421
|
+
await registryAssistantChat(
|
|
422
|
+
input({ client, toolCategories: ['figure-authoring'], hostContext: { figureId: 'fig-1' }, attachments: [PNG] })
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
expect(ran[0].context.attachments).toEqual([PNG])
|
|
426
|
+
expect(ran[0].context.host).toEqual({ figureId: 'fig-1' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('★ files the client put in the host context are not attachments', async () => {
|
|
430
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
431
|
+
|
|
432
|
+
await registryAssistantChat(
|
|
433
|
+
input({ client, toolCategories: ['figure-authoring'], hostContext: { image: { data: 'AAAA', mediaType: 'image/png' }, attachments: [PNG] } })
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
expect(ran[0].context.attachments).toEqual([])
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('the shared fixture builds the same shape the door hands — translator and attachments included', async () => {
|
|
440
|
+
const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
|
|
441
|
+
const t = (key: string) => key
|
|
442
|
+
const state = { domain: { id: 'dom-1' }, user: { id: 'u-1' } }
|
|
443
|
+
|
|
444
|
+
await registryAssistantChat(input({ client, state, toolCategories: ['figure-authoring'], t, attachments: [PNG] }))
|
|
445
|
+
|
|
446
|
+
expect(ran[0].context).toEqual(toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', t, attachments: [PNG] }))
|
|
447
|
+
})
|
|
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
|
+
})
|
|
@@ -25,7 +25,9 @@ import { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCh
|
|
|
25
25
|
import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
|
|
26
26
|
import { summarizeToolResult } from './summarize.js'
|
|
27
27
|
import type { GroundingSources } from '../utils/index.js'
|
|
28
|
-
import type { ToolCallContext } from '../tool-registry/types.js'
|
|
28
|
+
import type { ToolAttachment, ToolCallContext } from '../tool-registry/types.js'
|
|
29
|
+
import { defaultValueTranslate } from '../tool-registry/translate.js'
|
|
30
|
+
import { recordToolCall } from '../tool-registry/call-recorder.js'
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
/** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
|
|
@@ -67,8 +69,17 @@ export interface RegistryAssistantInput {
|
|
|
67
69
|
maxTokens?: number
|
|
68
70
|
/** Make the model call a tool before it may answer. For surfaces that report live state. */
|
|
69
71
|
requireToolOnFirstTurn?: boolean
|
|
70
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The request's translator — the stop notice is then in the reader's language, and tools write what a person
|
|
74
|
+
* reads with it (`context.t`). Without it tools get `defaultValueTranslate`: the English default values.
|
|
75
|
+
*/
|
|
71
76
|
t?: (key: string, params?: Record<string, any>) => string
|
|
77
|
+
/**
|
|
78
|
+
* The files sent with this turn, after the caller's server checked them — stored with this conversation, the
|
|
79
|
+
* conversation the caller's to write, the mode's policy met (ADR-0061, 2026-10-16 decision 2). Tools see them as
|
|
80
|
+
* `context.attachments`. Never built from the client's request: what a client puts in `hostContext` stays there.
|
|
81
|
+
*/
|
|
82
|
+
attachments?: ToolAttachment[]
|
|
72
83
|
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
73
84
|
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
74
85
|
}
|
|
@@ -103,6 +114,7 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
103
114
|
)
|
|
104
115
|
const specs = scope.specs
|
|
105
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])))
|
|
106
118
|
|
|
107
119
|
const tools = specs.map(spec => ({
|
|
108
120
|
name: spec.name,
|
|
@@ -121,7 +133,14 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
121
133
|
* `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
|
|
122
134
|
* 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
|
|
123
135
|
*/
|
|
124
|
-
const toolContext: ToolCallContext = {
|
|
136
|
+
const toolContext: ToolCallContext = {
|
|
137
|
+
state,
|
|
138
|
+
host: hostContext ?? {},
|
|
139
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
140
|
+
/* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */
|
|
141
|
+
t: input.t ?? defaultValueTranslate,
|
|
142
|
+
attachments: input.attachments ?? []
|
|
143
|
+
}
|
|
125
144
|
|
|
126
145
|
const result = await assistantChat<ToolCallContext, unknown>({
|
|
127
146
|
initialConversation: messages,
|
|
@@ -149,10 +168,21 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
149
168
|
*/
|
|
150
169
|
isWriteTool: () => false,
|
|
151
170
|
isActionTool: () => false,
|
|
152
|
-
|
|
171
|
+
/* Every call is handed to the recorders - an application's audit sees the tools other packages declared too. */
|
|
172
|
+
executeReadTool: async (call, context) => {
|
|
153
173
|
const spec = byName.get(call.name)
|
|
154
174
|
if (!spec) return undefined
|
|
155
|
-
|
|
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
|
+
}
|
|
156
186
|
},
|
|
157
187
|
validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),
|
|
158
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(
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* defines the neutral door, and that door's tests check it hands tools exactly this shape — so a tool
|
|
8
8
|
* test built on it fails when the tool reads a field no door fills.
|
|
9
9
|
*/
|
|
10
|
-
import type { ToolCallContext, ToolCallMode } from '../tool-registry/types.js'
|
|
10
|
+
import type { ToolAttachment, ToolCallContext, ToolCallMode, ToolTranslate } from '../tool-registry/types.js'
|
|
11
|
+
import { defaultValueTranslate } from '../tool-registry/translate.js'
|
|
11
12
|
|
|
12
13
|
export interface ToolCallContextFixtureInput {
|
|
13
14
|
domainId?: string
|
|
@@ -16,12 +17,18 @@ export interface ToolCallContextFixtureInput {
|
|
|
16
17
|
state?: Record<string, any>
|
|
17
18
|
host?: Record<string, any>
|
|
18
19
|
mode?: ToolCallMode
|
|
20
|
+
/** The request translator. Absent: the one the door hands when it was given none. */
|
|
21
|
+
t?: ToolTranslate
|
|
22
|
+
/** The checked files of the turn. Absent: none. */
|
|
23
|
+
attachments?: ToolAttachment[]
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export function toolCallContextFixture(input: ToolCallContextFixtureInput = {}): ToolCallContext {
|
|
22
27
|
const context: ToolCallContext = {
|
|
23
28
|
state: { domain: { id: input.domainId ?? 'domain-test' }, user: { id: input.userId ?? 'user-test' }, ...(input.state ?? {}) },
|
|
24
|
-
host: input.host ?? {}
|
|
29
|
+
host: input.host ?? {},
|
|
30
|
+
t: input.t ?? defaultValueTranslate,
|
|
31
|
+
attachments: input.attachments ?? []
|
|
25
32
|
}
|
|
26
33
|
if (input.mode) context.mode = input.mode
|
|
27
34
|
return context
|
|
@@ -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
|
+
}
|
|
@@ -17,6 +17,11 @@ export {
|
|
|
17
17
|
getToolRegistryWarnings
|
|
18
18
|
} from './registry'
|
|
19
19
|
|
|
20
|
+
export { defaultValueTranslate } from './translate'
|
|
21
|
+
|
|
22
|
+
export { registerToolCallRecorder, clearToolCallRecorders, recordToolCall } from './call-recorder'
|
|
23
|
+
export type { ToolCallRecord, ToolCallRecorder } from './call-recorder'
|
|
24
|
+
|
|
20
25
|
/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */
|
|
21
26
|
export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'
|
|
22
27
|
|
|
@@ -36,5 +41,9 @@ export type {
|
|
|
36
41
|
WriteToolContext,
|
|
37
42
|
ExternalToolContext,
|
|
38
43
|
ToolCallContext,
|
|
39
|
-
ToolCallMode
|
|
44
|
+
ToolCallMode,
|
|
45
|
+
ToolTranslate,
|
|
46
|
+
ToolAttachment,
|
|
47
|
+
ToolImageAttachment,
|
|
48
|
+
ToolTextAttachment
|
|
40
49
|
} from './types'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The translator a tool gets when the door was given none (ADR-0061, 2026-10-16 decision 1).
|
|
3
|
+
*
|
|
4
|
+
* A door that runs outside a request — a test, a job — has no request locale. The tool still writes the sentence a
|
|
5
|
+
* person reads: the English default value, as i18next does with its own fallback language, with the placeholders
|
|
6
|
+
* filled. A key with no default value comes back as the key, so a reader that compares (`wordsOf`) knows nothing was
|
|
7
|
+
* translated. One constant, so the door and the shared fixture hand the same function.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolTranslate } from './types'
|
|
10
|
+
|
|
11
|
+
export const defaultValueTranslate: ToolTranslate = (key, params) =>
|
|
12
|
+
String(params?.defaultValue ?? key).replace(/\{(\w+)\}/g, (whole, name) => (params && name in params ? String(params[name]) : whole))
|