@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/CHANGELOG.md +239 -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 +14 -6
- package/dist/handoff-D-Ujv-lA.d.cts +0 -131
- package/dist/handoff-D-Ujv-lA.d.ts +0 -131
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
376
|
-
// v1: history replay
|
|
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
|
-
*
|
|
413
|
-
* (
|
|
414
|
-
* `Agent.create({ handoffs: [...] })`.
|
|
436
|
+
* Describe one handoff target. Pass the result inside `Handoff.asPlugin({ targets })` or
|
|
437
|
+
* `Agent.create({ handoffs })`.
|
|
415
438
|
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
550
|
+
return slugifyAgentName(candidate);
|
|
472
551
|
}
|
|
473
552
|
async function handoffTo(sender, target, message, options = {}) {
|
|
474
553
|
const descriptor = Handoff.create(target, options);
|