@things-factory/ai-client-base 10.1.206 → 10.1.208

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.206",
3
+ "version": "10.1.208",
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.206",
27
+ "@things-factory/auth-base": "^10.1.208",
28
28
  "@things-factory/env": "^10.1.149",
29
- "@things-factory/setting-base": "^10.1.206",
30
- "@things-factory/shell": "^10.1.206",
29
+ "@things-factory/setting-base": "^10.1.208",
30
+ "@things-factory/shell": "^10.1.208",
31
31
  "openai": "^4.0.0"
32
32
  },
33
- "gitHead": "18588a058a4a6cb21c379b2da5517e077c6442ba"
33
+ "gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
34
34
  }
@@ -158,7 +158,11 @@ export interface AgenticLoopOptions {
158
158
 
159
159
  export interface AgenticLoopInput<TCtx = unknown, TOp = unknown> {
160
160
  initialConversation: AIMessage[]
161
- tools: any[]
161
+ /**
162
+ * The tools the model is offered. A function is asked before every call, so a conversation that opens tools in a
163
+ * turn (ADR-0061 decision 9, `openTools`) offers them from its next call on.
164
+ */
165
+ tools: any[] | (() => any[])
162
166
  options: AgenticLoopOptions
163
167
  /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */
164
168
  chat: (
@@ -256,6 +260,8 @@ export async function runAgenticLoop<TCtx = unknown, TOp = unknown>(
256
260
  let lastFailedToolName: string | undefined
257
261
  let consecutiveFailures = 0
258
262
 
263
+ const toolsNow = () => (typeof tools === 'function' ? tools() : tools)
264
+
259
265
  let iter = 0
260
266
  for (; iter < maxIterations; iter++) {
261
267
  // (1) AbortSignal — iteration 시작 시 체크
@@ -273,7 +279,7 @@ export async function runAgenticLoop<TCtx = unknown, TOp = unknown>(
273
279
  * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
274
280
  * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
275
281
  * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
276
- result = await retryTransient(() => chat(conversation, tools, {
282
+ result = await retryTransient(() => chat(conversation, toolsNow(), {
277
283
  systemPrompt: options.systemPrompt,
278
284
  model: options.model,
279
285
  temperature: options.temperature,
@@ -393,7 +399,7 @@ export async function runAgenticLoop<TCtx = unknown, TOp = unknown>(
393
399
  */
394
400
  if (options.salvageOnMaxIterations) {
395
401
  try {
396
- const salvaged = await chat(conversation, tools, {
402
+ const salvaged = await chat(conversation, toolsNow(), {
397
403
  systemPrompt: `${options.systemPrompt}\n\n${options.salvagePrompt ?? DEFAULT_SALVAGE_PROMPT}`,
398
404
  model: options.model,
399
405
  temperature: options.temperature,
@@ -6,6 +6,7 @@
6
6
  export * from './abort-notice.js'
7
7
  export * from './summarize.js'
8
8
  export * from './tool-scope.js'
9
+ export * from './tool-budget.js'
9
10
  export * from './registry-gate.js'
10
11
  export * from './assistant-chat.js'
11
12
  export * from './registry-assistant.js'
@@ -21,13 +21,14 @@
21
21
  * may be dispatched cannot drift apart.
22
22
  */
23
23
  import type { AIClient, AIMessage } from '../types.js'
24
- import { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCheck } from './tool-scope.js'
24
+ import { narrowToolScope, resolveToolScope, type ToolAccessCheck } from './tool-scope.js'
25
25
  import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
26
26
  import { resultShapeOf } from './summarize.js'
27
27
  import type { GroundingSources } from '../utils/index.js'
28
28
  import type { ToolAttachment, ToolCallContext, UnattendedRunRef } from '../tool-registry/types.js'
29
29
  import { defaultValueTranslate } from '../tool-registry/translate.js'
30
30
  import { digestOf, recordToolCall, type ToolCallRecord, type ToolCallRun } from '../tool-registry/call-recorder.js'
31
+ import { OPEN_TOOLS, TOOL_BUDGET_CATEGORY, toolOfferState, type ToolFront } from './tool-budget.js'
31
32
 
32
33
 
33
34
  /** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
@@ -46,6 +47,12 @@ export interface RegistryAssistantInput {
46
47
  * category, which is how a Board-creating tool reached any conversation.
47
48
  */
48
49
  toolCategories?: string[]
50
+ /**
51
+ * What goes in front when the scope is larger than one call carries (ADR-0061 decision 9) - the screen's records,
52
+ * the tools this conversation used. The rest the model opens with `openTools`. Without it, only `openTools` is in
53
+ * front of a scope that does not fit.
54
+ */
55
+ front?: ToolFront
49
56
  /**
50
57
  * Answers whether the caller holds a tool's declared privilege. Without it, every tool that
51
58
  * declares a privilege stays closed. The host builds it from the request — this package does
@@ -100,6 +107,8 @@ export interface RegistryAssistantInput {
100
107
  export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
101
108
  /** Tool names actually offered to the model this turn. The caller may log or return it. */
102
109
  offeredTools: string[]
110
+ /** Tools of the scope the model could have opened and did not (ADR-0061 decision 9) - with `offeredTools`, all it may use. */
111
+ openableTools: string[]
103
112
  }
104
113
 
105
114
  /**
@@ -125,21 +134,24 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
125
134
  await resolveToolScope(toolCategories, input.toolAccess),
126
135
  spec => spec.kind === 'read'
127
136
  )
128
- const specs = scope.specs
129
- const byName = new Map(specs.map(spec => [spec.name, spec]))
137
+ /* What one call carries is within the budget; what is offered and what may run are the same set as it grows. */
138
+ const offer = toolOfferState(scope, input.front)
130
139
  const categoryOf = new Map(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category.name] as [string, string])))
140
+ categoryOf.set(OPEN_TOOLS, TOOL_BUDGET_CATEGORY)
131
141
 
132
- const tools = specs.map(spec => ({
133
- name: spec.name,
134
- description: spec.themeNote ? `${spec.description} ${spec.themeNote}` : spec.description,
135
- parameters: spec.schema
136
- }))
142
+ const tools = () =>
143
+ offer.offered().map(spec => ({
144
+ name: spec.name,
145
+ description: spec.themeNote ? `${spec.description} ${spec.themeNote}` : spec.description,
146
+ parameters: spec.schema
147
+ }))
137
148
 
138
149
  /*
139
- * The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so
140
- * a surface never reads instructions for a category it cannot call.
150
+ * The rules for using a tool belong to whoever registered it. Collect only the offered ones, so
151
+ * a surface never reads instructions for a category it cannot call - an opened tool brings its
152
+ * category's with the opening.
141
153
  */
142
- const guidance = toolScopeGuidance(scope)
154
+ const guidance = [...offer.frontGuidance(), offer.catalog()].filter(Boolean)
143
155
  const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
144
156
 
145
157
  /*
@@ -187,7 +199,7 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
187
199
  chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),
188
200
  context: toolContext,
189
201
  dispatch: {
190
- isReadTool: name => byName.has(name),
202
+ isReadTool: name => offer.isOffered(name) || offer.isOpenable(name),
191
203
  /*
192
204
  * This surface has no document, so nothing here can write. Saying so plainly makes the loop
193
205
  * report an unknown name back to the model instead of accepting a call and dropping it.
@@ -196,7 +208,10 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
196
208
  isActionTool: () => false,
197
209
  /* Every call is handed to the recorders - an application's audit sees the tools other packages declared too. */
198
210
  executeReadTool: async (call, context) => {
199
- const spec = byName.get(call.name)
211
+ if (offer.isOpenable(call.name)) {
212
+ return { rejected: true, error: 'tool-not-offered', message: `${call.name} is not open yet. Open it with ${OPEN_TOOLS} first, then call it.` }
213
+ }
214
+ const spec = offer.offered().find(one => one.name === call.name)
200
215
  if (!spec) return undefined
201
216
  const args = call.arguments ?? {}
202
217
  const started = Date.now()
@@ -219,5 +234,5 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
219
234
  t: input.t
220
235
  })
221
236
 
222
- return { ...result, offeredTools: tools.map(tool => tool.name) }
237
+ return { ...result, offeredTools: offer.offered().map(spec => spec.name), openableTools: offer.openable() }
223
238
  }
@@ -0,0 +1,235 @@
1
+ /*
2
+ * What this guards: **no model call carries more than MAX_OFFERED_TOOLS tools, and nothing a person may do is lost by
3
+ * it** (ADR-0061 decision 9, operato-application design/ai/tool-budget.md).
4
+ *
5
+ * Office's conversation opened 165 tools and OpenAI refuses more than 128, so every turn of a person who may read
6
+ * everything failed. A scope that fits is offered whole as before. A larger one starts with what the caller puts in
7
+ * front and `openTools`; the rest is listed in the prompt, opened for the rest of the turn, and run through the same
8
+ * doors and recorders as any tool.
9
+ */
10
+ import { registryAssistantChat } from './registry-assistant'
11
+ import { MAX_OFFERED_TOOLS, OPEN_TOOLS, TOOL_BUDGET_CATEGORY, ToolBudgetError, planToolOffer } from './tool-budget'
12
+ import { resolveToolScope } from './tool-scope'
13
+ import { clearToolCallRecorders, clearToolRegistry, registerToolCallRecorder, registerToolCategory, type ToolCallRecord } from '../tool-registry/index.js'
14
+ import type { AIToolCall, AIToolChatResult } from '../types.js'
15
+
16
+ const TC = (name: string, args: any = {}, id = `t-${name}`): AIToolCall => ({ id, name, arguments: args, type: 'tool_use' } as any)
17
+ const reply = (text?: string, toolCalls: AIToolCall[] = []): AIToolChatResult =>
18
+ ({ text, toolCalls, stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn', usage: undefined } as any)
19
+
20
+ function recordingClient(replies: AIToolChatResult[] = [reply('answered')]) {
21
+ const seen: Array<{ tools: any[]; options: any; messages: any[] }> = []
22
+ let turn = 0
23
+ return {
24
+ seen,
25
+ client: {
26
+ chatWithTools: async (messages: any[], tools: any[], options: any) => {
27
+ seen.push({ tools: [...tools], options, messages: [...messages] })
28
+ return replies[Math.min(turn++, replies.length - 1)]
29
+ }
30
+ } as any
31
+ }
32
+ }
33
+
34
+ const ran: string[] = []
35
+
36
+ /** `count` categories of one read tool each - `list-0` … - behind the door `Query.<name>`. */
37
+ function registerLists(count: number, prefix = 'list') {
38
+ for (let i = 0; i < count; i++) {
39
+ const name = `read${prefix[0].toUpperCase()}${prefix.slice(1)}${i}`
40
+ registerToolCategory({
41
+ name: `${prefix}-${i}`,
42
+ guidance: `- To answer from ${prefix} ${i}, call ${name}.`,
43
+ specs: [
44
+ {
45
+ kind: 'read',
46
+ name,
47
+ description: `Read ${prefix} ${i}. Every number comes from its result.`,
48
+ doors: [`Query.${name}`],
49
+ schema: { type: 'object', properties: { from: { type: 'string' } } },
50
+ builder: () => {
51
+ ran.push(name)
52
+ return { rows: [{ i }] }
53
+ }
54
+ }
55
+ ]
56
+ } as any)
57
+ }
58
+ return Array.from({ length: count }, (_, i) => `${prefix}-${i}`)
59
+ }
60
+
61
+ const input = (over: any = {}) => ({
62
+ client: recordingClient().client,
63
+ messages: [{ role: 'user' as const, content: 'how much came in this month' }],
64
+ systemPrompt: 'you read the books',
65
+ state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
66
+ toolAccess: async () => true,
67
+ ...over
68
+ })
69
+
70
+ const names = (tools: any[]) => tools.map(tool => tool.name)
71
+
72
+ beforeEach(() => {
73
+ clearToolRegistry()
74
+ clearToolCallRecorders()
75
+ ran.length = 0
76
+ })
77
+ afterAll(() => clearToolCallRecorders())
78
+
79
+ describe('a scope that fits is offered whole, as before', () => {
80
+ it('★ every tool, no openTools, no list to open from', async () => {
81
+ const categories = registerLists(MAX_OFFERED_TOOLS)
82
+ const { client, seen } = recordingClient()
83
+ await registryAssistantChat(input({ client, toolCategories: categories }))
84
+
85
+ expect(seen[0].tools).toHaveLength(MAX_OFFERED_TOOLS)
86
+ expect(names(seen[0].tools)).not.toContain(OPEN_TOOLS)
87
+ expect(seen[0].options.systemPrompt).not.toContain(`Tools you can open with ${OPEN_TOOLS}`)
88
+ expect(seen[0].options.systemPrompt).toContain('- To answer from list 63, call readList63.')
89
+ })
90
+ })
91
+
92
+ describe('a scope larger than one call carries', () => {
93
+ it('★ the first call carries the front and openTools - never more than the budget', async () => {
94
+ const categories = registerLists(165)
95
+ const { client, seen } = recordingClient()
96
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3', 'list-150'] } }))
97
+
98
+ expect(names(seen[0].tools)).toEqual(['readList3', 'readList150', OPEN_TOOLS])
99
+ expect(seen[0].tools.length).toBeLessThanOrEqual(MAX_OFFERED_TOOLS)
100
+ })
101
+
102
+ it('★ the rest is listed once in the prompt, the opening tool says only how to open', async () => {
103
+ const categories = registerLists(165)
104
+ const { client, seen } = recordingClient()
105
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
106
+
107
+ const prompt = seen[0].options.systemPrompt
108
+ expect(prompt).toContain(`Tools you can open with ${OPEN_TOOLS}`)
109
+ expect(prompt).toContain('- readList150: Read list 150.')
110
+ expect(prompt).not.toContain('- readList3:')
111
+ /* Only the front's guidance - an unopened list's rules are not read to the model. */
112
+ expect(prompt).toContain('- To answer from list 3, call readList3.')
113
+ expect(prompt).not.toContain('call readList150.')
114
+ const opening = seen[0].tools.find(tool => tool.name === OPEN_TOOLS)
115
+ expect(opening.description).not.toContain('readList150')
116
+ expect(opening.description.length).toBeLessThan(1024)
117
+ })
118
+
119
+ it('★ an opened tool is offered from the next call on, with its guidance, and runs', async () => {
120
+ const categories = registerLists(165)
121
+ const { client, seen } = recordingClient([
122
+ reply(undefined, [TC(OPEN_TOOLS, { names: ['readList150'] })]),
123
+ reply(undefined, [TC('readList150', { from: '2026-10-01' })]),
124
+ reply('42 came in')
125
+ ])
126
+ const result = await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
127
+
128
+ expect(names(seen[0].tools)).not.toContain('readList150')
129
+ expect(names(seen[1].tools)).toContain('readList150')
130
+ const opened = JSON.parse(seen[1].messages.at(-1).content[0].content)
131
+ expect(opened).toMatchObject({ opened: ['readList150'], guidance: '- To answer from list 150, call readList150.' })
132
+ expect(ran).toEqual(['readList150'])
133
+ expect(result.offeredTools).toContain('readList150')
134
+ /* What it could still have opened - with the offered ones, all this person may use here. */
135
+ expect(result.openableTools).toHaveLength(165 - 2)
136
+ expect(result.openableTools).not.toContain('readList150')
137
+ })
138
+
139
+ it('★ the list says its tools are the model\'s to use - not something to tell the person is missing', async () => {
140
+ const categories = registerLists(165)
141
+ const { client, seen } = recordingClient()
142
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
143
+
144
+ const prompt = seen[0].options.systemPrompt
145
+ expect(prompt).toMatch(/never tell the person a tool on this list is missing/)
146
+ expect(prompt).toMatch(/never ask the person to choose instead of opening it/)
147
+ })
148
+
149
+ it('★ opening a tool that is already offered says so - call it now', async () => {
150
+ const categories = registerLists(165)
151
+ const { client, seen } = recordingClient([reply(undefined, [TC(OPEN_TOOLS, { names: ['readList3', 'readList150'] })]), reply('done')])
152
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
153
+
154
+ expect(JSON.parse(seen[1].messages.at(-1).content[0].content)).toMatchObject({ opened: ['readList150'], alreadyOffered: ['readList3'] })
155
+ })
156
+
157
+ it('★ a tool called before it is opened does not run - the model is told to open it', async () => {
158
+ const categories = registerLists(165)
159
+ const { client, seen } = recordingClient([reply(undefined, [TC('readList150')]), reply('done')])
160
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
161
+
162
+ expect(ran).toEqual([])
163
+ expect(JSON.parse(seen[1].messages.at(-1).content[0].content)).toMatchObject({ error: 'tool-not-offered' })
164
+ })
165
+
166
+ it('★ a tool whose door the person cannot pass is not in the list, cannot be opened and never runs', async () => {
167
+ const categories = registerLists(165)
168
+ const { client, seen } = recordingClient([reply(undefined, [TC(OPEN_TOOLS, { names: ['readList150'] })]), reply(undefined, [TC('readList150')]), reply('done')])
169
+ await registryAssistantChat(
170
+ input({ client, toolCategories: categories, front: { categories: ['list-3'] }, toolAccess: async (door: string) => door !== 'Query.readList150' })
171
+ )
172
+
173
+ expect(seen[0].options.systemPrompt).not.toContain('readList150')
174
+ expect(JSON.parse(seen[1].messages.at(-1).content[0].content)).toMatchObject({ error: 'tool-not-offered', names: ['readList150'] })
175
+ expect(names(seen[1].tools)).not.toContain('readList150')
176
+ expect(ran).toEqual([])
177
+ })
178
+
179
+ it('★ opening past the budget is refused with the room left - no call ever carries more', async () => {
180
+ const categories = registerLists(165)
181
+ const many = Array.from({ length: 70 }, (_, i) => `readList${i + 10}`)
182
+ const { client, seen } = recordingClient([
183
+ reply(undefined, [TC(OPEN_TOOLS, { names: many })]),
184
+ reply(undefined, [TC(OPEN_TOOLS, { names: many.slice(0, 20) })]),
185
+ reply('done')
186
+ ])
187
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] } }))
188
+
189
+ expect(JSON.parse(seen[1].messages.at(-1).content[0].content)).toMatchObject({ error: 'tool-budget-exceeded', room: MAX_OFFERED_TOOLS - 2 })
190
+ expect(seen[2].tools).toHaveLength(22)
191
+ for (const call of seen) expect(call.tools.length).toBeLessThanOrEqual(MAX_OFFERED_TOOLS)
192
+ })
193
+
194
+ it('★ the front tools fill only the room the categories leave', async () => {
195
+ const categories = registerLists(165)
196
+ const scope = await resolveToolScope(categories, async () => true)
197
+ const front = Array.from({ length: 100 }, (_, i) => `readList${i}`)
198
+ const plan = planToolOffer(scope, { categories: ['list-150'], tools: front })
199
+
200
+ expect(plan.offered).toHaveLength(MAX_OFFERED_TOOLS - 1)
201
+ expect(plan.offered[0].name).toBe('readList150')
202
+ expect(plan.openable).toHaveLength(165 - (MAX_OFFERED_TOOLS - 1))
203
+ })
204
+
205
+ it('★ front categories that do not fit are refused loudly, never cut', async () => {
206
+ const categories = registerLists(165)
207
+ const scope = await resolveToolScope(categories, async () => true)
208
+
209
+ expect(() => planToolOffer(scope, { categories: categories.slice(0, MAX_OFFERED_TOOLS) })).toThrow(ToolBudgetError)
210
+ await expect(registryAssistantChat(input({ toolCategories: categories, front: { categories: categories.slice(0, 80) } }))).rejects.toMatchObject({
211
+ code: 'tool-budget-exceeded'
212
+ })
213
+ })
214
+ })
215
+
216
+ describe('opening and the opened tool are recorded like any call (ADR-0061 decision 8, ADR-0141)', () => {
217
+ it('★ the recorders get the opening, under its own category, and the opened tool under its own', async () => {
218
+ const recorded: ToolCallRecord[] = []
219
+ registerToolCallRecorder(record => {
220
+ recorded.push(record)
221
+ })
222
+ const categories = registerLists(165)
223
+ const { client } = recordingClient([
224
+ reply(undefined, [TC(OPEN_TOOLS, { names: ['readList150'] })]),
225
+ reply(undefined, [TC('readList150', { from: '2026-10-01' })]),
226
+ reply('done')
227
+ ])
228
+ await registryAssistantChat(input({ client, toolCategories: categories, front: { categories: ['list-3'] }, mode: { kind: 'office.finance' } }))
229
+
230
+ expect(recorded.map(record => [record.tool, record.category, record.ok])).toEqual([
231
+ [OPEN_TOOLS, TOOL_BUDGET_CATEGORY, true],
232
+ ['readList150', 'list-150', true]
233
+ ])
234
+ })
235
+ })
@@ -0,0 +1,163 @@
1
+ /*
2
+ * How many tools one model call carries (ADR-0061 decision 9, operato-application design/ai/tool-budget.md).
3
+ *
4
+ * A conversation used to send every tool its mode opened on every call. Office's conversation opened 165, and OpenAI
5
+ * refuses more than 128 - every turn of a person who may read everything failed. The tools also went with their
6
+ * guidance, some 140,000 characters a call.
7
+ *
8
+ * Now a call carries at most `MAX_OFFERED_TOOLS`. When the scope is larger, the caller names what goes in front (the
9
+ * screen's records, the tools this conversation used); the rest is listed once in the prompt and the model opens what
10
+ * it needs with `openTools` for the rest of the turn. Whether a person may use a tool is decided as before - the list
11
+ * holds only the tools of their scope, and opening runs no tool.
12
+ */
13
+ import type { ToolSpec } from '../tool-registry/types.js'
14
+ import type { ScopedCategory, ToolScope } from './tool-scope.js'
15
+
16
+ /** Half the smallest provider limit known (OpenAI, 128). One constant - an application or a mode does not change it. */
17
+ export const MAX_OFFERED_TOOLS = 64
18
+
19
+ export const OPEN_TOOLS = 'openTools'
20
+
21
+ /** The category the opening tool is recorded under - it is not registered; no mode names it. */
22
+ export const TOOL_BUDGET_CATEGORY = 'tool-budget'
23
+
24
+ /** What the caller puts in front when the scope does not fit: whole categories first, then single tools while there is room. */
25
+ export interface ToolFront {
26
+ categories?: string[]
27
+ tools?: string[]
28
+ }
29
+
30
+ export class ToolBudgetError extends Error {
31
+ code = 'tool-budget-exceeded'
32
+ constructor(public front: number) {
33
+ super(`[tool-budget] the tools put in front (${front}) and openTools do not fit in ${MAX_OFFERED_TOOLS} - narrow the mode's own categories`)
34
+ }
35
+ }
36
+
37
+ export interface ToolOffer {
38
+ /** What the first call carries, openTools last when anything is left to open. */
39
+ offered: ToolSpec[]
40
+ /** In the scope and not offered - what openTools may open. */
41
+ openable: ToolSpec[]
42
+ }
43
+
44
+ /**
45
+ * What a turn starts with. The whole scope when it fits; otherwise the front categories (all of them, or it throws),
46
+ * the front tools while there is room, and openTools.
47
+ */
48
+ export function planToolOffer(scope: ToolScope, front: ToolFront = {}): ToolOffer {
49
+ if (scope.specs.length <= MAX_OFFERED_TOOLS) return { offered: [...scope.specs], openable: [] }
50
+
51
+ const room = MAX_OFFERED_TOOLS - 1
52
+ const picked = new Map<string, ToolSpec>()
53
+ for (const name of front.categories ?? []) {
54
+ for (const spec of scope.categories.find(category => category.name === name)?.specs ?? []) picked.set(spec.name, spec)
55
+ }
56
+ if (picked.size > room) throw new ToolBudgetError(picked.size)
57
+
58
+ const byName = new Map(scope.specs.map(spec => [spec.name, spec]))
59
+ for (const name of front.tools ?? []) {
60
+ if (picked.size >= room) break
61
+ const spec = byName.get(name)
62
+ if (spec) picked.set(name, spec)
63
+ }
64
+
65
+ const offered = [...picked.values()]
66
+ return { offered, openable: scope.specs.filter(spec => !picked.has(spec.name)) }
67
+ }
68
+
69
+ const ONE_LINE = 160
70
+
71
+ /** The first sentence of a description, on one line - what the list says of a tool. */
72
+ function oneLine(description: string | undefined): string {
73
+ const flat = String(description ?? '').replace(/\s+/g, ' ').trim()
74
+ const sentence = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat
75
+ return sentence.length > ONE_LINE ? `${sentence.slice(0, ONE_LINE - 1)}…` : sentence
76
+ }
77
+
78
+ /**
79
+ * The list of tools the model may open - one function builds it, wherever it goes. It goes in the prompt: OpenAI is
80
+ * reported to refuse a tool description over 1024 characters, and Office's list runs to some 150 lines.
81
+ */
82
+ export function openableToolCatalog(openable: ReadonlyArray<ToolSpec>): string {
83
+ if (openable.length === 0) return ''
84
+ return [
85
+ /*
86
+ * Office's model, offered a bank lines' total it could open, told the person that read was not open and asked them
87
+ * to pick a range (2026-10-22) - an app's prompt said a missing tool means a missing permission. What is on this
88
+ * list is the model's to use.
89
+ */
90
+ `Tools you can open with ${OPEN_TOOLS} (name: what it does). They are yours to use in this conversation, only not loaded yet. ` +
91
+ `When the question needs one, open it with ${OPEN_TOOLS} and call it: never tell the person a tool on this list is missing, ` +
92
+ 'and never ask the person to choose instead of opening it.',
93
+ ...openable.map(spec => `- ${spec.name}: ${oneLine(spec.description)}`)
94
+ ].join('\n')
95
+ }
96
+
97
+ /** The opening tool's definition. */
98
+ export function openToolsSpec(open: (names: string[]) => unknown): ToolSpec {
99
+ return {
100
+ kind: 'read',
101
+ name: OPEN_TOOLS,
102
+ description:
103
+ `Open more tools for the rest of this answer. Pass the exact names from the list of tools you can open; they are offered from your next step on, with how to use them. At most ${MAX_OFFERED_TOOLS} tools are open at once.`,
104
+ schema: {
105
+ type: 'object',
106
+ required: ['names'],
107
+ properties: { names: { type: 'array', items: { type: 'string' }, description: 'Names from the list of tools you can open.' } }
108
+ },
109
+ builder: (args: any) => open(Array.isArray(args?.names) ? args.names.map(String) : [])
110
+ } as ToolSpec
111
+ }
112
+
113
+ /**
114
+ * The offer of one turn, as it grows. `open` adds tools to what the next call carries; it never runs one.
115
+ */
116
+ export function toolOfferState(scope: ToolScope, front: ToolFront = {}) {
117
+ const plan = planToolOffer(scope, front)
118
+ const openable = new Map(plan.openable.map(spec => [spec.name, spec]))
119
+ const opening = openable.size > 0 ? openToolsSpec(names => open(names)) : undefined
120
+ const offered = new Map<string, ToolSpec>([...plan.offered, ...(opening ? [opening] : [])].map(spec => [spec.name, spec]))
121
+ const categoryOf = new Map<string, ScopedCategory>(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category] as [string, ScopedCategory])))
122
+
123
+ function open(names: string[]) {
124
+ const unknown = names.filter(name => !openable.has(name) && !offered.has(name))
125
+ if (unknown.length) {
126
+ return { rejected: true, error: 'tool-not-offered', names: unknown, message: `these are not in the list of tools you can open: ${unknown.join(', ')}. Use the exact names from the list.` }
127
+ }
128
+ const fresh = [...new Set(names)].filter(name => !offered.has(name))
129
+ const already = [...new Set(names)].filter(name => offered.has(name) && name !== OPEN_TOOLS)
130
+ const room = MAX_OFFERED_TOOLS - offered.size
131
+ if (fresh.length > room) {
132
+ return {
133
+ rejected: true,
134
+ error: 'tool-budget-exceeded',
135
+ room,
136
+ message: `only ${room} more tools fit in this answer. Open fewer - the ones this question needs - or answer from what you have read.`
137
+ }
138
+ }
139
+ for (const name of fresh) offered.set(name, openable.get(name)!)
140
+ const guidance = [...new Set(fresh.map(name => categoryOf.get(name)?.guidance).filter(Boolean))]
141
+ return {
142
+ opened: fresh,
143
+ ...(already.length ? { alreadyOffered: already } : {}),
144
+ ...(guidance.length ? { guidance: guidance.join('\n') } : {}),
145
+ message: already.length
146
+ ? `${already.join(', ')} ${already.length > 1 ? 'are' : 'is'} already open - call ${already.length > 1 ? 'them' : 'it'} now. ${fresh.length ? 'The others are open from your next step on.' : ''}`.trim()
147
+ : 'these tools are open from your next step on. Call them as the question needs.'
148
+ }
149
+ }
150
+
151
+ return {
152
+ /** What a call carries now. */
153
+ offered: () => [...offered.values()],
154
+ isOffered: (name: string) => offered.has(name),
155
+ /** In the scope and still to be opened - a call to one is answered with how to open it. */
156
+ isOpenable: (name: string) => openable.has(name) && !offered.has(name),
157
+ /** In the scope and not offered now. */
158
+ openable: () => [...openable.keys()].filter(name => !offered.has(name)),
159
+ /** The guidance of the categories in front - opened ones bring theirs with the result. */
160
+ frontGuidance: () => [...new Set(plan.offered.map(spec => categoryOf.get(spec.name)?.guidance).filter(Boolean) as string[])],
161
+ catalog: () => openableToolCatalog(plan.openable)
162
+ }
163
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "error.ai-not-configured": "이 사업장에는 AI 가 설정되어 있지 않습니다.",
3
3
  "error.ai-agent-not-available": "선택한 AI 에이전트를 이 사업장에서 사용할 수 없습니다.",
4
- "error.ai-agent-provider-unknown": "\"{provider}\" 은(는) 에이전트가 쓸 수 있는 제공처가 아닙니다.",
4
+ "error.ai-agent-provider-unknown": "\"{provider}\"은(는) 에이전트에서 지원하지 않는 제공자입니다.",
5
5
  "error.ai-agent-key-required": "\"{agent}\" 의 API 키를 입력해 주세요.",
6
6
  "error.ai-agent-name-required": "에이전트마다 이름이 있어야 합니다.",
7
7
  "error.ai-agent-model-required": "\"{agent}\" 의 모델을 선택해 주세요.",