@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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SDKAgent, Plugin } from '@theokit/sdk';
2
2
  import { ZodType } from 'zod';
3
- import { H as HandoffDescriptor, a as HandoffOptions } from './handoff-D-Ujv-lA.js';
4
- export { b as HandoffLoopError, c as HandoffNameCollisionError, d as HandoffPairLoopError, e as HandoffReceiverDisposedError, f as HandoffSelfReferenceError } from './handoff-D-Ujv-lA.js';
3
+ import { H as HandoffDescriptor, a as HandoffOptions } from './handoff-D7malWe_.js';
4
+ export { b as HandoffLoopError, c as HandoffNameCollisionError, d as HandoffPairLoopError, e as HandoffReceiverDisposedError, f as HandoffSelfReferenceError } from './handoff-D7malWe_.js';
5
5
 
6
6
  /**
7
7
  * Public `Handoff` class — factory for handoff descriptors (Adoption
@@ -9,7 +9,8 @@ export { b as HandoffLoopError, c as HandoffNameCollisionError, d as HandoffPair
9
9
  *
10
10
  * Usage:
11
11
  *
12
- * import { Agent, Handoff } from "@theokit/sdk";
12
+ * import { Agent } from "@theokit/sdk";
13
+ * import { Handoff } from "@theokit/sdk-handoff";
13
14
  *
14
15
  * const billing = await Agent.create({
15
16
  * name: "billing",
@@ -32,53 +33,167 @@ export { b as HandoffLoopError, c as HandoffNameCollisionError, d as HandoffPair
32
33
  * @public
33
34
  */
34
35
 
35
- /** Recommended system-prompt prefix for senders (D215 / EC-13). */
36
+ /**
37
+ * Prose to prepend to a SENDING agent's `systemPrompt` so the model knows the `transfer_to_*` tools
38
+ * exist and what they mean.
39
+ *
40
+ * ```ts
41
+ * systemPrompt: `${RECOMMENDED_HANDOFF_PROMPT_PREFIX}\n\nYou triage support requests.`
42
+ * ```
43
+ *
44
+ * Nothing applies it for you — neither {@link Handoff.create} nor {@link Handoff.asPlugin} touches
45
+ * the system prompt, so omitting it is legal and usually shows up as a model that never transfers.
46
+ * Only the sender needs it; the receiver is unaware it was handed a conversation.
47
+ *
48
+ * @public
49
+ */
36
50
  declare const RECOMMENDED_HANDOFF_PROMPT_PREFIX: string;
