auggy 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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,489 @@
1
+ /**
2
+ * link augment — peer-to-peer A2A v0.2 transport.
3
+ *
4
+ * Wires the @auggy/link library into augment-1 so two Auggy agents (or any
5
+ * A2A v0.2-speaking peer: LangChain agent, Python A2A agent, future Mesh
6
+ * coordinator) can exchange messages over HTTP with mutual bearer auth and
7
+ * no central service. Imported as an npm dependency at @auggy/link v0.1.1.
8
+ *
9
+ * What this augment owns:
10
+ * - The link HTTP service: GET /health, GET /.well-known/agent.json,
11
+ * POST /a2a/v1, GET /a2a/v1/stream (501 stub at v0.1). Bound on its own
12
+ * Bun.serve port (default 8081), SEPARATE from webTransport.
13
+ * - BearerAuthProvider config: which peers can call this Auggy, with which
14
+ * bearers, and what verified Participant identity is minted on match.
15
+ * - SqliteTaskStore: durable persistence for inbound async tasks (capacity
16
+ * reserved for v0.2+ TaskCreateOutcome work — see ADR-022 sequencing).
17
+ * - Outbound PeerClient: enumerated via an EnvAddressBook constructed from
18
+ * the agent.yaml `peers` config.
19
+ * - Tools: `link_send` (text-only synchronous send), `link_list` (enumerate
20
+ * configured peers so the LLM knows who it can call).
21
+ *
22
+ * Inbound flow:
23
+ * 1. @auggy/link receives an HTTP request on /a2a/v1.
24
+ * 2. BearerAuthProvider verifies the bearer → mints a verified Participant.
25
+ * 3. createLinkApp invokes onMessage(ctx: HandlerContext).
26
+ * 4. This module translates ctx → TurnTrigger and calls kernel.handleInbound.
27
+ * 5. The TurnResult is translated back → HandlerOutcome.
28
+ * 6. v0.1 returns ONLY MessageOutcome (sync) or ErrorOutcome.
29
+ * TaskCreateOutcome is deferred until augment-1 grows long-running task
30
+ * semantics — see ADR-022 for sequencing.
31
+ *
32
+ * Why this is NOT webTransport:
33
+ * - webTransport speaks AG-UI (SSE event protocol shaped for browser chat).
34
+ * - link speaks A2A v0.2 (JSON-RPC over HTTP, peer-to-peer agent traffic).
35
+ * - Operators run both simultaneously; they bind different ports and own
36
+ * non-overlapping path prefixes.
37
+ *
38
+ * Trust model:
39
+ * - BearerAuthProvider only admits configured peers, all of whom carry
40
+ * `trust: "agent"` at v0.1. Public/anonymous traffic NEVER reaches the
41
+ * onMessage callback — it's rejected with 401 before we see it.
42
+ * - The translation layer preserves trust_level verbatim; if v0.2 admits
43
+ * `creator` or `public` peers, augment-1 sees them as such automatically.
44
+ */
45
+
46
+ import { z } from "zod";
47
+ import {
48
+ BearerAuthProvider,
49
+ EnvAddressBook,
50
+ PeerClient,
51
+ SqliteTaskStore,
52
+ buildAgentCard,
53
+ createLinkApp,
54
+ isTaskResult,
55
+ type AgentCard as LinkAgentCard,
56
+ type HandlerContext as LinkHandlerContext,
57
+ type LinkAppHandle,
58
+ type MessageHandler as LinkMessageHandler,
59
+ type PeerBearerConfig,
60
+ } from "@auggy/link";
61
+
62
+ import { defineTool } from "../../helpers";
63
+ import type { Augment, ToolExecuteContext, TransportKernel, TransportSpec } from "../../types";
64
+ import { handlerContextToTrigger, turnResultToHandlerOutcome } from "./translate";
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Options
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * A configured peer. Each peer pair is symmetric: this Auggy uses `bearer` to
72
+ * call the peer (outbound), and accepts `inboundBearer` FROM the peer (inbound).
73
+ * The two bearers are independent — operators rotate them on independent
74
+ * schedules so an in-flight rotation never breaks both directions at once.
75
+ *
76
+ * url — peer's link endpoint (e.g. https://researcher.example.org)
77
+ * bearer — bearer this Auggy sends ON outbound requests TO the peer
78
+ * participantId — peer's UUID (the verified Participant.id our messages
79
+ * get tagged with on the peer's side; this Auggy must
80
+ * know it for AddressBook lookup symmetry)
81
+ * inboundBearer — bearer this Auggy ACCEPTS on inbound from the peer
82
+ * inboundBearerId — opaque audit id paired with inboundBearer (logged on
83
+ * verify; never on the wire)
84
+ */
85
+ export interface LinkPeerConfig {
86
+ url: string;
87
+ bearer: string;
88
+ participantId: string;
89
+ inboundBearer: string;
90
+ inboundBearerId: string;
91
+ }
92
+
93
+ /**
94
+ * Agent-card fields the link augment serves at /.well-known/agent.json.
95
+ * Note this is the LINK card, not augment-1's general agent card — they
96
+ * exist for different consumers (A2A peers vs AG-UI browsers).
97
+ */
98
+ export interface LinkAugmentAgentCard {
99
+ /** Stable UUID for this agent in the A2A network. */
100
+ id: string;
101
+ /** Display name shown in peer logs and address books. */
102
+ name: string;
103
+ /** One-line description of the agent's role. */
104
+ description: string;
105
+ /** Public URL this agent's link endpoint is reachable at. */
106
+ endpointUrl: string;
107
+ /** Capabilities the agent advertises (free-form strings at v0.1). */
108
+ capabilities?: string[];
109
+ }
110
+
111
+ /**
112
+ * Operator-facing options for the link augment. Configured in agent.yaml:
113
+ *
114
+ * augments:
115
+ * - name: link
116
+ * type: link
117
+ * options:
118
+ * port: 8081
119
+ * dbPath: ./link.db
120
+ * agentCard:
121
+ * id: <uuid>
122
+ * name: zip
123
+ * description: Front-door agent
124
+ * endpointUrl: https://zip.example.org
125
+ * peers:
126
+ * researcher:
127
+ * url: https://researcher.example.org
128
+ * bearer: ${RESEARCHER_BEARER}
129
+ * participantId: <uuid>
130
+ * inboundBearer: ${RESEARCHER_INBOUND_BEARER}
131
+ * inboundBearerId: <uuid>
132
+ */
133
+ export interface LinkAugmentOptions {
134
+ /** Port for the Bun.serve binding the link HTTP service. Default 8081. */
135
+ port?: number;
136
+ /** Path to the SQLite file backing SqliteTaskStore. */
137
+ dbPath: string;
138
+ /** Agent-card fields. */
139
+ agentCard: LinkAugmentAgentCard;
140
+ /**
141
+ * Configured peers keyed by their short name (the name the LLM uses with
142
+ * `link_send`). Empty map = the augment runs but has no callable peers,
143
+ * which is legal (operator may rely on inbound-only at first).
144
+ */
145
+ peers: Record<string, LinkPeerConfig>;
146
+ }
147
+
148
+ /**
149
+ * Test-only options. Production callers leave these unset; the augment
150
+ * defaults to real Bun.serve binding and real SqliteTaskStore.
151
+ */
152
+ export interface LinkAugmentInternalOptions extends LinkAugmentOptions {
153
+ /**
154
+ * Skip the Bun.serve binding. Useful for unit tests that exercise the
155
+ * MessageHandler closure directly without claiming a port. When set, the
156
+ * augment never calls Bun.serve — it builds the link handle and stores it
157
+ * so tests can drive it via the exported `_dispatchInbound` test hook.
158
+ */
159
+ _skipServer?: boolean;
160
+ /**
161
+ * Inject a custom PeerClient. Tests use this to replace the real outbound
162
+ * client with an in-process recorder.
163
+ */
164
+ _peerClient?: PeerClient;
165
+ /**
166
+ * Inject the AddressBook env used by the default PeerClient. Tests skip
167
+ * the synthesized PEERS / PEER_*_BEARER env mangling by passing an env
168
+ * object directly.
169
+ */
170
+ _addressBookEnv?: Record<string, string | undefined>;
171
+ /**
172
+ * Test-only side channel. When provided, the factory writes the production
173
+ * MessageHandler closure into `out.handler` so the test harness can drive
174
+ * the EXACT same code path that real link traffic exercises. Avoids
175
+ * duplicating the try/catch / translation pipeline in test code.
176
+ */
177
+ _captureMessageHandler?: { handler?: LinkMessageHandler };
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Helpers
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Build a frozen env map from the peers config for EnvAddressBook.
186
+ *
187
+ * EnvAddressBook reads:
188
+ * - `PEERS=a,b,c` (comma-separated peer names)
189
+ * - `PEER_<UPPER_NAME>_URL` / `PEER_<UPPER_NAME>_BEARER` for each name
190
+ *
191
+ * Names are uppercased and underscored exactly the way EnvAddressBook
192
+ * expects. The resulting map is passed to `new EnvAddressBook(env)`.
193
+ */
194
+ function buildAddressBookEnv(peers: Record<string, LinkPeerConfig>): Record<string, string> {
195
+ const names = Object.keys(peers);
196
+ const env: Record<string, string> = {
197
+ PEERS: names.join(","),
198
+ };
199
+ for (const [name, cfg] of Object.entries(peers)) {
200
+ const upper = name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
201
+ env[`PEER_${upper}_URL`] = cfg.url;
202
+ env[`PEER_${upper}_BEARER`] = cfg.bearer;
203
+ }
204
+ return env;
205
+ }
206
+
207
+ /**
208
+ * Build the BearerAuthProvider peers map. The auth provider expects keys to
209
+ * EQUAL the verified Participant.id; values carry the active bearer + audit
210
+ * id. v0.1 trusts every configured peer with `trust: "agent"` (link's
211
+ * design — see plan §6 for the trust alphabet).
212
+ */
213
+ function buildAuthPeers(
214
+ peers: Record<string, LinkPeerConfig>,
215
+ ): Readonly<Record<string, PeerBearerConfig>> {
216
+ const out: Record<string, PeerBearerConfig> = {};
217
+ for (const [name, cfg] of Object.entries(peers)) {
218
+ out[cfg.participantId] = {
219
+ participant: {
220
+ id: cfg.participantId,
221
+ locator: cfg.url,
222
+ type: "agent",
223
+ trust: "agent",
224
+ },
225
+ active: {
226
+ bearer: cfg.inboundBearer,
227
+ bearer_id: cfg.inboundBearerId,
228
+ },
229
+ };
230
+ // Reference `name` in a no-op so it appears in error messages if needed.
231
+ // (Useful for future expansion; intentional placeholder.)
232
+ void name;
233
+ }
234
+ return Object.freeze(out);
235
+ }
236
+
237
+ /**
238
+ * Build the link agent card from the operator config.
239
+ *
240
+ * v0.1 advertises an empty `skills: []` — augment-1's skill catalogue is
241
+ * not yet mapped to link's SkillDescriptor shape. v0.2 will harvest skill
242
+ * metadata from the agent's mounted skill folders.
243
+ */
244
+ function buildLinkAgentCard(card: LinkAugmentAgentCard): LinkAgentCard {
245
+ return buildAgentCard({
246
+ id: card.id,
247
+ name: card.name,
248
+ description: card.description,
249
+ endpoint_url: card.endpointUrl,
250
+ capabilities: card.capabilities ?? [],
251
+ skills: [],
252
+ });
253
+ }
254
+
255
+ /** Stable threadId for a given peer. */
256
+ function threadIdForPeer(participantId: string): string {
257
+ return `link-${participantId}`;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Factory
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export function link(opts: LinkAugmentInternalOptions): Augment {
265
+ const port = opts.port ?? 8081;
266
+ const peers = opts.peers ?? {};
267
+
268
+ // Build the AddressBook env once at construction time. The library's
269
+ // EnvAddressBook takes a frozen snapshot, so this can't be hot-reloaded
270
+ // until @auggy/link v0.2 lands richer config plumbing.
271
+ const addressBookEnv = opts._addressBookEnv ?? buildAddressBookEnv(peers);
272
+ const addressBook = new EnvAddressBook(addressBookEnv);
273
+
274
+ const peerClient = opts._peerClient ?? new PeerClient({ addressBook });
275
+
276
+ let kernel: TransportKernel | null = null;
277
+ let registeredName = "link";
278
+ let linkHandle: LinkAppHandle | null = null;
279
+ let server: ReturnType<typeof Bun.serve> | null = null;
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Inbound MessageHandler — the bridge from link → augment-1 kernel
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const onMessage: LinkMessageHandler = async function onMessage(ctx: LinkHandlerContext) {
286
+ if (!kernel) {
287
+ // Defense-in-depth: link's middleware shouldn't be wired before
288
+ // register fires (createLinkApp runs inside register), but if a future
289
+ // ordering bug pre-binds it, fail loudly rather than swallow the
290
+ // message.
291
+ return {
292
+ kind: "error",
293
+ code: -32603,
294
+ message: "link augment: kernel not yet registered",
295
+ };
296
+ }
297
+
298
+ const threadId = threadIdForPeer(ctx.from.id);
299
+ const trigger = handlerContextToTrigger(ctx, registeredName, threadId);
300
+ try {
301
+ const result = await kernel.handleInbound(trigger);
302
+ return turnResultToHandlerOutcome(result);
303
+ } catch (err) {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+ return {
306
+ kind: "error",
307
+ code: -32603,
308
+ message: `link augment: turn dispatch failed: ${message}`,
309
+ };
310
+ }
311
+ };
312
+
313
+ if (opts._captureMessageHandler) {
314
+ opts._captureMessageHandler.handler = onMessage;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // TransportSpec.identify
319
+ // ---------------------------------------------------------------------------
320
+ //
321
+ // link auth happens inside @auggy/link's BearerAuthProvider before
322
+ // `onMessage` fires — by the time the augment-1 kernel sees a trigger,
323
+ // the peer identity has already been resolved via
324
+ // participantToPeerIdentity. There's no transport-level identify path
325
+ // for link; the TransportSpec stub returns null.
326
+ const identify: TransportSpec["identify"] = () => null;
327
+
328
+ const transport: TransportSpec = {
329
+ async register(k: TransportKernel, augmentName: string) {
330
+ kernel = k;
331
+ registeredName = augmentName;
332
+
333
+ // Construct the link handle BEFORE binding Bun.serve so any
334
+ // configuration error surfaces synchronously at boot.
335
+ const taskStore = new SqliteTaskStore({ path: opts.dbPath });
336
+ const auth = new BearerAuthProvider({ peers: buildAuthPeers(peers) });
337
+ const agentCard = buildLinkAgentCard(opts.agentCard);
338
+
339
+ linkHandle = createLinkApp({
340
+ agentCard,
341
+ auth,
342
+ taskStore,
343
+ onMessage,
344
+ });
345
+
346
+ if (!opts._skipServer) {
347
+ server = Bun.serve({ port, fetch: linkHandle.fetch });
348
+ }
349
+ },
350
+ identify,
351
+ };
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Outbound tools
355
+ // ---------------------------------------------------------------------------
356
+
357
+ const linkSendTool = defineTool({
358
+ name: "link_send",
359
+ description:
360
+ "Send a text message to another agent via A2A peer-to-peer. The `to` parameter must be one of the peers configured in this agent's link config — call `link_list` to see them. Returns the peer's synchronous reply text when available, or a task id if the peer chose to handle the request asynchronously.",
361
+ category: "communication",
362
+ input: z.object({
363
+ to: z
364
+ .string()
365
+ .describe("Peer short name from the link config (also surfaced by `link_list`)."),
366
+ text: z.string().describe("Message text to send. v0.1 link traffic is text-only."),
367
+ }),
368
+ execute: async ({ to, text }, _ctx?: ToolExecuteContext) => {
369
+ const result = await peerClient.send({
370
+ to,
371
+ parts: [{ kind: "text", text }],
372
+ });
373
+
374
+ if (!result.ok) {
375
+ return JSON.stringify({
376
+ ok: false,
377
+ error: result.error.code,
378
+ message: result.error.message,
379
+ });
380
+ }
381
+
382
+ const { outcome } = result.value;
383
+ if (isTaskResult(outcome)) {
384
+ // Async task path — peer chose to create a Task. v0.1 doesn't yet
385
+ // wire task polling into augment-1's tool surface; return the id so
386
+ // the LLM (or operator) can follow up via future tools.
387
+ return JSON.stringify({
388
+ ok: true,
389
+ outcome: "task",
390
+ taskId: outcome.id,
391
+ });
392
+ }
393
+
394
+ // Sync message path — concatenate text parts (link is text-only at
395
+ // v0.1, so every part is a TextPart).
396
+ const replyText = outcome.parts.map((p) => (p.kind === "text" ? p.text : "")).join("");
397
+ return JSON.stringify({
398
+ ok: true,
399
+ outcome: "message",
400
+ text: replyText,
401
+ });
402
+ },
403
+ });
404
+
405
+ const linkListTool = defineTool({
406
+ name: "link_list",
407
+ description:
408
+ "List the peers configured for outbound A2A traffic. Returns an array of short names that can be used as the `to` argument to `link_send`.",
409
+ category: "communication",
410
+ input: z.object({}),
411
+ execute: async () => {
412
+ return JSON.stringify({ peers: Object.keys(peers) });
413
+ },
414
+ });
415
+
416
+ return {
417
+ name: "link",
418
+ capabilities: ["transport", "tools"],
419
+ transport,
420
+ tools: [linkSendTool, linkListTool],
421
+ async onShutdown() {
422
+ // Stop the server first so no new admissions enter; THEN drain
423
+ // in-flight requests via linkHandle.shutdown() so the store closes
424
+ // cleanly. Order matters: if the store closes while a request is
425
+ // still in flight, that request would see a store error.
426
+ if (server) {
427
+ try {
428
+ server.stop();
429
+ } catch (err) {
430
+ console.warn(`[link] server.stop() failed: ${(err as Error).message}`);
431
+ }
432
+ server = null;
433
+ }
434
+ if (linkHandle) {
435
+ try {
436
+ await linkHandle.shutdown();
437
+ } catch (err) {
438
+ console.warn(`[link] linkHandle.shutdown() failed: ${(err as Error).message}`);
439
+ }
440
+ linkHandle = null;
441
+ }
442
+ },
443
+ };
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Test-only hooks
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * Test-only helper: build an augment alongside a handle to the MessageHandler
452
+ * closure so integration tests can fire fake A2A messages without spinning up
453
+ * a real Bun.serve.
454
+ *
455
+ * Returns:
456
+ * - augment: the assembled Augment (caller passes to defineAgent)
457
+ * - dispatch: pass a fake HandlerContext to invoke onMessage directly;
458
+ * returns the resolved HandlerOutcome
459
+ *
460
+ * This intentionally lives in the same file as the factory so the closure
461
+ * over `onMessage` is in scope. The factory ignores the test hooks when
462
+ * called from production paths.
463
+ */
464
+ export function _createLinkForTesting(opts: LinkAugmentInternalOptions): {
465
+ augment: Augment;
466
+ dispatch: (ctx: LinkHandlerContext) => Promise<ReturnType<LinkMessageHandler>>;
467
+ } {
468
+ // Capture the production MessageHandler via the side channel so the test
469
+ // exercises THE SAME closure that real link traffic runs through. No
470
+ // duplicated try/catch or translation logic in test code.
471
+ const capture: { handler?: LinkMessageHandler } = {};
472
+ const internalOpts: LinkAugmentInternalOptions = {
473
+ ...opts,
474
+ _skipServer: true,
475
+ _captureMessageHandler: capture,
476
+ };
477
+ const augment = link(internalOpts);
478
+
479
+ const dispatch = async (ctx: LinkHandlerContext): Promise<ReturnType<LinkMessageHandler>> => {
480
+ if (!capture.handler) {
481
+ throw new Error(
482
+ "_createLinkForTesting: production handler not captured — link() factory contract changed?",
483
+ );
484
+ }
485
+ return capture.handler(ctx);
486
+ };
487
+
488
+ return { augment, dispatch };
489
+ }