@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.
- package/dist/governed-peers.js +61 -22
- package/package.json +3 -2
package/dist/governed-peers.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
const res = await fetch(`${base}/eve/v1/session/${encodeURIComponent(sessionId)}/stream?startIndex
|
|
80
|
-
if (!res.ok)
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|