@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.
@@ -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>;
@@ -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) {
@@ -117,7 +156,9 @@ export function governedPeers(options = {}) {
117
156
  return defineDynamic({
118
157
  events: {
119
158
  "turn.started": async () => {
120
- const peers = await discover(options);
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.0",
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",