@theokit/sdk-handoff 0.1.2 → 0.1.4
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 +268 -0
- package/LICENSE +2 -2
- package/README.md +13 -0
- package/dist/handoff-D7malWe_.d.cts +255 -0
- package/dist/handoff-D7malWe_.d.ts +255 -0
- package/dist/index.cjs +117 -38
- 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 +118 -39
- 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 +15 -7
- 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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
373
|
-
// 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 ?? [] }
|
|
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
|
-
*
|
|
410
|
-
* (
|
|
411
|
-
* `Agent.create({ handoffs: [...] })`.
|
|
433
|
+
* Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
|
|
434
|
+
* `Agent.create({ handoffs })`.
|
|
412
435
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
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
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
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
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
547
|
+
return slugifyAgentName(candidate);
|
|
469
548
|
}
|
|
470
549
|
async function handoffTo(sender, target, message, options = {}) {
|
|
471
550
|
const descriptor = Handoff.create(target, options);
|