@cubicecho/agent-core 2.0.2 → 2.0.4

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.
@@ -30,6 +30,8 @@ export function capabilitiesFor(baseUrl) {
30
30
  export function resetCapabilities() {
31
31
  capabilities.clear();
32
32
  }
33
+ /** Every flag on a `Capabilities`, typed, so a third one is compared without an edit here. */
34
+ const keys = (of) => Object.keys(of);
33
35
  /** `stream_options` is named in the refusal by every server that has not heard of it. */
34
36
  const REJECTS_USAGE = /stream_options/i;
35
37
  /**
@@ -61,6 +63,10 @@ const REJECTS_USAGE = /stream_options/i;
61
63
  */
62
64
  export async function negotiate(supports, send, { produced = { any: false }, onNotice } = {}) {
63
65
  for (;;) {
66
+ // What this attempt was built with. `capabilitiesFor` hands one object per endpoint to
67
+ // everyone on it, so a run starting alongside this one may latch a flag off while this call
68
+ // is in flight — and the branches below are guarded on the flag still being set.
69
+ const sent = { ...supports };
64
70
  try {
65
71
  return await send(supports, produced);
66
72
  }
@@ -76,9 +82,16 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
76
82
  supports.usageInStream = false;
77
83
  onNotice?.("server rejected stream_options; token counts unavailable");
78
84
  }
79
- else {
85
+ else if (keys(sent).every((flag) => sent[flag] === supports[flag])) {
80
86
  throw error;
81
87
  }
88
+ // Otherwise the refusal was answered by whoever got there first, and this attempt was
89
+ // built before the answer existed. Two runs opening on a fresh llama.cpp box both get the
90
+ // grammar error; the first latches it off and re-sends, and the second used to find the
91
+ // flag already clear, fall through to the throw and die on an error the process had just
92
+ // learned to fix — `isTransient` refuses a 400, so `runTurn` would not send it again
93
+ // either. Sending it again is the whole of the fix: flags only ever latch off, so this
94
+ // gives up after one pass per flag.
82
95
  }
83
96
  }
84
97
  }
package/dist/events.js CHANGED
@@ -22,6 +22,22 @@ const MAX_EVENTS = 1000;
22
22
  const TRIM_SLACK = 256;
23
23
  /** How long a finished run stays readable, for a watcher that arrives just after the end. */
24
24
  const RETAIN_MS = 60_000;
25
+ /**
26
+ * The same for a run that has not said `done`, which is a far more dangerous thing to drop.
27
+ *
28
+ * A finished run has nothing more to say, so forgetting it a minute later costs a late watcher
29
+ * a backlog and nothing else. An unfinished one is still writing: `touched` only moves on
30
+ * `emit`, so a live run that spends a minute inside one slow tool call looked exactly like an
31
+ * abandoned one and was reaped out from under itself. What made that more than a lost backlog
32
+ * is that the next `emit` builds a fresh stream with `seq` back at zero — and `seq` is the
33
+ * field `RunEvent` documents for ordering and de-duplication, so a client that reconnects
34
+ * across the gap discards the new events as ones it has already seen.
35
+ *
36
+ * The sweep still has to reap them, because a run killed by a signal or simply forgotten
37
+ * reaches no `done` either. This is the backstop for a caller that never says so; `endRun` is
38
+ * for one that knows.
39
+ */
40
+ const RETAIN_UNENDED_MS = 30 * 60_000;
25
41
  const streams = new Map();
26
42
  const streamFor = (runId) => {
27
43
  const existing = streams.get(runId);
@@ -52,9 +68,11 @@ const streamFor = (runId) => {
52
68
  let sweeping = null;
53
69
  function sweep() {
54
70
  sweeping = null;
55
- const deadline = Date.now() - RETAIN_MS;
71
+ const now = Date.now();
56
72
  for (const [runId, stream] of streams) {
57
- if (stream.listeners.size === 0 && stream.touched <= deadline)
73
+ if (stream.listeners.size)
74
+ continue;
75
+ if (stream.touched <= now - (stream.ended ? RETAIN_MS : RETAIN_UNENDED_MS))
58
76
  streams.delete(runId);
59
77
  }
60
78
  scheduleSweep();
@@ -73,6 +91,17 @@ function scheduleSweep() {
73
91
  * @param runId The run to forget. An id nothing was emitted under is ignored.
74
92
  */
75
93
  export function endRun(runId) {
94
+ const stream = streams.get(runId);
95
+ if (!stream)
96
+ return;
97
+ // Watchers are parked on a promise that only an `emit` to *this* stream can resolve, and the
98
+ // delete below puts it beyond the reach of every later one — the next `emit` builds a fresh
99
+ // stream and wakes nobody. So they are told the run is over first: a watcher that is handed
100
+ // `done` completes, runs its `finally` and lets its consumer go, where one left parked holds
101
+ // an open subscription that can never say anything again. An SSE client on the other end of
102
+ // that is a connection that never closes.
103
+ if (stream.listeners.size)
104
+ emit(runId, { kind: "done", ok: false, text: "run ended" });
76
105
  streams.delete(runId);
77
106
  }
78
107
  /**
@@ -209,7 +238,12 @@ export async function* watch(runId) {
209
238
  // A watcher can name a run that has not started, or will never start. Nothing was recorded
210
239
  // under it, so nothing is left behind either — and a run that has ended has nothing more to
211
240
  // say to anyone, so the last watcher leaving takes the backlog with it.
212
- if (stream.listeners.size === 0 && (stream.ended || stream.events.length === 0)) {
241
+ // Against `stream` rather than the id: this generator may have outlived its own entry — a
242
+ // sweep or an `endRun` drops it and a later `emit` files the same run under a new one — and
243
+ // the last watcher of the old stream has no business deleting the new one.
244
+ if (streams.get(runId) === stream &&
245
+ stream.listeners.size === 0 &&
246
+ (stream.ended || stream.events.length === 0)) {
213
247
  streams.delete(runId);
214
248
  }
215
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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",