51
+ /**
52
+ * Peer-to-peer delegation: one agent hands the conversation to another and stops.
53
+ *
54
+ * A handoff is not a subagent call. A subagent runs, returns a result, and the parent continues; a
55
+ * handoff TRANSFERS the turn — the target answers the user directly and the source does not resume.
56
+ * Reach for it when the right responder is a different agent (billing, escalation, a specialist),
57
+ * and for a tool-shaped "go find this out and come back", use `agents` / `Tool.create` instead.
58
+ *
59
+ * Two entry points, and they are not interchangeable:
60
+ *
61
+ * - {@link Handoff.create} builds one descriptor, for `Agent.create({ handoffs: [...] })`. A bare
62
+ * `SDKAgent` in that array is auto-wrapped, so call this explicitly only to customise (input
63
+ * schema, filter, callback).
64
+ * - {@link Handoff.asPlugin} installs a whole set as a plugin, which is the SDK 2.x+ shape and what
65
+ * the README's migration section points at.
66
+ *
67
+ * A namespace class: `new Handoff()` is a compile error, matching `Agent.create` / `Tool.create`.
68
+ */
37
69
  declare class Handoff {
38
70
  private constructor();
39
71
  /**
40
- * Build a `HandoffDescriptor` for a target agent. Wrap with custom options
41
- * (filter / inputType / callback / whitelist / etc); pass to
42
- * `Agent.create({ handoffs: [...] })`.
72
+ * Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
73
+ * `Agent.create({ handoffs })`.
74
+ *
75
+ * ```ts
76
+ * Handoff.create(billing, { toolName: "escalate_billing" })
77
+ * ```
78
+ *
79
+ * A bare `SDKAgent` in either array is auto-wrapped with empty options, so call this explicitly
80
+ * only to customise — see {@link HandoffOptions}, and note that `tools` there is currently
81
+ * ignored.
43
82
  *
44
- * Raw `SDKAgent` instances in `handoffs[]` are auto-wrapped by the runtime
45
- * — call `Handoff.create()` explicitly only when you need to customize.
83
+ * The tool the model sees is named `transfer_to_<slug>`, where the slug comes from the target's
84
+ * `name` (falling back to its `agentId`, then to `"anonymous"`) with a leading `agent-` stripped,
85
+ * every run of characters OUTSIDE `[A-Za-z0-9_-]` folded to a single `_`, leading and trailing
86
+ * `_` trimmed, and a 64-char truncation. Hyphens and underscores are preserved, so `"billing EU"`
87
+ * and `"billing (EU)"` both become `billing_EU` while `"billing-EU"` stays distinct. Two targets
88
+ * whose names collapse to the same slug are NOT caught here — the collision is raised later, when
89
+ * the set is normalised.
90
+ *
91
+ * Throws `ConfigurationError` with `code: "handoff_target_required"` for a null/undefined target,
92
+ * and `code: "handoff_target_invalid"` for anything without a `send` method. It validates the
93
+ * target only, never the options.
46
94
  */
47
95
  static create<TInput extends ZodType = ZodType>(target: SDKAgent, options?: HandoffOptions<TInput>): HandoffDescriptor<TInput>;
48
96
  /**
49
- * Plugin-based wiring (SDK 2.x preferred). Wraps `targets` in synthetic
50
- * `transfer_to_<receiver>` tools and registers them via `ctx.registerTool`
51
- * at agent init time.
97
+ * Expose each target as a `transfer_to_<receiver>` tool on the host agent — the SDK 2.x way to
98
+ * wire handoffs.
99
+ *
100
+ * ```ts
101
+ * const support = await Agent.create({
102
+ * name: "support",
103
+ * systemPrompt: `${RECOMMENDED_HANDOFF_PROMPT_PREFIX}\n\nYou answer support requests.`,
104
+ * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
105
+ * });
106
+ * ```
107
+ *
108
+ * Pass `parentAgentId` — it defaults to `"anonymous"`, and it is what self-reference detection
109
+ * and the chain trace compare against, so leaving it out weakens both. `maxHandoffDepth` defaults
110
+ * to 5.
111
+ *
112
+ * Four behaviours that are easy to be surprised by:
52
113
  *
53
- * Replaces the legacy `Agent.create({ handoffs: [...] })` option (which is
54
- * still supported as a transitional convenience while sdk-handoff is
55
- * installed — the framework lazy-imports the tool-injector at runtime).
114
+ * - **`maxHandoffDepth: 0`, or an empty `targets`, registers NOTHING** and returns a plugin that
115
+ * silently does nothing. There is no error and no warning; the model simply never sees a
116
+ * transfer tool.
117
+ * - **Registration is awaited.** `register()` returns a promise that settles once the tools are
118
+ * registered, and the plugin manager awaits it — so the transfer tools exist before the first
119
+ * `send()`, and a failure in the lazy import or in target validation reaches the caller.
120
+ * Before #355 it returned immediately and both of those were untrue.
121
+ * - **The receiver gets the user's LAST message, not the whole conversation.** The tool handler
122
+ * forwards the supervisor's transcript, from which the dispatcher takes the most recent user
123
+ * turn (#354 — before that it forwarded nothing, and the receiver was sent the literal string
124
+ * `` `(Handoff from <sender> — no prior user message in history.)` ``). That placeholder is
125
+ * still what a receiver gets when there genuinely is no prior user turn. Anything beyond the
126
+ * last question has to be in the target's own system prompt, or you drive the handoff yourself
127
+ * with {@link handoffTo}, which passes an explicit message through.
128
+ * - **The handoff tool never throws at the caller.** Every failure — loop detected, depth
129
+ * exceeded, disposed receiver, `isEnabled` false, input that fails `inputType` — is caught
130
+ * inside the tool handler and returned to the MODEL as
131
+ * `{"ok":false,"error":"<ErrorName>","message":"…"}`. The exported error classes are real, but
132
+ * in this wiring they never reach your `try`/`catch`; watch the tool results instead.
56
133
  *
57
- * @example
58
- * const support = await Agent.create({
59
- * name: "support",
60
- * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
61
- * });
134
+ * A self-referencing target and two targets resolving to the same tool name are both rejected —
135
+ * from `register`, which the plugin manager awaits — so `HandoffSelfReferenceError` and
136
+ * `HandoffNameCollisionError` reject the `Agent.create` you can `catch` around (#355; they used
137
+ * to arrive as an unhandled rejection instead, leaving an agent silently without handoff tools).
62
138
  */
63
139
  static asPlugin(opts: AsPluginOptions): Plugin;
64
140
  }
