@cubicecho/agent-core 2.21.1 → 2.22.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/README.md +45 -2
- package/dist/agent-loop.d.ts +89 -3
- package/dist/agent-loop.js +123 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/side-task.d.ts +35 -2
- package/dist/side-task.js +20 -9
- package/dist/spec.d.ts +13 -1
- package/llms.txt +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ only, Node >=22.
|
|
|
36
36
|
| `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
|
|
37
37
|
| `config` | The structural interfaces every function here asks for. |
|
|
38
38
|
| `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
|
|
39
|
-
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`,
|
|
39
|
+
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, `resolveApiKey` for a caller deciding which key an endpoint gets, and `taskCall` for running a side task on the settings an agent spec gives it. |
|
|
40
40
|
| `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. |
|
|
41
41
|
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
42
42
|
| `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
|
|
@@ -564,7 +564,9 @@ have to score well below the ones it kept — a hit just under the line scoring
|
|
|
564
564
|
one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
|
|
565
565
|
on. Matching nothing is not confident either, since the words cannot tell a request that needs no
|
|
566
566
|
tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
|
|
567
|
-
preselection at all, words included.
|
|
567
|
+
preselection at all, words included. `preselect` also takes a `temperature` and a
|
|
568
|
+
`reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
|
|
569
|
+
itself — see "Its side tasks" below.
|
|
568
570
|
|
|
569
571
|
A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
|
|
570
572
|
continued first. `continueTurn` is the same thing for a caller with its own loop:
|
|
@@ -1150,6 +1152,47 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
|
|
|
1150
1152
|
what lets a document travel between hosts without the loop growing an opinion about where an agent
|
|
1151
1153
|
comes from.
|
|
1152
1154
|
|
|
1155
|
+
### Its side tasks
|
|
1156
|
+
|
|
1157
|
+
`tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
|
|
1158
|
+
temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
|
|
1159
|
+
what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
|
|
1160
|
+
`preselect` already take, so a setting added to a task later arrives without the host's call
|
|
1161
|
+
changing:
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
const title = taskCall(config.tasks.title, { signal, onNotice }, config);
|
|
1165
|
+
const name = await ask(title.endpoint, title.model, "Name this chat.", firstMessage, title.options);
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
**An absent setting is the side task's own default, never the agent's.** A titler under an agent
|
|
1169
|
+
running at temperature 1.0 is still sent 0.3, and its ceiling is still `ask`'s 512, `summariser`'s
|
|
1170
|
+
1024 or `preselect`'s 256. What the task does state wins over the same field in the options beside
|
|
1171
|
+
it, so the options are where a host puts its own default for a task that names none. `maxTokens: 0`
|
|
1172
|
+
sends no ceiling, as it does on the main model.
|
|
1173
|
+
|
|
1174
|
+
`reasoningEffort` is the one that reads differently from the main model's. A side task is sent the
|
|
1175
|
+
no-thinking hints by default, and absent or `"off"` keeps them; a level — `"low"`, `"high"` — is
|
|
1176
|
+
sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
|
|
1177
|
+
that should deliberate.
|
|
1178
|
+
|
|
1179
|
+
The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
|
|
1180
|
+
sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
|
|
1181
|
+
share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
|
|
1182
|
+
another endpoint is sent only a key of its own, as `resolveApiKey` has it.
|
|
1183
|
+
|
|
1184
|
+
The loop reads two of them when asked to. `preselect: true` has `runAgentLoop` make the
|
|
1185
|
+
preselection itself with `tasks.toolSelect` — or with `toolSelectModel` on the agent's endpoint,
|
|
1186
|
+
which stays the flattened spelling for a host that is not on the spec — and `compact: true` has it
|
|
1187
|
+
fold the transcript before a step, once `planCompaction` says a `contextLength` window is filling,
|
|
1188
|
+
with `tasks.compaction` writing the summary and no falling back to the main model without it. Both
|
|
1189
|
+
are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
|
|
1190
|
+
a second request.
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
const result = await runAgentLoop({ config, preselect: { keywords: true }, compact: true, ... });
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1153
1196
|
## What is kept for the life of the process
|
|
1154
1197
|
|
|
1155
1198
|
Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
2
|
import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
|
|
3
3
|
import type { CatalogServer } from "./catalog.ts";
|
|
4
|
+
import { type CompactionOptions } from "./compaction.ts";
|
|
4
5
|
import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
|
|
5
6
|
import { type RunEventInput, type RunMetrics } from "./events.ts";
|
|
6
7
|
import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
|
|
7
8
|
import { type TokenLedger } from "./ledger.ts";
|
|
9
|
+
import { type SideTask, type SideTaskOptions } from "./side-task.ts";
|
|
8
10
|
import type { Turn, TurnUsage } from "./stream.ts";
|
|
9
11
|
import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
|
|
10
12
|
/**
|
|
@@ -64,6 +66,38 @@ export declare function resolveApiKey(own: {
|
|
|
64
66
|
baseUrl: string;
|
|
65
67
|
apiKey?: string;
|
|
66
68
|
}, env?: Record<string, string | undefined>): string;
|
|
69
|
+
/**
|
|
70
|
+
* A side task's settings as the call that honours them: its endpoint, its model, and the options
|
|
71
|
+
* `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
72
|
+
*
|
|
73
|
+
* A helper rather than a second signature on each of the four, so a setting added to a task later
|
|
74
|
+
* arrives through `options` without a host's call changing. What the task states wins over the
|
|
75
|
+
* same field in `options`: the host's code is written once and the task is what an operator
|
|
76
|
+
* configured over it, so a host passes its own ceiling as the value for a task that names none.
|
|
77
|
+
* A setting the task leaves out and `options` does too is absent from the result, which is what
|
|
78
|
+
* leaves it to the entry point's own default — 0.3, not the agent's temperature.
|
|
79
|
+
*
|
|
80
|
+
* A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
|
|
81
|
+
* unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
|
|
82
|
+
* such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
|
|
83
|
+
* the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
|
|
84
|
+
* is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
|
|
85
|
+
* agent's key is whatever the main turn is sent.
|
|
86
|
+
*
|
|
87
|
+
* @param task The task, ordinarily `resolved.tasks.<key>`.
|
|
88
|
+
* @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
|
|
89
|
+
* for the settings a task may leave out.
|
|
90
|
+
* @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
|
|
91
|
+
* as it stands.
|
|
92
|
+
*/
|
|
93
|
+
export declare function taskCall<Options extends SideTaskOptions = SideTaskOptions>(task: SideTask, options?: Options, agent?: {
|
|
94
|
+
baseUrl: string;
|
|
95
|
+
apiKey?: string;
|
|
96
|
+
}): {
|
|
97
|
+
endpoint: Endpoint;
|
|
98
|
+
model: string;
|
|
99
|
+
options: Options;
|
|
100
|
+
};
|
|
67
101
|
/**
|
|
68
102
|
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
69
103
|
*
|
|
@@ -83,13 +117,18 @@ export declare function resolveApiKey(own: {
|
|
|
83
117
|
* means by empty.
|
|
84
118
|
* @param catalog The servers to choose from.
|
|
85
119
|
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
86
|
-
* @param options Cancellation, notices, the reply ceiling (256), the
|
|
120
|
+
* @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
|
|
121
|
+
* effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
|
|
87
122
|
* (`MAX_PER_LOAD`), and whether to try the words first.
|
|
88
123
|
*/
|
|
89
|
-
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, keywords, }?: {
|
|
124
|
+
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: {
|
|
90
125
|
signal?: AbortSignal;
|
|
91
126
|
onNotice?: (message: string) => void;
|
|
92
127
|
maxTokens?: number;
|
|
128
|
+
/** As `SideTaskOptions.temperature`: absent is 0.3. */
|
|
129
|
+
temperature?: number;
|
|
130
|
+
/** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
|
|
131
|
+
reasoningEffort?: string;
|
|
93
132
|
maxPerLoad?: number;
|
|
94
133
|
/**
|
|
95
134
|
* Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
|
|
@@ -202,9 +241,14 @@ export interface AgentLoopOptions {
|
|
|
202
241
|
* `toolDiscovery` absent is eager, `maxRetries` absent is none, and `contextLength` is handed
|
|
203
242
|
* to `runTurn` as `contextLimit`, which sizes each request against the window before sending.
|
|
204
243
|
* `"proxy"` is on-demand loading behind a tool array that never changes; see `PROXY_TOOLS`.
|
|
244
|
+
*
|
|
245
|
+
* `tasks` is a resolved agent's, and is read only for the side tasks the loop was asked to run:
|
|
246
|
+
* `tasks.toolSelect` under `preselect` and `tasks.compaction` under `compact`. `toolSelectModel`
|
|
247
|
+
* is the flattened spelling of the first, for a host that is not on the spec.
|
|
205
248
|
*/
|
|
206
|
-
config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery">> & Partial<RetryPolicy> & {
|
|
249
|
+
config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery" | "toolSelectModel">> & Partial<RetryPolicy> & {
|
|
207
250
|
contextLength?: number;
|
|
251
|
+
tasks?: Readonly<Record<string, SideTask>>;
|
|
208
252
|
};
|
|
209
253
|
/**
|
|
210
254
|
* The standing instruction, sent as the first message. On-demand and proxied modes append the
|
|
@@ -260,6 +304,43 @@ export interface AgentLoopOptions {
|
|
|
260
304
|
* mode, or proxied, where no step is routed either way.
|
|
261
305
|
*/
|
|
262
306
|
preselectRouting?: "exclusive" | "append";
|
|
307
|
+
/**
|
|
308
|
+
* Has the loop make the preselection itself, before its first request, with the preselector
|
|
309
|
+
* the config names. Off by default: a host that passes a resolved agent and calls `preselect`
|
|
310
|
+
* on its own is not sent a second one.
|
|
311
|
+
*
|
|
312
|
+
* The preselector is `config.tasks.toolSelect` with everything it states — its endpoint, model,
|
|
313
|
+
* ceiling, temperature and reasoning effort, keyed as `taskCall` keys a task against `config` —
|
|
314
|
+
* and otherwise `config.toolSelectModel` on the config's own endpoint at `preselect`'s defaults.
|
|
315
|
+
* What it is asked about is the text of the last user message. Nothing is asked in eager mode,
|
|
316
|
+
* with neither preselector named, with no user message, or when `preselected` is given — an
|
|
317
|
+
* empty one included, which is how a host says it has already decided.
|
|
318
|
+
*
|
|
319
|
+
* `true` takes `preselect`'s defaults; an object passes `keywords` and `maxPerLoad` through.
|
|
320
|
+
* Its notices arrive as `notice` events, and a preselection that fails picks nothing rather
|
|
321
|
+
* than ending the run. It runs before the hooks gather, not beside them.
|
|
322
|
+
*/
|
|
323
|
+
preselect?: boolean | {
|
|
324
|
+
keywords?: boolean | KeywordPreselectOptions;
|
|
325
|
+
maxPerLoad?: number;
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Has the loop fold the transcript itself before a step, once `planCompaction` says the window
|
|
329
|
+
* is filling, with the summary written by `config.tasks.compaction` on its own settings. Off by
|
|
330
|
+
* default, and nothing is folded without that task — there is no falling back to the main model
|
|
331
|
+
* — or without a `config.contextLength` to plan against.
|
|
332
|
+
*
|
|
333
|
+
* It runs after `beforeStep`, on what that returned, and is `compactTranscript` and nothing
|
|
334
|
+
* more: the plan is made from the last request's reported prompt, or an estimate before the
|
|
335
|
+
* first step and after a `beforeStep` rewrite; `hooks`, when given, are told `beforeCompact`
|
|
336
|
+
* beside the summary; and a fold is said as a `notice` event. A summary that fails is a notice
|
|
337
|
+
* too and folds nothing. Tool results are not pruned — `beforeStep` is where a host does that.
|
|
338
|
+
*
|
|
339
|
+
* The result's `messages` then open with the summary in place of what was folded, so a host
|
|
340
|
+
* that keeps its transcript append-only wants `runCompaction` and a stored fold instead. `true`
|
|
341
|
+
* plans by `planCompaction`'s defaults; an object moves its thresholds.
|
|
342
|
+
*/
|
|
343
|
+
compact?: boolean | Pick<CompactionOptions, "compactAt" | "keepRatio" | "target">;
|
|
263
344
|
/**
|
|
264
345
|
* Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
|
|
265
346
|
* mode, where a tool is loaded only while its definition is in the history.
|
|
@@ -469,6 +550,11 @@ export interface AgentLoopResult {
|
|
|
469
550
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
470
551
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
471
552
|
*
|
|
553
|
+
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
554
|
+
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
555
|
+
* that task's own settings — a setting the task leaves out is the side task's default, never the
|
|
556
|
+
* run's `temperature` or `maxTokens`.
|
|
557
|
+
*
|
|
472
558
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
473
559
|
* and cancellation. See `AgentLoopOptions`.
|
|
474
560
|
*/
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
2
|
import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
|
+
import { compactTranscript, planCompaction, summariser, } from "./compaction.js";
|
|
4
5
|
import { continueTurn } from "./continuation.js";
|
|
5
6
|
import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
|
|
6
7
|
import { runMetrics } from "./events.js";
|
|
@@ -106,6 +107,52 @@ export function resolveApiKey(own, inherited, env = process.env) {
|
|
|
106
107
|
return NO_KEY;
|
|
107
108
|
return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* A side task's settings as the call that honours them: its endpoint, its model, and the options
|
|
112
|
+
* `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
113
|
+
*
|
|
114
|
+
* A helper rather than a second signature on each of the four, so a setting added to a task later
|
|
115
|
+
* arrives through `options` without a host's call changing. What the task states wins over the
|
|
116
|
+
* same field in `options`: the host's code is written once and the task is what an operator
|
|
117
|
+
* configured over it, so a host passes its own ceiling as the value for a task that names none.
|
|
118
|
+
* A setting the task leaves out and `options` does too is absent from the result, which is what
|
|
119
|
+
* leaves it to the entry point's own default — 0.3, not the agent's temperature.
|
|
120
|
+
*
|
|
121
|
+
* A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
|
|
122
|
+
* unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
|
|
123
|
+
* such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
|
|
124
|
+
* the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
|
|
125
|
+
* is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
|
|
126
|
+
* agent's key is whatever the main turn is sent.
|
|
127
|
+
*
|
|
128
|
+
* @param task The task, ordinarily `resolved.tasks.<key>`.
|
|
129
|
+
* @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
|
|
130
|
+
* for the settings a task may leave out.
|
|
131
|
+
* @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
|
|
132
|
+
* as it stands.
|
|
133
|
+
*/
|
|
134
|
+
export function taskCall(task, options, agent) {
|
|
135
|
+
const own = task.endpoint;
|
|
136
|
+
const shared = agent && (!own.baseUrl.trim() || sameUrl(own.baseUrl, agent.baseUrl));
|
|
137
|
+
const stated = {};
|
|
138
|
+
if (task.maxTokens !== undefined)
|
|
139
|
+
stated.maxTokens = task.maxTokens;
|
|
140
|
+
if (task.temperature !== undefined)
|
|
141
|
+
stated.temperature = task.temperature;
|
|
142
|
+
if (task.reasoningEffort !== undefined)
|
|
143
|
+
stated.reasoningEffort = task.reasoningEffort;
|
|
144
|
+
return {
|
|
145
|
+
endpoint: agent
|
|
146
|
+
? {
|
|
147
|
+
...own,
|
|
148
|
+
...(shared ? { baseUrl: agent.baseUrl } : {}),
|
|
149
|
+
apiKey: resolveApiKey(own, agent, {}),
|
|
150
|
+
}
|
|
151
|
+
: own,
|
|
152
|
+
model: task.model,
|
|
153
|
+
options: { ...options, ...stated },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
109
156
|
/**
|
|
110
157
|
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
111
158
|
*
|
|
@@ -125,10 +172,11 @@ export function resolveApiKey(own, inherited, env = process.env) {
|
|
|
125
172
|
* means by empty.
|
|
126
173
|
* @param catalog The servers to choose from.
|
|
127
174
|
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
128
|
-
* @param options Cancellation, notices, the reply ceiling (256), the
|
|
175
|
+
* @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
|
|
176
|
+
* effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
|
|
129
177
|
* (`MAX_PER_LOAD`), and whether to try the words first.
|
|
130
178
|
*/
|
|
131
|
-
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
|
|
179
|
+
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
|
|
132
180
|
if (!model || !catalog.some((server) => server.tools.length > 0))
|
|
133
181
|
return [];
|
|
134
182
|
if (keywords) {
|
|
@@ -141,7 +189,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
|
|
|
141
189
|
return guess.names;
|
|
142
190
|
}
|
|
143
191
|
}
|
|
144
|
-
const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
|
|
192
|
+
const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, temperature, reasoningEffort, signal, onNotice }), { onNotice });
|
|
145
193
|
return preselection(reply, catalog, maxPerLoad);
|
|
146
194
|
}
|
|
147
195
|
/**
|
|
@@ -240,6 +288,11 @@ const UNRUN_CALL = "Not run: the run stopped first.";
|
|
|
240
288
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
241
289
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
242
290
|
*
|
|
291
|
+
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
292
|
+
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
293
|
+
* that task's own settings — a setting the task leaves out is the side task's default, never the
|
|
294
|
+
* run's `temperature` or `maxTokens`.
|
|
295
|
+
*
|
|
243
296
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
244
297
|
* and cancellation. See `AgentLoopOptions`.
|
|
245
298
|
*/
|
|
@@ -270,6 +323,15 @@ export async function runAgentLoop(options) {
|
|
|
270
323
|
return result;
|
|
271
324
|
throw new ToolIterationLimit(`Stopped after ${options.config.maxToolIterations} tool iterations.`, standing.read());
|
|
272
325
|
}
|
|
326
|
+
/** A message's words, as the preselector is asked about them: its string, or its text parts. */
|
|
327
|
+
const userText = (message) => {
|
|
328
|
+
const content = message?.content;
|
|
329
|
+
if (typeof content === "string")
|
|
330
|
+
return content;
|
|
331
|
+
if (!Array.isArray(content))
|
|
332
|
+
return "";
|
|
333
|
+
return content.flatMap((part) => (part.type === "text" ? part.text : [])).join("\n");
|
|
334
|
+
};
|
|
273
335
|
/**
|
|
274
336
|
* The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
|
|
275
337
|
* when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
|
|
@@ -311,10 +373,44 @@ async function runSteps(options, standing) {
|
|
|
311
373
|
const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
|
|
312
374
|
// Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
|
|
313
375
|
const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
|
|
314
|
-
|
|
376
|
+
// The loop's own preselection, where it was asked for and the host has not already decided: by
|
|
377
|
+
// the task where the config carries one, and by the flattened name on its own endpoint where
|
|
378
|
+
// it does not. `preselect` answers an empty model with nothing, so neither named asks nothing.
|
|
379
|
+
const choose = async () => {
|
|
380
|
+
const prompt = userText(options.messages.findLast((message) => message.role === "user"));
|
|
381
|
+
if (!prompt.trim())
|
|
382
|
+
return [];
|
|
383
|
+
const asked = {
|
|
384
|
+
...(typeof options.preselect === "object" ? options.preselect : {}),
|
|
385
|
+
signal,
|
|
386
|
+
onNotice: notice,
|
|
387
|
+
};
|
|
388
|
+
const selector = config.tasks?.toolSelect;
|
|
389
|
+
if (!selector?.model) {
|
|
390
|
+
return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
|
|
391
|
+
}
|
|
392
|
+
const call = taskCall(selector, asked, config);
|
|
393
|
+
return preselect(call.endpoint, call.model, catalog, prompt, call.options);
|
|
394
|
+
};
|
|
395
|
+
const preselected = !onDemand
|
|
396
|
+
? []
|
|
397
|
+
: options.preselect && options.preselected === undefined
|
|
398
|
+
? await choose()
|
|
399
|
+
: [...(options.preselected ?? [])];
|
|
315
400
|
if (!proxied)
|
|
316
401
|
for (const name of preselected)
|
|
317
402
|
loaded.add(name);
|
|
403
|
+
// The loop's own compaction, which is the task's or nothing: a summary the main model writes is
|
|
404
|
+
// a second full-price request the operator did not ask for.
|
|
405
|
+
const compactor = options.compact ? config.tasks?.compaction : undefined;
|
|
406
|
+
const summarise = (() => {
|
|
407
|
+
if (!compactor?.model)
|
|
408
|
+
return undefined;
|
|
409
|
+
const call = taskCall(compactor, { signal, onNotice: notice }, config);
|
|
410
|
+
const write = summariser(call.endpoint, call.model, call.options);
|
|
411
|
+
// A summary that fails folds nothing, and the step goes out as it was.
|
|
412
|
+
return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
|
|
413
|
+
})();
|
|
318
414
|
const used = new Set();
|
|
319
415
|
const definitions = new Map();
|
|
320
416
|
for (const tool of tools) {
|
|
@@ -418,11 +514,32 @@ async function runSteps(options, standing) {
|
|
|
418
514
|
limit: config.contextLength ?? 0,
|
|
419
515
|
ledger,
|
|
420
516
|
});
|
|
517
|
+
let next = rewritten ?? messages;
|
|
518
|
+
if (summarise) {
|
|
519
|
+
const plan = planCompaction(next, {
|
|
520
|
+
...(typeof options.compact === "object" ? options.compact : {}),
|
|
521
|
+
limit: config.contextLength ?? 0,
|
|
522
|
+
// The last request's prompt describes this transcript only while nothing has rewritten it.
|
|
523
|
+
...(next === messages && previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
|
|
524
|
+
charsPerToken: charsPerTokenFor(supports, config.model),
|
|
525
|
+
...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
|
|
526
|
+
});
|
|
527
|
+
if (plan) {
|
|
528
|
+
const folded = await compactTranscript(next, plan, summarise, {
|
|
529
|
+
...(hooks
|
|
530
|
+
? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } }
|
|
531
|
+
: {}),
|
|
532
|
+
});
|
|
533
|
+
if (folded !== next)
|
|
534
|
+
notice(`compacted ${plan.cut - plan.from} messages into a summary`);
|
|
535
|
+
next = folded;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
421
538
|
// A rewrite may have folded a definition away, and a load answered "already loaded" would
|
|
422
539
|
// then point at nothing. Forgetting costs a definition sent twice at worst.
|
|
423
|
-
if (proxied &&
|
|
540
|
+
if (proxied && next !== messages)
|
|
424
541
|
loaded.clear();
|
|
425
|
-
messages =
|
|
542
|
+
messages = next;
|
|
426
543
|
ledger = rebaseLedger(ledger, before, messages);
|
|
427
544
|
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
428
545
|
// Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
10
|
* that differs between one server and the next.
|
|
11
11
|
*/
|
|
12
|
-
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
|
|
12
|
+
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, taskCall, } from "./agent-loop.ts";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
|
|
14
14
|
export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
15
15
|
export type { CatalogServer } from "./catalog.ts";
|
|
@@ -25,7 +25,7 @@ export { resetAll } from "./reset.ts";
|
|
|
25
25
|
export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
|
|
26
26
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
|
27
27
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
28
|
-
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
28
|
+
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
29
29
|
export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
|
|
30
30
|
export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
|
|
31
31
|
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
10
|
* that differs between one server and the next.
|
|
11
11
|
*/
|
|
12
|
-
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
|
|
12
|
+
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, taskCall, } from "./agent-loop.js";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
|
|
14
14
|
export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
15
15
|
export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
|
package/dist/side-task.d.ts
CHANGED
|
@@ -20,10 +20,22 @@ export declare const resetHints: () => void;
|
|
|
20
20
|
export type SideTaskInput = string | OpenAI.ChatCompletionContentPart[];
|
|
21
21
|
/** What a side task may be given. All optional — one given none of them still runs. */
|
|
22
22
|
export interface SideTaskOptions {
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on the reply, default 512. These answers are meant to be short. Zero or less sends
|
|
25
|
+
* none and leaves it to the server, which is what `maxTokens: 0` means on the main model too.
|
|
26
|
+
*/
|
|
24
27
|
maxTokens?: number;
|
|
25
28
|
/** Sampling temperature, default 0.3. Naming and classifying want the same answer twice. */
|
|
26
29
|
temperature?: number;
|
|
30
|
+
/**
|
|
31
|
+
* A reasoning effort to ask for in place of the no-thinking hints, for a side task an operator
|
|
32
|
+
* has pointed at a model that should deliberate — a compaction summary is the usual one.
|
|
33
|
+
*
|
|
34
|
+
* Absent, `""` and `"off"` all keep the hints, because off is what they already ask for. Any
|
|
35
|
+
* other value is sent as `reasoning_effort`, stepped up by `effortFor` where the model has
|
|
36
|
+
* refused it by value, and `chat_template_kwargs` is left out of the request altogether.
|
|
37
|
+
*/
|
|
38
|
+
reasoningEffort?: string;
|
|
27
39
|
/** Abandons the call, usually because the run it supports has gone away. */
|
|
28
40
|
signal?: AbortSignal;
|
|
29
41
|
/**
|
|
@@ -40,6 +52,27 @@ export interface SideTaskOptions {
|
|
|
40
52
|
*/
|
|
41
53
|
onNotice?: (message: string) => void;
|
|
42
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
|
|
57
|
+
*
|
|
58
|
+
* Declared here rather than imported from `spec.ts`, whose `ResolvedTask` it matches field for
|
|
59
|
+
* field: the spec module is one nothing else in `src/` imports, and a host that is not on the spec
|
|
60
|
+
* can write one of these by hand. An absent setting is the side task's own default — 0.3, the
|
|
61
|
+
* entry point's ceiling, thinking off — and never the main model's, so raising the agent's
|
|
62
|
+
* temperature for the chat does not make its titler creative. See `taskCall`.
|
|
63
|
+
*/
|
|
64
|
+
export interface SideTask {
|
|
65
|
+
/** The model that does the task. */
|
|
66
|
+
model: string;
|
|
67
|
+
/** Where that model is reached, which need not be where the agent's is. */
|
|
68
|
+
endpoint: Endpoint;
|
|
69
|
+
/** The reply's ceiling. Absent is the entry point's default; zero sends none. */
|
|
70
|
+
maxTokens?: number;
|
|
71
|
+
/** Absent is 0.3, whatever the agent's own temperature is. Zero means zero. */
|
|
72
|
+
temperature?: number;
|
|
73
|
+
/** As `SideTaskOptions.reasoningEffort`: a level replaces the no-thinking hints. */
|
|
74
|
+
reasoningEffort?: string;
|
|
75
|
+
}
|
|
43
76
|
/**
|
|
44
77
|
* Runs a side task and returns the reply text, thinking stripped. Throws like any request.
|
|
45
78
|
*
|
|
@@ -47,7 +80,7 @@ export interface SideTaskOptions {
|
|
|
47
80
|
* @param model The model to ask, usually smaller than the one running the work.
|
|
48
81
|
* @param system The instruction.
|
|
49
82
|
* @param user The input it applies to. Content parts where the model is being shown an image.
|
|
50
|
-
* @param options Reply ceiling, temperature, cancellation, notices.
|
|
83
|
+
* @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
|
|
51
84
|
*/
|
|
52
85
|
export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
|
|
53
86
|
/** What `askJson` takes besides a side task's options. */
|
package/dist/side-task.js
CHANGED
|
@@ -72,7 +72,7 @@ function rejectedTheRequest(error) {
|
|
|
72
72
|
* @param model The model to ask, usually smaller than the one running the work.
|
|
73
73
|
* @param system The instruction.
|
|
74
74
|
* @param user The input it applies to. Content parts where the model is being shown an image.
|
|
75
|
-
* @param options Reply ceiling, temperature, cancellation, notices.
|
|
75
|
+
* @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
|
|
76
76
|
*/
|
|
77
77
|
export function ask(config, model, system, user, options = {}) {
|
|
78
78
|
return complete(config, model, system, user, options);
|
|
@@ -81,22 +81,29 @@ export function ask(config, model, system, user, options = {}) {
|
|
|
81
81
|
* The request `ask` and `askJson` share, with `format` deciding the extra body fields from what
|
|
82
82
|
* the model and the endpoint have refused, rebuilt on every re-send.
|
|
83
83
|
*/
|
|
84
|
-
async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice }, format) {
|
|
84
|
+
async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
|
|
85
|
+
// The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
|
|
86
|
+
// since off is what the hints below already ask for.
|
|
87
|
+
const level = effortFor(undefined, reasoningEffort);
|
|
85
88
|
// Whether the last request carried an effort, which `negotiate` decides and not this function.
|
|
86
89
|
let sentEffort = false;
|
|
87
90
|
const send = (hints, effort, supports, refused) => {
|
|
88
91
|
// `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
|
|
89
92
|
// models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
|
|
90
93
|
// cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
|
|
91
|
-
|
|
94
|
+
// A level the caller chose goes through the same ladder, which only ever steps up from it.
|
|
95
|
+
const asked = effort ? effortFor(refused, level || "none") : "";
|
|
92
96
|
sentEffort = asked !== "";
|
|
93
97
|
return getClient(config).chat.completions.create({
|
|
94
98
|
model,
|
|
95
99
|
// The reasoning models want the ceiling spelled the other way, and they are exactly the
|
|
96
|
-
// models a side task most wants to stop deliberating.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
// models a side task most wants to stop deliberating. Zero is no ceiling, as it is on the
|
|
101
|
+
// main model: a spec's `maxTokens: 0` sent as `max_tokens: 0` asks for an empty reply.
|
|
102
|
+
...(maxTokens > 0
|
|
103
|
+
? refused && !refused.legacyTokenLimit
|
|
104
|
+
? { max_completion_tokens: maxTokens }
|
|
105
|
+
: { max_tokens: maxTokens }
|
|
106
|
+
: {}),
|
|
100
107
|
// One that will only run at the temperature it was built with is sent none: a side task
|
|
101
108
|
// wants the same answer twice, and 1.0 from that model is as close as it gets.
|
|
102
109
|
...(refused && !refused.chosenTemperature ? {} : { temperature }),
|
|
@@ -121,7 +128,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
121
128
|
onNotice,
|
|
122
129
|
});
|
|
123
130
|
const key = hintKey(endpointId(config), model);
|
|
124
|
-
|
|
131
|
+
// A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
|
|
132
|
+
// false` next to `reasoning_effort: "high"` is a request that contradicts itself.
|
|
133
|
+
const hints = !level && !noHints.has(key);
|
|
125
134
|
let response;
|
|
126
135
|
try {
|
|
127
136
|
response = await attempt(hints, true);
|
|
@@ -134,7 +143,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
134
143
|
const effort = sentEffort;
|
|
135
144
|
if (!(hints || effort) || !rejectedTheRequest(error))
|
|
136
145
|
throw error;
|
|
137
|
-
onNotice?.(
|
|
146
|
+
onNotice?.(level
|
|
147
|
+
? `${model} rejected a request carrying a reasoning effort; retrying without it`
|
|
148
|
+
: `${model} rejected a request carrying the no-thinking hints; retrying without them`);
|
|
138
149
|
response = await attempt(false, false);
|
|
139
150
|
// Latched on the finding, not the hypothesis: a context overflow is a 400 `negotiate` does
|
|
140
151
|
// not recognise too, and it fails the retry the same way, leaving the next call to try the
|
package/dist/spec.d.ts
CHANGED
|
@@ -132,8 +132,14 @@ export interface TaskSpec {
|
|
|
132
132
|
model?: string;
|
|
133
133
|
/** Where that model lives, when it is not on the agent's endpoint. */
|
|
134
134
|
endpoint?: EndpointSpec;
|
|
135
|
+
/** The reply's ceiling. Absent is the side task's own default, not the agent's `maxTokens`. */
|
|
135
136
|
maxTokens?: number;
|
|
137
|
+
/** Absent is the side task's own default, 0.3, whatever the agent's temperature is. */
|
|
136
138
|
temperature?: number;
|
|
139
|
+
/**
|
|
140
|
+
* A level has the task deliberate. Absent and `"off"` leave it with the no-thinking hints a
|
|
141
|
+
* side task is sent by default; see `SideTaskOptions.reasoningEffort`.
|
|
142
|
+
*/
|
|
137
143
|
reasoningEffort?: string;
|
|
138
144
|
}
|
|
139
145
|
/** A tool call bound to a lifecycle event, run by the host rather than the model. */
|
|
@@ -178,7 +184,13 @@ export interface SpecBundle {
|
|
|
178
184
|
*/
|
|
179
185
|
mcpServers?: SpecServer[];
|
|
180
186
|
}
|
|
181
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* One side task, resolved: a model, and the endpoint it is reached through.
|
|
189
|
+
*
|
|
190
|
+
* The three settings are present only where a layer stated them, and nothing is filled in from
|
|
191
|
+
* the agent's own model — `taskCall` turns one into a call, and what is absent there is left to
|
|
192
|
+
* the side task's default.
|
|
193
|
+
*/
|
|
182
194
|
export interface ResolvedTask {
|
|
183
195
|
model: string;
|
|
184
196
|
/** This task's endpoint, which is the agent's unless a layer named another. */
|
package/llms.txt
CHANGED
|
@@ -26,6 +26,7 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
|
|
|
26
26
|
- `ToolCallOutcome` (type) — What one tool call did, in the order the model asked.
|
|
27
27
|
- `ToolCallRequest` (type) — One call the model made, as `dispatch` and `onToolCall` are handed it.
|
|
28
28
|
- `ToolCallResult` (type) — One call's whole answer, as `onToolResult` is handed it.
|
|
29
|
+
- `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
29
30
|
|
|
30
31
|
### calibration
|
|
31
32
|
|
|
@@ -240,6 +241,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
|
|
|
240
241
|
- `listLines` — A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate them with.
|
|
241
242
|
- `parseJson` — Models are asked for JSON and often answer with prose around it, or a fenced block.
|
|
242
243
|
- `resetHints` — Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints.
|
|
244
|
+
- `SideTask` (type) — One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
|
|
243
245
|
- `SideTaskInput` (type) — The input a side task applies its instruction to: text, or the content parts a vision model reads.
|
|
244
246
|
- `SideTaskOptions` (type) — What a side task may be given.
|
|
245
247
|
- `tryAsk` — A side task is never worth failing the work it supports.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai",
|