@theokit/sdk-handoff 0.1.2 → 0.1.3

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.
@@ -0,0 +1,255 @@
1
+ import { SDKAgent } from '@theokit/sdk';
2
+ import { ZodType } from 'zod';
3
+
4
+ /**
5
+ * Type-leaf — `HandoffDescriptor` extracted as a generic over `TAgent` so
6
+ * neither `agent.ts` nor `handoff.ts` need to import the other for type
7
+ * resolution. Closes the audit's last LOW type-only cycle #4
8
+ * (`types/agent.ts ↔ types/handoff.ts`) per plan
9
+ * arch-review-fixes-2026-06-06 § Phase 4 / T4.1 follow-up.
10
+ *
11
+ * BREAKING (per user direction "sem retro compat"): `HandoffDescriptor`
12
+ * gained a second generic parameter `TAgent` for the target shape. Existing
13
+ * consumers using `HandoffDescriptor<MyInput>` now resolve to
14
+ * `HandoffDescriptor<MyInput, SDKAgent>` via the back-compat default in
15
+ * `handoff.ts`'s re-export.
16
+ *
17
+ * @public
18
+ */
19
+
20
+ /**
21
+ * Read-only snapshot of a handoff about to happen, passed to `onHandoff` and `isEnabled`.
22
+ *
23
+ * @public
24
+ */
25
+ interface HandoffContext {
26
+ /** The `parentAgentId` the wiring was built with — `"anonymous"` when it was left unset. */
27
+ readonly senderAgentId: string;
28
+ /** `agentId` of the target about to receive the conversation. */
29
+ readonly receiverAgentId: string;
30
+ /**
31
+ * Hops recorded so far in this dispatch. Because chain state is rebuilt per tool invocation, it
32
+ * is `1` for essentially every real handoff — it is not a running total across a `send()`.
33
+ */
34
+ readonly currentDepth: number;
35
+ /**
36
+ * Agent ids traversed, ending with the receiver this context describes. Same caveat as
37
+ * `currentDepth`: it covers this dispatch, not the whole conversation.
38
+ */
39
+ readonly chain: ReadonlyArray<string>;
40
+ }
41
+ /**
42
+ * The transcript wrapper passed to `inputFilter`. `messages` is widened to
43
+ * `unknown[]` so this type doesn't import from `messages.ts` (avoids cycle
44
+ * — implementations cast to `SDKMessage[]` internally).
45
+ *
46
+ * Always arrives EMPTY today — see {@link HandoffOptions.inputFilter}.
47
+ *
48
+ * @public
49
+ */
50
+ interface HandoffHistory {
51
+ readonly messages: ReadonlyArray<unknown>;
52
+ }
53
+ /**
54
+ * Options accepted by `Handoff.create(target, opts?)`.
55
+ *
56
+ * @public
57
+ */
58
+ interface HandoffOptions<TInput extends ZodType = ZodType> {
59
+ /**
60
+ * Name of the synthetic tool, replacing the derived `transfer_to_<slug>`.
61
+ *
62
+ * Taken verbatim — it is not validated against the provider's tool-name rules, and it is what the
63
+ * collision check compares, so two targets sharing an explicit name still collide.
64
+ */
65
+ readonly toolName?: string;
66
+ /**
67
+ * Description shown to the model, replacing the generic "Transfer the conversation to the
68
+ * `<agentId>` agent." This is the ONLY thing telling the model when to pick this target over its
69
+ * siblings, so a default description in a multi-target setup routes badly.
70
+ */
71
+ readonly toolDescription?: string;
72
+ /**
73
+ * Called after the input is parsed and BEFORE the receiver is invoked. `parsed` is whatever
74
+ * `inputType` produced, or `undefined` when no `inputType` was set.
75
+ *
76
+ * Awaited, and NOT isolated: throwing from here aborts the handoff, which is the supported way to
77
+ * veto one after inspecting the arguments. Use it for logging and audit; the transfer has not
78
+ * happened yet.
79
+ */
80
+ readonly onHandoff?: (ctx: HandoffContext, parsed: TInput extends ZodType ? unknown : undefined) => void | Promise<void>;
81
+ /**
82
+ * Zod schema for the arguments the model must supply, replacing the default
83
+ * `{ reason?: string }`. It becomes the tool's JSON Schema, so it is also how you ask the model
84
+ * for structured routing data.
85
+ *
86
+ * Parsed with `.parse()`, so a rejection aborts the handoff. Note the parsed value is used ONLY
87
+ * for `onHandoff` and for lifting a `reason` field into telemetry — it is NOT forwarded to the
88
+ * receiver, which sees only the message text.
89
+ */
90
+ readonly inputType?: TInput;
91
+ /**
92
+ * Hook to rewrite the transcript before the receiver sees it — the intended place for redaction.
93
+ *
94
+ * It receives the supervisor's transcript as the tool handler saw it, and the receiver is sent
95
+ * the last user turn SURVIVING this filter — so dropping a message here does keep it from the
96
+ * receiver. Until #354 both wirings passed `{ messages: [] }`, which made this hook a no-op that
97
+ * looked like redaction.
98
+ *
99
+ * Failures are swallowed: a throw falls back to the unfiltered history with one warning on
100
+ * stderr per process — so a broken redactor fails OPEN, not closed.
101
+ */
102
+ readonly inputFilter?: (history: HandoffHistory) => HandoffHistory | Promise<HandoffHistory>;
103
+ /**
104
+ * Restrict the receiving agent, for THIS handoff only, to the tools named here.
105
+ *
106
+ * Wired to `SendOptions.activeTools`: names match EXACTLY against the receiver's registered tool
107
+ * names, an empty list restricts to the empty set (fail-closed), and omitting the option imposes
108
+ * no restriction. It narrows what the receiver may call; it never grants a tool the receiver
109
+ * does not have.
110
+ *
111
+ * LOCAL RUNTIME ONLY — a cloud agent ignores `activeTools`, so this cannot restrict one. Before
112
+ * #356 nothing read this field at all, in either runtime.
113
+ */
114
+ readonly tools?: ReadonlyArray<string>;
115
+ /**
116
+ * Gate on this handoff, as a boolean or a predicate evaluated at dispatch time with the same
117
+ * `ctx` that `onHandoff` receives.
118
+ *
119
+ * `false` does NOT hide the tool from the model — the tool is still registered and still
120
+ * offered; the dispatch simply fails with `Handoff to <id> is disabled (isEnabled returned
121
+ * false)`, which the tool wiring hands back as a failed tool result. To remove a target from the
122
+ * model's view, leave it out of `targets`.
123
+ */
124
+ readonly isEnabled?: boolean | ((ctx: HandoffContext) => boolean | Promise<boolean>);
125
+ }
126
+ /**
127
+ * Public `Handoff` shape — what `Handoff.create()` returns. Read-only
128
+ * accessors only; behavior lives in the engine.
129
+ *
130
+ * Generic over `TAgent` so this leaf has no dependency on a concrete
131
+ * agent type. Consumers typically import the convenience alias
132
+ * `HandoffDescriptor<TInput>` from `@theokit/sdk` which fixes `TAgent`
133
+ * to `SDKAgent`.
134
+ *
135
+ * @public
136
+ */
137
+ interface HandoffDescriptor$1<TInput extends ZodType = ZodType, TAgent = unknown> {
138
+ readonly target: TAgent;
139
+ readonly options: HandoffOptions<TInput>;
140
+ /** Resolved tool name (after applying toolName override or default `transfer_to_<receiver>`). */
141
+ readonly resolvedToolName: string;
142
+ }
143
+
144
+ /**
145
+ * Public types for `Agent.create({ handoffs })` + `Handoff.create()` +
146
+ * `Agent.handoffTo()` (Adoption Roadmap #4; ADRs D214-D229).
147
+ *
148
+ * Pattern: handoff-as-tool. Each handoff destination becomes a synthetic
149
+ * `transfer_to_<receiver>` function tool exposed to the LLM. Runtime
150
+ * intercepts the tool call and routes the next turn to the receiver.
151
+ *
152
+ * T4.1 follow-up (cycle #4 closed): `HandoffDescriptor` + its leaf-friendly
153
+ * sibling types now live in `./handoff-descriptor.ts` (generic over
154
+ * `TAgent`). This module re-exports the leaf types pinned to `SDKAgent`,
155
+ * keeps the runtime error classes, and removes the back-edge to `agent.ts`.
156
+ *
157
+ * @public
158
+ */
159
+
160
+ /**
161
+ * What `Handoff.create` returns: a target plus its options plus the resolved tool name.
162
+ *
163
+ * Pinned to `SDKAgent` — the back-compat shape for callers who imported
164
+ * `import type { HandoffDescriptor } from "@theokit/sdk"` before the T4.1 follow-up. It is a plain
165
+ * data record: constructing one by hand works, and skips the target validation `Handoff.create`
166
+ * performs.
167
+ *
168
+ * @public
169
+ */
170
+ type HandoffDescriptor<TInput extends ZodType = ZodType> = HandoffDescriptor$1<TInput, SDKAgent>;
171
+ /**
172
+ * Thrown when a chain exceeds `maxHandoffDepth` (default 5). `depth` is the CAP that was exceeded,
173
+ * not the depth reached; `chain` is the full path of agent ids.
174
+ *
175
+ * Rare in practice: chain state is rebuilt per dispatch, so depth restarts at 1 on every tool call.
176
+ * Repeated ping-pong surfaces as {@link HandoffPairLoopError} instead. *
177
+ * WHERE YOU SEE IT: only when you drive a handoff yourself, via `handoffTo(...)`. In the
178
+ * tool-based wirings (`Handoff.asPlugin` / `Agent.create({ handoffs })`) the handler catches every
179
+ * error and hands the MODEL a `{"ok":false,"error":"<name>","message":"…"}` tool result, so this
180
+ * class is observable there only as that `error` string.
181
+ */
182
+ declare class HandoffLoopError extends Error {
183
+ readonly name = "HandoffLoopError";
184
+ readonly depth: number;
185
+ readonly chain: ReadonlyArray<string>;
186
+ constructor(depth: number, chain: ReadonlyArray<string>);
187
+ }
188
+ /**
189
+ * Thrown when the same `sender -> receiver` pair fires twice inside one dispatch — the ping-pong
190
+ * guard, and the loop protection that actually fires in practice.
191
+ *
192
+ * A -> B -> A is allowed by this check (different pairs); a repeated A -> B is not. *
193
+ * WHERE YOU SEE IT: only when you drive a handoff yourself, via `handoffTo(...)`. In the
194
+ * tool-based wirings (`Handoff.asPlugin` / `Agent.create({ handoffs })`) the handler catches every
195
+ * error and hands the MODEL a `{"ok":false,"error":"<name>","message":"…"}` tool result, so this
196
+ * class is observable there only as that `error` string.
197
+ */
198
+ declare class HandoffPairLoopError extends Error {
199
+ readonly name = "HandoffPairLoopError";
200
+ readonly senderAgentId: string;
201
+ readonly receiverAgentId: string;
202
+ constructor(senderAgentId: string, receiverAgentId: string);
203
+ }
204
+ /**
205
+ * Thrown when a target's `agentId` equals the parent's — self-handoff, which recurses forever.
206
+ *
207
+ * Compared against `parentAgentId` as a STRING, which defaults to `"anonymous"` in
208
+ * `Handoff.asPlugin`: leave it unset and a genuine self-reference goes undetected.
209
+ *
210
+ * Raised while the target list is normalised, which in `Handoff.asPlugin` happens inside an
211
+ * unawaited async registration — it arrives as an unhandled rejection there, not as a throw from
212
+ * `Agent.create`.
213
+ */
214
+ declare class HandoffSelfReferenceError extends Error {
215
+ readonly name = "HandoffSelfReferenceError";
216
+ readonly agentId: string;
217
+ constructor(agentId: string);
218
+ }
219
+ /**
220
+ * Thrown when the target agent was disposed before the handoff reached it — detected at dispatch
221
+ * time, since nothing unregisters the tool when an agent is disposed.
222
+ *
223
+ * Typical cause: the receiver was created in a narrower scope than the sender and cleaned up first. *
224
+ * WHERE YOU SEE IT: only when you drive a handoff yourself, via `handoffTo(...)`. In the
225
+ * tool-based wirings (`Handoff.asPlugin` / `Agent.create({ handoffs })`) the handler catches every
226
+ * error and hands the MODEL a `{"ok":false,"error":"<name>","message":"…"}` tool result, so this
227
+ * class is observable there only as that `error` string.
228
+ */
229
+ declare class HandoffReceiverDisposedError extends Error {
230
+ readonly name = "HandoffReceiverDisposedError";
231
+ readonly receiverAgentId: string;
232
+ constructor(receiverAgentId: string);
233
+ }
234
+ /**
235
+ * Thrown when two targets of the same parent resolve to the same `transfer_to_*` name — the model
236
+ * would have no way to pick between them.
237
+ *
238
+ * Easy to hit without duplicate agents, but not in the way the folding rule suggests: `-` and `_`
239
+ * SURVIVE the slug, and only runs of other characters fold to a single `_`, which is then trimmed
240
+ * at both ends. So `"billing EU"`, `"billing_EU"`, `"billing.EU"` and `"billing (EU)"` all resolve
241
+ * to `transfer_to_billing_EU` and collide — the last one because the `_` left by the closing paren
242
+ * is trimmed off the end. `"billing-EU"` keeps its hyphen, resolves to `transfer_to_billing-EU`,
243
+ * and collides with none of them. Set `toolName` on one of the colliding pair.
244
+ *
245
+ * Raised while the target list is normalised, which in `Handoff.asPlugin` happens inside an
246
+ * unawaited async registration — it arrives as an unhandled rejection there, not as a throw from
247
+ * `Agent.create`.
248
+ */
249
+ declare class HandoffNameCollisionError extends Error {
250
+ readonly name = "HandoffNameCollisionError";
251
+ readonly conflictingName: string;
252
+ constructor(conflictingName: string);
253
+ }
254
+
255
+ export { type HandoffDescriptor as H, type HandoffOptions as a, HandoffLoopError as b, HandoffNameCollisionError as c, HandoffPairLoopError as d, HandoffReceiverDisposedError as e, HandoffSelfReferenceError as f };
package/dist/index.cjs CHANGED
@@ -20,6 +20,17 @@ var __export = (target, all) => {
20
20
  __defProp(target, name, { get: all[name], enumerable: true });
21
21
  };
