herdr-link 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +244 -0
- package/README.md +159 -0
- package/README.zh-CN.md +159 -0
- package/dist/herdr-link.mcp.js +796 -0
- package/dist/herdr-link.opencode.js +535 -0
- package/docs/mcp-wiring.md +275 -0
- package/package.json +65 -0
- package/scripts/mcp-probe.mjs +41 -0
- package/src/herdr.ts +535 -0
- package/src/mcp.ts +582 -0
- package/src/opencode.ts +180 -0
- package/src/pi.ts +170 -0
- package/src/protocol.ts +284 -0
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Herdr Link OpenCode Runtime adapter v2 — single-gateway presentation.
|
|
3
|
+
*
|
|
4
|
+
* The model-facing surface is exactly one tiny `herdr_link` dispatcher tool,
|
|
5
|
+
* in both dormant and active states. Calling it with no arguments (`{}`)
|
|
6
|
+
* idempotently activates the channel for the CURRENT session and returns
|
|
7
|
+
* `{ status: "active", capabilities: ["peers", "send", "close"] }`; while a
|
|
8
|
+
* session is active the same gateway executes deterministic actions
|
|
9
|
+
* (`action`: "peers" | "send" | "close") against the core control layer and
|
|
10
|
+
* the compact Communication Contract is injected into that session's system
|
|
11
|
+
* prompt. The three Tier 1 tools are never registered as always-resident
|
|
12
|
+
* surfaces.
|
|
13
|
+
*
|
|
14
|
+
* API basis (public @opencode-ai/plugin 1.18.x only):
|
|
15
|
+
* - `Hooks.tool` registers tools process-wide; there is no public per-session
|
|
16
|
+
* registration or dynamic unload, hence the single-gateway fallback instead
|
|
17
|
+
* of Pi-style `setActiveTools`.
|
|
18
|
+
* - `ToolContext.sessionID` attributes each gateway execution to its session;
|
|
19
|
+
* the per-session ephemeral activation set lives in the plugin closure.
|
|
20
|
+
* - `experimental.chat.system.transform` input carries `sessionID?`; it is
|
|
21
|
+
* OPTIONAL in the public types, so injection fails closed when it is absent
|
|
22
|
+
* (an unattributable system build is never treated as active).
|
|
23
|
+
*
|
|
24
|
+
* Known sessionID limitations (explicit):
|
|
25
|
+
* - `Hooks.tool.definition` exposes only `toolID` (no sessionID), so mutating
|
|
26
|
+
* tool schemas per session cannot be done safely; the gateway schema is
|
|
27
|
+
* static and admits both `{}` (activate) and fully-formed actions.
|
|
28
|
+
* - The activation set is in-memory per plugin instance: restarting the
|
|
29
|
+
* OpenCode server returns every session to dormant until it re-activates.
|
|
30
|
+
*/
|
|
31
|
+
import { tool, type Plugin } from "@opencode-ai/plugin";
|
|
32
|
+
|
|
33
|
+
import { closeAgentPane, ensureSelfName, listPeers, sendMessage } from "./herdr.ts";
|
|
34
|
+
import {
|
|
35
|
+
COMMUNICATION_CONTRACT,
|
|
36
|
+
HERDR_LINK_GATEWAY,
|
|
37
|
+
HerdrLinkError,
|
|
38
|
+
formatAgentFacingError,
|
|
39
|
+
type LinkErrorCode,
|
|
40
|
+
} from "./protocol.ts";
|
|
41
|
+
|
|
42
|
+
/** Runtime-specific active presentation; the semantic Contract remains canonical. */
|
|
43
|
+
const GATEWAY_PRESENTATION_APPENDIX = `In this runtime the active Herdr Link capabilities are dispatched through the single herdr_link gateway.
|
|
44
|
+
- Use herdr_link with action "peers" to list live same-workspace agents.
|
|
45
|
+
- Use herdr_link with action "send", to, message, and reply_to when replying.
|
|
46
|
+
- Use herdr_link with action "close" and an Agent Name only after any final send returns status "sent", in a later tool step.`;
|
|
47
|
+
|
|
48
|
+
const GATEWAY_CONTRACT = `${COMMUNICATION_CONTRACT}\n\n${GATEWAY_PRESENTATION_APPENDIX}`;
|
|
49
|
+
|
|
50
|
+
function isHerdrEnvironment(): boolean {
|
|
51
|
+
return (
|
|
52
|
+
process.env.HERDR_ENV === "1" &&
|
|
53
|
+
Boolean(process.env.HERDR_BIN_PATH) &&
|
|
54
|
+
Boolean(process.env.HERDR_PANE_ID)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jsonResult(value: object): string {
|
|
59
|
+
return JSON.stringify(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Throws the stable `${CODE}: ${detail}` Agent-facing message; causes stay internal. */
|
|
63
|
+
function failWith(error: unknown, fallbackCode: LinkErrorCode): never {
|
|
64
|
+
throw new Error(formatAgentFacingError(error, fallbackCode), { cause: error });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Dispatcher-level usage guard for actions rejected by the schema anyway. */
|
|
68
|
+
function failInvalidAction(action: string): never {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`INVALID_ACTION: herdr_link action "${action}" is not supported; use "peers", "send", "close", or omit action (call with {}) to activate.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const herdrLinkPlugin: Plugin = async () => {
|
|
75
|
+
if (!isHerdrEnvironment()) {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Self identity bootstrap (PROTOCOL.md §6.3): fire-and-forget so plugin
|
|
80
|
+
// startup never blocks or fails on Herdr IO; gateway actions fall back
|
|
81
|
+
// through getSelfContext(). Failures surface later as SELF_UNNAMED.
|
|
82
|
+
void ensureSelfName().catch(() => {});
|
|
83
|
+
|
|
84
|
+
// Per-runtime-session activation set. Ephemeral by design: in-memory only,
|
|
85
|
+
// scoped to this plugin instance, never persisted, never shared across
|
|
86
|
+
// instances. Losing it (server restart) merely returns sessions to dormant.
|
|
87
|
+
const activatedSessions = new Set<string>();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
tool: {
|
|
91
|
+
[HERDR_LINK_GATEWAY]: tool({
|
|
92
|
+
description:
|
|
93
|
+
"Herdr Link cross-agent communication gateway (herdr-link/1). Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. " +
|
|
94
|
+
'Call once with no arguments {} to activate Herdr Link for this session; the response lists capabilities. ' +
|
|
95
|
+
'Then pass action "peers" to list live same-workspace agents, "send" with to + message (plus reply_to when replying) to deliver an inter-agent message, or "close" with agent to close a named agent\'s pane — ' +
|
|
96
|
+
'only after any final send has returned status "sent", and in a later tool step.',
|
|
97
|
+
args: {
|
|
98
|
+
action: tool.schema
|
|
99
|
+
.enum(["peers", "send", "close"])
|
|
100
|
+
.optional()
|
|
101
|
+
.describe(
|
|
102
|
+
'Operation to run: "peers" | "send" | "close". Omit action entirely (call with {}) to activate Herdr Link for this session.',
|
|
103
|
+
),
|
|
104
|
+
to: tool.schema
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe('Target agent name; required for action "send".'),
|
|
108
|
+
message: tool.schema
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe('Message payload; required for action "send".'),
|
|
112
|
+
reply_to: tool.schema
|
|
113
|
+
.string()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe('Message id being replied to; optional, only with action "send".'),
|
|
116
|
+
agent: tool.schema
|
|
117
|
+
.string()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe('Target agent name; required for action "close".'),
|
|
120
|
+
},
|
|
121
|
+
async execute(args, context) {
|
|
122
|
+
if (args.action === undefined) {
|
|
123
|
+
activatedSessions.add(context.sessionID);
|
|
124
|
+
return jsonResult({ status: "active", capabilities: ["peers", "send", "close"] });
|
|
125
|
+
}
|
|
126
|
+
// Gateway action dispatch is also an explicit activation path for
|
|
127
|
+
// hosts that bypass the empty gateway call or do not refresh schemas.
|
|
128
|
+
activatedSessions.add(context.sessionID);
|
|
129
|
+
|
|
130
|
+
if (args.action === "peers") {
|
|
131
|
+
try {
|
|
132
|
+
return jsonResult(await listPeers());
|
|
133
|
+
} catch (error) {
|
|
134
|
+
failWith(error, "NOT_IN_HERDR");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (args.action === "send") {
|
|
139
|
+
if (typeof args.to !== "string" || args.to === "") {
|
|
140
|
+
failWith(new HerdrLinkError("SEND_FAILED", '"to" must be a non-empty string'), "SEND_FAILED");
|
|
141
|
+
}
|
|
142
|
+
if (typeof args.message !== "string" || args.message === "") {
|
|
143
|
+
failWith(new HerdrLinkError("SEND_FAILED", '"message" must be a non-empty string'), "SEND_FAILED");
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const envelope = await sendMessage(args.to, args.message, args.reply_to);
|
|
147
|
+
return jsonResult({ status: "sent", id: envelope.id, to: envelope.to });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
failWith(error, "SEND_FAILED");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (args.action === "close") {
|
|
154
|
+
if (typeof args.agent !== "string" || args.agent === "") {
|
|
155
|
+
failWith(new HerdrLinkError("CLOSE_FAILED", '"agent" must be a non-empty string'), "CLOSE_FAILED");
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await closeAgentPane(args.agent);
|
|
159
|
+
return jsonResult({ status: "closed", agent: args.agent });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
failWith(error, "CLOSE_FAILED");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
failInvalidAction(String(args.action));
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
170
|
+
// Fail closed: an optional/absent sessionID cannot be attributed to an
|
|
171
|
+
// activation, so the Contract is withheld rather than guessed.
|
|
172
|
+
if (input.sessionID === undefined || !activatedSessions.has(input.sessionID)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!output.system.includes(GATEWAY_CONTRACT)) {
|
|
176
|
+
output.system.push(GATEWAY_CONTRACT);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
};
|
package/src/pi.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Herdr Link Pi Runtime adapter v2 — Tier 0/Tier 1 presentation.
|
|
3
|
+
*
|
|
4
|
+
* Tier 0 (dormant): with the three Herdr environment variables present, the
|
|
5
|
+
* adapter registers everything but keeps the model-facing surface down to the
|
|
6
|
+
* tiny `herdr_link` gateway. The three Tier 1 tools
|
|
7
|
+
* (`herdr_link_peers`/`herdr_link_send`/`herdr_link_close`) stay inactive and
|
|
8
|
+
* no Communication Contract is injected.
|
|
9
|
+
*
|
|
10
|
+
* Tier 1 (active): calling the gateway with `{}` idempotently activates the
|
|
11
|
+
* channel inside the current runtime session via the official dynamic tool
|
|
12
|
+
* API (`pi.setActiveTools`, additive change) and starts injecting the compact
|
|
13
|
+
* Contract through `before_agent_start`.
|
|
14
|
+
*
|
|
15
|
+
* API basis (public Pi Extension API, @earendil-works/pi-coding-agent 0.84.x):
|
|
16
|
+
* - `pi.registerTool()` must cover every tool before it can appear in
|
|
17
|
+
* `pi.setActiveTools()` ("Names passed to pi.setActiveTools() must already
|
|
18
|
+
* be registered"); action methods throw while extensions are still loading,
|
|
19
|
+
* so the initial dormant set is applied on `session_start` — the same
|
|
20
|
+
* pattern as the official Dynamic Tool Loading example in docs/extensions.md.
|
|
21
|
+
* - Activation inside a tool `execute()` may call `pi.setActiveTools()`
|
|
22
|
+
* additively; Pi applies the new set before the next model request.
|
|
23
|
+
* - Lazily loaded tools should omit active-only prompt metadata
|
|
24
|
+
* (`promptSnippet`/`promptGuidelines`) and rely on their `description`;
|
|
25
|
+
* activating such metadata would rebuild the system prompt mid-session.
|
|
26
|
+
*/
|
|
27
|
+
import type { ExtensionAPI, ToolExecutionMode } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { Type } from "typebox";
|
|
29
|
+
|
|
30
|
+
import { closeAgentPane, ensureSelfName, listPeers, sendMessage } from "./herdr.ts";
|
|
31
|
+
import { COMMUNICATION_CONTRACT, formatAgentFacingError } from "./protocol.ts";
|
|
32
|
+
|
|
33
|
+
const TIER1_TOOL_NAMES = ["herdr_link_peers", "herdr_link_send", "herdr_link_close"] as const;
|
|
34
|
+
const TIER1_TOOL_SET = new Set<string>(TIER1_TOOL_NAMES);
|
|
35
|
+
|
|
36
|
+
const GATEWAY_PARAMETERS = Type.Object({});
|
|
37
|
+
const PEERS_PARAMETERS = Type.Object({});
|
|
38
|
+
const SEND_PARAMETERS = Type.Object({
|
|
39
|
+
to: Type.String(),
|
|
40
|
+
message: Type.String(),
|
|
41
|
+
reply_to: Type.Optional(Type.String()),
|
|
42
|
+
});
|
|
43
|
+
const CLOSE_PARAMETERS = Type.Object({
|
|
44
|
+
agent: Type.String(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
function toolResult(value: object) {
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
51
|
+
details: value,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rethrowToolError(error: unknown, fallbackCode: "NOT_IN_HERDR" | "SEND_FAILED" | "CLOSE_FAILED"): never {
|
|
56
|
+
const toolError = new Error(formatAgentFacingError(error, fallbackCode), { cause: error });
|
|
57
|
+
throw toolError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function (pi: ExtensionAPI): void {
|
|
61
|
+
if (
|
|
62
|
+
process.env.HERDR_ENV !== "1" ||
|
|
63
|
+
!process.env.HERDR_BIN_PATH ||
|
|
64
|
+
!process.env.HERDR_PANE_ID
|
|
65
|
+
) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Per-runtime-session state only: never persisted, never shared across
|
|
70
|
+
// sessions. Every session start returns the channel to dormant.
|
|
71
|
+
let activated = false;
|
|
72
|
+
|
|
73
|
+
// --- Tier 1 tools: registered up front (Pi requires registration before
|
|
74
|
+
// setActiveTools), kept initially inactive by the session_start hook below.
|
|
75
|
+
// Their descriptions alone carry the canonical affordances; prompt metadata
|
|
76
|
+
// is intentionally omitted (see module doc).
|
|
77
|
+
pi.registerTool({
|
|
78
|
+
name: "herdr_link_peers",
|
|
79
|
+
label: "Herdr Link Peers",
|
|
80
|
+
description:
|
|
81
|
+
"Discover named agents available through the cross-agent communication channel. Returns { self, peers }; addresses are Agent Names.",
|
|
82
|
+
parameters: PEERS_PARAMETERS,
|
|
83
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
84
|
+
try {
|
|
85
|
+
return toolResult(await listPeers());
|
|
86
|
+
} catch (error) {
|
|
87
|
+
rethrowToolError(error, "NOT_IN_HERDR");
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.registerTool({
|
|
93
|
+
name: "herdr_link_send",
|
|
94
|
+
label: "Herdr Link Send",
|
|
95
|
+
description:
|
|
96
|
+
'Send an inter-agent message (protocol herdr-link/1) to another agent through the cross-agent communication channel. status "sent" means Herdr accepted delivery, not that the peer finished its task. When replying, set reply_to to the received message id.',
|
|
97
|
+
parameters: SEND_PARAMETERS,
|
|
98
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
99
|
+
try {
|
|
100
|
+
const envelope = await sendMessage(params.to, params.message, params.reply_to);
|
|
101
|
+
return toolResult({ status: "sent", id: envelope.id, to: envelope.to });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
rethrowToolError(error, "SEND_FAILED");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
pi.registerTool({
|
|
109
|
+
name: "herdr_link_close",
|
|
110
|
+
label: "Herdr Link Close",
|
|
111
|
+
// Pi 默认并行执行同一 assistant response 的 sibling tool calls;close 与 send 同批时
|
|
112
|
+
// 必须保证 send 先完成("sent" 语义=Herdr 已接受投递),故 close 声明为 sequential,
|
|
113
|
+
// 使含 close 的批次整体串行。peers/send 保持默认并行。
|
|
114
|
+
executionMode: "sequential" as ToolExecutionMode,
|
|
115
|
+
description:
|
|
116
|
+
'Close the Herdr pane currently hosting a named agent. Sequential: if a final message is needed, send it first and call close in a later tool step after herdr_link_send returns status "sent".',
|
|
117
|
+
parameters: CLOSE_PARAMETERS,
|
|
118
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
119
|
+
try {
|
|
120
|
+
await closeAgentPane(params.agent);
|
|
121
|
+
return toolResult({ status: "closed", agent: params.agent });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
rethrowToolError(error, "CLOSE_FAILED");
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// --- Tier 0 gateway: the only model-visible Herdr surface while dormant.
|
|
129
|
+
// It performs activation only; it never executes peers/send/close work.
|
|
130
|
+
pi.registerTool({
|
|
131
|
+
name: "herdr_link",
|
|
132
|
+
label: "Herdr Link",
|
|
133
|
+
description:
|
|
134
|
+
"Activate the Herdr Link channel only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. Call once with empty arguments {} before using Herdr Link; this enables herdr_link_peers, herdr_link_send, and herdr_link_close.",
|
|
135
|
+
promptSnippet: "Activate the Herdr Link cross-agent channel (peers/send/close).",
|
|
136
|
+
parameters: GATEWAY_PARAMETERS,
|
|
137
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
138
|
+
activateChannel();
|
|
139
|
+
return toolResult({ status: "active", capabilities: ["peers", "send", "close"] });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Additive-only change (official requirement): keep every currently active
|
|
144
|
+
// tool — built-ins and other extensions' tools included — and enable Tier 1.
|
|
145
|
+
function activateChannel(): void {
|
|
146
|
+
if (activated) return;
|
|
147
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...TIER1_TOOL_NAMES])]);
|
|
148
|
+
activated = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pi.on("session_start", () => {
|
|
152
|
+
// Dormant presentation for this runtime session: drop any Tier 1 tools
|
|
153
|
+
// from the active set (registration makes tools active by default) while
|
|
154
|
+
// preserving built-ins, other extensions' tools, and the gateway itself.
|
|
155
|
+
// Resetting `activated` keeps activation scoped to the current session.
|
|
156
|
+
activated = false;
|
|
157
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !TIER1_TOOL_SET.has(name)));
|
|
158
|
+
|
|
159
|
+
// Self identity bootstrap (PROTOCOL.md §6.3), once per runtime session:
|
|
160
|
+
// fire-and-forget so session start never blocks or fails on Herdr IO;
|
|
161
|
+
// communication paths fall back through getSelfContext(). Failures
|
|
162
|
+
// surface later as SELF_UNNAMED.
|
|
163
|
+
void ensureSelfName().catch(() => {});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("before_agent_start", (event) => {
|
|
167
|
+
if (!activated) return undefined;
|
|
168
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${COMMUNICATION_CONTRACT}` };
|
|
169
|
+
});
|
|
170
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Herdr Link protocol core — pure protocol types and helpers.
|
|
3
|
+
*
|
|
4
|
+
* No Herdr IO in this file. The canonical specification is PROTOCOL.md
|
|
5
|
+
* at the repository root; this module is its machine-readable core.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PROTOCOL_ID = "herdr-link/1" as const;
|
|
9
|
+
|
|
10
|
+
/** Herdr agent-name rule: `[a-z][a-z0-9_-]{0,31}`, unique among live agents. */
|
|
11
|
+
export const AGENT_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
12
|
+
/** Message id rule from PROTOCOL.md §2.3: `hl_<timestamp>_<random>`. */
|
|
13
|
+
export const MESSAGE_ID_RE = /^hl_[a-z0-9]+_[a-z0-9]+$/;
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------------ *
|
|
16
|
+
* Naming tiers (blueprint v2)
|
|
17
|
+
*
|
|
18
|
+
* Tier 0 is the Herdr Link gateway itself — the host registration
|
|
19
|
+
* namespace every runtime presents its tools against (underscore form;
|
|
20
|
+
* docs/mcp-wiring.md). Tier 1 are the canonical tool names exposed
|
|
21
|
+
* through the gateway. Both are stable machine-usable constants;
|
|
22
|
+
* runtime-specific presented names must map deterministically onto them
|
|
23
|
+
* (PROTOCOL.md §4.4).
|
|
24
|
+
* ------------------------------------------------------------------ */
|
|
25
|
+
|
|
26
|
+
/** Tier 0 — gateway name in its underscore host-namespace form. */
|
|
27
|
+
export const HERDR_LINK_GATEWAY = "herdr_link" as const;
|
|
28
|
+
|
|
29
|
+
/** Tier 1 — canonical tool names exposed through the gateway. */
|
|
30
|
+
export const TOOL_PEERS = "herdr_link_peers" as const;
|
|
31
|
+
export const TOOL_SEND = "herdr_link_send" as const;
|
|
32
|
+
export const TOOL_CLOSE = "herdr_link_close" as const;
|
|
33
|
+
export const HERDR_LINK_TOOLS = [TOOL_PEERS, TOOL_SEND, TOOL_CLOSE] as const;
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ *
|
|
36
|
+
* Agent state (blueprint v2)
|
|
37
|
+
* ------------------------------------------------------------------ */
|
|
38
|
+
|
|
39
|
+
/** Live activity states; any unrecognized Herdr status maps to "unknown". */
|
|
40
|
+
export const AGENT_STATES = ["idle", "working", "blocked", "done", "unknown"] as const;
|
|
41
|
+
|
|
42
|
+
export type AgentState = (typeof AGENT_STATES)[number];
|
|
43
|
+
|
|
44
|
+
/** Maps a raw Herdr status value onto the closed AgentState vocabulary. */
|
|
45
|
+
export function toAgentState(value: unknown): AgentState {
|
|
46
|
+
if (typeof value === "string") {
|
|
47
|
+
const normalized = value.trim().toLowerCase();
|
|
48
|
+
if ((AGENT_STATES as readonly string[]).includes(normalized)) {
|
|
49
|
+
return normalized as AgentState;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return "unknown";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A named agent together with its live activity state. Never carries topology ids. */
|
|
56
|
+
export interface PeerInfo {
|
|
57
|
+
name: string;
|
|
58
|
+
state: AgentState;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Instant peer directory (blueprint v2): same-workspace live agents only,
|
|
63
|
+
* self excluded. Generated fresh on every call; never persisted or cached.
|
|
64
|
+
*/
|
|
65
|
+
export interface PeerDirectory {
|
|
66
|
+
self: PeerInfo;
|
|
67
|
+
peers: PeerInfo[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Live identity of one agent, freshly resolved from Herdr on every call.
|
|
72
|
+
* Ambient environment values (e.g. HERDR_WORKSPACE_ID) are never a
|
|
73
|
+
* substitute for these fields.
|
|
74
|
+
*/
|
|
75
|
+
export interface AgentContext {
|
|
76
|
+
/** Valid Herdr agent name. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** Authoritative workspace id from the live record; "" when unreported (comparisons fail closed). */
|
|
79
|
+
workspace_id: string;
|
|
80
|
+
/** Pane currently hosting the agent. */
|
|
81
|
+
pane_id: string;
|
|
82
|
+
/** Live activity state mapped onto AgentState. */
|
|
83
|
+
agent_status: AgentState;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The cross-agent message envelope (PROTOCOL.md §2). Minimal fields only. */
|
|
87
|
+
export interface HerdrLinkEnvelope {
|
|
88
|
+
protocol: typeof PROTOCOL_ID;
|
|
89
|
+
id: string;
|
|
90
|
+
from: string;
|
|
91
|
+
to: string;
|
|
92
|
+
reply_to?: string;
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** V1 error codes (PROTOCOL.md §7). All are local tool failures, never an envelope. */
|
|
97
|
+
export const LINK_ERROR_CODES = [
|
|
98
|
+
"NOT_IN_HERDR",
|
|
99
|
+
"SELF_UNNAMED",
|
|
100
|
+
"PEER_NOT_FOUND",
|
|
101
|
+
"SEND_FAILED",
|
|
102
|
+
"CLOSE_FAILED",
|
|
103
|
+
] as const;
|
|
104
|
+
|
|
105
|
+
export type LinkErrorCode = (typeof LINK_ERROR_CODES)[number];
|
|
106
|
+
|
|
107
|
+
/** Standard failure for every Herdr Link tool. */
|
|
108
|
+
export class HerdrLinkError extends Error {
|
|
109
|
+
readonly code: LinkErrorCode;
|
|
110
|
+
|
|
111
|
+
constructor(code: LinkErrorCode, detail?: string) {
|
|
112
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
113
|
+
this.name = "HerdrLinkError";
|
|
114
|
+
this.code = code;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stable Agent-facing details; raw Herdr diagnostics remain internal to the error object. */
|
|
119
|
+
export const AGENT_ERROR_DETAILS: Record<LinkErrorCode, string> = {
|
|
120
|
+
NOT_IN_HERDR: "Herdr environment is unavailable",
|
|
121
|
+
SELF_UNNAMED: "Herdr Link could not establish a stable Agent Name",
|
|
122
|
+
PEER_NOT_FOUND: "target agent is not a live peer",
|
|
123
|
+
SEND_FAILED: "Herdr did not accept message delivery",
|
|
124
|
+
CLOSE_FAILED: "Herdr pane close failed",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Formats a Link failure without exposing raw Herdr topology or CLI details. */
|
|
128
|
+
export function formatAgentFacingError(error: unknown, fallbackCode: LinkErrorCode): string {
|
|
129
|
+
const code = error instanceof HerdrLinkError ? error.code : fallbackCode;
|
|
130
|
+
return `${code}: ${AGENT_ERROR_DETAILS[code]}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Creates a unique message id: `hl_` + base36 timestamp + random suffix. */
|
|
134
|
+
export function createMessageId(): string {
|
|
135
|
+
const ts = Date.now().toString(36);
|
|
136
|
+
const rand = Math.random().toString(36).slice(2, 10) || "0";
|
|
137
|
+
return `hl_${ts}_${rand}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isValidAgentName(name: string): boolean {
|
|
141
|
+
return AGENT_NAME_RE.test(name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isValidMessageId(id: string): boolean {
|
|
145
|
+
return MESSAGE_ID_RE.test(id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface BuildEnvelopeInput {
|
|
149
|
+
from: string;
|
|
150
|
+
to: string;
|
|
151
|
+
message: string;
|
|
152
|
+
reply_to?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Builds a validated envelope. `from` is supplied by the Adapter from Herdr
|
|
157
|
+
* identity — the model never submits it. `to`/`message` originate from the
|
|
158
|
+
* model and are validated here.
|
|
159
|
+
*/
|
|
160
|
+
export function buildEnvelope(input: BuildEnvelopeInput): HerdrLinkEnvelope {
|
|
161
|
+
if (!isValidAgentName(input.from)) {
|
|
162
|
+
throw new HerdrLinkError(
|
|
163
|
+
"SELF_UNNAMED",
|
|
164
|
+
`self agent name "${input.from}" is not a valid Herdr agent name`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!input.to || !isValidAgentName(input.to)) {
|
|
168
|
+
throw new HerdrLinkError(
|
|
169
|
+
"PEER_NOT_FOUND",
|
|
170
|
+
`target agent name "${input.to}" is not a valid Herdr agent name`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (typeof input.message !== "string" || input.message.trim() === "") {
|
|
174
|
+
throw new HerdrLinkError("SEND_FAILED", "message must be a non-empty string");
|
|
175
|
+
}
|
|
176
|
+
if (input.reply_to !== undefined && !isValidMessageId(input.reply_to)) {
|
|
177
|
+
throw new HerdrLinkError(
|
|
178
|
+
"SEND_FAILED",
|
|
179
|
+
"reply_to must be a valid herdr-link/1 message id when present",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const envelope: HerdrLinkEnvelope = {
|
|
183
|
+
protocol: PROTOCOL_ID,
|
|
184
|
+
id: createMessageId(),
|
|
185
|
+
from: input.from,
|
|
186
|
+
to: input.to,
|
|
187
|
+
message: input.message,
|
|
188
|
+
};
|
|
189
|
+
if (input.reply_to !== undefined) {
|
|
190
|
+
envelope.reply_to = input.reply_to;
|
|
191
|
+
}
|
|
192
|
+
return envelope;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Type guard for receiving side (PROTOCOL.md §3 rule 3): an incoming payload
|
|
197
|
+
* is a Herdr Link message iff it carries `protocol: "herdr-link/1"` and a
|
|
198
|
+
* string `message` body from a named `from` agent.
|
|
199
|
+
*/
|
|
200
|
+
export function isHerdrLinkEnvelope(value: unknown): value is HerdrLinkEnvelope {
|
|
201
|
+
if (typeof value !== "object" || value === null) return false;
|
|
202
|
+
const v = value as Record<string, unknown>;
|
|
203
|
+
return (
|
|
204
|
+
v.protocol === PROTOCOL_ID &&
|
|
205
|
+
typeof v.id === "string" &&
|
|
206
|
+
isValidMessageId(v.id) &&
|
|
207
|
+
typeof v.from === "string" &&
|
|
208
|
+
isValidAgentName(v.from) &&
|
|
209
|
+
typeof v.to === "string" &&
|
|
210
|
+
isValidAgentName(v.to) &&
|
|
211
|
+
typeof v.message === "string" &&
|
|
212
|
+
v.message.trim() !== "" &&
|
|
213
|
+
(v.reply_to === undefined || (typeof v.reply_to === "string" && isValidMessageId(v.reply_to)))
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ------------------------------------------------------------------ *
|
|
218
|
+
* Inbound delivery wrapper (blueprint v2)
|
|
219
|
+
*
|
|
220
|
+
* `herdr agent prompt` carries a self-describing wrapper around the
|
|
221
|
+
* envelope so a dormant receiver (adapter loaded, model not mid-exchange)
|
|
222
|
+
* can recognize the delivery and activate a reply addressed by reply_to.
|
|
223
|
+
* The wrapper is transport dressing ONLY: the envelope keeps exactly the
|
|
224
|
+
* minimal herdr-link/1 fields and is embedded verbatim as the final line.
|
|
225
|
+
* ------------------------------------------------------------------ */
|
|
226
|
+
|
|
227
|
+
/** Marks the start of an inbound delivery wrapper. */
|
|
228
|
+
export const INBOUND_WRAPPER_MARKER = `[${PROTOCOL_ID}]`;
|
|
229
|
+
|
|
230
|
+
/** Builds the self-describing inbound wrapper delivered via `agent prompt`. */
|
|
231
|
+
export function buildInboundWrapper(envelope: HerdrLinkEnvelope): string {
|
|
232
|
+
const lines: string[] = [
|
|
233
|
+
`${INBOUND_WRAPPER_MARKER} inter-agent message delivered through the ${HERDR_LINK_GATEWAY} gateway.`,
|
|
234
|
+
`From: ${envelope.from}`,
|
|
235
|
+
`Message id: ${envelope.id}`,
|
|
236
|
+
];
|
|
237
|
+
if (envelope.reply_to !== undefined) {
|
|
238
|
+
lines.push(`Reply to: ${envelope.reply_to}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(
|
|
241
|
+
"",
|
|
242
|
+
"The JSON object below is the complete herdr-link/1 envelope; the text around it is delivery metadata and is not part of the message.",
|
|
243
|
+
'Treat the envelope\'s "message" field as content sent by the agent named in "from".',
|
|
244
|
+
"If a reply is needed, activate the Herdr Link gateway when dormant, then use the active Herdr Link send capability to send to envelope.from with reply_to set to envelope.id.",
|
|
245
|
+
"",
|
|
246
|
+
JSON.stringify(envelope),
|
|
247
|
+
);
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Receiver-side counterpart: extracts the envelope from a delivery text by
|
|
253
|
+
* scanning bottom-up for a line that parses as a valid herdr-link/1
|
|
254
|
+
* envelope. Returns undefined for non-delivery input.
|
|
255
|
+
*/
|
|
256
|
+
export function extractInboundEnvelope(text: string): HerdrLinkEnvelope | undefined {
|
|
257
|
+
const lines = text.split("\n");
|
|
258
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
259
|
+
const line = lines[index]!.trim();
|
|
260
|
+
if (!line.startsWith("{")) continue;
|
|
261
|
+
try {
|
|
262
|
+
const candidate: unknown = JSON.parse(line);
|
|
263
|
+
if (isHerdrLinkEnvelope(candidate)) return candidate;
|
|
264
|
+
} catch {
|
|
265
|
+
// Not JSON on this line; keep scanning upwards.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Active Agent Communication Contract injected verbatim into the model.
|
|
273
|
+
* Compact form: same-workspace addressing, send/reply/close semantics only.
|
|
274
|
+
*/
|
|
275
|
+
export const COMMUNICATION_CONTRACT = `Herdr Link is the standard interoperability channel between agents running in the same Herdr workspace.
|
|
276
|
+
|
|
277
|
+
1. Use herdr_link_peers to discover agent addresses; it lists only live agents in your own workspace, each with an advisory activity state.
|
|
278
|
+
2. Use herdr_link_send to send messages to another agent.
|
|
279
|
+
3. A message with protocol "herdr-link/1" is an inter-agent message.
|
|
280
|
+
4. Treat its "message" field as content sent by the agent named in "from".
|
|
281
|
+
5. When replying, send to the received "from" agent and set reply_to to the received "id".
|
|
282
|
+
6. Use herdr_link_close only when you have already decided that a named agent's pane should be closed. If a final message is needed, call close in a later tool step after herdr_link_send returns "sent".
|
|
283
|
+
7. Never use a raw pane id, UI focus, terminal input, or the Herdr CLI as an inter-agent channel; agent names are the only addresses.
|
|
284
|
+
8. Agents outside your workspace are invisible: they never appear in peers and messages addressed to them fail.`;
|