@things-factory/ai-client-base 10.1.81 → 10.1.129
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 +25 -1
- package/dist-server/service/agentic-loop/agentic-loop.js +69 -7
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/abort-notice.js +3 -2
- package/dist-server/service/assistant/abort-notice.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.d.ts +2 -0
- package/dist-server/service/assistant/registry-assistant.js +10 -1
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/providers/openai.d.ts +5 -0
- package/dist-server/service/providers/openai.js +5 -1
- package/dist-server/service/providers/openai.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 +125 -2
- package/server/service/agentic-loop/agentic-loop.ts +69 -9
- package/server/service/assistant/abort-notice.ts +3 -2
- package/server/service/assistant/assistant-chat.test.ts +2 -2
- package/server/service/assistant/registry-assistant.test.ts +20 -1
- package/server/service/assistant/registry-assistant.ts +10 -1
- package/server/service/providers/openai.ts +5 -2
|
@@ -644,8 +644,8 @@ describe('runAgenticLoop — max iterations 표면화', () => {
|
|
|
644
644
|
|
|
645
645
|
expect(r.lastText).toBe('알아낸 것으로 답한다')
|
|
646
646
|
expect(seen[seen.length - 1]).toBe('none')
|
|
647
|
-
/* 사유는 남는다 — 살려냈다고 한도에 닿은 사실이 사라지지 않는다. */
|
|
648
|
-
expect(r.abortReason).toEqual({ type: 'max_iterations', iter: 2 })
|
|
647
|
+
/* 사유는 남는다 — 살려냈다고 한도에 닿은 사실이 사라지지 않는다. 살려냈다는 것도 함께 남는다. */
|
|
648
|
+
expect(r.abortReason).toEqual({ type: 'max_iterations', iter: 2, salvaged: true })
|
|
649
649
|
})
|
|
650
650
|
|
|
651
651
|
test('★ 살려내기가 실패해도 삼키지 않는다 — 원래대로 한도 사유가 남는다', async () => {
|
|
@@ -917,3 +917,126 @@ describe('runAgenticLoop — 같은 조치는 한 번만 제안된다', () => {
|
|
|
917
917
|
expect(r.proposals).toHaveLength(2)
|
|
918
918
|
})
|
|
919
919
|
})
|
|
920
|
+
|
|
921
|
+
describe('a read tool that hands back a picture (design/ai/shared-dock.md §9-4)', () => {
|
|
922
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3])
|
|
923
|
+
|
|
924
|
+
test('★ the picture rides as an image part after the results - never in the JSON, the grounding or the usage', async () => {
|
|
925
|
+
const sent: any[][] = []
|
|
926
|
+
let turn = 0
|
|
927
|
+
const input = mkInput(
|
|
928
|
+
{
|
|
929
|
+
chat: async (conversation: any) => {
|
|
930
|
+
sent.push(conversation)
|
|
931
|
+
return turn++ === 0 ? okResult(undefined, [TC('readChatAttachment', { attachmentId: 'a1' })]) : okResult('영수증 금액은 12,400원입니다')
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
isReadTool: name => name === 'readChatAttachment',
|
|
936
|
+
executeReadTool: () => ({ attachmentId: 'a1', type: 'image', images: [{ data: png, mediaType: 'image/png' }] })
|
|
937
|
+
}
|
|
938
|
+
)
|
|
939
|
+
const r = await runAgenticLoop(input)
|
|
940
|
+
const last = sent[1][sent[1].length - 1]
|
|
941
|
+
expect(last.role).toBe('user')
|
|
942
|
+
expect(last.content.map((part: any) => part.type)).toEqual(['tool_result', 'image'])
|
|
943
|
+
expect(last.content[1].source).toEqual({ kind: 'base64', data: png, mediaType: 'image/png' })
|
|
944
|
+
expect(JSON.parse(last.content[0].content)).toEqual({ attachmentId: 'a1', type: 'image', imagesAttached: 1 })
|
|
945
|
+
expect(JSON.stringify(r.groundedTexts)).not.toContain('"images":')
|
|
946
|
+
expect(JSON.stringify(r.groundedTexts)).not.toContain('mediaType')
|
|
947
|
+
expect(JSON.stringify(r.toolUsages)).not.toContain('"images":')
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
test('a picture of a type every provider does not take, or with no bytes, is left out and said', async () => {
|
|
951
|
+
const { takeToolImages } = await import('./agentic-loop')
|
|
952
|
+
const { value, images } = takeToolImages({ images: [{ data: png, mediaType: 'image/tiff' }, { data: Buffer.alloc(0), mediaType: 'image/png' }, { data: png.toString('base64'), mediaType: 'IMAGE/JPEG' }] })
|
|
953
|
+
expect(images.map(one => (one.source as any).mediaType)).toEqual(['image/jpeg'])
|
|
954
|
+
expect(value).toEqual({ imagesAttached: 1, imagesLeftOut: 2 })
|
|
955
|
+
expect(takeToolImages({ text: 'a' })).toEqual({ value: { text: 'a' }, images: [] })
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
test('★ OpenAI - the tool messages first, then the pictures as one user message (a tool message holds text only)', async () => {
|
|
959
|
+
const { toSDKMessages } = await import('../providers/openai')
|
|
960
|
+
const messages = toSDKMessages({
|
|
961
|
+
role: 'user',
|
|
962
|
+
content: [
|
|
963
|
+
{ type: 'tool_result', toolUseId: 'c1', content: '{"imagesAttached":1}' },
|
|
964
|
+
{ type: 'image', source: { kind: 'base64', data: png, mediaType: 'image/png' } }
|
|
965
|
+
]
|
|
966
|
+
} as any)
|
|
967
|
+
expect(messages.map((one: any) => one.role)).toEqual(['tool', 'user'])
|
|
968
|
+
expect(messages[1].content[0]).toEqual({ type: 'image_url', image_url: { url: `data:image/png;base64,${png.toString('base64')}` } })
|
|
969
|
+
})
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
describe('what only the card needs (design/ai/read-targets.md §4)', () => {
|
|
973
|
+
test('★ a proposal\'s forScreen reaches the card, never the model', async () => {
|
|
974
|
+
const sent: any[][] = []
|
|
975
|
+
let turn = 0
|
|
976
|
+
const rows = Array.from({ length: 300 }, (_, i) => ({ amount: i }))
|
|
977
|
+
const input = mkInput(
|
|
978
|
+
{
|
|
979
|
+
chat: async (conversation: any) => {
|
|
980
|
+
sent.push(conversation)
|
|
981
|
+
return turn++ === 0 ? okResult(undefined, [TC('queryOfficeCashTransaction')]) : okResult('300건입니다')
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
isReadTool: () => true,
|
|
986
|
+
executeReadTool: () => ({ proposed: true, kind: 'workspace', count: 300, forScreen: { workspace: { name: 'w', title: 't', blocks: [{ kind: 'table', rows }] } } })
|
|
987
|
+
}
|
|
988
|
+
)
|
|
989
|
+
const r = await runAgenticLoop(input)
|
|
990
|
+
const toModel = JSON.parse(sent[1][sent[1].length - 1].content[0].content)
|
|
991
|
+
expect(toModel).toEqual({ proposed: true, kind: 'workspace', count: 300 })
|
|
992
|
+
expect(r.proposals[0].workspace.blocks[0].rows).toHaveLength(300)
|
|
993
|
+
expect(JSON.stringify(r.toolUsages)).not.toContain('"forScreen"')
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
test('only a proposal carries it - a plain result keeps the field as it is', async () => {
|
|
997
|
+
const { takeForScreen } = await import('./agentic-loop')
|
|
998
|
+
expect(takeForScreen({ forScreen: { a: 1 } })).toEqual({ value: { forScreen: { a: 1 } }, forScreen: {} })
|
|
999
|
+
})
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
/*
|
|
1003
|
+
* A tool that names its proposal keeps that name (2026-10-02, Figure and AI lanes): figure-service gives a UUID the
|
|
1004
|
+
* modeller creates the new figure under, and the effect key written over it refused the run. Folding is by the effect
|
|
1005
|
+
* key, kept apart as `effectKey`.
|
|
1006
|
+
*/
|
|
1007
|
+
describe('runAgenticLoop — a proposal keeps the name its tool gave it', () => {
|
|
1008
|
+
const twice = (results: any[]) => {
|
|
1009
|
+
let call = 0
|
|
1010
|
+
return mkInput(
|
|
1011
|
+
{
|
|
1012
|
+
chat: async () => (++call <= results.length ? okResult(undefined, [TC('proposeFigureCandidate', {}, `t${call}`)]) : okResult('done'))
|
|
1013
|
+
},
|
|
1014
|
+
{ isReadTool: () => true, executeReadTool: () => results[call - 1] }
|
|
1015
|
+
)
|
|
1016
|
+
}
|
|
1017
|
+
const uuid = '3f1c2a9e-7b4d-4c1e-9a2f-5d6e7f8a9b0c'
|
|
1018
|
+
|
|
1019
|
+
test('the tool\'s key stays the key; the effect key goes beside it', async () => {
|
|
1020
|
+
const r = await runAgenticLoop(twice([{ proposed: true, key: uuid, steps: [{ kind: 'rename', name: 'Bin' }] }]))
|
|
1021
|
+
expect(r.proposals).toHaveLength(1)
|
|
1022
|
+
expect(r.proposals[0].key).toBe(uuid)
|
|
1023
|
+
expect(r.proposals[0].effectKey).toMatch(/^proposeFigureCandidate#/)
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
test('the same proposal twice, worded differently, is still one card', async () => {
|
|
1027
|
+
const r = await runAgenticLoop(
|
|
1028
|
+
twice([
|
|
1029
|
+
{ proposed: true, key: uuid, label: 'one', steps: [{ kind: 'rename', name: 'Bin' }] },
|
|
1030
|
+
{ proposed: true, key: uuid, label: 'two', steps: [{ kind: 'rename', name: 'Bin' }] }
|
|
1031
|
+
])
|
|
1032
|
+
)
|
|
1033
|
+
expect(r.proposals).toHaveLength(1)
|
|
1034
|
+
expect(r.toolUsages.map((u: any) => u.outcome)).toEqual(['proposed', 'folded'])
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
test('a tool that gives no key gets the effect key, as before', async () => {
|
|
1038
|
+
const r = await runAgenticLoop(twice([{ proposed: true, values: { capacity: 10 } }]))
|
|
1039
|
+
expect(r.proposals[0].key).toBe(r.proposals[0].effectKey)
|
|
1040
|
+
expect(r.proposals[0].key).toMatch(/^proposeFigureCandidate#/)
|
|
1041
|
+
})
|
|
1042
|
+
})
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { createHash } from 'node:crypto'
|
|
40
40
|
|
|
41
41
|
import debug from 'debug'
|
|
42
|
-
import type { AIMessage, AIToolCall, AIToolChatResult } from '../types.js'
|
|
42
|
+
import type { AIImageMediaType, AIImagePart, AIMessage, AIToolCall, AIToolChatResult } from '../types.js'
|
|
43
43
|
import { retryTransient } from '../utils/index.js'
|
|
44
44
|
|
|
45
45
|
const log = debug('things-factory:ai:agentic-loop')
|
|
@@ -200,7 +200,7 @@ export interface AgenticLoopResult<TOp = unknown> {
|
|
|
200
200
|
abortReason?:
|
|
201
201
|
| { type: 'provider_error'; message: string; iter: number }
|
|
202
202
|
| { type: 'aborted'; iter: number }
|
|
203
|
-
| { type: 'max_iterations'; iter: number }
|
|
203
|
+
| { type: 'max_iterations'; iter: number; salvaged?: boolean }
|
|
204
204
|
| { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }
|
|
205
205
|
}
|
|
206
206
|
|
|
@@ -403,6 +403,8 @@ export async function runAgenticLoop<TCtx = unknown, TOp = unknown>(
|
|
|
403
403
|
})
|
|
404
404
|
if (salvaged?.text) {
|
|
405
405
|
lastText = salvaged.text
|
|
406
|
+
/* The wrap-up answered from what was found and says what is missing; no notice need follow it. */
|
|
407
|
+
abortReason = { type: 'max_iterations', iter, salvaged: true }
|
|
406
408
|
stopReason = salvaged.stopReason ?? stopReason
|
|
407
409
|
log('iter %d: salvaged a final answer without tools', iter)
|
|
408
410
|
}
|
|
@@ -534,13 +536,18 @@ async function processToolCalls<TCtx, TOp>(
|
|
|
534
536
|
iter: number
|
|
535
537
|
): Promise<any[]> {
|
|
536
538
|
const toolResultParts: any[] = []
|
|
539
|
+
const toolResultImages: AIImagePart[] = []
|
|
537
540
|
|
|
538
541
|
for (const tc of toolCalls) {
|
|
539
542
|
if (dispatch.isReadTool(tc.name)) {
|
|
540
543
|
/* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).
|
|
541
544
|
* await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
|
|
542
545
|
* (문서 가져오기·트윈 구조 조회 등 — 무엇인지는 부르는 쪽이 안다). */
|
|
543
|
-
|
|
546
|
+
/* Pictures a read tool hands back ride as image parts after the results - never inside the JSON. */
|
|
547
|
+
const { value: given, images } = takeToolImages(await dispatch.executeReadTool(tc, context))
|
|
548
|
+
toolResultImages.push(...images)
|
|
549
|
+
/* What only the card needs (a table of a thousand rows) goes to the screen, never to the model. */
|
|
550
|
+
const { value, forScreen } = takeForScreen(given)
|
|
544
551
|
/* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).
|
|
545
552
|
* 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */
|
|
546
553
|
/* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */
|
|
@@ -549,13 +556,22 @@ async function processToolCalls<TCtx, TOp>(
|
|
|
549
556
|
/* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가
|
|
550
557
|
* 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.
|
|
551
558
|
* 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */
|
|
552
|
-
const candidate = { tool: tc.name, ...(value as any) }
|
|
553
|
-
const
|
|
554
|
-
if (proposals.some(p => p.
|
|
559
|
+
const candidate = { tool: tc.name, ...(value as any), ...forScreen }
|
|
560
|
+
const effectKey = proposalEffectKey(candidate)
|
|
561
|
+
if (proposals.some(p => p.effectKey === effectKey)) {
|
|
555
562
|
outcome = 'folded' // 접힌 사실을 남긴다 — "왜 카드가 하나뿐인가" 가 추적 가능해야 한다
|
|
556
563
|
} else {
|
|
557
|
-
/*
|
|
558
|
-
|
|
564
|
+
/*
|
|
565
|
+
* 키를 함께 실어 보낸다 — 카드가 같은 규칙으로 다시 세지 않게(원본은 하나).
|
|
566
|
+
*
|
|
567
|
+
* **도구가 이름을 준 제안은 그 이름을 쓴다**(2026-10-02, Figure 레인 · AI 레인 합의). figure-service 의
|
|
568
|
+
* 도구는 제안에 UUID `key` 를 붙이고, 모델러는 그 UUID 로 새 도형을 만든다(ADR-0097 5.6절 5항). 효과 키로
|
|
569
|
+
* 덮어쓰면 「제안 식별자가 올바르지 않습니다」로 실행이 막혔다. 같은 조치인지는 효과 키(`effectKey`)로
|
|
570
|
+
* 따로 판정하므로 접기는 그대로다. 도구 `key` 는 **한 대화 안에서 유일해야 한다** — 겹치면 입력안의 「새
|
|
571
|
+
* 카드가 옛 카드를 대신함」 판정과 결말 줄이 섞인다. 이름을 주지 않는 도구(ai-assistant 의 제안 도구들)는
|
|
572
|
+
* 지금처럼 효과 키를 받는다.
|
|
573
|
+
*/
|
|
574
|
+
proposals.push({ ...candidate, key: typeof (candidate as any).key === 'string' && (candidate as any).key ? (candidate as any).key : effectKey, effectKey })
|
|
559
575
|
outcome = 'proposed'
|
|
560
576
|
}
|
|
561
577
|
} else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {
|
|
@@ -656,5 +672,49 @@ async function processToolCalls<TCtx, TOp>(
|
|
|
656
672
|
}
|
|
657
673
|
}
|
|
658
674
|
|
|
659
|
-
|
|
675
|
+
/* Every provider takes a picture after the results of the same turn (design/ai/shared-dock.md §9-4). */
|
|
676
|
+
return [...toolResultParts, ...toolResultImages]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* A proposal's reserved `forScreen` field, taken out of what the model reads and kept for the card.
|
|
681
|
+
*
|
|
682
|
+
* A read tool that has queried many rows puts them on the card (the AI 작업판 table) but tells the model only the
|
|
683
|
+
* count, the totals and a few rows - the model does not copy a table it never received, so the card's numbers are
|
|
684
|
+
* the query's own (operato-application/design/ai/read-targets.md §4). Only a proposal (`proposed: true`) carries it.
|
|
685
|
+
*/
|
|
686
|
+
export function takeForScreen(result: any): { value: any; forScreen: Record<string, unknown> } {
|
|
687
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || result.proposed !== true || !result.forScreen || typeof result.forScreen !== 'object') {
|
|
688
|
+
return { value: result, forScreen: {} }
|
|
689
|
+
}
|
|
690
|
+
const { forScreen, ...value } = result
|
|
691
|
+
return { value, forScreen }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const TOOL_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The pictures a read tool returned in its reserved `images` field, taken out of the result.
|
|
698
|
+
*
|
|
699
|
+
* A tool result reaches the model as JSON text; a picture cannot. So a read tool that must show one - a picture
|
|
700
|
+
* sent in an earlier turn of the conversation - returns `images: [{ data, mediaType }]`, and the loop sends each as an
|
|
701
|
+
* image part of the same turn. The bytes never stay in the JSON, the grounding texts or the usage record. A picture
|
|
702
|
+
* of a type the providers do not all take is left out and said in the result.
|
|
703
|
+
*/
|
|
704
|
+
export function takeToolImages(result: any): { value: any; images: AIImagePart[] } {
|
|
705
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || !Array.isArray(result.images)) return { value: result, images: [] }
|
|
706
|
+
const { images: given, ...value } = result
|
|
707
|
+
const images: AIImagePart[] = []
|
|
708
|
+
let left = 0
|
|
709
|
+
for (const one of given) {
|
|
710
|
+
const data = typeof one?.data === 'string' ? Buffer.from(one.data, 'base64') : one?.data
|
|
711
|
+
const mediaType = String(one?.mediaType ?? '').toLowerCase()
|
|
712
|
+
if ((Buffer.isBuffer(data) || data instanceof Uint8Array) && data.length && TOOL_IMAGE_TYPES.includes(mediaType)) {
|
|
713
|
+
images.push({ type: 'image', source: { kind: 'base64', data, mediaType: mediaType as AIImageMediaType } })
|
|
714
|
+
} else left++
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
value: { ...value, imagesAttached: images.length, ...(left ? { imagesLeftOut: left } : {}) },
|
|
718
|
+
images
|
|
719
|
+
}
|
|
660
720
|
}
|
|
@@ -27,7 +27,7 @@ const NOTICE_DEFAULTS: Record<string, string> = {
|
|
|
27
27
|
'ai-assistant.notice.stopped': 'the request stopped partway, so only part of it was done.',
|
|
28
28
|
'ai-assistant.notice.provider-busy': 'the ai service did not answer just now. try again in a moment.',
|
|
29
29
|
'ai-assistant.notice.provider-error': 'something went wrong while handling this. tell an administrator if it keeps happening.',
|
|
30
|
-
'ai-assistant.notice.too-long': 'this
|
|
30
|
+
'ai-assistant.notice.too-long': 'this stopped after {iter} lookups without an answer.',
|
|
31
31
|
'ai-assistant.notice.tool-kept-failing': '{tool} failed {count} times in a row, so it stopped. try saying it differently.'
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -59,7 +59,8 @@ export function formatAbortNotice(
|
|
|
59
59
|
? line('ai-assistant.notice.provider-busy', {}, t)
|
|
60
60
|
: line('ai-assistant.notice.provider-error', {}, t)
|
|
61
61
|
case 'max_iterations':
|
|
62
|
-
|
|
62
|
+
/* A salvaged answer already says what it could not find. */
|
|
63
|
+
return abortReason.salvaged ? '' : line('ai-assistant.notice.too-long', { iter: abortReason.iter }, t)
|
|
63
64
|
case 'repeated_validation_failure':
|
|
64
65
|
return line('ai-assistant.notice.tool-kept-failing', { tool: abortReason.toolName, count: abortReason.consecutive }, t)
|
|
65
66
|
}
|
|
@@ -95,7 +95,7 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
|
|
|
95
95
|
)
|
|
96
96
|
expect(r.loop.abortReason?.type).toBe('max_iterations')
|
|
97
97
|
/* No translator was handed in, so the line falls back to English. */
|
|
98
|
-
expect(r.reply).toMatch(/stopped
|
|
98
|
+
expect(r.reply).toMatch(/stopped after 2 lookups/)
|
|
99
99
|
})
|
|
100
100
|
|
|
101
101
|
it('★ 통지의 말은 읽는 사람의 것이다 — 번역기를 주면 그것이 쓴 문장이 붙는다', async () => {
|
|
@@ -115,7 +115,7 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
|
|
|
115
115
|
)
|
|
116
116
|
expect(asked[0].key).toBe('ai-assistant.notice.too-long')
|
|
117
117
|
expect(asked[0].params.iter).toBe(2)
|
|
118
|
-
expect(asked[0].params.defaultValue).toMatch(/stopped
|
|
118
|
+
expect(asked[0].params.defaultValue).toMatch(/stopped after/)
|
|
119
119
|
expect(r.reply).toContain('멈췄습니다(2단계)')
|
|
120
120
|
})
|
|
121
121
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* apply them, so offering them would put a name in front of the model that leads nowhere. These
|
|
12
12
|
* tests fix that: offered and dispatchable are the same set, and it is the answering kinds only.
|
|
13
13
|
*/
|
|
14
|
-
import { registryAssistantChat } from './registry-assistant'
|
|
14
|
+
import { registryAssistantChat, REGISTRY_MAX_ITERATIONS } from './registry-assistant'
|
|
15
15
|
import { clearToolRegistry, registerToolCategory } from '../tool-registry/index.js'
|
|
16
16
|
import { toolCallContextFixture } from '../testing/tool-call-context.js'
|
|
17
17
|
import type { AIToolCall, AIToolChatResult } from '../types.js'
|
|
@@ -357,3 +357,22 @@ describe('the tool context carries the conversation', () => {
|
|
|
357
357
|
expect(ran[0].context).not.toHaveProperty('mode')
|
|
358
358
|
})
|
|
359
359
|
})
|
|
360
|
+
|
|
361
|
+
describe('a question that needs many lookups', () => {
|
|
362
|
+
it('★ at the limit the model answers from what it read — no notice tells the person to split the question', async () => {
|
|
363
|
+
const seen: any[] = []
|
|
364
|
+
const client = {
|
|
365
|
+
chatWithTools: async (_msgs: any, _tools: any[], options: any) => {
|
|
366
|
+
seen.push(options.toolChoice)
|
|
367
|
+
return options.toolChoice === 'none' ? okResult('found two; the third is missing') : okResult(undefined, [TC('listFigures')])
|
|
368
|
+
}
|
|
369
|
+
} as any
|
|
370
|
+
|
|
371
|
+
const result = await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
|
|
372
|
+
|
|
373
|
+
expect(ran).toHaveLength(REGISTRY_MAX_ITERATIONS)
|
|
374
|
+
expect(REGISTRY_MAX_ITERATIONS).toBeGreaterThan(8)
|
|
375
|
+
expect(seen[seen.length - 1]).toBe('none')
|
|
376
|
+
expect(result.reply).toBe('found two; the third is missing')
|
|
377
|
+
})
|
|
378
|
+
})
|
|
@@ -28,6 +28,9 @@ import type { GroundingSources } from '../utils/index.js'
|
|
|
28
28
|
import type { ToolCallContext } from '../tool-registry/types.js'
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
/** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
|
|
32
|
+
export const REGISTRY_MAX_ITERATIONS = 16
|
|
33
|
+
|
|
31
34
|
export interface RegistryAssistantInput {
|
|
32
35
|
/** Provider client. Must implement `chatWithTools`. */
|
|
33
36
|
client: AIClient
|
|
@@ -128,7 +131,13 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
128
131
|
model: input.model,
|
|
129
132
|
temperature: input.temperature,
|
|
130
133
|
maxTokens: input.maxTokens,
|
|
131
|
-
requireToolOnFirstTurn: input.requireToolOnFirstTurn
|
|
134
|
+
requireToolOnFirstTurn: input.requireToolOnFirstTurn,
|
|
135
|
+
/*
|
|
136
|
+
* A question across a product's records takes many lookups. At the limit the model answers from
|
|
137
|
+
* what it read and says what is missing — the person is never told to split the question.
|
|
138
|
+
*/
|
|
139
|
+
maxIterations: REGISTRY_MAX_ITERATIONS,
|
|
140
|
+
salvageOnMaxIterations: true
|
|
132
141
|
},
|
|
133
142
|
chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),
|
|
134
143
|
context: toolContext,
|
|
@@ -186,7 +186,7 @@ function buildSDKMessages(messages: AIMessage[], externalSystem?: string): any[]
|
|
|
186
186
|
* 한 AIMessage 가 다중 tool_result 를 가지면 OpenAI 측에서 여러 role:'tool' 메시지로
|
|
187
187
|
* 분리해야 함 (OpenAI 는 메시지당 tool_call_id 하나만 매핑). 따라서 array 를 반환.
|
|
188
188
|
*/
|
|
189
|
-
function toSDKMessages(msg: AIMessage): any[] {
|
|
189
|
+
export function toSDKMessages(msg: AIMessage): any[] {
|
|
190
190
|
if (typeof msg.content === 'string') {
|
|
191
191
|
return [{ role: msg.role, content: msg.content }]
|
|
192
192
|
}
|
|
@@ -198,11 +198,14 @@ function toSDKMessages(msg: AIMessage): any[] {
|
|
|
198
198
|
|
|
199
199
|
// tool_result 가 있으면 각각을 role='tool' 메시지로 분리 (OpenAI 요구사항).
|
|
200
200
|
if (toolResultParts.length > 0) {
|
|
201
|
-
|
|
201
|
+
const results = toolResultParts.map((r: any) => ({
|
|
202
202
|
role: 'tool',
|
|
203
203
|
tool_call_id: r.toolUseId,
|
|
204
204
|
content: r.content
|
|
205
205
|
}))
|
|
206
|
+
/* A tool message holds text only - pictures a read tool returned follow as one user message (agentic-loop takeToolImages). */
|
|
207
|
+
const images = otherParts.filter(p => p.type === 'image')
|
|
208
|
+
return images.length ? [...results, { role: 'user', content: images.map(toSDKContentPart) }] : results
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
// assistant 측 tool_use 가 있으면 message.tool_calls 로
|