@cubicecho/agent-core 2.2.1 → 2.2.3

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
@@ -46,7 +46,7 @@ the exports rather than a second description of them; CI fails if the committed
46
46
 
47
47
  `negotiate` wrapping `streamTurn` is the whole of one turn against an endpoint: the request is
48
48
  re-sent for as long as the answer is this server refusing something the request can do without,
49
- and nothing is re-sent once it has started answering.
49
+ and nothing is re-sent once the model has started answering.
50
50
 
51
51
  ```ts
52
52
  import {
@@ -116,9 +116,12 @@ OpenAI and `gpt-4o` behind a proxy need not be the same weights, and one that re
116
116
  effort must not speak for the other.
117
117
 
118
118
  `send` takes a callback rather than a body because the body has to be rebuilt from the latched
119
- flags. `produced` is one box per attempt — `streamTurn` sets it as soon as the server says
120
- anything, and the re-send reads it — so a caller with its own retry budget passes one in
121
- (`{ produced }`) and reads it afterwards to decide whether the failure is worth another attempt.
119
+ flags. `produced` is one box per attempt — `streamTurn` sets it on the first chunk that carries
120
+ text, reasoning or a piece of a tool call, and the re-send reads it — so a caller with its own
121
+ retry budget passes one in (`{ produced }`) and reads it afterwards to decide whether the failure
122
+ is worth another attempt. The content-free `{"role":"assistant"}` most servers open a stream with
123
+ does not set it: nothing has been shown to anybody yet, so an endpoint that primes the stream and
124
+ then wedges is retried like one that never answered at all.
122
125
 
123
126
  `idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
124
127
  still talking is never cut off however long it takes, and one that has stopped answering raises
package/dist/retry.d.ts CHANGED
@@ -33,10 +33,11 @@ export declare const compact: (tokens: number) => string;
33
33
  * built the entire transcript into a string on every call and threw it away having read nothing
34
34
  * but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
35
35
  * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
36
- * which the envelope constants put back — one per key rather than one per message, since the
37
- * three keys only some shapes carry are most of what a tool-using transcript is made of. What
38
- * is left is a message's escaping, which is not a constant and is small against an estimate
39
- * that is already characters over four.
36
+ * which the envelope constants put back — one per key and one per content part, rather than one
37
+ * per message, since the keys only some shapes carry and the parts a client appends block by
38
+ * block are most of what a tool-using transcript is made of. What is left is a message's
39
+ * escaping, which is not a constant and is small against an estimate that is already characters
40
+ * over four.
40
41
  *
41
42
  * @param body The request as it will be sent, tools included.
42
43
  */
package/dist/retry.js CHANGED
@@ -40,6 +40,14 @@ const NAME_KEY = 10;
40
40
  const TOOL_CALL_ID_KEY = 18;
41
41
  /** The same for `"tool_calls":[]` around the calls; each call's own comma is in `CALL_ENVELOPE`. */
42
42
  const TOOL_CALLS_KEY = 15;
43
+ // A content part is an object in the body the same way a message is, and its two keys are in the
44
+ // request exactly as `tool_call_id` was. Charging only `part.text` left them out, which is 6.5
45
+ // tokens per part — short in proportion to how finely the content is split rather than to how
46
+ // much it says, so a transcript a client appends block by block is worst hit.
47
+ /** What `{"type":"text","text":""},` costs around a text part. */
48
+ const TEXT_PART = 26;
49
+ /** The same for `{"type":"refusal","refusal":""},` around a refusal part. */
50
+ const REFUSAL_PART = 32;
43
51
  /** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
44
52
  const CHARS_PER_TOKEN = 4;
45
53
  /** How many characters one message is worth: its keys, and its content in whichever shape. */
