@cubicecho/agent-core 1.1.0 → 1.3.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 +40 -0
- package/dist/capabilities.d.ts +13 -7
- package/dist/capabilities.js +8 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/reset.d.ts +20 -0
- package/dist/reset.js +29 -0
- package/dist/run-turn.d.ts +38 -0
- package/dist/run-turn.js +33 -0
- package/dist/stream.d.ts +14 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,12 +29,52 @@ only, Node >=22.
|
|
|
29
29
|
| `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
|
|
30
30
|
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
|
|
31
31
|
| `config` | The structural interfaces every function here asks for. |
|
|
32
|
+
| `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. |
|
|
33
|
+
| `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
|
|
32
34
|
| `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
|
|
33
35
|
| `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
|
|
34
36
|
|
|
35
37
|
What is **not** here is the work: orchestration, prompts, and whatever the run is about. That
|
|
36
38
|
is the caller's, and it is the part that actually differs between one server and the next.
|
|
37
39
|
|
|
40
|
+
## A turn
|
|
41
|
+
|
|
42
|
+
`negotiate` wrapping `streamTurn` is the whole of one turn against an endpoint: the request is
|
|
43
|
+
re-sent for as long as the answer is this server refusing something the request can do without,
|
|
44
|
+
and nothing is re-sent once it has started answering.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
capabilitiesFor, getClient, negotiate, relaxTools, sanitizeTools, streamTurn, timeoutMs,
|
|
49
|
+
} from "@cubicecho/agent-core";
|
|
50
|
+
|
|
51
|
+
const declared = sanitizeTools(tools);
|
|
52
|
+
const supports = capabilitiesFor(config.baseUrl);
|
|
53
|
+
|
|
54
|
+
const turn = await negotiate(supports, (supports, produced) =>
|
|
55
|
+
streamTurn(
|
|
56
|
+
getClient(config),
|
|
57
|
+
{
|
|
58
|
+
model, messages, stream: true,
|
|
59
|
+
// Rebuilt per attempt: what the endpoint has refused is latched off by the line above.
|
|
60
|
+
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
61
|
+
tools: supports.strictSchemas ? declared : relaxTools(declared),
|
|
62
|
+
},
|
|
63
|
+
{ produced, signal, idleMs: timeoutMs(config), onOutput: (text) => emit(runId, { kind: "output", text }) },
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`send` takes a callback rather than a body because the body has to be rebuilt from the latched
|
|
69
|
+
flags. `produced` is one box per attempt — `streamTurn` sets it as soon as the server says
|
|
70
|
+
anything, and the re-send reads it — so a caller with its own retry budget passes one in
|
|
71
|
+
(`{ produced }`) and reads it afterwards to decide whether the failure is worth another attempt.
|
|
72
|
+
|
|
73
|
+
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
|
|
74
|
+
still talking is never cut off however long it takes, and one that has stopped answering raises
|
|
75
|
+
`EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
|
|
76
|
+
`requestTimeoutSeconds` of zero, which waits forever — what a local model answering slowly needs.
|
|
77
|
+
|
|
38
78
|
## The config seam
|
|
39
79
|
|
|
40
80
|
Nothing here imports a config type from a consumer, and no function asks for a whole
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Produced } from "./stream.ts";
|
|
1
2
|
/**
|
|
2
3
|
* What an endpoint turned out not to support, and answering it when it says so.
|
|
3
4
|
*
|
|
@@ -32,13 +33,13 @@ export declare function capabilitiesFor(baseUrl: string): Capabilities;
|
|
|
32
33
|
export declare function resetCapabilities(): void;
|
|
33
34
|
export interface NegotiateOptions {
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* The flag `send` will be given, for a caller that has to read it after `negotiate` returns.
|
|
37
|
+
*
|
|
38
|
+
* An outer retry loop needs it: nothing is retried once the server has started answering, and
|
|
39
|
+
* by the time a rejected promise is in hand the turn is over. Callers without one can leave
|
|
40
|
+
* this out and take the flag from `send`'s second argument, which is the same object.
|
|
38
41
|
*/
|
|
39
|
-
produced?:
|
|
40
|
-
any: boolean;
|
|
41
|
-
};
|
|
42
|
+
produced?: Produced;
|
|
42
43
|
/** Told what was given up on, for a watcher who would otherwise see an unexplained pause. */
|
|
43
44
|
onNotice?: (message: string) => void;
|
|
44
45
|
}
|
|
@@ -58,5 +59,10 @@ export interface NegotiateOptions {
|
|
|
58
59
|
* `stream_options` is present or absent rather than adjusted. It is generic over what it
|
|
59
60
|
* resolves, so a caller whose request resolves a stream object before any chunk is read is the
|
|
60
61
|
* same shape as one that resolves a finished turn.
|
|
62
|
+
*
|
|
63
|
+
* It is handed the `produced` flag rather than being expected to close over one. There is only
|
|
64
|
+
* ever one flag in a turn — the same box `streamTurn` sets and the re-send below reads — and a
|
|
65
|
+
* caller that passed it to only one of the two got a turn that had already streamed tokens sent
|
|
66
|
+
* again, silently, with the watcher seeing every one of them twice.
|
|
61
67
|
*/
|
|
62
|
-
export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities) => Promise<T>, { produced, onNotice }?: NegotiateOptions): Promise<T>;
|
|
68
|
+
export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced) => Promise<T>, { produced, onNotice }?: NegotiateOptions): Promise<T>;
|
package/dist/capabilities.js
CHANGED
|
@@ -46,14 +46,19 @@ const REJECTS_USAGE = /stream_options/i;
|
|
|
46
46
|
* `stream_options` is present or absent rather than adjusted. It is generic over what it
|
|
47
47
|
* resolves, so a caller whose request resolves a stream object before any chunk is read is the
|
|
48
48
|
* same shape as one that resolves a finished turn.
|
|
49
|
+
*
|
|
50
|
+
* It is handed the `produced` flag rather than being expected to close over one. There is only
|
|
51
|
+
* ever one flag in a turn — the same box `streamTurn` sets and the re-send below reads — and a
|
|
52
|
+
* caller that passed it to only one of the two got a turn that had already streamed tokens sent
|
|
53
|
+
* again, silently, with the watcher seeing every one of them twice.
|
|
49
54
|
*/
|
|
50
|
-
export async function negotiate(supports, send, { produced, onNotice } = {}) {
|
|
55
|
+
export async function negotiate(supports, send, { produced = { any: false }, onNotice } = {}) {
|
|
51
56
|
for (;;) {
|
|
52
57
|
try {
|
|
53
|
-
return await send(supports);
|
|
58
|
+
return await send(supports, produced);
|
|
54
59
|
}
|
|
55
60
|
catch (error) {
|
|
56
|
-
if (produced
|
|
61
|
+
if (produced.any)
|
|
57
62
|
throw error;
|
|
58
63
|
const detail = errorMessage(error);
|
|
59
64
|
if (supports.strictSchemas && isGrammarError(detail)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,11 @@ export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetCl
|
|
|
15
15
|
export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
|
|
16
16
|
export { errorMessage } from "./errors.ts";
|
|
17
17
|
export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, reset, watch, } from "./events.ts";
|
|
18
|
+
export { resetAll } from "./reset.ts";
|
|
18
19
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
20
|
+
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
|
19
21
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
20
22
|
export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
21
|
-
export { type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
23
|
+
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
22
24
|
export { estimateTokens } from "./tokens.ts";
|
|
23
25
|
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.ts";
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,9 @@ export { capabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.j
|
|
|
13
13
|
export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
|
|
14
14
|
export { errorMessage } from "./errors.js";
|
|
15
15
|
export { emit, endRun, fold, history, reset, watch, } from "./events.js";
|
|
16
|
+
export { resetAll } from "./reset.js";
|
|
16
17
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
18
|
+
export { runTurn } from "./run-turn.js";
|
|
17
19
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
18
20
|
export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
|
|
19
21
|
export { streamTurn, } from "./stream.js";
|
package/dist/reset.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgets everything this package remembers between calls.
|
|
3
|
+
*
|
|
4
|
+
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
5
|
+
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
6
|
+
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
7
|
+
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `reset` stay
|
|
8
|
+
* exported, because a test that means to clear one thing should say so.
|
|
9
|
+
*
|
|
10
|
+
* This is for the other case, which is every teardown. What all four hold is *latched
|
|
11
|
+
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
12
|
+
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
13
|
+
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
14
|
+
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
15
|
+
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
16
|
+
*
|
|
17
|
+
* It is also the seam that does not need finding again. A fifth module with a cache is a fifth
|
|
18
|
+
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resetAll(): void;
|
package/dist/reset.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resetCapabilities } from "./capabilities.js";
|
|
2
|
+
import { resetClients } from "./client.js";
|
|
3
|
+
import { reset as resetEvents } from "./events.js";
|
|
4
|
+
import { resetHints } from "./side-task.js";
|
|
5
|
+
/**
|
|
6
|
+
* Forgets everything this package remembers between calls.
|
|
7
|
+
*
|
|
8
|
+
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
9
|
+
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
10
|
+
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
11
|
+
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `reset` stay
|
|
12
|
+
* exported, because a test that means to clear one thing should say so.
|
|
13
|
+
*
|
|
14
|
+
* This is for the other case, which is every teardown. What all four hold is *latched
|
|
15
|
+
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
16
|
+
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
17
|
+
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
18
|
+
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
19
|
+
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
20
|
+
*
|
|
21
|
+
* It is also the seam that does not need finding again. A fifth module with a cache is a fifth
|
|
22
|
+
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
23
|
+
*/
|
|
24
|
+
export function resetAll() {
|
|
25
|
+
resetClients();
|
|
26
|
+
resetCapabilities();
|
|
27
|
+
resetHints();
|
|
28
|
+
resetEvents();
|
|
29
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
import { type Capabilities } from "./capabilities.ts";
|
|
3
|
+
import { type StreamTurnOptions, type Turn } from "./stream.ts";
|
|
4
|
+
/**
|
|
5
|
+
* One turn, given as many attempts as the caller allows.
|
|
6
|
+
*
|
|
7
|
+
* Two different things are being recovered from here, and they nest. The inner one is a
|
|
8
|
+
* capability the endpoint turns out not to have — `stream_options`, a grammar keyword — which
|
|
9
|
+
* is a refusal: it is answered by sending a lesser request, and it latches against that
|
|
10
|
+
* endpoint for the life of the process, so it costs one failed call rather than one a run.
|
|
11
|
+
* The outer one is the endpoint being unreachable, busy or silent, which is not about this
|
|
12
|
+
* request at all and is worth simply waiting out.
|
|
13
|
+
*
|
|
14
|
+
* Both are bounded by the same rule: nothing is sent again once the server has started
|
|
15
|
+
* answering. The tokens are already out and on their way to whoever is watching, and a second
|
|
16
|
+
* attempt would say everything twice. That is what `produced` is, one box per attempt.
|
|
17
|
+
*/
|
|
18
|
+
/** A retry is not the same event as a downgrade, but a watcher wants to be told about both. */
|
|
19
|
+
export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
20
|
+
/**
|
|
21
|
+
* How many times a lost request is worth sending again. Zero is one attempt, which is the
|
|
22
|
+
* default because a caller with no retry budget in its settings should not inherit one.
|
|
23
|
+
* A downgrade does not spend an attempt: it is a different request, not the same one again.
|
|
24
|
+
*/
|
|
25
|
+
maxRetries?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Told what was given up on and what is being waited out, for a watcher who would otherwise
|
|
28
|
+
* see an unexplained pause. Carries both the capability notices and the retry notices.
|
|
29
|
+
*/
|
|
30
|
+
onNotice?: (message: string) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
34
|
+
* the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools`
|
|
35
|
+
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
36
|
+
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, ...stream }?: RunTurnOptions): Promise<Turn>;
|
package/dist/run-turn.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { negotiate } from "./capabilities.js";
|
|
2
|
+
import { errorMessage } from "./errors.js";
|
|
3
|
+
import { backoffMs, isTransient, sleep } from "./retry.js";
|
|
4
|
+
import { streamTurn } from "./stream.js";
|
|
5
|
+
/**
|
|
6
|
+
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
7
|
+
* the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools`
|
|
8
|
+
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
9
|
+
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
10
|
+
*/
|
|
11
|
+
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, ...stream } = {}) {
|
|
12
|
+
for (let attempt = 0;; attempt++) {
|
|
13
|
+
const produced = { any: false };
|
|
14
|
+
try {
|
|
15
|
+
return await negotiate(supports, (capabilities, box) => streamTurn(client, request(capabilities), { ...stream, produced: box }), { produced, onNotice });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
// The abort is read before the classification, not after. A run stopped by its operator
|
|
19
|
+
// can trip the idle watchdog on the way out, and `EndpointSilent` is transient by the
|
|
20
|
+
// rules in `retry.ts` — so classifying first brings a cancelled run back from the dead.
|
|
21
|
+
if (produced.any || stream.signal?.aborted)
|
|
22
|
+
throw error;
|
|
23
|
+
if (attempt >= maxRetries || !isTransient(error))
|
|
24
|
+
throw error;
|
|
25
|
+
const wait = backoffMs(attempt);
|
|
26
|
+
// Reported in whatever unit reads as a number: the first backoff is under a second, and
|
|
27
|
+
// "retrying in 0s" is what rounding it to seconds says.
|
|
28
|
+
const delay = wait < 1000 ? `${Math.round(wait)}ms` : `${Math.round(wait / 1000)}s`;
|
|
29
|
+
onNotice?.(`${errorMessage(error)} — retrying in ${delay} (${attempt + 1}/${maxRetries})`);
|
|
30
|
+
await sleep(wait, stream.signal);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/stream.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export interface Turn {
|
|
|
21
21
|
toolCalls: OpenAI.ChatCompletionMessageToolCall[];
|
|
22
22
|
usage: TurnUsage;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the server has started answering.
|
|
26
|
+
*
|
|
27
|
+
* A box rather than a return value because it has to be readable *while* the request is in
|
|
28
|
+
* flight: the rules in `retry.ts` are built on the premise that a stream which has already
|
|
29
|
+
* emitted tokens must never be replayed, and by the time a rejected promise is in hand the turn
|
|
30
|
+
* is over. There is one of these per attempt, shared by everything that has a say in whether
|
|
31
|
+
* the attempt is repeated. See `negotiate`.
|
|
32
|
+
*/
|
|
33
|
+
export interface Produced {
|
|
34
|
+
any: boolean;
|
|
35
|
+
}
|
|
24
36
|
export interface StreamTurnOptions {
|
|
25
37
|
signal?: AbortSignal;
|
|
26
38
|
/**
|
|
@@ -29,15 +41,8 @@ export interface StreamTurnOptions {
|
|
|
29
41
|
* needs.
|
|
30
42
|
*/
|
|
31
43
|
idleMs?: number;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
* retried. The rules in `retry.ts` are built on the premise that a stream which has already
|
|
35
|
-
* emitted tokens must never be replayed, and this is the flag that says so — a caller that
|
|
36
|
-
* has to thread it by hand is a caller that can forget to.
|
|
37
|
-
*/
|
|
38
|
-
produced?: {
|
|
39
|
-
any: boolean;
|
|
40
|
-
};
|
|
44
|
+
/** Set as soon as the server has said anything, so a failed call knows if it can be retried. */
|
|
45
|
+
produced?: Produced;
|
|
41
46
|
/** The model's scratchpad, as it arrives. */
|
|
42
47
|
onThinking?: (delta: string) => void;
|
|
43
48
|
/** The model's answer, as it arrives. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|