65
141
  /**
66
- * Options for `Handoff.asPlugin()`. `parentAgentId` defaults to `"anonymous"`;
67
- * pass the host agent's `name` for correct loop detection in chains.
142
+ * Options for {@link Handoff.asPlugin}.
143
+ *
144
+ * @public
68
145
  */
69
146
  interface AsPluginOptions {
147
+ /**
148
+ * Agents this one may transfer to. A bare `SDKAgent` is auto-wrapped; use
149
+ * {@link Handoff.create} for a customised entry.
150
+ *
151
+ * An EMPTY array registers no tools at all and produces a plugin that does nothing — silently.
152
+ */
70
153
  readonly targets: ReadonlyArray<SDKAgent | HandoffDescriptor>;
154
+ /**
155
+ * Identity of the HOST agent, as it will appear in the chain trace. Default `"anonymous"`.
156
+ *
157
+ * Self-reference detection compares `target.agentId` against this exact string, so a default
158
+ * `"anonymous"` means an agent listing itself among `targets` is NOT caught, and the pair-loop
159
+ * guard is the only thing left between you and a recursion.
160
+ */
71
161
  readonly parentAgentId?: string;
162
+ /**
163
+ * Maximum hops in one chain before `HandoffLoopError`. Default 5. `0` disables handoffs entirely
164
+ * rather than allowing zero hops.
165
+ *
166
+ * The counter is created FRESH for each tool invocation, so it bounds one dispatch, not the whole
167
+ * `send()` — cross-tool depth accumulation is not implemented. In practice a single invocation
168
+ * makes one hop, so this rarely fires; the pair guard (same sender → same receiver twice) is what
169
+ * actually catches ping-pong.
170
+ */
72
171
  readonly maxHandoffDepth?: number;
73
172
  }
74
173
  /**
75
- * Imperative escape hatch (D225). Useful for tests / programmatic flows
76
- * that need deterministic handoff without LLM routing.
174
+ * Hand `message` to `target` right now and return its reply text — no LLM routing, no tool call.
175
+ *
176
+ * ```ts
177
+ * const reply = await handoffTo(triage, billing, "refund for order 42");
178
+ * ```
179
+ *
180
+ * Use it for tests and for flows where YOU decide the destination. Unlike the plugin wiring, this
181
+ * passes the message through verbatim, so the receiver actually sees what the user said.
77
182
  *
78
- * NOTE: this is a STANDALONE helper rather than a method on `SDKAgent`
79
- * to avoid invasive refactor of the agent class. Behavior is identical
80
- * to invoking the corresponding synthetic tool would be.
183
+ * It THROWS, where the tool-based path swallows: a disposed receiver raises
184
+ * `HandoffReceiverDisposedError`, `isEnabled: false` raises a plain `Error`, and an
185
+ * `inputType` that rejects raises a plain `Error` wrapping the Zod message. Depth is fixed at 5 and
186
+ * the chain state is fresh per call, so `HandoffLoopError` is unreachable here and only the
187
+ * same-pair guard can fire — within a single call, never across calls.
188
+ *
189
+ * A receiver that does not finish cleanly does not throw either: you get the sentinel string
190
+ * `` `(Handoff target <id> returned status=<status>)` `` as the reply. Check for it if the
191
+ * distinction matters.
192
+ *
193
+ * Standalone rather than a method on `SDKAgent` so the agent class need not know about handoffs.
194
+ *
195
+ * @public
81
196
  */
82
197
  declare function handoffTo(sender: SDKAgent, target: SDKAgent, message: string, options?: HandoffOptions): Promise<string>;
83
198
 
84
- export { type AsPluginOptions, Handoff, HandoffDescriptor, HandoffOptions, RECOMMENDED_HANDOFF_PROMPT_PREFIX, handoffTo };
199
+ export { type AsPluginOptions, Handoff, type HandoffDescriptor, type HandoffOptions, RECOMMENDED_HANDOFF_PROMPT_PREFIX, handoffTo };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'module';
2
2
  import { z, toJSONSchema } from 'zod';
3
- import { Plugin } from '@theokit/sdk';
3
+ import { ConfigurationError, Plugin } from '@theokit/sdk';
4
4
 
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -17,6 +17,17 @@ var __export = (target, all) => {
17
17
  __defProp(target, name, { get: all[name], enumerable: true });
18
18
  };
