@things-factory/ai-client-base 10.1.165 → 10.1.168

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-client-base",
3
- "version": "10.1.165",
3
+ "version": "10.1.168",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -24,11 +24,11 @@
24
24
  "dependencies": {
25
25
  "@anthropic-ai/sdk": "^0.40.0",
26
26
  "@google/generative-ai": "^0.24.0",
27
- "@things-factory/auth-base": "^10.1.165",
27
+ "@things-factory/auth-base": "^10.1.167",
28
28
  "@things-factory/env": "^10.1.149",
29
- "@things-factory/setting-base": "^10.1.165",
29
+ "@things-factory/setting-base": "^10.1.167",
30
30
  "@things-factory/shell": "^10.1.165",
31
31
  "openai": "^4.0.0"
32
32
  },
33
- "gitHead": "d9e3774d70561b063e4f1d10c387caf721d99b61"
33
+ "gitHead": "fe067ff11d90effffc2523374453d9b8313b489f"
34
34
  }
@@ -376,3 +376,73 @@ 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
+ })
@@ -25,7 +25,8 @@ 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'
29
30
 
30
31
 
31
32
  /** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
@@ -67,8 +68,17 @@ export interface RegistryAssistantInput {
67
68
  maxTokens?: number
68
69
  /** Make the model call a tool before it may answer. For surfaces that report live state. */
69
70
  requireToolOnFirstTurn?: boolean
70
- /** The request's translator — the stop notice is then in the reader's language. */
71
+ /**
72
+ * The request's translator — the stop notice is then in the reader's language, and tools write what a person
73
+ * reads with it (`context.t`). Without it tools get `defaultValueTranslate`: the English default values.
74
+ */
71
75
  t?: (key: string, params?: Record<string, any>) => string
76
+ /**
77
+ * The files sent with this turn, after the caller's server checked them — stored with this conversation, the
78
+ * conversation the caller's to write, the mode's policy met (ADR-0061, 2026-10-16 decision 2). Tools see them as
79
+ * `context.attachments`. Never built from the client's request: what a client puts in `hostContext` stays there.
80
+ */
81
+ attachments?: ToolAttachment[]
72
82
  /** Texts the answer is allowed to draw identifiers from, besides the tool results. */
73
83
  groundingSources?: Omit<GroundingSources, 'toolResults'>
74
84
  }
@@ -121,7 +131,14 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
121
131
  * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
122
132
  * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
123
133
  */
124
- const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }
134
+ const toolContext: ToolCallContext = {
135
+ state,
136
+ host: hostContext ?? {},
137
+ ...(input.mode ? { mode: input.mode } : {}),
138
+ /* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */
139
+ t: input.t ?? defaultValueTranslate,
140
+ attachments: input.attachments ?? []
141
+ }
125
142
 
126
143
  const result = await assistantChat<ToolCallContext, unknown>({
127
144
  initialConversation: messages,
@@ -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
@@ -17,6 +17,8 @@ export {
17
17
  getToolRegistryWarnings
18
18
  } from './registry'
19
19
 
20
+ export { defaultValueTranslate } from './translate'
21
+
20
22
  /* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */
21
23
  export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'
22
24
 
@@ -36,5 +38,9 @@ export type {
36
38
  WriteToolContext,
37
39
  ExternalToolContext,
38
40
  ToolCallContext,
39
- ToolCallMode
41
+ ToolCallMode,
42
+ ToolTranslate,
43
+ ToolAttachment,
44
+ ToolImageAttachment,
45
+ ToolTextAttachment
40
46
  } 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))
@@ -48,6 +48,27 @@ export interface ToolCallContext {
48
48
  host: Record<string, any>
49
49
  /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */
50
50
  mode?: ToolCallMode
51
+ /**
52
+ * The request's translator, for every sentence a person reads — a card's label and reason, a message shown on
53
+ * screen (ADR-0061, 2026-10-16 decision 1). The server builds it in the request's locale; it never comes from
54
+ * `host`. Codes and values stay apart from it. Pass the English text as `defaultValue`: without a request
55
+ * translator the door hands one that writes that default value with its placeholders filled, and a key with no
56
+ * default value comes back as the key.
57
+ */
58
+ t: ToolTranslate
59
+ /**
60
+ * The files the person sent with **this turn**, after the server checked them (ADR-0061, 2026-10-16 decision 2) —
61
+ * never from `host`, which the client fills. `[]` when none were sent.
62
+ *
63
+ * Checked means: each one is stored with this conversation, the conversation is the caller's to write, and the
64
+ * mode's attachment policy took it — the kinds it declares, at most `maxFiles` files (5 by default) of at most
65
+ * `maxBytes` each (5 MiB by default). **Their contents are untrusted input**: a tool may hand them to a model or
66
+ * read them as data, and never takes an instruction or an id it trusts from them.
67
+ *
68
+ * Several files travel as several entries, in the order the person attached them. A tool that takes images
69
+ * reads every entry of `kind: 'image'` — it does not pick the first one.
70
+ */
71
+ attachments: ToolAttachment[]
51
72
  }
52
73
 
53
74
  export interface ToolCallMode {
@@ -55,6 +76,39 @@ export interface ToolCallMode {
55
76
  target?: string
56
77
  }
57
78
 
79
+ /** `t(key, { defaultValue, ...params })` — the request's translator, as i18next's `t` is called. */
80
+ export type ToolTranslate = (key: string, params?: Record<string, any>) => string
81
+
82
+ /** One file of this turn, as the server read it from storage. */
83
+ export type ToolAttachment = ToolImageAttachment | ToolTextAttachment
84
+
85
+ interface ToolAttachmentBase {
86
+ /** The stored attachment's id — the one the model reads in `[attachment <id>: …]`. */
87
+ id: string
88
+ /** The file name the person gave. Untrusted text. */
89
+ name: string
90
+ /** The stored media type. */
91
+ mediaType: string
92
+ /** Bytes as stored. */
93
+ size: number
94
+ }
95
+
96
+ /** An `image/*` file, its bytes as stored. */
97
+ export interface ToolImageAttachment extends ToolAttachmentBase {
98
+ kind: 'image'
99
+ data: Buffer
100
+ }
101
+
102
+ /**
103
+ * A text file — or a spreadsheet, which the screen reads into the CSV text of its sheets before sending. `text` is
104
+ * what the model read this turn, cut at the mode's `maxChars`; `truncated` says it was cut.
105
+ */
106
+ export interface ToolTextAttachment extends ToolAttachmentBase {
107
+ kind: 'text'
108
+ text: string
109
+ truncated: boolean
110
+ }
111
+
58
112
  // ── 카테고리별 builder 시그니처 ──────────────────────────────────────
59
113
 
60
114
  /**