@cubicecho/agent-core 1.1.0 → 1.2.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 CHANGED
@@ -29,12 +29,51 @@ 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
+ | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
32
33
  | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
33
34
  | `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
34
35
 
35
36
  What is **not** here is the work: orchestration, prompts, and whatever the run is about. That
36
37
  is the caller's, and it is the part that actually differs between one server and the next.
37
38
 
39
+ ## A turn
40
+
41
+ `negotiate` wrapping `streamTurn` is the whole of one turn against an endpoint: the request is
42
+ re-sent for as long as the answer is this server refusing something the request can do without,
43
+ and nothing is re-sent once it has started answering.
44
+
45
+ ```ts
46
+ import {
47
+ capabilitiesFor, getClient, negotiate, relaxTools, sanitizeTools, streamTurn, timeoutMs,
48
+ } from "@cubicecho/agent-core";
49
+
50
+ const declared = sanitizeTools(tools);
51
+ const supports = capabilitiesFor(config.baseUrl);
52
+
53
+ const turn = await negotiate(supports, (supports, produced) =>
54
+ streamTurn(
55
+ getClient(config),
56
+ {
57
+ model, messages, stream: true,
58
+ // Rebuilt per attempt: what the endpoint has refused is latched off by the line above.
59
+ ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
60
+ tools: supports.strictSchemas ? declared : relaxTools(declared),
61
+ },
62
+ { produced, signal, idleMs: timeoutMs(config), onOutput: (text) => emit(runId, { kind: "output", text }) },
63
+ ),
64
+ );
65
+ ```
66
+
67
+ `send` takes a callback rather than a body because the body has to be rebuilt from the latched
68
+ flags. `produced` is one box per attempt — `streamTurn` sets it as soon as the server says
69
+ anything, and the re-send reads it — so a caller with its own retry budget passes one in
70
+ (`{ produced }`) and reads it afterwards to decide whether the failure is worth another attempt.
71
+
72
+ `idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
73
+ still talking is never cut off however long it takes, and one that has stopped answering raises
74
+ `EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
75
+ `requestTimeoutSeconds` of zero, which waits forever — what a local model answering slowly needs.
76
+
38
77
  ## The config seam
39
78
 
40
79
  Nothing here imports a config type from a consumer, and no function asks for a whole
@@ -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
- * Whether the server has started answering. Nothing is re-sent once it has: the tokens are
36
- * already out and on their way to whoever is watching, and a second attempt would say
37
- * everything twice. See `streamTurn`, which sets this.
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>;
@@ -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?.any)
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,10 @@ 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";
19
20
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
20
21
  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";
22
+ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
22
23
  export { estimateTokens } from "./tokens.ts";
23
24
  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,6 +13,7 @@ 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";
17
18
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
18
19
  export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
@@ -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
+ }
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
- * Set as soon as the server has said anything, so a failed call knows whether it can be
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.1.0",
3
+ "version": "1.2.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",