@theokit/sdk-handoff 0.1.1 → 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/CHANGELOG.md +259 -0
- package/LICENSE +2 -2
- package/README.md +14 -1
- package/dist/handoff-D7malWe_.d.cts +255 -0
- package/dist/handoff-D7malWe_.d.ts +255 -0
- package/dist/index.cjs +124 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +143 -28
- package/dist/index.d.ts +143 -28
- package/dist/index.js +125 -41
- package/dist/index.js.map +1 -1
- package/dist/internal/tool-injector.cjs +26 -11
- package/dist/internal/tool-injector.cjs.map +1 -1
- package/dist/internal/tool-injector.d.cts +2 -2
- package/dist/internal/tool-injector.d.ts +2 -2
- package/dist/internal/tool-injector.js +26 -11
- package/dist/internal/tool-injector.js.map +1 -1
- package/package.json +14 -6
- package/dist/handoff-D-Ujv-lA.d.cts +0 -131
- package/dist/handoff-D-Ujv-lA.d.ts +0 -131
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-
|
|
4
|
-
export { b as HandoffLoopError, c as HandoffNameCollisionError, d as HandoffPairLoopError, e as HandoffReceiverDisposedError, f as HandoffSelfReferenceError } from './handoff-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
41
|
-
* (
|
|
42
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
67
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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,17 +1,33 @@
|
|
|
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;
|
|
7
|
-
var __esm = (fn, res) => function __init() {
|
|
8
|
-
|
|
7
|
+
var __esm = (fn, res, err) => function __init() {
|
|
8
|
+
if (err) throw err[0];
|
|
9
|
+
try {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
throw err = [e], e;
|
|
13
|
+
}
|
|
9
14
|
};
|
|
10
15
|
var __export = (target, all) => {
|
|
11
16
|
for (var name in all)
|
|
12
17
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
13
18
|
};
|
|
14
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
|
+
|
|
15
31
|
// src/types/handoff.ts
|
|
16
32
|
var HandoffLoopError, HandoffPairLoopError, HandoffSelfReferenceError, HandoffReceiverDisposedError, HandoffNameCollisionError;
|
|
17
33
|
var init_handoff = __esm({
|
|
@@ -225,11 +241,15 @@ function extractUserText(content) {
|
|
|
225
241
|
const text = content.filter((c) => c?.type === "text").map((c) => c.text).join("\n");
|
|
226
242
|
return text.length > 0 ? text : void 0;
|
|
227
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
|
+
}
|
|
228
250
|
function extractLastUserMessage(history, senderAgentId) {
|
|
229
251
|
for (let i = history.messages.length - 1; i >= 0; i -= 1) {
|
|
230
|
-
const
|
|
231
|
-
if (m?.type !== "user" || m.message?.role !== "user") continue;
|
|
232
|
-
const text = extractUserText(m.message.content);
|
|
252
|
+
const text = userTextOf(history.messages[i]);
|
|
233
253
|
if (text !== void 0) return text;
|
|
234
254
|
}
|
|
235
255
|
return `(Handoff from ${senderAgentId} \u2014 no prior user message in history.)`;
|
|
@@ -262,7 +282,11 @@ async function dispatchHandoff(args) {
|
|
|
262
282
|
toolName: descriptor.resolvedToolName
|
|
263
283
|
});
|
|
264
284
|
try {
|
|
265
|
-
const
|
|
285
|
+
const toolAllowlist = descriptor.options.tools;
|
|
286
|
+
const run = await receiver.send(
|
|
287
|
+
lastUserMessage,
|
|
288
|
+
toolAllowlist !== void 0 ? { activeTools: [...toolAllowlist] } : {}
|
|
289
|
+
);
|
|
266
290
|
const result = await run.wait();
|
|
267
291
|
const reply = buildReply(result, receiver.agentId);
|
|
268
292
|
return {
|
|
@@ -341,10 +365,7 @@ function autoWrap(agent) {
|
|
|
341
365
|
}
|
|
342
366
|
function resolveTargetName(agent) {
|
|
343
367
|
const candidate = agent.name ?? agent.agentId ?? "anonymous";
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
function slugify(input) {
|
|
347
|
-
return input.replace(/^agent-/i, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "anonymous";
|
|
368
|
+
return slugifyAgentName(candidate);
|
|
348
369
|
}
|
|
349
370
|
function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
|
|
350
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.`;
|
|
@@ -356,7 +377,7 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
|
|
|
356
377
|
name: descriptor.resolvedToolName,
|
|
357
378
|
description,
|
|
358
379
|
inputSchema,
|
|
359
|
-
handler: async (input) => {
|
|
380
|
+
handler: async (input, ctx) => {
|
|
360
381
|
const chainState = createChainState(parentAgentId, maxHandoffDepth);
|
|
361
382
|
try {
|
|
362
383
|
const { reply, result } = await dispatchHandoff({
|
|
@@ -364,8 +385,12 @@ function buildHandoffTool(parentAgentId, descriptor, maxHandoffDepth) {
|
|
|
364
385
|
senderAgentId: parentAgentId,
|
|
365
386
|
chainState,
|
|
366
387
|
rawInputJson: input,
|
|
367
|
-
|
|
368
|
-
// v1: history replay
|
|
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 ?? [] }
|
|
369
394
|
});
|
|
370
395
|
return JSON.stringify({
|
|
371
396
|
ok: true,
|
|
@@ -388,9 +413,13 @@ var init_tool_injector = __esm({
|
|
|
388
413
|
init_handoff();
|
|
389
414
|
init_dispatcher();
|
|
390
415
|
init_registry();
|
|
416
|
+
init_slugify_agent_name();
|
|
391
417
|
init_to_json_schema();
|
|
392
418
|
}
|
|
393
419
|
});
|
|
420
|
+
|
|
421
|
+
// src/handoff.ts
|
|
422
|
+
init_slugify_agent_name();
|
|
394
423
|
var RECOMMENDED_HANDOFF_PROMPT_PREFIX = `
|
|
395
424
|
You can transfer the conversation to other specialist agents when their
|
|
396
425
|
expertise matches the user's request. Invoke the appropriate
|
|
@@ -401,19 +430,39 @@ var Handoff = class {
|
|
|
401
430
|
constructor() {
|
|
402
431
|
}
|
|
403
432
|
/**
|
|
404
|
-
*
|
|
405
|
-
* (
|
|
406
|
-
*
|
|
433
|
+
* Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
|
|
434
|
+
* `Agent.create({ handoffs })`.
|
|
435
|
+
*
|
|
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.
|
|
407
451
|
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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.
|
|
410
455
|
*/
|
|
411
456
|
static create(target, options = {}) {
|
|
412
457
|
if (target === void 0 || target === null) {
|
|
413
|
-
throw new
|
|
458
|
+
throw new ConfigurationError("Handoff.create: target agent is required", {
|
|
459
|
+
code: "handoff_target_required"
|
|
460
|
+
});
|
|
414
461
|
}
|
|
415
462
|
if (typeof target.send !== "function") {
|
|
416
|
-
throw new
|
|
463
|
+
throw new ConfigurationError("Handoff.create: target must be an SDKAgent instance", {
|
|
464
|
+
code: "handoff_target_invalid"
|
|
465
|
+
});
|
|
417
466
|
}
|
|
418
467
|
const resolvedToolName = options.toolName ?? `transfer_to_${slugifyName(target)}`;
|
|
419
468
|
return {
|
|
@@ -423,19 +472,47 @@ var Handoff = class {
|
|
|
423
472
|
};
|
|
424
473
|
}
|
|
425
474
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
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:
|
|
429
491
|
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
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.
|
|
433
511
|
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* });
|
|
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).
|
|
439
516
|
*/
|
|
440
517
|
static asPlugin(opts) {
|
|
441
518
|
const parent = opts.parentAgentId ?? "anonymous";
|
|
@@ -445,22 +522,29 @@ var Handoff = class {
|
|
|
445
522
|
name: `handoff-${parent}`,
|
|
446
523
|
version: "1.0.0",
|
|
447
524
|
kind: "general",
|
|
448
|
-
register
|
|
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) {
|
|
449
535
|
if (maxDepth === 0 || targets.length === 0) return;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
})();
|
|
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
|
+
}
|
|
457
541
|
}
|
|
458
542
|
});
|
|
459
543
|
}
|
|
460
544
|
};
|
|
461
545
|
function slugifyName(agent) {
|
|
462
546
|
const candidate = agent.name ?? agent.agentId ?? "anonymous";
|
|
463
|
-
return candidate
|
|
547
|
+
return slugifyAgentName(candidate);
|
|
464
548
|
}
|
|
465
549
|
async function handoffTo(sender, target, message, options = {}) {
|
|
466
550
|
const descriptor = Handoff.create(target, options);
|