@kybernesis/dispatch 0.3.0 → 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.d.ts +15 -0
- package/dist/governed-peers.js +64 -23
- package/package.json +3 -2
package/dist/governed-peers.d.ts
CHANGED
|
@@ -35,5 +35,20 @@ export interface GovernedPeersOptions {
|
|
|
35
35
|
cacheMs?: number;
|
|
36
36
|
/** How long to wait for a peer's answer before giving up. */
|
|
37
37
|
timeoutMs?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Peers that already have a declared `remotePeer()` file, so they are not
|
|
40
|
+
* offered twice.
|
|
41
|
+
*
|
|
42
|
+
* Some peers earn their file: `remotePeer` forwards the human's principal,
|
|
43
|
+
* which is the only correct way to reach an agent holding personal data.
|
|
44
|
+
* Discovery would list those peers too, and an agent shown two routes to the
|
|
45
|
+
* same colleague will sometimes pick the one that answers as itself — the
|
|
46
|
+
* quieter of the two failures, because it succeeds and returns less.
|
|
47
|
+
*
|
|
48
|
+
* Naming a peer here is not the thing this module exists to remove. It does
|
|
49
|
+
* not make the peer reachable; the file already did that. It says "handled
|
|
50
|
+
* elsewhere", and removing the line costs a duplicate, not a capability.
|
|
51
|
+
*/
|
|
52
|
+
declared?: string[];
|
|
38
53
|
}
|
|
39
54
|
export declare function governedPeers(options?: GovernedPeersOptions): import("eve").DynamicSentinel<Record<string, unknown> | null>;
|
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) {
|
|
@@ -117,7 +156,9 @@ export function governedPeers(options = {}) {
|
|
|
117
156
|
return defineDynamic({
|
|
118
157
|
events: {
|
|
119
158
|
"turn.started": async () => {
|
|
120
|
-
const
|
|
159
|
+
const all = await discover(options);
|
|
160
|
+
const skip = new Set((options.declared ?? []).map((n) => n.toLowerCase()));
|
|
161
|
+
const peers = all.filter((p) => !skip.has(p.name.toLowerCase()));
|
|
121
162
|
if (peers.length === 0)
|
|
122
163
|
return null;
|
|
123
164
|
const tools = {};
|
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",
|