@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/dist-server/service/agentic-loop/agentic-loop.d.ts +5 -1
- package/dist-server/service/agentic-loop/agentic-loop.js +3 -2
- package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant/index.d.ts +1 -0
- package/dist-server/service/assistant/index.js +1 -0
- package/dist-server/service/assistant/index.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.d.ts +9 -0
- package/dist-server/service/assistant/registry-assistant.js +15 -9
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/tool-budget.d.ts +50 -0
- package/dist-server/service/assistant/tool-budget.js +137 -0
- package/dist-server/service/assistant/tool-budget.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/agentic-loop/agentic-loop.ts +9 -3
- package/server/service/assistant/index.ts +1 -0
- package/server/service/assistant/registry-assistant.ts +29 -14
- package/server/service/assistant/tool-budget.test.ts +235 -0
- package/server/service/assistant/tool-budget.ts +163 -0
- package/translations/ko.json +1 -1
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.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.
|
|
27
|
+
"@things-factory/auth-base": "^10.1.208",
|
|
28
28
|
"@things-factory/env": "^10.1.149",
|
|
29
|
-
"@things-factory/setting-base": "^10.1.
|
|
30
|
-
"@things-factory/shell": "^10.1.
|
|
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": "
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
@@ -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,
|
|
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
|
-
|
|
129
|
-
const
|
|
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 =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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 =
|
|
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 =>
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/translations/ko.json
CHANGED
|
@@ -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}\" 의 모델을 선택해 주세요.",
|