@@ -51,11 +59,13 @@ function messageChars(message) {
51
59
  else if (Array.isArray(content))
52
60
  for (const part of content) {
53
61
  // Text and refusal parts carry their own strings; an image or an audio part carries a URL
54
- // or a blob, and neither is priced by its length anyway.
62
+ // or a blob, and neither is priced by its length anyway — a vision model does not charge
63
+ // an image by its base64 length, so counting the data URL would overshoot by more than
64
+ // leaving the part out undershoots.
55
65
  if (part.type === "text")
56
- chars += part.text.length;
66
+ chars += TEXT_PART + part.text.length;
57
67
  else if (part.type === "refusal")
58
- chars += part.refusal.length;
68
+ chars += REFUSAL_PART + part.refusal.length;
59
69
  }
60
70
  if ("name" in message && typeof message.name === "string")
61
71
  chars += NAME_KEY + message.name.length;
@@ -105,10 +115,11 @@ function toolsCost(tools) {
105
115
  * built the entire transcript into a string on every call and threw it away having read nothing
106
116
  * but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
107
117
  * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
108
- * which the envelope constants put back — one per key rather than one per message, since the
109
- * three keys only some shapes carry are most of what a tool-using transcript is made of. What
110
- * is left is a message's escaping, which is not a constant and is small against an estimate
111
- * that is already characters over four.
118
+ * which the envelope constants put back — one per key and one per content part, rather than one
119
+ * per message, since the keys only some shapes carry and the parts a client appends block by
120
+ * block are most of what a tool-using transcript is made of. What is left is a message's
121
+ * escaping, which is not a constant and is small against an estimate that is already characters
122
+ * over four.
112
123
  *
113
124
  * @param body The request as it will be sent, tools included.
114
125
  */
@@ -12,9 +12,11 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
12
12
  * The outer one is the endpoint being unreachable, busy or silent, which is not about this
13
13
  * request at all and is worth simply waiting out.
14
14
  *
15
- * Both are bounded by the same rule: nothing is sent again once the server has started
15
+ * Both are bounded by the same rule: nothing is sent again once the model has started
16
16
  * answering. The tokens are already out and on their way to whoever is watching, and a second
17
- * attempt would say everything twice. That is what `produced` is, one box per attempt.
17
+ * attempt would say everything twice. That is what `produced` is, one box per attempt — set by
18
+ * a chunk that carried something rather than by a chunk arriving, so the empty opening chunk
19
+ * most servers send does not cost the retry.
18
20
  */
19
21
  /** A retry is not the same event as a downgrade, but a watcher wants to be told about both. */
20
22
  export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
package/dist/stream.d.ts CHANGED
@@ -22,13 +22,19 @@ export interface Turn {
22
22
  usage: TurnUsage;
23
23
  }
24
24
  /**
25
- * Whether the server has started answering.
25
+ * Whether the model has said anything a second attempt would say twice.
26
26
  *
27
27
  * A box rather than a return value because it has to be readable *while* the request is in
28
28
  * flight: the rules in `retry.ts` are built on the premise that a stream which has already
29
29
  * emitted tokens must never be replayed, and by the time a rejected promise is in hand the turn
30
30
  * is over. There is one of these per attempt, shared by everything that has a say in whether
31
31
  * the attempt is repeated. See `negotiate`.
32
+ *
33
+ * What sets it is a chunk that carried something — text, reasoning, or a piece of a tool call —
34
+ * rather than a chunk arriving. Those read as the same sentence until a server puts an empty
35
+ * chunk between them, and most of them do: a stream usually opens with a content-free
36
+ * `{"role":"assistant"}` that shows nobody anything, and a turn that latched on it could never
37
+ * be retried however early it then died.
32
38
  */
33
39
  export interface Produced {
34
40
  any: boolean;
@@ -43,7 +49,7 @@ export interface StreamTurnOptions {
43
49
  * needs.
44
50
  */
45
51
  idleMs?: number;
46
- /** Set as soon as the server has said anything, so a failed call knows if it can be retried. */
52
+ /** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
47
53
  produced?: Produced;
48
54
  /** The model's scratchpad, as it arrives. */
49
55
  onThinking?: (delta: string) => void;
package/dist/stream.js CHANGED
@@ -52,8 +52,8 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
52
52
  const calls = new Map();
53
53
  const usage = { prompt: 0, completion: 0, total: 0 };
54
54
  for await (const chunk of stream) {
55
- if (produced)
56
- produced.any = true;
55
+ // Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
56
+ // being alive, which is all the watchdog is asking about.
57
57
  rearm();
58
58
  // Assigned rather than accumulated. `stream_options.include_usage` sends one final chunk
59
59
  // and the two agree there, but a server that reports cumulatively per chunk makes a sum
@@ -68,6 +68,15 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
68
68
  if (!delta)
69
69
  continue;
70
70
  const thinking = delta.reasoning_content || delta.reasoning || "";
71
+ // Latched on what the chunk carried, not on its having arrived. Most OpenAI-compatible
72
+ // servers open a stream with a content-free `{"role":"assistant"}` before the first
73
+ // token; latching on that made an endpoint that primes and then wedges unrepeatable,
74
+ // which is exactly the case the watchdog raises `EndpointSilent` for. Tool-call
75
+ // fragments count even though no callback reports them: a partial call is state the turn
76
+ // has accumulated, and losing a retry is the safer half of that trade. Set before the
77
+ // callbacks, so a watcher that throws mid-token cannot be told the same token twice.
78
+ if (produced && (thinking || delta.content || delta.tool_calls?.length))
79
+ produced.any = true;
71
80
  if (thinking)
72
81
  onThinking?.(thinking);
73
82
  if (delta.content) {
package/llms.txt CHANGED
@@ -115,7 +115,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
115
115
 
116
116
  Reading one streamed turn back into a message.
117
117
 
118
- - `Produced` (type) — Whether the server has started answering.
118
+ - `Produced` (type) — Whether the model has said anything a second attempt would say twice.
119
119
  - `StreamTurnOptions` (type) — What `streamTurn` takes besides the request body.
120
120
  - `streamTurn` — Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a message.
121
121
  - `Turn` (type) — One streamed turn, put back together into the shape a loop and a transcript work with.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
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",