getbotons 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { sandboxProblem, resetSandboxChecks } from '../sandbox.ts';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
|
+
import type { ModelEfforts } from '../../../shared/effort.ts';
|
|
5
|
+
import type { ApiKeyProvider } from '../../../shared/types.ts';
|
|
6
|
+
import { type ApiKeys, keyFilter, redactKey } from '../api-keys.ts';
|
|
7
|
+
import { log } from '../log.ts';
|
|
8
|
+
import { costOf, type ModelRate, RATE_SOURCES, rateFor, round } from '../rates.ts';
|
|
9
|
+
import { CapError, type Reservation, type Spending } from '../spend.ts';
|
|
10
|
+
import { shorten } from '../util.ts';
|
|
11
|
+
import { fitRequest, type History, type HistoryItem, type HistoryStore } from './api-history.ts';
|
|
12
|
+
import { type ToolCall, type ToolOutcome, Toolset, toolsKey } from './api-tools.ts';
|
|
13
|
+
import { type Scrub, SecretInAnswer, type ToolResults, type UserInput, type Wire, WireError, type WireItem, type WireRound } from './api-wire.ts';
|
|
14
|
+
import type { FailureKind } from './failures.ts';
|
|
15
|
+
import { type Provider, ProviderError, type ProviderStatus, type TurnRequest, type TurnResult, type TurnUsage } from './types.ts';
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* A Boton's brain on the owner's own Anthropic or OpenAI API key: Botons runs the agent loop itself. Each turn sends
|
|
19
|
+
* the conversation (kept by the server, bounded to fit) with the turn's tools; runs the tool calls the answer asks for
|
|
20
|
+
* (api-tools.ts: in the OS sandbox for Workspace access); sends their results back; and stops when the model answers
|
|
21
|
+
* without one, or at a bound: MAX_ROUNDS requests, MAX_CALLS calls an answer, PARALLEL at a time, REQUEST_MAX_BYTES a
|
|
22
|
+
* request, the turn's time limit, a stop, or a spending cap.
|
|
23
|
+
*
|
|
24
|
+
* Nothing is sent to the provider twice by Botons, and no turn is run again after a tool did something: a failure after
|
|
25
|
+
* that is not "transient", so the engine doesn't retry the turn (its tools would run twice). Every request is reserved
|
|
26
|
+
* under the caps as they are just before it goes (spend.ts).
|
|
27
|
+
*
|
|
28
|
+
* The key: a turn uses the one in use when it starts (saved, or the environment's), and only that one. Before each
|
|
29
|
+
* request, in the same synchronous step as its reservation and send, the key in use is compared with it (the key, where
|
|
30
|
+
* it came from, and its generation); if it was removed or replaced, or the saved one went and the environment's took its
|
|
31
|
+
* place, the turn stops there with nothing more sent. A request already on its way finishes. Every text that comes back
|
|
32
|
+
* (the model's, a tool's, the provider's errors, the drafts) has every key Botons holds at that moment taken out (the
|
|
33
|
+
* turn's own and any saved since), and so does everything the history keeps (api-wire.ts's sanitize() for the model's
|
|
34
|
+
* answers); a request body is checked last of all and is never sent while it holds one.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const MAX_ROUNDS = 40;
|
|
38
|
+
export const MAX_CALLS = 16;
|
|
39
|
+
export const PARALLEL = 4;
|
|
40
|
+
/**
|
|
41
|
+
* The most a request's JSON body may be, in bytes, exactly as it is sent: its instructions, tool definitions,
|
|
42
|
+
* conversation and the images this turn attached (as base64). A bound on what Botons sends, not a model's context
|
|
43
|
+
* window: a model may take less, and then the provider refuses the request.
|
|
44
|
+
*/
|
|
45
|
+
export const REQUEST_MAX_BYTES = 600_000;
|
|
46
|
+
/** What a request's framing adds to an item, at most, over its own JSON. */
|
|
47
|
+
const ITEM_FRAMING = 128;
|
|
48
|
+
/** No silence from the provider longer than this, mid-answer. */
|
|
49
|
+
const IDLE_MS = 180_000;
|
|
50
|
+
/** The least output a request is worth sending with, when a cap lowers it. */
|
|
51
|
+
const MIN_OUTPUT = 1024;
|
|
52
|
+
/** The tokens a request carries that its JSON doesn't show (the provider's tool-use prompt and message framing). */
|
|
53
|
+
const HIDDEN_TOKENS = 4096;
|
|
54
|
+
const MAX_IMAGE = 5 * 1024 * 1024;
|
|
55
|
+
/** What the wire adds around an image's base64 in a request (its type, media type and the JSON around them). */
|
|
56
|
+
const IMAGE_FRAMING = 256;
|
|
57
|
+
const IMAGE_TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
58
|
+
|
|
59
|
+
export interface ApiBrainDeps {
|
|
60
|
+
keys: ApiKeys;
|
|
61
|
+
history: HistoryStore;
|
|
62
|
+
spending: Spending;
|
|
63
|
+
/** BOTONS_OAUTH_ALLOW: what connector requests may reach. */
|
|
64
|
+
allow: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class Stopped extends Error {}
|
|
68
|
+
class TimedOut extends Error {}
|
|
69
|
+
class KeyChanged extends Error {}
|
|
70
|
+
/** Not sent: the request couldn't be made to fit, or would have carried a key. */
|
|
71
|
+
class NotSent extends Error {}
|
|
72
|
+
|
|
73
|
+
type Credential = { key: string; source: 'saved' | 'environment'; generation: number };
|
|
74
|
+
|
|
75
|
+
export class ApiBrainProvider implements Provider {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly label: string;
|
|
78
|
+
readonly askFirst = false;
|
|
79
|
+
readonly toolFree = true;
|
|
80
|
+
readonly serverTools = true;
|
|
81
|
+
#wire: Wire;
|
|
82
|
+
#deps: ApiBrainDeps;
|
|
83
|
+
#filters = new Map<string, (t: string) => boolean>();
|
|
84
|
+
|
|
85
|
+
constructor(wire: Wire, deps: ApiBrainDeps) {
|
|
86
|
+
this.#wire = wire;
|
|
87
|
+
this.#deps = deps;
|
|
88
|
+
this.id = wire.id;
|
|
89
|
+
this.label = wire.label;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get #p(): ApiKeyProvider {
|
|
93
|
+
return this.#wire.provider;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
recheck(): void { resetSandboxChecks(); }
|
|
97
|
+
|
|
98
|
+
async info(): Promise<ProviderStatus> {
|
|
99
|
+
return this.snapshot();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
snapshot(): ProviderStatus {
|
|
103
|
+
try {
|
|
104
|
+
return this.#info();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// The keys live in the database: when it can't be read, that is the database's failure, said once there.
|
|
107
|
+
log.debug('could not read the API key state', { provider: this.id, err });
|
|
108
|
+
const reason = 'Botons could not read its API keys from the database.';
|
|
109
|
+
return { id: this.id, label: this.label, kind: 'api', available: false, reason, installed: true, version: null, loggedIn: null, checkedAt: null, models: [], defaultModel: this.#wire.defaultModel, askFirst: false };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#info(): ProviderStatus {
|
|
114
|
+
const keys = this.#deps.keys;
|
|
115
|
+
const state = keys.state(this.#p);
|
|
116
|
+
const has = state.source !== 'none';
|
|
117
|
+
const listed = keys.models(this.#p);
|
|
118
|
+
const testedOk = keys.testedAt(this.#p);
|
|
119
|
+
const models = listed.length
|
|
120
|
+
? listed.filter((m) => m.lifecycle !== 'retired').map((m) => ({ id: m.id, label: m.displayName ?? m.id, ...(m.effort ? { efforts: m.effort } : {}) }))
|
|
121
|
+
: [{ id: this.#wire.defaultModel, label: this.#wire.defaultModel }];
|
|
122
|
+
return {
|
|
123
|
+
id: this.id,
|
|
124
|
+
label: this.label,
|
|
125
|
+
kind: 'api',
|
|
126
|
+
workspaceProblem: sandboxProblem(undefined, { quiet: true }) ? 'Not available on this server: the API tools sandbox could not start.' : null,
|
|
127
|
+
available: has,
|
|
128
|
+
reason: has ? null : `No ${state.label} API key: save one in Settings → API keys (or set ${state.envVar} in the server's environment).`,
|
|
129
|
+
installed: true,
|
|
130
|
+
version: null,
|
|
131
|
+
loggedIn: has ? (testedOk ? true : null) : false,
|
|
132
|
+
checkedAt: testedOk,
|
|
133
|
+
models,
|
|
134
|
+
defaultModel: this.#wire.defaultModel,
|
|
135
|
+
askFirst: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
modelEfforts(model: string): ModelEfforts | null {
|
|
140
|
+
const m = this.#deps.keys.models(this.#p).find((x) => x.id === (model || this.#wire.defaultModel));
|
|
141
|
+
return m?.effort ? { model: m.id, label: m.displayName ?? m.id, levels: m.effort } : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Only Botons' own connectors reach here (connectors.resolve); each must speak MCP over HTTP or stdio. */
|
|
145
|
+
fitMcpServers(servers: Parameters<NonNullable<Provider['fitMcpServers']>>[0]) {
|
|
146
|
+
return { servers, skipped: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every key Botons holds now, and the turn's own: what is taken out of all that comes back. */
|
|
150
|
+
#scrub(own: string): Scrub {
|
|
151
|
+
const filter = (k: string) => {
|
|
152
|
+
let f = this.#filters.get(k);
|
|
153
|
+
if (!f) {
|
|
154
|
+
if (this.#filters.size > 16) this.#filters.clear();
|
|
155
|
+
f = keyFilter(k);
|
|
156
|
+
this.#filters.set(k, f);
|
|
157
|
+
}
|
|
158
|
+
return f;
|
|
159
|
+
};
|
|
160
|
+
const keys = () => {
|
|
161
|
+
try {
|
|
162
|
+
return [...new Set([own, ...this.#deps.keys.knownKeys()])];
|
|
163
|
+
} catch {
|
|
164
|
+
return [own];
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
holds: (t) => !!t && keys().some((k) => filter(k)(t)),
|
|
169
|
+
redact: (t) => keys().reduce((out, k) => (filter(k)(out) ? redactKey(out, k) : out), t),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The history's items with every key known now out of them, in place (so the turn's own input stays the same item). */
|
|
174
|
+
#clean(history: History, scrub: Scrub) {
|
|
175
|
+
const kept: HistoryItem[] = [];
|
|
176
|
+
for (const it of history.items) {
|
|
177
|
+
if (it.kind === 'user') {
|
|
178
|
+
const d = it.data as { text?: unknown };
|
|
179
|
+
if (typeof d?.text === 'string' && scrub.holds(d.text)) it.data = { ...d, text: scrub.redact(d.text) };
|
|
180
|
+
} else if (it.kind === 'tools') {
|
|
181
|
+
const d = it.data as ToolResults;
|
|
182
|
+
if (Array.isArray(d?.results) && d.results.some((r) => scrub.holds(r.text))) it.data = { ...d, results: d.results.map((r) => ({ ...r, text: scrub.redact(r.text) })) };
|
|
183
|
+
} else if (scrub.holds(JSON.stringify(it.data))) {
|
|
184
|
+
try {
|
|
185
|
+
const clean = this.#wire.sanitize(it.data, scrub);
|
|
186
|
+
it.data = clean.assistant;
|
|
187
|
+
} catch {
|
|
188
|
+
// A kept answer that can't be cleaned goes, and the tool results that answered it with it.
|
|
189
|
+
const i = history.items.indexOf(it);
|
|
190
|
+
const next = history.items[i + 1];
|
|
191
|
+
if (next?.kind === 'tools') next.data = { results: [] };
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
kept.push(it);
|
|
196
|
+
}
|
|
197
|
+
history.items = kept.filter((it) => !(it.kind === 'tools' && !(it.data as ToolResults).results?.length));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async runTurn(req: TurnRequest): Promise<TurnResult> {
|
|
201
|
+
const keys = this.#deps.keys;
|
|
202
|
+
const cred: Credential | null = keys.keyFor(this.#p);
|
|
203
|
+
if (!cred) throw new ProviderError(`There is no ${keys.state(this.#p).label} API key: save one in Settings → API keys.`, false, { kind: 'auth' });
|
|
204
|
+
// Spending is counted for every request; a turn with nothing to count it against isn't run.
|
|
205
|
+
if (!req.spend) throw new ProviderError(`${this.label} can't run a turn without its spending account.`, false, { kind: 'other' });
|
|
206
|
+
const scope = req.spend;
|
|
207
|
+
const sameKey = () => {
|
|
208
|
+
const now = keys.keyFor(this.#p);
|
|
209
|
+
return !!now && now.key === cred.key && now.source === cred.source && now.generation === cred.generation;
|
|
210
|
+
};
|
|
211
|
+
const scrub = this.#scrub(cred.key);
|
|
212
|
+
const redact = scrub.redact;
|
|
213
|
+
const model = req.model || this.#wire.defaultModel;
|
|
214
|
+
const effort = req.effort ?? '';
|
|
215
|
+
const started = Date.now();
|
|
216
|
+
const deadline = started + req.timeoutMs;
|
|
217
|
+
const rate = rateFor(this.#p, model);
|
|
218
|
+
|
|
219
|
+
let history: History;
|
|
220
|
+
let sessionId = req.sessionId;
|
|
221
|
+
if (sessionId) {
|
|
222
|
+
const loaded = this.#deps.history.load(sessionId, this.id);
|
|
223
|
+
if (!loaded) throw new ProviderError(`${this.label}: the session ${sessionId} is gone`, true);
|
|
224
|
+
history = loaded;
|
|
225
|
+
this.#clean(history, scrub);
|
|
226
|
+
} else {
|
|
227
|
+
sessionId = this.#deps.history.newId(this.#wire.prefix);
|
|
228
|
+
history = { v: 1, provider: this.id, toolsKey: null, model: null, items: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const images = this.#images(req.images ?? []);
|
|
232
|
+
const prompt = redact(req.prompt);
|
|
233
|
+
const user: UserInput = { text: prompt, ...(images.length ? { images } : {}) };
|
|
234
|
+
// The new input, and what the history keeps of it (a note in place of each image).
|
|
235
|
+
const inHistory: HistoryItem = { kind: 'user', data: { text: images.length ? `${prompt}\n\n[${images.length} image${images.length > 1 ? 's' : ''} were attached here]` : prompt } };
|
|
236
|
+
history.items.push(inHistory);
|
|
237
|
+
|
|
238
|
+
let tools: Toolset;
|
|
239
|
+
try {
|
|
240
|
+
tools = await Toolset.open({ req, botonCli: req.botonCli ?? null, allow: this.#deps.allow, redact });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
throw new ProviderError(`${this.label} can't run this Boton's tools: ${redact((err as Error).message)}`, false, { kind: 'other' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const usage: TurnUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, model };
|
|
246
|
+
const cost = { committed: 0, held: 0 };
|
|
247
|
+
let rounds = 0;
|
|
248
|
+
let sent = 0;
|
|
249
|
+
let acted = false;
|
|
250
|
+
let usedTools = false;
|
|
251
|
+
let lastText = '';
|
|
252
|
+
let strippedOnce = false;
|
|
253
|
+
const warnings: string[] = [];
|
|
254
|
+
const usageNow = (): TurnUsage => {
|
|
255
|
+
const out: TurnUsage = { ...usage, numTurns: rounds };
|
|
256
|
+
if (rate) {
|
|
257
|
+
out.costUsd = round(cost.committed + cost.held);
|
|
258
|
+
out.costBasis = 'estimate';
|
|
259
|
+
if (cost.held > 0) out.costHeldUsd = round(cost.held);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
};
|
|
263
|
+
const save = () => {
|
|
264
|
+
try {
|
|
265
|
+
this.#clean(history, scrub);
|
|
266
|
+
this.#deps.history.save(sessionId!, history);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
log.warn('could not save an API session', { provider: this.id, err });
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const fail = (message: string, kind: FailureKind, o: { retryAt?: Date | null; timedOut?: boolean } = {}): ProviderError => {
|
|
272
|
+
// A provider error after a tool did something is never "transient": running the turn again would run it twice.
|
|
273
|
+
const safeKind = acted && kind === 'transient' ? 'other' : kind;
|
|
274
|
+
const err = new ProviderError(redact(message) + (acted && kind === 'transient' ? ' The turn had already run tools, so it is not tried again.' : ''), false, { kind: safeKind, retryAt: o.retryAt ?? null });
|
|
275
|
+
if (o.timedOut) err.exit = { exitCode: null, signal: null, timedOut: true, stderrTail: '' };
|
|
276
|
+
err.usage = usageNow();
|
|
277
|
+
err.mcpStartup = tools.startup;
|
|
278
|
+
return err;
|
|
279
|
+
};
|
|
280
|
+
const account = (r: Reservation, round: WireRound | null) => {
|
|
281
|
+
// Billed by what it reported; with no valid report, held at its most.
|
|
282
|
+
if (round?.usage && rate) {
|
|
283
|
+
const usd = costOf(rate, { inputTokens: round.usage.input, outputTokens: round.usage.output, cacheReadTokens: round.usage.cacheRead, cacheWriteTokens: round.usage.cacheWrite });
|
|
284
|
+
r.settle(usd);
|
|
285
|
+
cost.committed = round_(cost.committed + usd);
|
|
286
|
+
} else {
|
|
287
|
+
r.settle(null);
|
|
288
|
+
if (r.usd !== null) cost.held = round_(cost.held + r.usd);
|
|
289
|
+
}
|
|
290
|
+
if (round?.usage) {
|
|
291
|
+
usage.inputTokens += round.usage.input;
|
|
292
|
+
usage.outputTokens += round.usage.output;
|
|
293
|
+
usage.cacheReadTokens += round.usage.cacheRead;
|
|
294
|
+
usage.cacheWriteTokens += round.usage.cacheWrite;
|
|
295
|
+
}
|
|
296
|
+
if (round?.model) usage.model = round.model;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for (;;) {
|
|
301
|
+
if (req.signal.aborted) throw new Stopped('Stopped.');
|
|
302
|
+
if (Date.now() >= deadline) throw fail(`${this.label} timed out.`, 'other', { timedOut: true });
|
|
303
|
+
if (rounds >= MAX_ROUNDS) {
|
|
304
|
+
warnings.push(`stopped after ${MAX_ROUNDS} requests to the model`);
|
|
305
|
+
lastText = `${lastText ? `${lastText}\n\n` : ''}(Botons stopped this turn after ${MAX_ROUNDS} requests to the model: what was done so far stands.)`;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
rounds++;
|
|
309
|
+
const specs = tools.specs;
|
|
310
|
+
const key = toolsKey(specs);
|
|
311
|
+
// Reasoning is bound to the conversation and tools it was made under: when those change, it goes.
|
|
312
|
+
if (history.toolsKey && history.toolsKey !== key) this.#strip(history);
|
|
313
|
+
history.toolsKey = key;
|
|
314
|
+
let round: WireRound;
|
|
315
|
+
try {
|
|
316
|
+
round = await this.#round({ req, model, effort, history, inHistory, user, specs, rate, scope, cred, sameKey, scrub, deadline, account, onSent: () => sent++ });
|
|
317
|
+
} catch (err) {
|
|
318
|
+
// Reasoning the provider won't take under this history (it changed since): without it, once.
|
|
319
|
+
if (err instanceof WireError && !strippedOnce && this.#wire.reasoningRefused(err)) {
|
|
320
|
+
strippedOnce = true;
|
|
321
|
+
this.#strip(history);
|
|
322
|
+
rounds--;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
if (round.assistant) history.items.push({ kind: 'assistant', data: round.assistant });
|
|
328
|
+
history.model = round.model ?? model;
|
|
329
|
+
lastText = redact(round.text);
|
|
330
|
+
// A reply only: a call it asks for anyway is never run.
|
|
331
|
+
if (round.stop === 'tools' && round.calls.length && req.toolFree) {
|
|
332
|
+
history.items.pop();
|
|
333
|
+
warnings.push('it asked for a tool in a turn that has none');
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (round.stop === 'tools' && round.calls.length) {
|
|
337
|
+
usedTools = true;
|
|
338
|
+
const results = await this.#runCalls(round.calls, tools, req, deadline, () => (acted = true));
|
|
339
|
+
history.items.push({ kind: 'tools', data: { results: results.map((r) => ({ ...r, text: redact(r.text) })) } });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (round.stop === 'max_tokens') warnings.push('the answer was cut at its length limit');
|
|
343
|
+
if (round.stop === 'refusal') lastText = lastText || `(${this.label} declined to answer${round.stopDetail ? `: ${redact(round.stopDetail)}` : ''}.)`;
|
|
344
|
+
else if (round.stop === 'paused') {
|
|
345
|
+
// The provider paused a long answer of its own; it carries on when sent back as it is.
|
|
346
|
+
continue;
|
|
347
|
+
} else if (round.stop === 'other' && round.stopDetail) warnings.push(`the answer ended: ${redact(round.stopDetail)}`);
|
|
348
|
+
if (round.stop === 'max_tokens' && !lastText) lastText = '(The answer was cut short: at its length limit, or to stay within a spending cap.)';
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
save();
|
|
353
|
+
tools.close();
|
|
354
|
+
if (err instanceof ProviderError) throw err;
|
|
355
|
+
if (err instanceof Stopped || req.signal.aborted) throw Object.assign(new ProviderError('Stopped.', false), { usage: usageNow(), mcpStartup: tools.startup });
|
|
356
|
+
if (err instanceof TimedOut) throw fail(`${this.label} timed out.`, 'other', { timedOut: true });
|
|
357
|
+
const stands = acted ? ' What it did before that stands.' : '';
|
|
358
|
+
if (err instanceof CapError) throw fail(sent ? `Stopped by the spending cap: ${err.message}${stands}` : `Not run (spending cap): ${err.message}`, 'other');
|
|
359
|
+
if (err instanceof KeyChanged)
|
|
360
|
+
throw fail(`The ${keys.state(this.#p).label} API key changed while this turn ran (removed or replaced in Settings → API keys), so it stopped before its next request.${stands} The next turn uses the key in use then.`, 'auth');
|
|
361
|
+
if (err instanceof NotSent) throw fail(`${err.message}${stands}`, 'other');
|
|
362
|
+
if (err instanceof SecretInAnswer) throw fail(`${this.label}'s answer held an API key in ${err.message}, which Botons can't take out without breaking it, so none of that answer was kept and the turn stopped.${stands}`, 'other');
|
|
363
|
+
if (err instanceof WireError) throw fail(this.#describe(err), this.#kind(err), { retryAt: err.kind === 'quota' ? new Date(Date.now() + (err.retryAfterMs ?? 30 * 60_000)) : null });
|
|
364
|
+
throw fail(`${this.label} failed: ${shorten((err as Error).message, 300)}`, 'other');
|
|
365
|
+
}
|
|
366
|
+
save();
|
|
367
|
+
tools.close();
|
|
368
|
+
const what: ('chat' | 'tools' | 'vision')[] = ['chat', ...(usedTools ? (['tools'] as const) : []), ...(images.length ? (['vision'] as const) : [])];
|
|
369
|
+
try {
|
|
370
|
+
keys.observe(this.#p, cred, usage.model ?? model, what);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
log.debug('could not note what a turn showed of its model', { provider: this.id, err });
|
|
373
|
+
}
|
|
374
|
+
log.info('api turn', { provider: this.id, model: usage.model ?? model, rounds, toolsUsed: usedTools, priced: !!rate, rates: rate ? RATE_SOURCES[this.#p].checked : undefined });
|
|
375
|
+
return { sessionId, text: lastText, usage: usageNow(), mcpStartup: tools.startup, ...(warnings.length ? { warnings } : {}) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The history as sent, with this turn's images in its own input (the history keeps notes in their place). */
|
|
379
|
+
#withImages(items: HistoryItem[], inHistory: HistoryItem, user: UserInput): WireItem[] {
|
|
380
|
+
return items.map((it) => (it === inHistory ? { kind: 'user', data: user } : (it as WireItem)));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#strip(history: History) {
|
|
384
|
+
history.items = history.items.flatMap((it) => {
|
|
385
|
+
if (it.kind !== 'assistant') return [it];
|
|
386
|
+
const kept = this.#wire.stripReasoning(it.data);
|
|
387
|
+
return kept ? [{ ...it, data: kept }] : [];
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* One request: fitted to REQUEST_MAX_BYTES (or not sent), checked for keys (or not sent), reserved under the caps
|
|
393
|
+
* as they are now and sent with the turn's key, in one synchronous step after the key is checked; sent once (or
|
|
394
|
+
* once more, reserved again, when the provider refused it before generating anything and says what it takes).
|
|
395
|
+
*/
|
|
396
|
+
async #round(o: {
|
|
397
|
+
req: TurnRequest;
|
|
398
|
+
model: string;
|
|
399
|
+
effort: string;
|
|
400
|
+
history: History;
|
|
401
|
+
inHistory: HistoryItem;
|
|
402
|
+
user: UserInput;
|
|
403
|
+
specs: Toolset['specs'];
|
|
404
|
+
rate: ModelRate | null;
|
|
405
|
+
scope: NonNullable<TurnRequest['spend']>;
|
|
406
|
+
cred: Credential;
|
|
407
|
+
sameKey: () => boolean;
|
|
408
|
+
scrub: Scrub;
|
|
409
|
+
deadline: number;
|
|
410
|
+
account: (r: Reservation, round: WireRound | null) => void;
|
|
411
|
+
onSent: () => void;
|
|
412
|
+
}): Promise<WireRound> {
|
|
413
|
+
const { req, history, scrub } = o;
|
|
414
|
+
const noCalls = !!req.toolFree;
|
|
415
|
+
const system = scrub.redact(req.systemPrompt);
|
|
416
|
+
const build = (items: WireItem[], maxOutput: number) => this.#wire.body({ model: o.model, effort: o.effort, system, items, tools: o.specs, maxOutput, noCalls });
|
|
417
|
+
const maxOutput = this.#wire.maxOutput(o.effort);
|
|
418
|
+
const bytes = (b: unknown) => Buffer.byteLength(JSON.stringify(b));
|
|
419
|
+
|
|
420
|
+
// The bound: the whole request, this turn's images included (they go in every request of the turn), and the
|
|
421
|
+
// conversation fitted to what is left of it.
|
|
422
|
+
const images = o.history.items.includes(o.inHistory) ? (o.user.images ?? []) : [];
|
|
423
|
+
const imageBytes = images.reduce((n, img) => n + img.base64.length + IMAGE_FRAMING, 0);
|
|
424
|
+
const fixed = bytes(build([], maxOutput)) + imageBytes;
|
|
425
|
+
const fit = fitRequest(
|
|
426
|
+
history.items.map((it) => it),
|
|
427
|
+
REQUEST_MAX_BYTES - ITEM_FRAMING * history.items.length,
|
|
428
|
+
fixed,
|
|
429
|
+
);
|
|
430
|
+
if (!fit.ok) {
|
|
431
|
+
const kb = (n: number) => `${Math.ceil(n / 1024)} KB`;
|
|
432
|
+
throw new NotSent(
|
|
433
|
+
fit.why === 'input' && imageBytes
|
|
434
|
+
? `Not sent: this message, with its ${images.length > 1 ? `${images.length} images` : 'image'} (${kb(imageBytes)} as sent) and the Boton's instructions and tools, is ${kb(fit.over)} more than the ${kb(REQUEST_MAX_BYTES)} Botons sends to the model at once, images included. Attach fewer or smaller images, or put them in the workspace and ask about the files instead.`
|
|
435
|
+
: fit.why === 'input'
|
|
436
|
+
? `Not sent: this message, with the Boton's instructions and tools, is ${kb(fit.over)} more than the ${kb(REQUEST_MAX_BYTES)} Botons sends to the model at once. Put the long text in a file in the workspace and ask about the file instead.`
|
|
437
|
+
: `Stopped before its next request: this turn's conversation has grown past the ${kb(REQUEST_MAX_BYTES)} Botons sends to the model at once, even with older messages and earlier tool output left out.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (fit.trimmed || fit.compacted) {
|
|
441
|
+
history.items = fit.items;
|
|
442
|
+
if (fit.trimmed) this.#strip(history);
|
|
443
|
+
}
|
|
444
|
+
// Last: no request goes with a key in it (one saved since the history was cleaned, too).
|
|
445
|
+
let plain = build(history.items as WireItem[], maxOutput);
|
|
446
|
+
if (scrub.holds(JSON.stringify(plain))) {
|
|
447
|
+
this.#clean(history, scrub);
|
|
448
|
+
plain = build(history.items as WireItem[], maxOutput);
|
|
449
|
+
if (scrub.holds(JSON.stringify(plain))) throw new NotSent('Not sent: the request would have held an API key (in the Boton’s instructions, or in the conversation where Botons can’t take it out). Take the key out of what the Boton keeps, then try again.');
|
|
450
|
+
}
|
|
451
|
+
if (bytes(plain) > REQUEST_MAX_BYTES) throw new NotSent(`Stopped before its next request: this turn's conversation has grown past the ${Math.ceil(REQUEST_MAX_BYTES / 1024)} KB Botons sends to the model at once.`);
|
|
452
|
+
|
|
453
|
+
// Its text with every key known now out of it, as the history's.
|
|
454
|
+
const items = this.#withImages(history.items, o.inHistory, { ...o.user, text: scrub.redact(o.user.text) });
|
|
455
|
+
const timeout = AbortSignal.timeout(Math.max(1, o.deadline - Date.now()));
|
|
456
|
+
const signal = AbortSignal.any([req.signal, timeout]);
|
|
457
|
+
|
|
458
|
+
// What is checked of the body that goes: all of it but the images' base64, which is checked as the bytes it holds.
|
|
459
|
+
const data = new Set(images.map((img) => img.base64));
|
|
460
|
+
const withoutImages = (body: Record<string, unknown>) => JSON.stringify(body, (_k, v) => (typeof v === 'string' && data.has(v) ? '' : v));
|
|
461
|
+
/** The body exactly as it would go, within the bound and holding no key known now; else nothing is reserved or sent. */
|
|
462
|
+
const check = (body: Record<string, unknown>) => {
|
|
463
|
+
const imagesHold = images.some((img) => scrub.holds(Buffer.from(img.base64, 'base64').toString('latin1')));
|
|
464
|
+
const size = bytes(body);
|
|
465
|
+
if (size > REQUEST_MAX_BYTES)
|
|
466
|
+
throw new NotSent(
|
|
467
|
+
`Not sent: the request${imageBytes ? `, with this message's ${images.length > 1 ? 'images' : 'image'},` : ''} is ${Math.ceil(size / 1024)} KB, more than the ${Math.ceil(REQUEST_MAX_BYTES / 1024)} KB Botons sends to the model at once${imageBytes ? ', images included. Attach fewer or smaller images' : ''}.`,
|
|
468
|
+
);
|
|
469
|
+
if (scrub.holds(withoutImages(body)) || imagesHold) throw new NotSent('Not sent: the request would have held an API key (in the Boton’s instructions, the conversation or an attached image). Take the key out, then try again.');
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/** Check, reserve, then send at once: nothing in between can change the key, the body or the account. */
|
|
473
|
+
const go = (body: Record<string, unknown>): { r: Reservation; answer: Promise<WireRound> } => {
|
|
474
|
+
check(body);
|
|
475
|
+
if (!o.sameKey()) throw new KeyChanged();
|
|
476
|
+
// At most one token per byte of what is sent, plus what the provider adds.
|
|
477
|
+
const inputTokens = bytes(body) + HIDDEN_TOKENS;
|
|
478
|
+
const field = 'max_tokens' in body ? 'max_tokens' : 'max_output_tokens';
|
|
479
|
+
const r = this.#deps.spending.reserve(o.scope, { provider: this.id, model: o.model, rate: o.rate, inputTokens, maxOutput: Number(body[field]), minOutput: MIN_OUTPUT });
|
|
480
|
+
let send = body;
|
|
481
|
+
try {
|
|
482
|
+
// Lowered to fit a cap: the same body with the smaller output cap (no longer, so the reservation still holds).
|
|
483
|
+
if (r.maxOutput < Number(body[field])) send = { ...body, [field]: r.maxOutput };
|
|
484
|
+
if (bytes(send) > bytes(body)) throw new NotSent('Not sent: the request grew when its output cap was lowered.');
|
|
485
|
+
const answer = this.#wire.send(send, {
|
|
486
|
+
origin: this.#deps.keys.origin(this.#p),
|
|
487
|
+
key: o.cred.key,
|
|
488
|
+
signal,
|
|
489
|
+
onText: (t) => req.onText?.(scrub.redact(t)),
|
|
490
|
+
onActivity: req.onActivity,
|
|
491
|
+
idleMs: IDLE_MS,
|
|
492
|
+
});
|
|
493
|
+
o.onSent();
|
|
494
|
+
return { r, answer };
|
|
495
|
+
} catch (err) {
|
|
496
|
+
r.release();
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const answered = async (body: Record<string, unknown>): Promise<WireRound> => {
|
|
501
|
+
const { r, answer } = go(body);
|
|
502
|
+
let round: WireRound;
|
|
503
|
+
try {
|
|
504
|
+
round = await answer;
|
|
505
|
+
} catch (err) {
|
|
506
|
+
// Refused with an error status before anything was generated: nothing billed. Anything else may have been.
|
|
507
|
+
if (err instanceof WireError && !err.sent) r.release();
|
|
508
|
+
else o.account(r, null);
|
|
509
|
+
throw err;
|
|
510
|
+
}
|
|
511
|
+
o.account(r, round);
|
|
512
|
+
return round;
|
|
513
|
+
};
|
|
514
|
+
try {
|
|
515
|
+
const body = build(items, maxOutput);
|
|
516
|
+
let round: WireRound;
|
|
517
|
+
try {
|
|
518
|
+
round = await answered(body);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
// Refused before anything was generated: a body the provider takes instead, sent once.
|
|
521
|
+
const again = err instanceof WireError && !err.sent ? this.#wire.adjust(body, err) : null;
|
|
522
|
+
if (!again) throw err;
|
|
523
|
+
round = await answered(again);
|
|
524
|
+
}
|
|
525
|
+
// Nothing of the answer is kept or shown with a key in it (SecretInAnswer when it can't be taken out).
|
|
526
|
+
const clean = this.#wire.sanitize(round.assistant, scrub, round.calls);
|
|
527
|
+
return { ...round, assistant: clean.assistant, text: clean.text, calls: clean.calls, stopDetail: round.stopDetail ? scrub.redact(round.stopDetail) : null };
|
|
528
|
+
} catch (err) {
|
|
529
|
+
if (timeout.aborted && !req.signal.aborted) throw new TimedOut();
|
|
530
|
+
if (req.signal.aborted) throw new Stopped('Stopped.');
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async #runCalls(calls: ToolCall[], tools: Toolset, req: TurnRequest, deadline: number, onActed: () => void): Promise<{ id: string; name: string; text: string; isError: boolean }[]> {
|
|
536
|
+
const results: { id: string; name: string; text: string; isError: boolean }[] = new Array(calls.length);
|
|
537
|
+
let next = 0;
|
|
538
|
+
const worker = async () => {
|
|
539
|
+
for (;;) {
|
|
540
|
+
const i = next++;
|
|
541
|
+
if (i >= calls.length) return;
|
|
542
|
+
const call = calls[i];
|
|
543
|
+
if (i >= MAX_CALLS) {
|
|
544
|
+
results[i] = { id: call.id, name: call.name, text: `Not run: at most ${MAX_CALLS} tool calls an answer.`, isError: true };
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (req.signal.aborted) {
|
|
548
|
+
results[i] = { id: call.id, name: call.name, text: 'Not run: the turn was stopped.', isError: true };
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const d = tools.describe(call);
|
|
552
|
+
req.onActivity(d.activity);
|
|
553
|
+
try {
|
|
554
|
+
req.onStep?.(d.step);
|
|
555
|
+
} catch {}
|
|
556
|
+
let out: ToolOutcome;
|
|
557
|
+
if (call.secret) out = { text: 'Not run: its input held an API key, which Botons keeps out of every tool.', isError: true, acted: false };
|
|
558
|
+
else if (call.input === null) out = { text: 'The tool input was not valid JSON.', isError: true, acted: false };
|
|
559
|
+
else out = await tools.run(call, req.signal, deadline, req.onToolResult);
|
|
560
|
+
if (out.acted) onActed();
|
|
561
|
+
results[i] = { id: call.id, name: call.name, text: out.text, isError: out.isError };
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, calls.length) }, worker));
|
|
565
|
+
return results;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#images(paths: string[]): { mediaType: string; base64: string }[] {
|
|
569
|
+
const out: { mediaType: string; base64: string }[] = [];
|
|
570
|
+
for (const p of paths) {
|
|
571
|
+
const type = IMAGE_TYPES[extname(p).toLowerCase()];
|
|
572
|
+
try {
|
|
573
|
+
if (!type || statSync(p).size > MAX_IMAGE) continue;
|
|
574
|
+
out.push({ mediaType: type, base64: readFileSync(p).toString('base64') });
|
|
575
|
+
} catch {}
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#describe(err: WireError): string {
|
|
581
|
+
switch (err.kind) {
|
|
582
|
+
case 'auth':
|
|
583
|
+
return `The ${this.#deps.keys.state(this.#p).label} API refused the key (401): check it in Settings → API keys.`;
|
|
584
|
+
case 'forbidden':
|
|
585
|
+
return `The ${this.#deps.keys.state(this.#p).label} API refused this request (403): the key may not use this model. ${err.message}`;
|
|
586
|
+
case 'not_found':
|
|
587
|
+
return `The ${this.#deps.keys.state(this.#p).label} API doesn't know this model for the key (404). ${err.message}`;
|
|
588
|
+
default:
|
|
589
|
+
return err.message;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Transient only when trying the turn again can't repeat anything (fail() makes sure of that). */
|
|
594
|
+
#kind(err: WireError): FailureKind {
|
|
595
|
+
switch (err.kind) {
|
|
596
|
+
case 'auth':
|
|
597
|
+
return 'auth';
|
|
598
|
+
case 'quota':
|
|
599
|
+
return 'usage_limit';
|
|
600
|
+
case 'network':
|
|
601
|
+
case 'stream':
|
|
602
|
+
case 'overloaded':
|
|
603
|
+
case 'server':
|
|
604
|
+
case 'rate_limited':
|
|
605
|
+
return 'transient';
|
|
606
|
+
default:
|
|
607
|
+
return 'other';
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const round_ = round;
|