19
19
 
20
+ // src/internal/slugify-agent-name.ts
21
+ function slugifyAgentName(candidate) {
22
+ return candidate.slice(0, MAX_INPUT_LENGTH).replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
23
+ }
24
+ var MAX_INPUT_LENGTH;
25
+ var init_slugify_agent_name = __esm({
26
+ "src/internal/slugify-agent-name.ts"() {
27
+ MAX_INPUT_LENGTH = 1024;
28
+ }
29
+ });
30
+
20
31
  // src/types/handoff.ts
21
32
  var HandoffLoopError, HandoffPairLoopError, HandoffSelfReferenceError, HandoffReceiverDisposedError, HandoffNameCollisionError;
22
33
  var init_handoff = __esm({
@@ -230,11 +241,15 @@ function extractUserText(content) {
230
241
  const text = content.filter((c) => c?.type === "text").map((c) => c.text).join("\n");
231
242
  return text.length > 0 ? text : void 0;
232
243
  }
244
+ function userTextOf(entry) {
245
+ const m = entry;
246
+ if (m?.type === "user" && m.message?.role === "user") return extractUserText(m.message.content);
247
+ if (m?.role === "user") return extractUserText(m.content);
248
+ return void 0;
249
+ }
233
250
  function extractLastUserMessage(history, senderAgentId) {
234
251
  for (let i = history.messages.length - 1; i >= 0; i -= 1) {
235
- const m = history.messages[i];
236
- if (m?.type !== "user" || m.message?.role !== "user") continue;
237
- const text = extractUserText(m.message.content);
252
+ const text = userTextOf(history.messages[i]);
238
253
  if (text !== void 0) return text;
239
254
  }
240
255
  return `(Handoff from ${senderAgentId} \u2014 no prior user message in history.)`;
@@ -267,7 +282,11 @@ async function dispatchHandoff(args) {
267
282
  toolName: descriptor.resolvedToolName
268
283
  });
269
284
  try {
270
- const run = await receiver.send(lastUserMessage);
285
+ const toolAllowlist = descriptor.options.tools;
286
+ const run = await receiver.send(
287
+ lastUserMessage,
288
+ toolAllowlist !== void 0 ? { activeTools: [...toolAllowlist] } : {}
289
+ );
271
290
  const result = await run.wait();
272
291
  const reply = buildReply(result, receiver.agentId);
273
292
  return {
@@ -346,10 +365,7 @@ function autoWrap(agent) {
346
365
  }
347
366
  function resolveTargetName(agent) {
348
367
  const candidate = agent.name ?? agent.agentId ?? "anonymous";
349
- return slugify(candidate);
350
- }
351
- function slugify(input) {
352
- return input.replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
368
+ return slugifyAgentName(candidate);
353
369
  }
354
370
  function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
355
371
  const description = descriptor.options.toolDescription ?? `Transfer the conversation to the ${descriptor.target.agentId} agent. Use this when the user's request matches their specialty.`;
@@ -361,7 +377,7 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
361
377
  name: descriptor.resolvedToolName,
362
378
  description,
363
379
  inputSchema,
364
- handler: async (input) => {
380
+ handler: async (input, ctx) => {
365
381
  const chainState = createChainState(parentAgentId, maxHandoffDepth);
366
382
  try {
367
383
  const { reply, result } = await dispatchHandoff({
@@ -369,8 +385,12 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
369
385
  senderAgentId: parentAgentId,
370
386
  chainState,
371
387
  rawInputJson: input,
372
- history: { messages: [] }
373
- // v1: history replay deferred
388
+ // #354 the supervisor's transcript, which the SDK hands every tool handler as
389
+ // `ctx.messages`. This used to be `{ messages: [] }` with the note "v1: history replay
390
+ // deferred", so the dispatcher found no user message and sent the receiver the
391
+ // placeholder instead of the question — and `inputFilter`, the documented redaction
392
+ // hook, was handed an empty transcript to redact.
393
+ history: { messages: ctx?.messages ?? [] }
374
394
  });
375
395
  return JSON.stringify({
376
396
  ok: true,
@@ -393,9 +413,13 @@ var init_tool_injector = __esm({
393
413
  init_handoff();
394
414
  init_dispatcher();
395
415
  init_registry();
416
+ init_slugify_agent_name();
396
417
  init_to_json_schema();
397
418
  }
398
419
  });
420
+
421
+ // src/handoff.ts
422
+ init_slugify_agent_name();
399
423
  var RECOMMENDED_HANDOFF_PROMPT_PREFIX = `
400
424
  You can transfer the conversation to other specialist agents when their
401
425
  expertise matches the user's request. Invoke the appropriate
@@ -406,19 +430,39 @@ var Handoff = class {
406
430
  constructor() {
407
431
  }
408
432
  /**
409
- * Build a `HandoffDescriptor` for a target agent. Wrap with custom options
410
- * (filter / inputType / callback / whitelist / etc); pass to
411
- * `Agent.create({ handoffs: [...] })`.
433
+ * Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
434
+ * `Agent.create({ handoffs })`.
412
435
  *
413
- * Raw `SDKAgent` instances in `handoffs[]` are auto-wrapped by the runtime
414
- * — call `Handoff.create()` explicitly only when you need to customize.
436
+ * ```ts
437
+ * Handoff.create(billing, { toolName: "escalate_billing" })
438
+ * ```
439
+ *
440
+ * A bare `SDKAgent` in either array is auto-wrapped with empty options, so call this explicitly
441
+ * only to customise — see {@link HandoffOptions}, and note that `tools` there is currently
442
+ * ignored.
443
+ *
444
+ * The tool the model sees is named `transfer_to_<slug>`, where the slug comes from the target's
445
+ * `name` (falling back to its `agentId`, then to `"anonymous"`) with a leading `agent-` stripped,
446
+ * every run of characters OUTSIDE `[A-Za-z0-9_-]` folded to a single `_`, leading and trailing
447
+ * `_` trimmed, and a 64-char truncation. Hyphens and underscores are preserved, so `"billing EU"`
448
+ * and `"billing (EU)"` both become `billing_EU` while `"billing-EU"` stays distinct. Two targets
449
+ * whose names collapse to the same slug are NOT caught here — the collision is raised later, when
450
+ * the set is normalised.
451
+ *
452
+ * Throws `ConfigurationError` with `code: "handoff_target_required"` for a null/undefined target,
453
+ * and `code: "handoff_target_invalid"` for anything without a `send` method. It validates the
454
+ * target only, never the options.
415
455
  */
416
456
  static create(target, options = {}) {
417
457
  if (target === void 0 || target === null) {
418
- throw new Error("Handoff.create: target agent is required");
458
+ throw new ConfigurationError("Handoff.create: target agent is required", {
459
+ code: "handoff_target_required"
460
+ });
419
461
  }
420
462
  if (typeof target.send !== "function") {
421
- throw new Error("Handoff.create: target must be an SDKAgent instance");
463
+ throw new ConfigurationError("Handoff.create: target must be an SDKAgent instance", {
464
+ code: "handoff_target_invalid"
465
+ });
422
466
  }
423
467
  const resolvedToolName = options.toolName ?? `transfer_to_${slugifyName(target)}`;
424
468
  return {
@@ -428,19 +472,47 @@ var Handoff = class {
428
472
  };
429
473
  }
430
474
  /**
431
- * Plugin-based wiring (SDK 2.x preferred). Wraps `targets` in synthetic
432
- * `transfer_to_<receiver>` tools and registers them via `ctx.registerTool`
433
- * at agent init time.
475
+ * Expose each target as a `transfer_to_<receiver>` tool on the host agent — the SDK 2.x way to
476
+ * wire handoffs.
477
+ *
478
+ * ```ts
479
+ * const support = await Agent.create({
480
+ * name: "support",
481
+ * systemPrompt: `${RECOMMENDED_HANDOFF_PROMPT_PREFIX}\n\nYou answer support requests.`,
482
+ * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
483
+ * });
484
+ * ```
485
+ *
486
+ * Pass `parentAgentId` — it defaults to `"anonymous"`, and it is what self-reference detection
487
+ * and the chain trace compare against, so leaving it out weakens both. `maxHandoffDepth` defaults
488
+ * to 5.
489
+ *
490
+ * Four behaviours that are easy to be surprised by:
434
491
  *
435
- * Replaces the legacy `Agent.create({ handoffs: [...] })` option (which is
436
- * still supported as a transitional convenience while sdk-handoff is
437
- * installed — the framework lazy-imports the tool-injector at runtime).
492
+ * - **`maxHandoffDepth: 0`, or an empty `targets`, registers NOTHING** and returns a plugin that
493
+ * silently does nothing. There is no error and no warning; the model simply never sees a
494
+ * transfer tool.
495
+ * - **Registration is awaited.** `register()` returns a promise that settles once the tools are
496
+ * registered, and the plugin manager awaits it — so the transfer tools exist before the first
497
+ * `send()`, and a failure in the lazy import or in target validation reaches the caller.
498
+ * Before #355 it returned immediately and both of those were untrue.
499
+ * - **The receiver gets the user's LAST message, not the whole conversation.** The tool handler
500
+ * forwards the supervisor's transcript, from which the dispatcher takes the most recent user
501
+ * turn (#354 — before that it forwarded nothing, and the receiver was sent the literal string
502
+ * `` `(Handoff from <sender> — no prior user message in history.)` ``). That placeholder is
503
+ * still what a receiver gets when there genuinely is no prior user turn. Anything beyond the
504
+ * last question has to be in the target's own system prompt, or you drive the handoff yourself
505
+ * with {@link handoffTo}, which passes an explicit message through.
506
+ * - **The handoff tool never throws at the caller.** Every failure — loop detected, depth
507
+ * exceeded, disposed receiver, `isEnabled` false, input that fails `inputType` — is caught
508
+ * inside the tool handler and returned to the MODEL as
509
+ * `{"ok":false,"error":"<ErrorName>","message":"…"}`. The exported error classes are real, but
510
+ * in this wiring they never reach your `try`/`catch`; watch the tool results instead.
438
511
  *
439
- * @example
440
- * const support = await Agent.create({
441
- * name: "support",
442
- * plugins: [Handoff.asPlugin({ parentAgentId: "support", targets: [billing] })],
443
- * });
512
+ * A self-referencing target and two targets resolving to the same tool name are both rejected —
513
+ * from `register`, which the plugin manager awaits — so `HandoffSelfReferenceError` and
514
+ * `HandoffNameCollisionError` reject the `Agent.create` you can `catch` around (#355; they used
515
+ * to arrive as an unhandled rejection instead, leaving an agent silently without handoff tools).
444
516
  */
445
517
  static asPlugin(opts) {
446
518
  const parent = opts.parentAgentId ?? "anonymous";
@@ -450,22 +522,29 @@ var Handoff = class {
450
522
  name: `handoff-${parent}`,
451
523
  version: "1.0.0",
452
524
  kind: "general",
453
- register(ctx) {
525
+ // #355 — `async`, and the promise is RETURNED. The plugin contract types `register` as
526
+ // `(ctx) => void | Promise<void>` and the manager awaits it, so returning it is all this
527
+ // needed. It used to run an unawaited async IIFE and return immediately: the tools appeared a
528
+ // module-load later, so whether they existed for the first `send()` depended on timing no
529
+ // caller controls, and `normalizeHandoffs`' validation errors became unhandled rejections
530
+ // that could not be caught around `Agent.create`.
531
+ //
532
+ // The import stays lazy — deferring it until `register` runs is what keeps the cold path
533
+ // lean, and that never required leaving it unawaited INSIDE `register`.
534
+ async register(ctx) {
454
535
  if (maxDepth === 0 || targets.length === 0) return;
455
- void (async () => {
456
- const { normalizeHandoffs: normalizeHandoffs2, buildHandoffTool: buildHandoffTool2 } = await Promise.resolve().then(() => (init_tool_injector(), tool_injector_exports));
457
- const normalized = normalizeHandoffs2(parent, targets);
458
- for (const { descriptor } of normalized) {
459
- ctx.registerTool(buildHandoffTool2(parent, descriptor, maxDepth));
460
- }
461
- })();
536
+ const { normalizeHandoffs: normalizeHandoffs2, buildHandoffTool: buildHandoffTool2 } = await Promise.resolve().then(() => (init_tool_injector(), tool_injector_exports));
537
+ const normalized = normalizeHandoffs2(parent, targets);
538
+ for (const { descriptor } of normalized) {
539
+ ctx.registerTool(buildHandoffTool2(parent, descriptor, maxDepth));
540
+ }
462
541
  }
463
542
  });
464
543
  }
465
544
  };
466
545
  function slugifyName(agent) {
467
546
  const candidate = agent.name ?? agent.agentId ?? "anonymous";
468
- return candidate.replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
547
+ return slugifyAgentName(candidate);
469
548
  }
470
549
  async function handoffTo(sender, target, message, options = {}) {
471
550
  const descriptor = Handoff.create(target, options);