tinywebui 0.1.3 → 0.1.5
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/README.md +20 -13
- package/bin/tinywebui.js +69 -63
- package/package.json +25 -8
- package/public/app.js +40 -14
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -49
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +83 -20
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -138
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -451
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +98 -66
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -100
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +428 -392
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
|
@@ -3,21 +3,20 @@ import { approvalFor } from '../config/approval.js';
|
|
|
3
3
|
import { runHooks } from '../harness/agent.js';
|
|
4
4
|
import { digest } from './compact.js';
|
|
5
5
|
import { EVENT } from '../../public/shared/events.js';
|
|
6
|
-
|
|
7
6
|
/**
|
|
8
7
|
* Key for spotting a repeated call. Keys are sorted at every depth: a flat
|
|
9
8
|
* replacer list would also act as a whitelist on nested objects and collapse
|
|
10
9
|
* {q:{text:"a"}} and {q:{text:"b"}} into the same key.
|
|
11
10
|
*/
|
|
12
11
|
function stableKey(value) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return `[${value.map(stableKey).join(',')}]`;
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
return `{${Object.keys(value).sort()
|
|
16
|
+
.map((k) => `${JSON.stringify(k)}:${stableKey(value[k])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
19
19
|
}
|
|
20
|
-
|
|
21
20
|
/**
|
|
22
21
|
* TinyWebUI's tool policy, as the loop's `executeToolBatch`: argument
|
|
23
22
|
* validation, duplicate suppression, approvals, MCP dispatch, artifacts and
|
|
@@ -25,215 +24,227 @@ function stableKey(value) {
|
|
|
25
24
|
* the closure, so one executor serves exactly one turn.
|
|
26
25
|
*/
|
|
27
26
|
export function toolExecutor({ cfg, chatId, store, hub, emit, unattended, approve, askUser, footer: roundFooter, hooks, signal }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const STOPPED = 'Error: the run was stopped before this call finished.';
|
|
55
|
-
|
|
56
|
-
/** Approval, as the first beforeToolCall hook. */
|
|
57
|
-
const approval = async ({ name, args, call }) => {
|
|
58
|
-
const decision = await gate(name, args, call.id);
|
|
59
|
-
if (decision === 'unattended') {
|
|
60
|
-
return {
|
|
61
|
-
block: `Error: "${name}" needs the user's approval before it runs, and this is an`
|
|
62
|
-
+ ' unattended scheduled run with no one to ask, so it was not called. Finish what'
|
|
63
|
-
+ ' you can without it and say in your result that it still needs doing.'
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
if (decision === 'deny') {
|
|
67
|
-
return {
|
|
68
|
-
block: `The user declined this call to "${name}", so it was not run. Do not retry it`
|
|
69
|
-
+ ' unless they ask; continue without it, or ask them how they would like to proceed.'
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
return undefined;
|
|
73
|
-
};
|
|
74
|
-
const before = [approval, ...(hooks.beforeToolCall || [])];
|
|
75
|
-
|
|
76
|
-
/** Runs the afterToolCall chain; a failing hook leaves the result as it stood. */
|
|
77
|
-
const after = async (ctx) => {
|
|
78
|
-
try {
|
|
79
|
-
await runHooks(hooks.afterToolCall, ctx, (o) => {
|
|
80
|
-
if (o.result !== undefined) ctx.result = o.result;
|
|
81
|
-
if (o.stop) stop = true;
|
|
82
|
-
return false;
|
|
83
|
-
});
|
|
84
|
-
} catch (err) {
|
|
85
|
-
if (signal?.aborted) throw err;
|
|
86
|
-
emit({ type: EVENT.NOTICE, text: `Tool result hook failed for "${ctx.name}" (${err.message}); result kept as returned.` });
|
|
87
|
-
}
|
|
88
|
-
return ctx.result;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Everything that has to happen before a call may run: argument checks,
|
|
93
|
-
* duplicate suppression and the beforeToolCall chain (approval included).
|
|
94
|
-
* Returns `{ args, result }` for a call that will not run, `{ args, run }`
|
|
95
|
-
* for one that will.
|
|
96
|
-
*/
|
|
97
|
-
const prepare = async (call, round) => {
|
|
98
|
-
const name = call.function.name;
|
|
99
|
-
let args = {};
|
|
100
|
-
let badArgs = null;
|
|
101
|
-
try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; }
|
|
102
|
-
catch (err) { badArgs = err.message; }
|
|
103
|
-
if (!badArgs && (args === null || typeof args !== 'object' || Array.isArray(args))) {
|
|
104
|
-
badArgs = 'arguments must be a JSON object';
|
|
105
|
-
}
|
|
106
|
-
emit({ type: EVENT.TOOL_CALL, id: call.id, name, args: badArgs ? {} : args });
|
|
107
|
-
|
|
108
|
-
if (badArgs) {
|
|
109
|
-
// Never run a tool on arguments the model did not write. Falling back
|
|
110
|
-
// to {} quietly runs it on its defaults, and the model then reasons as
|
|
111
|
-
// if its own arguments had been used.
|
|
112
|
-
return {
|
|
113
|
-
args: {},
|
|
114
|
-
result: `Error: the arguments for "${name}" were not valid JSON (${badArgs}), so the`
|
|
115
|
-
+ ' tool was not called. Retry with a well-formed JSON object.'
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
const dupKey = `${name}:${stableKey(args)}`;
|
|
119
|
-
const seenRound = hub.isIdempotent?.(name) ? seenCalls.get(dupKey) : undefined;
|
|
120
|
-
if (seenRound !== undefined) {
|
|
121
|
-
return {
|
|
122
|
-
args,
|
|
123
|
-
result: `Skipped: this is an identical call to "${name}" with the same arguments`
|
|
124
|
-
+ ` already made in round ${seenRound + 1} of this turn, and this tool returns the same`
|
|
125
|
-
+ ' result for the same arguments. Reuse what you got back then.'
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
const ctx = { name, args, call, round, signal };
|
|
129
|
-
let blocked = null;
|
|
130
|
-
try {
|
|
131
|
-
await runHooks(before, ctx, (o) => {
|
|
132
|
-
if (o.block === undefined) return false;
|
|
133
|
-
blocked = String(o.block);
|
|
134
|
-
return true;
|
|
135
|
-
});
|
|
136
|
-
} catch (err) {
|
|
137
|
-
if (signal?.aborted) throw err;
|
|
138
|
-
// A policy check that cannot decide must not let the call through.
|
|
139
|
-
emit({ type: EVENT.NOTICE, text: `Tool policy hook failed for "${name}" (${err.message}); call not run.` });
|
|
140
|
-
blocked = `Error: a policy check for "${name}" failed (${err.message}), so the tool was not`
|
|
141
|
-
+ ' called. Continue without it.';
|
|
142
|
-
}
|
|
143
|
-
if (blocked !== null) return { args, result: blocked };
|
|
144
|
-
seenCalls.set(dupKey, round);
|
|
145
|
-
return { args, run: ctx };
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Runs one prepared call and its afterToolCall chain. A stop abandons it
|
|
150
|
-
* rather than waiting on a tool that may never answer; the call still gets
|
|
151
|
-
* a result, so the transcript stays valid.
|
|
152
|
-
*/
|
|
153
|
-
const run = async (ctx) => {
|
|
154
|
-
if (signal?.aborted) return STOPPED;
|
|
155
|
-
let onAbort;
|
|
156
|
-
const stopped = new Promise((r) => {
|
|
157
|
-
onAbort = () => r(STOPPED);
|
|
158
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
159
|
-
});
|
|
160
|
-
try {
|
|
161
|
-
const result = await Promise.race([hub.call(ctx.name, ctx.args, toolCtx), stopped]);
|
|
162
|
-
if (result === STOPPED) return result;
|
|
163
|
-
return await after({ ...ctx, result });
|
|
164
|
-
} catch (err) {
|
|
165
|
-
if (signal?.aborted) return STOPPED;
|
|
166
|
-
throw err;
|
|
167
|
-
} finally {
|
|
168
|
-
signal?.removeEventListener('abort', onAbort);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const execute = async (calls, { round }) => {
|
|
173
|
-
// Every call is checked and approved before any of them runs, so no
|
|
174
|
-
// write can start while a later call in the batch still waits on the user.
|
|
175
|
-
const prepared = [];
|
|
176
|
-
// The assistant turn holding these calls is already stored, so a Stop that
|
|
177
|
-
// lands while one waits for approval must still answer all of them.
|
|
178
|
-
for (const call of calls) {
|
|
179
|
-
try {
|
|
180
|
-
prepared.push(signal?.aborted ? { args: {}, result: STOPPED } : await prepare(call, round));
|
|
181
|
-
} catch (err) {
|
|
182
|
-
if (!signal?.aborted) throw err;
|
|
183
|
-
prepared.push({ args: {}, result: STOPPED });
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// One sequential call makes the whole batch sequential: deterministic, and
|
|
188
|
-
// no guessing at which calls could safely overlap it.
|
|
189
|
-
const parallel = prepared.every((p) => !p.run || hub.executionMode?.(p.run.name) === 'parallel');
|
|
190
|
-
const finish = async (p, call) => {
|
|
191
|
-
if (p.run) p.result = await run(p.run);
|
|
192
|
-
emit({ type: EVENT.TOOL_RESULT, id: call.id, name: call.function.name, result: p.result });
|
|
27
|
+
const toolCtx = { store, chatId, budget: cfg.expandCharBudget || 8000, unattended, askUser: unattended ? null : askUser };
|
|
28
|
+
// Same tool name + same args, seen earlier in this turn, on a tool that has
|
|
29
|
+
// declared its result depends only on its arguments: re-running it only burns
|
|
30
|
+
// a round. Tracked by round so the model can be told exactly when it already
|
|
31
|
+
// did this. Anything else -- a browser snapshot after a click, a list after a
|
|
32
|
+
// create -- is run again, because its answer may well have changed.
|
|
33
|
+
const seenCalls = new Map();
|
|
34
|
+
// "Always allow" is saved by the server, but this turn's cfg is a snapshot
|
|
35
|
+
// taken before it; remembered here so the next call in the turn doesn't ask.
|
|
36
|
+
const allowedNow = new Set();
|
|
37
|
+
// Set by an afterToolCall hook; honoured once the batch is complete.
|
|
38
|
+
let stop = false;
|
|
39
|
+
/** Resolves one call against the approval policy: 'allow', 'deny' or 'unattended'. */
|
|
40
|
+
const gate = async (name, args, id) => {
|
|
41
|
+
if (allowedNow.has(name) || approvalFor(cfg, hub, name, args) === 'auto')
|
|
42
|
+
return 'allow';
|
|
43
|
+
if (unattended)
|
|
44
|
+
return 'unattended';
|
|
45
|
+
if (!approve)
|
|
46
|
+
return 'deny';
|
|
47
|
+
emit({ type: EVENT.APPROVAL, id, name, args });
|
|
48
|
+
const decision = await approve({ id, name, args });
|
|
49
|
+
emit({ type: EVENT.APPROVAL_DONE, id, name, decision });
|
|
50
|
+
if (decision === 'always')
|
|
51
|
+
allowedNow.add(name);
|
|
52
|
+
return decision === 'always' || decision === 'allow' ? 'allow' : 'deny';
|
|
193
53
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
54
|
+
const STOPPED = 'Error: the run was stopped before this call finished.';
|
|
55
|
+
/** Approval, as the first beforeToolCall hook. */
|
|
56
|
+
const approval = async ({ name, args, call }) => {
|
|
57
|
+
const decision = await gate(name, args, call.id);
|
|
58
|
+
if (decision === 'unattended') {
|
|
59
|
+
return {
|
|
60
|
+
block: `Error: "${name}" needs the user's approval before it runs, and this is an`
|
|
61
|
+
+ ' unattended scheduled run with no one to ask, so it was not called. Finish what'
|
|
62
|
+
+ ' you can without it and say in your result that it still needs doing.'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (decision === 'deny') {
|
|
66
|
+
return {
|
|
67
|
+
block: `The user declined this call to "${name}", so it was not run. Do not retry it`
|
|
68
|
+
+ ' unless they ask; continue without it, or ask them how they would like to proceed.'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
73
|
+
const before = [approval, ...(hooks.beforeToolCall || [])];
|
|
74
|
+
/** Runs the afterToolCall chain; a failing hook leaves the result as it stood. */
|
|
75
|
+
const after = async (ctx) => {
|
|
76
|
+
try {
|
|
77
|
+
await runHooks(hooks.afterToolCall, ctx, (o) => {
|
|
78
|
+
if (o.result !== undefined)
|
|
79
|
+
ctx.result = o.result;
|
|
80
|
+
if (o.stop)
|
|
81
|
+
stop = true;
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
throw err;
|
|
88
|
+
emit({ type: EVENT.NOTICE, text: `Tool result hook failed for "${ctx.name}" (${err.message}); result kept as returned.` });
|
|
89
|
+
}
|
|
90
|
+
return ctx.result;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Everything that has to happen before a call may run: argument checks,
|
|
94
|
+
* duplicate suppression and the beforeToolCall chain (approval included).
|
|
95
|
+
* Returns `{ args, result }` for a call that will not run, `{ args, run }`
|
|
96
|
+
* for one that will.
|
|
97
|
+
*/
|
|
98
|
+
const prepare = async (call, round) => {
|
|
99
|
+
const name = call.function.name;
|
|
100
|
+
let args = {};
|
|
101
|
+
let badArgs = null;
|
|
102
|
+
try {
|
|
103
|
+
args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
badArgs = err.message;
|
|
107
|
+
}
|
|
108
|
+
if (!badArgs && (args === null || typeof args !== 'object' || Array.isArray(args))) {
|
|
109
|
+
badArgs = 'arguments must be a JSON object';
|
|
110
|
+
}
|
|
111
|
+
emit({ type: EVENT.TOOL_CALL, id: call.id, name, args: badArgs ? {} : args });
|
|
112
|
+
if (badArgs) {
|
|
113
|
+
// Never run a tool on arguments the model did not write. Falling back
|
|
114
|
+
// to {} quietly runs it on its defaults, and the model then reasons as
|
|
115
|
+
// if its own arguments had been used.
|
|
116
|
+
return {
|
|
117
|
+
args: {},
|
|
118
|
+
result: `Error: the arguments for "${name}" were not valid JSON (${badArgs}), so the`
|
|
119
|
+
+ ' tool was not called. Retry with a well-formed JSON object.'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const dupKey = `${name}:${stableKey(args)}`;
|
|
123
|
+
const seenRound = hub.isIdempotent?.(name) ? seenCalls.get(dupKey) : undefined;
|
|
124
|
+
if (seenRound !== undefined) {
|
|
125
|
+
return {
|
|
126
|
+
args,
|
|
127
|
+
result: `Skipped: this is an identical call to "${name}" with the same arguments`
|
|
128
|
+
+ ` already made in round ${seenRound + 1} of this turn, and this tool returns the same`
|
|
129
|
+
+ ' result for the same arguments. Reuse what you got back then.'
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const ctx = { name, args, call, round, signal };
|
|
133
|
+
let blocked = null;
|
|
134
|
+
try {
|
|
135
|
+
await runHooks(before, ctx, (o) => {
|
|
136
|
+
if (o.block === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
blocked = String(o.block);
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (signal?.aborted)
|
|
144
|
+
throw err;
|
|
145
|
+
// A policy check that cannot decide must not let the call through.
|
|
146
|
+
emit({ type: EVENT.NOTICE, text: `Tool policy hook failed for "${name}" (${err.message}); call not run.` });
|
|
147
|
+
blocked = `Error: a policy check for "${name}" failed (${err.message}), so the tool was not`
|
|
148
|
+
+ ' called. Continue without it.';
|
|
149
|
+
}
|
|
150
|
+
if (blocked !== null)
|
|
151
|
+
return { args, result: blocked };
|
|
152
|
+
seenCalls.set(dupKey, round);
|
|
153
|
+
return { args, run: ctx };
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Runs one prepared call and its afterToolCall chain. A stop abandons it
|
|
157
|
+
* rather than waiting on a tool that may never answer; the call still gets
|
|
158
|
+
* a result, so the transcript stays valid.
|
|
159
|
+
*/
|
|
160
|
+
const run = async (ctx) => {
|
|
161
|
+
if (signal?.aborted)
|
|
162
|
+
return STOPPED;
|
|
163
|
+
let onAbort;
|
|
164
|
+
const stopped = new Promise((r) => {
|
|
165
|
+
onAbort = () => r(STOPPED);
|
|
166
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
const result = await Promise.race([hub.call(ctx.name, ctx.args, toolCtx), stopped]);
|
|
170
|
+
if (result === STOPPED)
|
|
171
|
+
return result;
|
|
172
|
+
return await after({ ...ctx, result });
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (signal?.aborted)
|
|
176
|
+
return STOPPED;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
signal?.removeEventListener('abort', onAbort);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const execute = async (calls, { round }) => {
|
|
184
|
+
// Every call is checked and approved before any of them runs, so no
|
|
185
|
+
// write can start while a later call in the batch still waits on the user.
|
|
186
|
+
const prepared = [];
|
|
187
|
+
// The assistant turn holding these calls is already stored, so a Stop that
|
|
188
|
+
// lands while one waits for approval must still answer all of them.
|
|
189
|
+
for (const call of calls) {
|
|
190
|
+
try {
|
|
191
|
+
prepared.push(signal?.aborted ? { args: {}, result: STOPPED } : await prepare(call, round));
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (!signal?.aborted)
|
|
195
|
+
throw err;
|
|
196
|
+
prepared.push({ args: {}, result: STOPPED });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// One sequential call makes the whole batch sequential: deterministic, and
|
|
200
|
+
// no guessing at which calls could safely overlap it.
|
|
201
|
+
const parallel = prepared.every((p) => !p.run || hub.executionMode?.(p.run.name) === 'parallel');
|
|
202
|
+
const finish = async (p, call) => {
|
|
203
|
+
if (p.run)
|
|
204
|
+
p.result = await run(p.run);
|
|
205
|
+
emit({ type: EVENT.TOOL_RESULT, id: call.id, name: call.function.name, result: p.result });
|
|
206
|
+
};
|
|
207
|
+
if (parallel)
|
|
208
|
+
await Promise.all(prepared.map((p, i) => finish(p, calls[i])));
|
|
209
|
+
else
|
|
210
|
+
for (const [i, p] of prepared.entries())
|
|
211
|
+
await finish(p, calls[i]);
|
|
212
|
+
// Persisted in the order the model made the calls, whatever order they finished in.
|
|
213
|
+
const out = [];
|
|
214
|
+
for (const [i, call] of calls.entries()) {
|
|
215
|
+
const name = call.function.name;
|
|
216
|
+
const { args, result } = prepared[i];
|
|
217
|
+
const text = String(result);
|
|
218
|
+
// Every tool result becomes an artifact, whatever tool produced it. This
|
|
219
|
+
// is the one thing that makes compaction possible later: content can only
|
|
220
|
+
// leave the window safely if the stub left behind can bring it back. The
|
|
221
|
+
// artifact keeps the raw output; the budget footer is harness chatter.
|
|
222
|
+
const artifactId = store.messages.addArtifact(chatId, { toolName: name, args, content: text });
|
|
223
|
+
const footer = i === calls.length - 1 ? roundFooter(round) : '';
|
|
224
|
+
const msg = { role: 'tool', tool_call_id: call.id, content: text + footer, artifact_id: artifactId };
|
|
225
|
+
// Two caps. Past `maxInlineChars` a result is stubbed for every LATER
|
|
226
|
+
// turn: the round that asked for it still reads it whole, since a head and
|
|
227
|
+
// a tail leave no way to aim a grep at the middle and blind paging costs
|
|
228
|
+
// more rounds than the inline text ever cost in tokens. Past
|
|
229
|
+
// `maxTurnChars` it is stubbed even for this turn, because a result that
|
|
230
|
+
// size would be resent in full on every remaining round and can blow the
|
|
231
|
+
// window on its own. The turn cap is an append and costs no cache. The
|
|
232
|
+
// inline cap is not: the next turn swaps full text for the stub, which
|
|
233
|
+
// is a rewrite -- runChat opens any due epoch on that same turn so the
|
|
234
|
+
// conversation pays for one cold request, not two.
|
|
235
|
+
const inlineCap = cfg.maxInlineChars || 0;
|
|
236
|
+
const turnCap = cfg.maxTurnChars || 0;
|
|
237
|
+
const overTurn = turnCap > 0 && text.length > turnCap;
|
|
238
|
+
if (overTurn || (inlineCap > 0 && text.length > inlineCap)) {
|
|
239
|
+
msg.stub_text = digest({ id: artifactId, tool_name: name, content: text }) + (overTurn ? footer : '');
|
|
240
|
+
}
|
|
241
|
+
store.messages.add(chatId, msg);
|
|
242
|
+
out.push({
|
|
243
|
+
wire: toWire(overTurn ? msg : { ...msg, stub_text: null }),
|
|
244
|
+
message: msg.stub_text ? { ...msg, compacted: true } : msg
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
return { execute, stopRequested: () => stop };
|
|
239
250
|
}
|