22
22
 
23
+ // src/internal/slugify-agent-name.ts
24
+ function slugifyAgentName(candidate) {
25
+ return candidate.slice(0, MAX_INPUT_LENGTH).replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
26
+ }
27
+ var MAX_INPUT_LENGTH;
28
+ var init_slugify_agent_name = __esm({
29
+ "src/internal/slugify-agent-name.ts"() {
30
+ MAX_INPUT_LENGTH = 1024;
31
+ }
32
+ });
33
+
23
34
  // src/types/handoff.ts
24
35
  exports.HandoffLoopError = void 0; exports.HandoffPairLoopError = void 0; exports.HandoffSelfReferenceError = void 0; exports.HandoffReceiverDisposedError = void 0; exports.HandoffNameCollisionError = void 0;
25
36
  var init_handoff = __esm({
@@ -233,11 +244,15 @@ function extractUserText(content) {
233
244
  const text = content.filter((c) => c?.type === "text").map((c) => c.text).join("\n");
234
245
  return text.length > 0 ? text : void 0;
235
246
  }
247
+ function userTextOf(entry) {
248
+ const m = entry;
249
+ if (m?.type === "user" && m.message?.role === "user") return extractUserText(m.message.content);
250
+ if (m?.role === "user") return extractUserText(m.content);
251
+ return void 0;
252
+ }
236
253
  function extractLastUserMessage(history, senderAgentId) {
237
254
  for (let i = history.messages.length - 1; i >= 0; i -= 1) {
238
- const m = history.messages[i];
239
- if (m?.type !== "user" || m.message?.role !== "user") continue;
240
- const text = extractUserText(m.message.content);
255
+ const text = userTextOf(history.messages[i]);
241
256
  if (text !== void 0) return text;
242
257
  }
243
258
  return `(Handoff from ${senderAgentId} \u2014 no prior user message in history.)`;
@@ -270,7 +285,11 @@ async function dispatchHandoff(args) {
270
285
  toolName: descriptor.resolvedToolName
271
286
  });
272
287
  try {
273
- const run = await receiver.send(lastUserMessage);
288
+ const toolAllowlist = descriptor.options.tools;
289
+ const run = await receiver.send(
290
+ lastUserMessage,
291
+ toolAllowlist !== void 0 ? { activeTools: [...toolAllowlist] } : {}
292
+ );
274
293
  const result = await run.wait();
275
294
  const reply = buildReply(result, receiver.agentId);
276
295
  return {
@@ -349,10 +368,7 @@ function autoWrap(agent) {
349
368
  }
350
369
  function resolveTargetName(agent) {
351
370
  const candidate = agent.name ?? agent.agentId ?? "anonymous";
352
- return slugify(candidate);
353
- }
354
- function slugify(input) {
355
- return input.replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
371
+ return slugifyAgentName(candidate);
356
372
  }
357
373
  function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
358
374
  const description = descriptor.options.toolDescription ?? `Transfer the conversation to the ${descriptor.target.agentId} agent. Use this when the user's request matches their specialty.`;
@@ -364,7 +380,7 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
364
380
  name: descriptor.resolvedToolName,
365
381
  description,
366
382
  inputSchema,
367
- handler: async (input) => {
383
+ handler: async (input, ctx) => {
368
384
  const chainState = createChainState(parentAgentId, maxHandoffDepth);
369
385
  try {
370
386
  const { reply, result } = await dispatchHandoff({
@@ -372,8 +388,12 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
372
388
  senderAgentId: parentAgentId,
373
389
  chainState,
374
390
  rawInputJson: input,
375
- history: { messages: [] }
376
- // v1: history replay deferred
391
+ // #354 the supervisor's transcript, which the SDK hands every tool handler as
392
+ // `ctx.messages`. This used to be `{ messages: [] }` with the note "v1: history replay
393
+ // deferred", so the dispatcher found no user message and sent the receiver the
394
+ // placeholder instead of the question — and `inputFilter`, the documented redaction
395
+ // hook, was handed an empty transcript to redact.
396
+ history: { messages: ctx?.messages ?? [] }
377
397
  });
378
398
  return JSON.stringify({
379
399
  ok: true,
@@ -396,9 +416,13 @@ var init_tool_injector = __esm({
396
416
  init_handoff();
397
417
  init_dispatcher();
398
418
  init_registry();
419
+ init_slugify_agent_name();
399
420
  init_to_json_schema();
400
421
  }
401
422
  });
423
+
424
+ // src/handoff.ts
425
+ init_slugify_agent_name();
402
426
  var RECOMMENDED_HANDOFF_PROMPT_PREFIX = `
403
427
  You can transfer the conversation to other specialist agents when their
404
428
  expertise matches the user's request. Invoke the appropriate
@@ -409,19 +433,39 @@ var Handoff = class {
409
433
  constructor() {
410
434
  }
411
435
  /**
412
- * Build a `HandoffDescriptor` for a target agent. Wrap with custom options
413
- * (filter / inputType / callback / whitelist / etc); pass to
414
- * `Agent.create({ handoffs: [...] })`.
436
+ * Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
437
+ * `Agent.create({ handoffs })`.
415
438
  *
416
- * Raw `SDKAgent` instances in `handoffs[]` are auto-wrapped by the runtime
417
- * — call `Handoff.create()` explicitly only when you need to customize.
439
+ * ```ts
440
+ * Handoff.create(billing, { toolName: "escalate_billing" })
441
+ * ```
442
+ *
443
+ * A bare `SDKAgent` in either array is auto-wrapped with empty options, so call this explicitly
444
+ * only to customise — see {@link HandoffOptions}, and note that `tools` there is currently
445
+ * ignored.
446
+ *
447
+ * The tool the model sees is named `transfer_to_<slug>`, where the slug comes from the target's
448
+ * `name` (falling back to its `agentId`, then to `"anonymous"`) with a leading `agent-` stripped,
449
+ * every run of characters OUTSIDE `[A-Za-z0-9_-]` folded to a single `_`, leading and trailing
450
+ * `_` trimmed, and a 64-char truncation. Hyphens and underscores are preserved, so `"billing EU"`
451
+ * and `"billing (EU)"` both become `billing_EU` while `"billing-EU"` stays distinct. Two targets
452
+ * whose names collapse to the same slug are NOT caught here — the collision is raised later, when
453
+ * the set is normalised.
454
+ *
455
+ * Throws `ConfigurationError` with `code: "handoff_target_required"` for a null/undefined target,
456
+ * and `code: "handoff_target_invalid"` for anything without a `send` method. It validates the
457
+ * target only, never the options.
418
458
  */
419
459
  static create(target, options = {}) {
420
460
  if (target === void 0 || target === null) {
421
- throw new Error("Handoff.create: target agent is required");
461
+ throw new sdk.ConfigurationError("Handoff.create: target agent is required", {
462
+ code: "handoff_target_required"
463
+ });
422
464
  }
423
465
  if (typeof target.send !== "function") {
424
- throw new Error("Handoff.create: target must be an SDKAgent instance");
466
+ throw new sdk.ConfigurationError("Handoff.create: target must be an SDKAgent instance", {
467
+ code: "handoff_target_invalid"
468
+ });
425
469
  }
426
470
  const resolvedToolName = options.toolName ?? `transfer_to_${slugifyName(target)}`;
427
471
  return {
@@ -431,19 +475,47 @@ var Handoff = class {
431
475
  };
432
476
  }
433
477
  /**
434
- * Plugin-based wiring (SDK 2.x preferred). Wraps `targets` in synthetic
435
- * `transfer_to_<receiver>` tools and registers them via `ctx.registerTool`
436
- * at agent init time.
478
+ * Expose each target as a `transfer_to_<receiver>` tool on the host agent — the SDK 2.x way to
479
+ * wire handoffs.
480
+ *
481
+ * ```ts
482
+ * const support = await Agent.create({
483
+ * name: "support",
484
+ * systemPrompt: `${RECOMMENDED_HANDOFF_PROMPT_PREFIX}\n\nYou answer support requests.`,
485
+ * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
486
+ * });
487
+ * ```
488
+ *
489
+ * Pass `parentAgentId` — it defaults to `"anonymous"`, and it is what self-reference detection
490
+ * and the chain trace compare against, so leaving it out weakens both. `maxHandoffDepth` defaults
491
+ * to 5.
492
+ *
493
+ * Four behaviours that are easy to be surprised by:
437
494
  *
438
- * Replaces the legacy `Agent.create({ handoffs: [...] })` option (which is
439
- * still supported as a transitional convenience while sdk-handoff is
440
- * installed — the framework lazy-imports the tool-injector at runtime).
495
+ * - **`maxHandoffDepth: 0`, or an empty `targets`, registers NOTHING** and returns a plugin that
496
+ * silently does nothing. There is no error and no warning; the model simply never sees a
497
+ * transfer tool.
498
+ * - **Registration is awaited.** `register()` returns a promise that settles once the tools are
499
+ * registered, and the plugin manager awaits it — so the transfer tools exist before the first
500
+ * `send()`, and a failure in the lazy import or in target validation reaches the caller.
501
+ * Before #355 it returned immediately and both of those were untrue.
502
+ * - **The receiver gets the user's LAST message, not the whole conversation.** The tool handler
503
+ * forwards the supervisor's transcript, from which the dispatcher takes the most recent user
504
+ * turn (#354 — before that it forwarded nothing, and the receiver was sent the literal string
505
+ * `` `(Handoff from <sender> — no prior user message in history.)` ``). That placeholder is
506
+ * still what a receiver gets when there genuinely is no prior user turn. Anything beyond the
507
+ * last question has to be in the target's own system prompt, or you drive the handoff yourself
508
+ * with {@link handoffTo}, which passes an explicit message through.
509
+ * - **The handoff tool never throws at the caller.** Every failure — loop detected, depth
510
+ * exceeded, disposed receiver, `isEnabled` false, input that fails `inputType` — is caught
511
+ * inside the tool handler and returned to the MODEL as
512
+ * `{"ok":false,"error":"<ErrorName>","message":"…"}`. The exported error classes are real, but
513
+ * in this wiring they never reach your `try`/`catch`; watch the tool results instead.
441
514
  *
442
- * @example
443
- * const support = await Agent.create({
444
- * name: "support",
445
- * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
446
- * });
515
+ * A self-referencing target and two targets resolving to the same tool name are both rejected —
516
+ * from `register`, which the plugin manager awaits — so `HandoffSelfReferenceError` and
517
+ * `HandoffNameCollisionError` reject the `Agent.create` you can `catch` around (#355; they used
518
+ * to arrive as an unhandled rejection instead, leaving an agent silently without handoff tools).
447
519
  */
448
520
  static asPlugin(opts) {
449
521
  const parent = opts.parentAgentId ?? "anonymous";
@@ -453,22 +525,29 @@ var Handoff = class {
453
525
  name: `handoff-${parent}`,
454
526
  version: "1.0.0",
455
527
  kind: "general",
456
- register(ctx) {
528
+ // #355 — `async`, and the promise is RETURNED. The plugin contract types `register` as
529
+ // `(ctx) => void | Promise<void>` and the manager awaits it, so returning it is all this
530
+ // needed. It used to run an unawaited async IIFE and return immediately: the tools appeared a
531
+ // module-load later, so whether they existed for the first `send()` depended on timing no
532
+ // caller controls, and `normalizeHandoffs`' validation errors became unhandled rejections
533
+ // that could not be caught around `Agent.create`.
534
+ //
535
+ // The import stays lazy — deferring it until `register` runs is what keeps the cold path
536
+ // lean, and that never required leaving it unawaited INSIDE `register`.
537
+ async register(ctx) {
457
538
  if (maxDepth === 0 || targets.length === 0) return;
458
- void (async () => {
459
- const { normalizeHandoffs: normalizeHandoffs2, buildHandoffTool: buildHandoffTool2 } = await Promise.resolve().then(() => (init_tool_injector(), tool_injector_exports));
460
- const normalized = normalizeHandoffs2(parent, targets);
461
- for (const { descriptor } of normalized) {
462
- ctx.registerTool(buildHandoffTool2(parent, descriptor, maxDepth));
463
- }
464
- })();
539
+ const { normalizeHandoffs: normalizeHandoffs2, buildHandoffTool: buildHandoffTool2 } = await Promise.resolve().then(() => (init_tool_injector(), tool_injector_exports));
540
+ const normalized = normalizeHandoffs2(parent, targets);
541
+ for (const { descriptor } of normalized) {
542
+ ctx.registerTool(buildHandoffTool2(parent, descriptor, maxDepth));
543
+ }
465
544
  }
466
545
  });
467
546
  }
468
547
  };
469
548
  function slugifyName(agent) {
470
549
  const candidate = agent.name ?? agent.agentId ?? "anonymous";
471
- return candidate.replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
550
+ return slugifyAgentName(candidate);
472
551
  }
473
552
  async function handoffTo(sender, target, message, options = {}) {
474
553
  const descriptor = Handoff.create(target, options);