@kybernesis/dispatch 0.2.1 → 0.3.0
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 +39 -0
- package/dist/governed-peers.js +145 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every agent this one is ALLOWED to call, as tools — resolved from the control
|
|
3
|
+
* plane, not from files.
|
|
4
|
+
*
|
|
5
|
+
* `remotePeer` needs a file per peer, which means a customer who grants an edge
|
|
6
|
+
* in the admin then has to write code and redeploy before it does anything.
|
|
7
|
+
* That is not a rough edge, it is a broken promise: the permission exists, the
|
|
8
|
+
* UI says so, and nothing happens. This closes it — grant an edge and the tool
|
|
9
|
+
* appears on the next turn; revoke it and it is gone.
|
|
10
|
+
*
|
|
11
|
+
* Mount once:
|
|
12
|
+
*
|
|
13
|
+
* ```ts title="agent/tools/peers.ts"
|
|
14
|
+
* import { governedPeers } from "@kybernesis/dispatch";
|
|
15
|
+
* export default governedPeers();
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* What this does NOT do, and it matters: `remotePeer` forwards the human's
|
|
19
|
+
* principal, so the callee answers as the person who asked and can only surface
|
|
20
|
+
* what they are entitled to see. These calls carry the CALLING AGENT's
|
|
21
|
+
* identity instead. For peers that reach personal data, keep the declared
|
|
22
|
+
* `remotePeer` — the file is worth it there. This is the right default for
|
|
23
|
+
* everything else.
|
|
24
|
+
*/
|
|
25
|
+
export interface GovernedPeersOptions {
|
|
26
|
+
/** Control-plane base URL. Defaults to KYBERNESIS_ISSUER. */
|
|
27
|
+
issuer?: string;
|
|
28
|
+
/** This agent's credential. Defaults to KYBERNESIS_AGENT_CREDENTIAL. */
|
|
29
|
+
credential?: string;
|
|
30
|
+
/**
|
|
31
|
+
* How long a peer list is reused before asking again. Discovery runs before
|
|
32
|
+
* every turn, so without this a room full of agents re-asks the control
|
|
33
|
+
* plane on every message. Short enough that a revoke is felt in a minute.
|
|
34
|
+
*/
|
|
35
|
+
cacheMs?: number;
|
|
36
|
+
/** How long to wait for a peer's answer before giving up. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function governedPeers(options?: GovernedPeersOptions): import("eve").DynamicSentinel<Record<string, unknown> | null>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineDynamic, defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
function config(options) {
|
|
5
|
+
const issuer = (options.issuer ?? process.env.KYBERNESIS_ISSUER ?? "https://agent.kybernesis.ai").replace(/\/$/, "");
|
|
6
|
+
const credential = options.credential ?? process.env.KYBERNESIS_AGENT_CREDENTIAL;
|
|
7
|
+
return { issuer, credential };
|
|
8
|
+
}
|
|
9
|
+
async function discover(options) {
|
|
10
|
+
const { issuer, credential } = config(options);
|
|
11
|
+
if (!credential)
|
|
12
|
+
return [];
|
|
13
|
+
const key = `${issuer}:${credential.slice(-12)}`;
|
|
14
|
+
const hit = cache.get(key);
|
|
15
|
+
const ttl = options.cacheMs ?? 60_000;
|
|
16
|
+
if (hit && Date.now() - hit.at < ttl)
|
|
17
|
+
return hit.peers;
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${issuer}/api/agent/peers`, {
|
|
20
|
+
headers: { authorization: `Bearer ${credential}` },
|
|
21
|
+
signal: AbortSignal.timeout(6_000),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return hit?.peers ?? [];
|
|
25
|
+
const body = (await res.json());
|
|
26
|
+
const peers = body.peers ?? [];
|
|
27
|
+
cache.set(key, { at: Date.now(), peers });
|
|
28
|
+
return peers;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// A control plane that is briefly unreachable must not cost the agent its
|
|
32
|
+
// other tools, and must not stall the turn. Last known list, or nothing.
|
|
33
|
+
return hit?.peers ?? [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Mint a short-lived token for one callee, then hold the conversation. */
|
|
37
|
+
async function ask(options, peer, message) {
|
|
38
|
+
const { issuer, credential } = config(options);
|
|
39
|
+
if (!credential)
|
|
40
|
+
throw new Error("This agent has no control-plane credential, so it cannot call another agent.");
|
|
41
|
+
const minted = await fetch(`${issuer}/api/agent/session`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${credential}` },
|
|
44
|
+
body: JSON.stringify({ callee: peer.name }),
|
|
45
|
+
signal: AbortSignal.timeout(20_000),
|
|
46
|
+
});
|
|
47
|
+
if (!minted.ok) {
|
|
48
|
+
const detail = (await minted.json().catch(() => ({})));
|
|
49
|
+
// The edge was revoked between discovery and the call, or the peer was
|
|
50
|
+
// disabled. Say which — a bare 403 here reads as a broken agent.
|
|
51
|
+
if (detail.error === "edge_not_granted") {
|
|
52
|
+
throw new Error(`Not allowed to contact ${peer.name} — the edge was revoked.`);
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Could not get permission to contact ${peer.name} (${minted.status}).`);
|
|
55
|
+
}
|
|
56
|
+
const { token } = (await minted.json());
|
|
57
|
+
if (!token)
|
|
58
|
+
throw new Error(`The control plane issued no token for ${peer.name}.`);
|
|
59
|
+
const base = peer.url.replace(/\/$/, "");
|
|
60
|
+
const timeout = options.timeoutMs ?? 180_000;
|
|
61
|
+
const started = await fetch(`${base}/eve/v1/session`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
64
|
+
body: JSON.stringify({ message }),
|
|
65
|
+
signal: AbortSignal.timeout(30_000),
|
|
66
|
+
});
|
|
67
|
+
if (!started.ok) {
|
|
68
|
+
throw new Error(`${peer.name} refused the message (HTTP ${started.status}).`);
|
|
69
|
+
}
|
|
70
|
+
const { sessionId } = (await started.json());
|
|
71
|
+
if (!sessionId)
|
|
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;
|
|
77
|
+
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);
|
|
89
|
+
// Only a terminal message is the answer: eve emits narration as its own
|
|
90
|
+
// completed message with finishReason "tool-calls", and treating those
|
|
91
|
+
// as the reply returns the peer's intentions instead of its answer.
|
|
92
|
+
if (event.type === "message.completed" && event.data?.finishReason === "stop") {
|
|
93
|
+
reply = String(event.data.message ?? "");
|
|
94
|
+
}
|
|
95
|
+
if (event.type === "session.waiting" || event.type === "turn.completed") {
|
|
96
|
+
if (reply)
|
|
97
|
+
return reply;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* a partial line; the next poll re-reads from this index */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (reply)
|
|
105
|
+
return reply;
|
|
106
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
107
|
+
}
|
|
108
|
+
if (reply)
|
|
109
|
+
return reply;
|
|
110
|
+
throw new Error(`${peer.name} did not answer within ${Math.round(timeout / 1000)}s.`);
|
|
111
|
+
}
|
|
112
|
+
/** A tool name an agent can actually call: letters, digits, underscore. */
|
|
113
|
+
function toolName(peer) {
|
|
114
|
+
return `ask_${peer.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase()}`;
|
|
115
|
+
}
|
|
116
|
+
export function governedPeers(options = {}) {
|
|
117
|
+
return defineDynamic({
|
|
118
|
+
events: {
|
|
119
|
+
"turn.started": async () => {
|
|
120
|
+
const peers = await discover(options);
|
|
121
|
+
if (peers.length === 0)
|
|
122
|
+
return null;
|
|
123
|
+
const tools = {};
|
|
124
|
+
for (const peer of peers) {
|
|
125
|
+
tools[toolName(peer.name)] = defineTool({
|
|
126
|
+
// The purpose recorded on the grant IS the routing hint. It was
|
|
127
|
+
// already being written by whoever granted the edge; using it here
|
|
128
|
+
// means the admin's description of why an edge exists is what the
|
|
129
|
+
// model reads when deciding to use it.
|
|
130
|
+
description: `Send a message to "${peer.name}", a separate agent, and get its reply. ` +
|
|
131
|
+
(peer.purpose ? `Granted for: ${peer.purpose}. ` : "") +
|
|
132
|
+
`Use it for work that belongs to that agent rather than answering from your own context.`,
|
|
133
|
+
inputSchema: z.object({
|
|
134
|
+
message: z
|
|
135
|
+
.string()
|
|
136
|
+
.describe("What to ask it. Write it as you would to a colleague — it has no view of this conversation."),
|
|
137
|
+
}),
|
|
138
|
+
execute: (input) => ask(options, peer, input.message),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return tools;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -24,3 +24,4 @@
|
|
|
24
24
|
export { remotePeer, type RemotePeerOptions } from "./remote-peer.js";
|
|
25
25
|
export { type GovernedOptions } from "./governed.js";
|
|
26
26
|
export { dispatchChannel, peerSubject, type DispatchChannelOptions, type PeerRef, } from "./channel.js";
|
|
27
|
+
export { governedPeers, type GovernedPeersOptions } from "./governed-peers.js";
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/dispatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|