@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.
@@ -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
- const value = await dispatch.executeReadTool(tc, context)
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 key = proposalEffectKey(candidate)
554
- if (proposals.some(p => p.key === key)) {
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
- proposals.push({ ...candidate, key })
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
- return toolResultParts
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 ran long and stopped at step {iter}. try asking in smaller pieces.',
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
- return line('ai-assistant.notice.too-long', { iter: abortReason.iter }, t)
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 at step 2/)
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 at step/)
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
- return toolResultParts.map((r: any) => ({
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 로