@cubicecho/agent-core 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Van Treese
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @cubicecho/agent-core
2
+
3
+ The endpoint-agnostic half of an OpenAI-compatible agent loop.
4
+
5
+ Extracted from three servers that had each written it separately — `kanban_server`,
6
+ `task_server` and `min-agent` — after the copies drifted far enough that a fix in one was a bug
7
+ still live in another. See [`standards/extraction-backlog.md`](../standards/extraction-backlog.md).
8
+
9
+ ## What is here
10
+
11
+ | Module | What it does |
12
+ | --- | --- |
13
+ | `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
14
+ | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. |
15
+ | `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. |
16
+ | `events` | The in-memory bus a watcher reads while a run happens. |
17
+ | `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
18
+ | `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
19
+ | `config` | The structural interfaces every function here asks for. |
20
+
21
+ What is **not** here is the work: orchestration, prompts, and whatever the run is about. That
22
+ is the caller's, and it is the part that actually differs between one server and the next.
23
+
24
+ ## The config seam
25
+
26
+ Nothing here imports a config type from a consumer, and no function asks for a whole
27
+ configuration. Each takes the narrowest shape it reads — `Endpoint`, `ModelParams`,
28
+ `ToolPolicy`, `RetryPolicy` — and a caller satisfies it structurally:
29
+
30
+ ```ts
31
+ import { getClient, type Endpoint } from "@cubicecho/agent-core";
32
+
33
+ // A Drizzle settings row, a resolved agent, or a zod-inferred config: all three already are one.
34
+ const client = getClient(settings satisfies Endpoint);
35
+ ```
36
+
37
+ This matters because the three consumers do not agree on the fields. `task_server`'s settings
38
+ row has no `contextLength`; `min-agent` spells it `contextLimit` and has no timeout or retry
39
+ budget at all. A single god interface would have forced two of them to grow columns they have
40
+ no use for.
41
+
42
+ ## Where the merged behaviour came from
43
+
44
+ - `schema-compat` — `kanban_server`/`task_server`'s version, which strips **every** sibling of a
45
+ `$ref` rather than just `default`. `min-agent`'s did the latter, which leaves `nullable` beside
46
+ the surviving `$ref` on `anyOf: [{$ref}, {type: "null"}]` — the shape a schema-generated server
47
+ emits at every optional argument — and a strict validator rejects the tool.
48
+ - `tool-loading` — theirs, plus `min-agent`'s `carryOver`/`MAX_CARRIED`, which bounds the tool
49
+ array across a multi-turn conversation. Both suites' assertions are kept.
50
+ - `events` — `kanban_server`'s `usage` totals **and** `task_server`'s `step` grouping, including
51
+ its `fold` fix: two blocks in different steps are not one block.
52
+ - `retry` — `kanban_server`'s, which is the only one of the three with the `ContextOverflow`
53
+ guard. `min-agent` had no retry layer at all.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The contract between whatever holds the tools and the loop that offers them to a model.
3
+ *
4
+ * This lives here rather than with the MCP pool because it is what `tool-loading.ts` reads, and
5
+ * nothing about it is MCP-specific: it is a list of names and one-line descriptions, grouped by
6
+ * where they came from. A caller with tools from somewhere else entirely satisfies it by
7
+ * building the array.
8
+ */
9
+ /** One server's tools, without their JSON schemas — the cheap half of a tool definition. */
10
+ export interface CatalogServer {
11
+ id: string;
12
+ label: string;
13
+ tools: {
14
+ name: string;
15
+ description: string;
16
+ }[];
17
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The contract between whatever holds the tools and the loop that offers them to a model.
3
+ *
4
+ * This lives here rather than with the MCP pool because it is what `tool-loading.ts` reads, and
5
+ * nothing about it is MCP-specific: it is a list of names and one-line descriptions, grouped by
6
+ * where they came from. A caller with tools from somewhere else entirely satisfies it by
7
+ * building the array.
8
+ */
9
+ export {};
@@ -0,0 +1,34 @@
1
+ import OpenAI from "openai";
2
+ import type { Endpoint } from "./config.ts";
3
+ /**
4
+ * The SDK insists on a non-empty key even where the server will not look at it. This is what it
5
+ * gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
6
+ * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
7
+ */
8
+ export declare const NO_KEY = "agent-core";
9
+ /** Zero or less means no limit, which the SDK spells as `undefined`. */
10
+ export declare const timeoutMs: (config: Pick<Endpoint, "requestTimeoutSeconds">) => number | undefined;
11
+ export declare function getClient(config: Endpoint): OpenAI;
12
+ /** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
13
+ export interface ModelInfo {
14
+ id: string;
15
+ contextLength: number;
16
+ }
17
+ /** Asks an endpoint what it serves, and remembers the answer. */
18
+ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
19
+ /**
20
+ * How much a model will read, in tokens. Zero means nobody knows.
21
+ *
22
+ * `declared` is the operator's own number, and it wins outright: an endpoint can report the
23
+ * window a model was *built* with while serving it in a much smaller one — llama.cpp will
24
+ * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
25
+ * the honest-looking number is a run that fails at the endpoint instead.
26
+ *
27
+ * Otherwise the listing is asked, once per endpoint. A server that will not list models still
28
+ * has to be able to run a turn: a failure here is an unknown window, not a failed run.
29
+ */
30
+ export declare function contextLimitFor(config: Endpoint & {
31
+ model: string;
32
+ }, declared?: number): Promise<number>;
33
+ /** Forgets every cached client and listing. For tests, and for a settings change under test. */
34
+ export declare function resetClients(): void;
package/dist/client.js ADDED
@@ -0,0 +1,111 @@
1
+ import OpenAI from "openai";
2
+ /**
3
+ * The SDK insists on a non-empty key even where the server will not look at it. This is what it
4
+ * gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
5
+ * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
6
+ */
7
+ export const NO_KEY = "agent-core";
8
+ /** Zero or less means no limit, which the SDK spells as `undefined`. */
9
+ export const timeoutMs = (config) => config.requestTimeoutSeconds > 0 ? config.requestTimeoutSeconds * 1000 : undefined;
10
+ /**
11
+ * A client per endpoint, made once and kept.
12
+ *
13
+ * The SDK holds its own connection pool, and a run makes a request per tool iteration on top of
14
+ * whatever side tasks it asks for — building a fresh client for each of them throws that pool
15
+ * away every time. A map rather than the single slot it would otherwise be, because agents each
16
+ * name their own endpoint: two running side by side on different servers would evict each
17
+ * other's client on every request.
18
+ *
19
+ * `maxRetries: 0` turns the SDK's own retrying off. Streaming is what this is for, and a stream
20
+ * that has already emitted tokens must not be replayed from the top — the caller knows whether
21
+ * anything has been produced yet and the SDK does not. See `retry.ts`.
22
+ */
23
+ const clients = new Map();
24
+ export function getClient(config) {
25
+ const apiKey = config.apiKey || NO_KEY;
26
+ const timeout = timeoutMs(config);
27
+ // Stringified rather than joined on a separator: no character is impossible in a URL or a
28
+ // key, and two different endpoints must never resolve to the same cached client.
29
+ const key = JSON.stringify([config.baseUrl, apiKey, timeout]);
30
+ const existing = clients.get(key);
31
+ if (existing)
32
+ return existing;
33
+ const client = new OpenAI({ baseURL: config.baseUrl, apiKey, timeout, maxRetries: 0 });
34
+ clients.set(key, client);
35
+ return client;
36
+ }
37
+ /**
38
+ * The context window, spelled every way a server spells it.
39
+ *
40
+ * None of these is in the OpenAI listing schema, so every server that says anything says it as
41
+ * an extra key of its own: `context_length` is llama.cpp and LM Studio, `max_model_len` vLLM,
42
+ * `n_ctx` the raw llama bindings. Whichever turns up first is taken — a server reporting two
43
+ * of them is reporting the same number twice.
44
+ */
45
+ const CONTEXT_KEYS = [
46
+ "context_length",
47
+ "max_context_window",
48
+ "max_model_len",
49
+ "context_window",
50
+ "n_ctx",
51
+ ];
52
+ function contextLengthOf(model) {
53
+ const record = model;
54
+ for (const key of CONTEXT_KEYS) {
55
+ const value = record[key];
56
+ if (typeof value === "number" && value > 0)
57
+ return value;
58
+ }
59
+ return 0;
60
+ }
61
+ /**
62
+ * The last listing from each endpoint, so a run can size its window without a round trip.
63
+ *
64
+ * Keyed the same way the clients are, because two endpoints are two different sets of models
65
+ * and one of them having answered says nothing about the other. It is only ever a cache of
66
+ * something asked for anyway: nothing here refreshes it, and a listing that fails leaves
67
+ * whatever was there rather than emptying it.
68
+ */
69
+ const listings = new Map();
70
+ const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
71
+ /** Asks an endpoint what it serves, and remembers the answer. */
72
+ export async function listModels(config) {
73
+ const { data } = await getClient(config).models.list();
74
+ const models = data
75
+ .map((model) => ({ id: model.id, contextLength: contextLengthOf(model) }))
76
+ .sort((a, b) => a.id.localeCompare(b.id));
77
+ listings.set(endpointKey(config), models);
78
+ return models;
79
+ }
80
+ /**
81
+ * How much a model will read, in tokens. Zero means nobody knows.
82
+ *
83
+ * `declared` is the operator's own number, and it wins outright: an endpoint can report the
84
+ * window a model was *built* with while serving it in a much smaller one — llama.cpp will
85
+ * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
86
+ * the honest-looking number is a run that fails at the endpoint instead.
87
+ *
88
+ * Otherwise the listing is asked, once per endpoint. A server that will not list models still
89
+ * has to be able to run a turn: a failure here is an unknown window, not a failed run.
90
+ */
91
+ export async function contextLimitFor(config, declared = 0) {
92
+ if (declared > 0)
93
+ return declared;
94
+ // A failure is not remembered: an endpoint that was down when the last run started is not an
95
+ // endpoint with no models, and the one listing this costs is nothing beside the run itself.
96
+ if (!listings.has(endpointKey(config))) {
97
+ try {
98
+ await listModels(config);
99
+ }
100
+ catch {
101
+ return 0;
102
+ }
103
+ }
104
+ const listed = listings.get(endpointKey(config)) ?? [];
105
+ return listed.find((model) => model.id === config.model)?.contextLength ?? 0;
106
+ }
107
+ /** Forgets every cached client and listing. For tests, and for a settings change under test. */
108
+ export function resetClients() {
109
+ clients.clear();
110
+ listings.clear();
111
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * What this package needs to know about a caller's configuration.
3
+ *
4
+ * Deliberately not one interface. The three servers that consume this each hold their settings
5
+ * in a different shape — a resolved agent row, a settings row read straight from the database,
6
+ * a zod-inferred config object — and they do not all carry the same fields: `task_server`'s
7
+ * settings row has no `contextLength`, and `min-agent` spells it `contextLimit` and has no
8
+ * timeout or retry budget at all.
9
+ *
10
+ * So every function here asks for the narrowest thing it actually reads, and a caller satisfies
11
+ * it structurally. Nothing imports a config *type* from a consumer, and no consumer has to grow
12
+ * a field it has no use for in order to call `getClient`.
13
+ */
14
+ /** Where to send a request and how long to wait. Everything that talks to a server needs this. */
15
+ export interface Endpoint {
16
+ /** Any OpenAI-compatible base URL: OpenAI, Ollama, LM Studio, vLLM, OpenRouter, ... */
17
+ baseUrl: string;
18
+ /** Empty is normal — a local server ignores it. See `getClient` for what is sent instead. */
19
+ apiKey: string;
20
+ /** Zero or less means no limit. */
21
+ requestTimeoutSeconds: number;
22
+ }
23
+ /** What to ask the model for. */
24
+ export interface ModelParams {
25
+ model: string;
26
+ maxTokens: number;
27
+ temperature: number;
28
+ }
29
+ /** How tools reach the model, and how long it may keep calling them. */
30
+ export interface ToolPolicy {
31
+ /**
32
+ * "eager" sends every tool definition on every request. "ondemand" sends a name-only
33
+ * catalogue and lets the model pull in the schemas it needs. See `tool-loading.ts`.
34
+ */
35
+ toolDiscovery: "eager" | "ondemand";
36
+ /** The model that does the preselection pass. Empty means don't preselect. */
37
+ toolSelectModel: string;
38
+ /** Hard stop on runaway tool loops. */
39
+ maxToolIterations: number;
40
+ }
41
+ /** How many times a lost or refused request is worth sending again. See `retry.ts`. */
42
+ export interface RetryPolicy {
43
+ maxRetries: number;
44
+ }
45
+ /**
46
+ * A whole agent configuration — every part, plus the two fields that belong to no group.
47
+ *
48
+ * Provided for callers that want one name for the lot. Nothing in this package asks for it:
49
+ * the functions take the parts, so a caller missing `contextLength` can still use all of them
50
+ * bar the window guard.
51
+ */
52
+ export interface AgentConfig extends Endpoint, ModelParams, ToolPolicy, RetryPolicy {
53
+ /** The agent's own standing instruction, if it has one. */
54
+ systemPrompt: string;
55
+ /** What the operator says this model reads, in tokens. Zero means ask the endpoint. */
56
+ contextLength: number;
57
+ }
package/dist/config.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What this package needs to know about a caller's configuration.
3
+ *
4
+ * Deliberately not one interface. The three servers that consume this each hold their settings
5
+ * in a different shape — a resolved agent row, a settings row read straight from the database,
6
+ * a zod-inferred config object — and they do not all carry the same fields: `task_server`'s
7
+ * settings row has no `contextLength`, and `min-agent` spells it `contextLimit` and has no
8
+ * timeout or retry budget at all.
9
+ *
10
+ * So every function here asks for the narrowest thing it actually reads, and a caller satisfies
11
+ * it structurally. Nothing imports a config *type* from a consumer, and no consumer has to grow
12
+ * a field it has no use for in order to call `getClient`.
13
+ */
14
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What went wrong, as a string.
3
+ *
4
+ * Almost everything caught here ends up in a run row, a tool result or a log line, and a
5
+ * `catch` binds `unknown` — so the same three-branch ternary was being written at every site
6
+ * that had to say what happened.
7
+ */
8
+ export declare const errorMessage: (error: unknown) => string;
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What went wrong, as a string.
3
+ *
4
+ * Almost everything caught here ends up in a run row, a tool result or a log line, and a
5
+ * `catch` binds `unknown` — so the same three-branch ternary was being written at every site
6
+ * that had to say what happened.
7
+ */
8
+ export const errorMessage = (error) => error instanceof Error ? error.message : String(error);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What a run is doing, while it is doing it.
3
+ *
4
+ * A run row only exists as a before and an after: it is written when the agent starts and
5
+ * updated when it stops, and everything in between — the thinking, the tool the model reached
6
+ * for, the argument it got wrong — is gone by the time anyone can read it. This is that middle,
7
+ * kept in memory and handed to whoever is watching.
8
+ *
9
+ * In memory on purpose: it is debugging output, worth nothing once the run has finished and its
10
+ * outcome is in the database. Nothing here survives a restart, and nothing here is the record.
11
+ */
12
+ export type RunEventKind =
13
+ /** A step of a caller's own flow began. `name` is the step, `text` its kind. */
14
+ "step"
15
+ /** A decision step chose an arm. `text` is the arm it took. */
16
+ | "decision"
17
+ /** A new turn of the agent loop began, inside whichever step is running. */
18
+ | "turn"
19
+ /** Reasoning tokens, as they arrive. */
20
+ | "thinking"
21
+ /** Reply tokens, as they arrive. */
22
+ | "output"
23
+ /** The model asked for a tool, with the arguments it chose. */
24
+ | "tool-call"
25
+ /** A tool came back, with what it said. */
26
+ | "tool-result"
27
+ /** Something the runner did that is not the model's doing — a preselection, a retry. */
28
+ | "notice"
29
+ /** What the run has cost so far, as the endpoint reported it at the end of a turn. */
30
+ | "usage"
31
+ /** The run ended. Always last, and always sent. */
32
+ | "done";
33
+ /**
34
+ * What a run has spent, counted from the start of the run rather than for the turn that
35
+ * carried it: a client draws the latest one it has seen and needs no arithmetic of its own,
36
+ * and one lost to the backlog cap costs nothing because the next supersedes it.
37
+ */
38
+ export interface RunUsage {
39
+ promptTokens: number;
40
+ completionTokens: number;
41
+ totalTokens: number;
42
+ }
43
+ export interface RunEvent {
44
+ runId: string;
45
+ /** Per-run counter, from 1. Lets a client order and de-duplicate what it receives. */
46
+ seq: number;
47
+ at: Date;
48
+ kind: RunEventKind;
49
+ /** The delta, the arguments, the result, or the reason — whatever the kind carries. */
50
+ text: string;
51
+ /** Tool name on the tool kinds, otherwise empty. */
52
+ name: string;
53
+ /**
54
+ * The caller's own flow step this happened inside, so a watcher can group a run the way the
55
+ * work is written. Empty for events that belong to the run rather than to any one step, and
56
+ * empty throughout for a caller with no steps at all.
57
+ */
58
+ step: string;
59
+ /** Outcome on `tool-result` and `done`, otherwise null. */
60
+ ok: boolean | null;
61
+ /** Running totals on `usage`, otherwise null. */
62
+ usage: RunUsage | null;
63
+ }
64
+ /** What `emit` is given: the run and the sequence are the bus's to assign. */
65
+ export type RunEventInput = Pick<RunEvent, "kind"> & Partial<Omit<RunEvent, "kind">>;
66
+ /** Records one event and hands it to everyone watching that run. Never throws at the caller. */
67
+ export declare function emit(runId: string, input: RunEventInput): RunEvent;
68
+ /**
69
+ * Everything that has happened on a run, then everything that happens next, until it ends.
70
+ *
71
+ * The backlog comes first so a watcher that joins halfway through — or after the run finished,
72
+ * inside the retention window — reads the same story as one that was there from the start.
73
+ */
74
+ export declare function watch(runId: string): AsyncGenerator<RunEvent>;
75
+ /** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
76
+ export declare const history: (runId: string) => RunEvent[];
77
+ /** Test seam: forget every run, so one test's events cannot be read by the next. */
78
+ export declare const reset: () => void;
79
+ /**
80
+ * Consecutive tokens of one kind are one thing being said, not hundreds of things.
81
+ *
82
+ * A client that reads a run in snapshots rather than token by token wants it that way: a
83
+ * reasoning model spends ten thousand deltas on a paragraph, and a paragraph is what it meant.
84
+ * Each block carries the `seq` of its last event, so asking for what came after one block
85
+ * picks up exactly where it left off.
86
+ */
87
+ export declare function fold(events: RunEvent[]): RunEvent[];
package/dist/events.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * What a run is doing, while it is doing it.
3
+ *
4
+ * A run row only exists as a before and an after: it is written when the agent starts and
5
+ * updated when it stops, and everything in between — the thinking, the tool the model reached
6
+ * for, the argument it got wrong — is gone by the time anyone can read it. This is that middle,
7
+ * kept in memory and handed to whoever is watching.
8
+ *
9
+ * In memory on purpose: it is debugging output, worth nothing once the run has finished and its
10
+ * outcome is in the database. Nothing here survives a restart, and nothing here is the record.
11
+ */
12
+ /** How many events one run keeps for a watcher that joins late. A chatty run loses its oldest. */
13
+ const MAX_EVENTS = 1000;
14
+ /**
15
+ * How far past the cap the backlog is allowed to run before it is trimmed.
16
+ *
17
+ * Dropping the oldest event on every push means shifting a thousand-element array tens of
18
+ * thousands of times over a reasoning run — the one thing in here that would ever show up in a
19
+ * profile. Trimming in batches makes it a few dozen splices instead, at the cost of the backlog
20
+ * sometimes being a little longer than the cap, which nothing depends on.
21
+ */
22
+ const TRIM_SLACK = 256;
23
+ /** How long a finished run stays readable, for a watcher that arrives just after the end. */
24
+ const RETAIN_MS = 60_000;
25
+ const streams = new Map();
26
+ const streamFor = (runId) => {
27
+ const existing = streams.get(runId);
28
+ if (existing)
29
+ return existing;
30
+ const stream = { events: [], listeners: new Set(), seq: 0 };
31
+ streams.set(runId, stream);
32
+ return stream;
33
+ };
34
+ /** Records one event and hands it to everyone watching that run. Never throws at the caller. */
35
+ export function emit(runId, input) {
36
+ const stream = streamFor(runId);
37
+ const event = {
38
+ runId,
39
+ seq: ++stream.seq,
40
+ at: new Date(),
41
+ text: "",
42
+ name: "",
43
+ step: "",
44
+ ok: null,
45
+ usage: null,
46
+ ...input,
47
+ };
48
+ stream.events.push(event);
49
+ if (stream.events.length > MAX_EVENTS + TRIM_SLACK) {
50
+ stream.events.splice(0, stream.events.length - MAX_EVENTS);
51
+ }
52
+ for (const listener of stream.listeners)
53
+ listener(event);
54
+ if (event.kind === "done") {
55
+ // Kept for a moment so a watcher that arrives just after the end still sees how it went,
56
+ // then dropped: a finished run's record is the row, not this.
57
+ setTimeout(() => {
58
+ if (streams.get(runId) === stream && stream.listeners.size === 0)
59
+ streams.delete(runId);
60
+ }, RETAIN_MS).unref?.();
61
+ }
62
+ return event;
63
+ }
64
+ /**
65
+ * Everything that has happened on a run, then everything that happens next, until it ends.
66
+ *
67
+ * The backlog comes first so a watcher that joins halfway through — or after the run finished,
68
+ * inside the retention window — reads the same story as one that was there from the start.
69
+ */
70
+ export async function* watch(runId) {
71
+ const stream = streamFor(runId);
72
+ const queue = [...stream.events];
73
+ let wake = null;
74
+ const listener = (event) => {
75
+ queue.push(event);
76
+ wake?.();
77
+ };
78
+ stream.listeners.add(listener);
79
+ try {
80
+ for (;;) {
81
+ while (queue.length > 0) {
82
+ const event = queue.shift();
83
+ yield event;
84
+ // `done` is the last event a run will ever have, so the subscription completes rather
85
+ // than leaving the client holding an open stream that will never say anything again.
86
+ if (event.kind === "done")
87
+ return;
88
+ }
89
+ await new Promise((resolve) => {
90
+ wake = resolve;
91
+ });
92
+ wake = null;
93
+ }
94
+ }
95
+ finally {
96
+ stream.listeners.delete(listener);
97
+ // A watcher can name a run that has not started, or will never start. Nothing was recorded
98
+ // under it, so nothing is left behind either.
99
+ if (stream.listeners.size === 0 && stream.events.length === 0)
100
+ streams.delete(runId);
101
+ }
102
+ }
103
+ /** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
104
+ export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
105
+ /** Test seam: forget every run, so one test's events cannot be read by the next. */
106
+ export const reset = () => streams.clear();
107
+ /**
108
+ * Consecutive tokens of one kind are one thing being said, not hundreds of things.
109
+ *
110
+ * A client that reads a run in snapshots rather than token by token wants it that way: a
111
+ * reasoning model spends ten thousand deltas on a paragraph, and a paragraph is what it meant.
112
+ * Each block carries the `seq` of its last event, so asking for what came after one block
113
+ * picks up exactly where it left off.
114
+ */
115
+ export function fold(events) {
116
+ const blocks = [];
117
+ for (const event of events) {
118
+ const last = blocks[blocks.length - 1];
119
+ const mergeable = event.kind === "thinking" || event.kind === "output";
120
+ if (last && mergeable && last.kind === event.kind && last.step === event.step) {
121
+ blocks[blocks.length - 1] = {
122
+ ...last,
123
+ seq: event.seq,
124
+ at: event.at,
125
+ text: last.text + event.text,
126
+ };
127
+ }
128
+ else {
129
+ blocks.push(event);
130
+ }
131
+ }
132
+ return blocks;
133
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The endpoint-agnostic half of an OpenAI-compatible agent loop.
3
+ *
4
+ * What is here is everything that does not know what the agent is *for*: making a tool schema
5
+ * a strict server will accept, getting tool definitions in front of a model without paying for
6
+ * all of them, one-shot calls that support a run, the event bus a watcher reads, a pooled
7
+ * client, and the rules about retrying. What is not here is the work — orchestration, prompts,
8
+ * and whatever the run is about — because that is the caller's, and it is the part that differs
9
+ * between one server and the next.
10
+ */
11
+ export type { CatalogServer } from "./catalog.ts";
12
+ export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
13
+ export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
14
+ export { errorMessage } from "./errors.ts";
15
+ export { emit, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, reset, watch, } from "./events.ts";
16
+ export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
17
+ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
18
+ export { ask, clean, estimateTokens, listLines, parseJson, type SideTaskOptions, tryAsk, } from "./side-task.ts";
19
+ 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 ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The endpoint-agnostic half of an OpenAI-compatible agent loop.
3
+ *
4
+ * What is here is everything that does not know what the agent is *for*: making a tool schema
5
+ * a strict server will accept, getting tool definitions in front of a model without paying for
6
+ * all of them, one-shot calls that support a run, the event bus a watcher reads, a pooled
7
+ * client, and the rules about retrying. What is not here is the work — orchestration, prompts,
8
+ * and whatever the run is about — because that is the caller's, and it is the part that differs
9
+ * between one server and the next.
10
+ */
11
+ export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
12
+ export { errorMessage } from "./errors.js";
13
+ export { emit, fold, history, reset, watch, } from "./events.js";
14
+ export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
15
+ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
16
+ export { ask, clean, estimateTokens, listLines, parseJson, tryAsk, } from "./side-task.js";
17
+ 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.js";
@@ -0,0 +1,57 @@
1
+ import OpenAI from "openai";
2
+ /**
3
+ * Everything about a request failing that is not about what the request said.
4
+ *
5
+ * A run makes a request per tool iteration against an endpoint that may be a laptop's llama.cpp
6
+ * or a hosted API, and the two fail in different ways for different reasons. This is the part
7
+ * of the loop with nothing to do with the model's answer: whether the request was lost, whether
8
+ * it was too big to have been sent at all, and how long to wait before sending it again.
9
+ */
10
+ /** The endpoint stopped answering mid-request. Its own class so the retry can recognise it. */
11
+ export declare class EndpointSilent extends Error {
12
+ readonly name = "EndpointSilent";
13
+ }
14
+ /**
15
+ * The request was bigger than the model will read. Its own class so nothing retries it: sending
16
+ * the same too-large request again is the same refusal, one round trip later.
17
+ */
18
+ export declare class ContextOverflow extends Error {
19
+ readonly name = "ContextOverflow";
20
+ }
21
+ /** 1234 → "1.2k". The numbers in an overflow message are large and nobody reads the units digit. */
22
+ export declare const compact: (tokens: number) => string;
23
+ /**
24
+ * What this request will cost the window, in tokens, near enough.
25
+ *
26
+ * Characters over four, because there is no tokenizer here and there is not going to be one:
27
+ * a server that will not say how big its window is will not lend us its vocabulary either.
28
+ * The estimate runs low on tool schemas — JSON packs more tokens into a character than prose
29
+ * does — and that is the side to be wrong on, since the cost of guessing high is a run refused
30
+ * that would have worked, and the cost of guessing low is the endpoint's own refusal, which is
31
+ * where we were before this existed.
32
+ */
33
+ export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming) => number;
34
+ export declare const isOverflow: (detail: string) => boolean;
35
+ /**
36
+ * Below this, the window is nobody's business and is not asked for.
37
+ *
38
+ * Finding out what a model reads costs a listing against its endpoint, and a run whose whole
39
+ * request is a few thousand tokens fits anything anyone serves — spending a round trip to
40
+ * confirm that, on every run of every card, would be the cost of the guard falling on the
41
+ * runs that never needed it. A model in a window smaller than this exists, and a request that
42
+ * overruns one is left to the endpoint's own complaint, which reads properly now either way.
43
+ */
44
+ export declare const SMALLEST_LIKELY_WINDOW = 8192;
45
+ /**
46
+ * Whether a failed request is worth trying again.
47
+ *
48
+ * The question is whether the request was *refused or lost*, rather than answered with a
49
+ * complaint about its contents: a connection that never landed, a server too busy or too broken
50
+ * to answer, an endpoint that went quiet. A 400 for a malformed tool schema would fail exactly
51
+ * the same way on every attempt, and the two capability cases below are negotiated rather than
52
+ * retried blindly.
53
+ */
54
+ export declare function isTransient(error: unknown): boolean;
55
+ /** Exponential, with jitter so several tasks failing at once do not return in lockstep. */
56
+ export declare const backoffMs: (attempt: number) => number;
57
+ export declare const sleep: (ms: number, signal?: AbortSignal) => Promise<void>;