@kybernesis/dispatch 0.3.1 → 0.3.2

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.
@@ -70,44 +70,83 @@ async function ask(options, peer, message) {
70
70
  const { sessionId } = (await started.json());
71
71
  if (!sessionId)
72
72
  throw new Error(`${peer.name} did not open a session.`);
73
- // Read its stream to the end of the turn. The peer may think for a while, so
74
- // this waits on CONTENT rather than on a fixed deadline per poll.
75
- const began = Date.now();
76
- let index = 0;
73
+ /**
74
+ * Read the turn off the peer's NDJSON stream, incrementally.
75
+ *
76
+ * The stream FOLLOWS the live session: it stays open after the turn so the
77
+ * next one arrives on the same connection. Buffering it whole — `await
78
+ * res.text()` — therefore waits for a connection that never closes, and the
79
+ * call dies on its own timeout having already received the answer.
80
+ *
81
+ * That failure is host-shaped, which is what made it confusing: a callee on
82
+ * serverless ends its response when the function returns, so a buffered read
83
+ * resolves and everything looks correct. Against a callee on a long-lived
84
+ * host, the identical code hangs every time.
85
+ *
86
+ * So: parse line by line as bytes arrive, and stop at the turn boundary.
87
+ */
88
+ const controller = new AbortController();
89
+ const deadline = setTimeout(() => controller.abort(), timeout);
77
90
  let reply = "";
78
- while (Date.now() - began < timeout) {
79
- const res = await fetch(`${base}/eve/v1/session/${encodeURIComponent(sessionId)}/stream?startIndex=${index}`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(60_000) });
80
- if (!res.ok)
81
- break;
82
- const text = await res.text();
83
- for (const line of text.split("\n")) {
84
- if (!line.trim())
85
- continue;
86
- index += 1;
87
- try {
88
- const event = JSON.parse(line);
91
+ try {
92
+ const res = await fetch(`${base}/eve/v1/session/${encodeURIComponent(sessionId)}/stream?startIndex=0`, { headers: { authorization: `Bearer ${token}` }, signal: controller.signal });
93
+ if (!res.ok || !res.body)
94
+ throw new Error(`${peer.name} would not stream its answer (HTTP ${res.status}).`);
95
+ const reader = res.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let buffer = "";
98
+ for (;;) {
99
+ const { done, value } = await reader.read();
100
+ if (done)
101
+ break;
102
+ buffer += decoder.decode(value, { stream: true });
103
+ // Keep the trailing fragment: a chunk boundary lands mid-line often
104
+ // enough that dropping it loses whole events under load.
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop() ?? "";
107
+ for (const line of lines) {
108
+ if (!line.trim())
109
+ continue;
110
+ let event;
111
+ try {
112
+ event = JSON.parse(line);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
89
117
  // Only a terminal message is the answer: eve emits narration as its own
90
118
  // completed message with finishReason "tool-calls", and treating those
91
119
  // as the reply returns the peer's intentions instead of its answer.
92
120
  if (event.type === "message.completed" && event.data?.finishReason === "stop") {
93
121
  reply = String(event.data.message ?? "");
94
122
  }
123
+ if (event.type === "turn.failed" || event.type === "session.failed") {
124
+ const detail = event.data?.message;
125
+ throw new Error(`${peer.name} failed to answer${detail ? `: ${detail}` : "."}`);
126
+ }
95
127
  if (event.type === "session.waiting" || event.type === "turn.completed") {
128
+ await reader.cancel().catch(() => { });
96
129
  if (reply)
97
130
  return reply;
131
+ throw new Error(`${peer.name} finished its turn without a reply.`);
98
132
  }
99
133
  }
100
- catch {
101
- /* a partial line; the next poll re-reads from this index */
102
- }
103
134
  }
104
135
  if (reply)
105
136
  return reply;
106
- await new Promise((r) => setTimeout(r, 1_500));
137
+ throw new Error(`${peer.name} closed the stream without answering.`);
138
+ }
139
+ catch (error) {
140
+ if (reply)
141
+ return reply;
142
+ if (error?.name === "AbortError") {
143
+ throw new Error(`${peer.name} did not answer within ${Math.round(timeout / 1000)}s.`);
144
+ }
145
+ throw error;
146
+ }
147
+ finally {
148
+ clearTimeout(deadline);
107
149
  }
108
- if (reply)
109
- return reply;
110
- throw new Error(`${peer.name} did not answer within ${Math.round(timeout / 1000)}s.`);
111
150
  }
112
151
  /** A tool name an agent can actually call: letters, digits, underscore. */
113
152
  function toolName(peer) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/dispatch",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Agent-to-agent dispatch for eve agents: declared remote-peer edges with principal forwarding on by default, and a receiver channel that makes trusting the wrong caller hard to express.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "build": "tsc -p tsconfig.build.json",
29
29
  "prepare": "tsc -p tsconfig.build.json",
30
- "typecheck": "tsc --noEmit"
30
+ "typecheck": "tsc --noEmit",
31
+ "prepublishOnly": "node ../../scripts/prepublish.mjs"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/node": "24.x",