@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,273 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1a4bbcf: The declared `@theokit/sdk` peer ranges stop promising versions the packages do not compile against.
|
|
8
|
+
|
|
9
|
+
All three declared `>=4.0.0`. `4.0.1` is the lowest published version that range admits — what a
|
|
10
|
+
consumer pinning conservatively, or resolving under an older transitive constraint, lands on. npm
|
|
11
|
+
resolves the combination with no `ERESOLVE` and no peer warning, and the build then fails on
|
|
12
|
+
`TS2552: Cannot find name` and `TS2305: has no exported member`.
|
|
13
|
+
|
|
14
|
+
The floors were measured by bisecting the 116 stable 4.x releases with a real build as the oracle.
|
|
15
|
+
Each one has its immediately preceding version failing, so these are exact versions rather than
|
|
16
|
+
intervals:
|
|
17
|
+
|
|
18
|
+
| package | floor | evidence |
|
|
19
|
+
| ---------------------- | ---------- | ------------------------------- |
|
|
20
|
+
| `@theokit/sdk-budget` | `>=4.54.0` | `4.53.1` fails, `4.54.0` passes |
|
|
21
|
+
| `@theokit/sdk-handoff` | `>=4.54.0` | `4.53.1` fails, `4.54.0` passes |
|
|
22
|
+
| `@theokit/sdk-memory` | `>=4.53.1` | `4.53.0` fails, `4.53.1` passes |
|
|
23
|
+
|
|
24
|
+
`sdk-memory` sits one release below the other two: this is not one shared migration, it is three
|
|
25
|
+
packages that each drifted past their own declared floor.
|
|
26
|
+
|
|
27
|
+
The oracle deletes every `dist/` before building. Without that the build reads a sibling's output
|
|
28
|
+
compiled against a different version, which is how a package "passes" against an SDK missing its
|
|
29
|
+
symbols — the failure mode that made the earlier measurement disagree with CI
|
|
30
|
+
(usetheokit/theokit-sdk#423).
|
|
31
|
+
|
|
32
|
+
## 0.1.3
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- e3f2a82: Public-API documentation reviewed file by file, and corrected wherever it disagreed
|
|
37
|
+
with the code. The docblocks ship in the `.d.ts`, so these read as behaviour changes
|
|
38
|
+
in an editor even though no behaviour changed.
|
|
39
|
+
|
|
40
|
+
The corrections that change what a caller would do:
|
|
41
|
+
|
|
42
|
+
- **`sdk-cache` documented its own premise backwards.** The header example labelled a
|
|
43
|
+
semantic hit as if it avoided the provider call. `asPlugin()` returns the cached
|
|
44
|
+
answer as `recalledContext`, which the agent loop injects as a `<memory-context>`
|
|
45
|
+
block _before_ the prompt — the request still goes to the provider. The two modes
|
|
46
|
+
are now labelled separately, with a table saying which one short-circuits and which
|
|
47
|
+
one seeds.
|
|
48
|
+
- **`sdk-handoff`'s five error classes said "throw".** Under the plugin wiring the
|
|
49
|
+
handler never throws; every failure becomes a tool result `{"ok":false,…}` handed
|
|
50
|
+
back to the model. Each class now says where it is actually observable. The header
|
|
51
|
+
also told readers to `import { Handoff } from "@theokit/sdk"`, from which it was
|
|
52
|
+
extracted.
|
|
53
|
+
- **`sdk-budget`'s `charge()` claimed idempotency across concurrent calls.** The mutex
|
|
54
|
+
serialises, it does not deduplicate: two identical calls record twice. Related, and
|
|
55
|
+
newly documented: with `maxUsd` set, a model missing from the pricing table denies
|
|
56
|
+
every request rather than passing it — and the table matches by exact string, so
|
|
57
|
+
`"openai/gpt-4o"` does not match `"gpt-4o"`.
|
|
58
|
+
- **The three `memory-*` adapters advertised an env-var fallback they do not read**,
|
|
59
|
+
and their peer dependencies are required rather than optional. Their behavioural
|
|
60
|
+
differences are now stated where they break the "interchangeable adapter"
|
|
61
|
+
assumption — honcho ignores `k` and always throws on `delete`; mem0 recalls across
|
|
62
|
+
sessions by design; supermemory ignores `sessionId` entirely.
|
|
63
|
+
- **`sdk-memory`'s `truncated` flag was documented as its own inverse**, and its
|
|
64
|
+
dreaming sweep claimed a mutex it never takes against the writer it names.
|
|
65
|
+
- **`sdk-tools`** corrected `run_vitest`'s unreachable `no_vitest` code, `truncation`'s
|
|
66
|
+
replacement-character claim, and two return shapes missing a live error code.
|
|
67
|
+
- **`acp`/`cli`** corrected sixteen statements including a named error class that is
|
|
68
|
+
not the one raised, a handler documented as calling `fork()` that refuses
|
|
69
|
+
unconditionally, handlers described as pure that mint ids and mutate a store, a
|
|
70
|
+
config loader credited to Zod in a package that does not import it, and a `--force`
|
|
71
|
+
scaffold described as atomic that deletes the destination before the rename.
|
|
72
|
+
|
|
73
|
+
Undocumented public symbols were documented across every package, with each claim
|
|
74
|
+
checked against the implementation rather than inferred from the name.
|
|
75
|
+
|
|
76
|
+
- e368fc1: Every published declaration file now compiles without `skipLibCheck` (#345). The
|
|
77
|
+
DTS rollup emitted symbols as a re-export from a chunk while omitting them from
|
|
78
|
+
that chunk's `import`, and dropped type-only imports from external packages —
|
|
79
|
+
leaving 51 unresolved references across ten of the twelve packages. Nothing broke
|
|
80
|
+
at runtime, and `tsc` stayed green for anyone with `skipLibCheck` on, but a
|
|
81
|
+
consumer running type-aware lint saw every type reached through one degrade to
|
|
82
|
+
`error`.
|
|
83
|
+
|
|
84
|
+
The declarations are repaired at build time from the compiler's own diagnostics.
|
|
85
|
+
No source or API change.
|
|
86
|
+
|
|
87
|
+
- 5742ab2: `Handoff.asPlugin(...).register()` now returns a promise that settles once the transfer tools are
|
|
88
|
+
registered.
|
|
89
|
+
|
|
90
|
+
It used to start an unawaited async IIFE and return, so the tools appeared a module-load later —
|
|
91
|
+
whether they existed for the first `send()` depended on timing no caller controlled — and
|
|
92
|
+
`HandoffSelfReferenceError` / `HandoffNameCollisionError` became unhandled rejections that could not
|
|
93
|
+
be caught around `Agent.create`, leaving an agent silently without handoff tools. The plugin
|
|
94
|
+
contract already typed `register` as returning `void | Promise<void>` and the manager already
|
|
95
|
+
awaited it. The import stays lazy.
|
|
96
|
+
|
|
97
|
+
- 33524a5: A handoff now tells the receiving agent what the user asked.
|
|
98
|
+
|
|
99
|
+
Both wirings built the transfer tool through a handler that dispatched with an empty transcript, so
|
|
100
|
+
the receiver was sent the literal string `(Handoff from <sender> — no prior user message in
|
|
101
|
+
history.)` and answered from that plus its own system prompt. The handler now forwards the
|
|
102
|
+
supervisor's transcript, which the SDK hands every tool handler, and the dispatcher takes the last
|
|
103
|
+
user turn from it.
|
|
104
|
+
|
|
105
|
+
`HandoffOptions.inputFilter` was dead in the same way — invoked, always with nothing to filter, so a
|
|
106
|
+
caller who wired a redactor believed the transcript was being scrubbed. It now receives the real
|
|
107
|
+
transcript, and dropping a message there does keep it from the receiver.
|
|
108
|
+
|
|
109
|
+
- 9f0362c: `Handoff.create()` refusals now carry an error class and a stable code.
|
|
110
|
+
|
|
111
|
+
Both of its guards raised a bare error, so a caller wanting to distinguish "no target given" from
|
|
112
|
+
"the target is not an agent" had only the message text to match on — and message text is not a
|
|
113
|
+
contract. It changes whenever someone improves the wording, and nothing tells the consumer their
|
|
114
|
+
check stopped working.
|
|
115
|
+
|
|
116
|
+
Each refusal is now a `ConfigurationError` with its own code. The change is additive rather than
|
|
117
|
+
breaking: the error class extends `Error`, so anything catching `Error` still catches it, and both
|
|
118
|
+
messages are unchanged.
|
|
119
|
+
|
|
120
|
+
- 1484293: The schema converter that decides what a model is shown now has tests.
|
|
121
|
+
|
|
122
|
+
It converts a handoff's input schema into the JSON Schema the model receives, and it had no coverage
|
|
123
|
+
at all. A defect there is not a crash — the model is shown the wrong contract, then fails to satisfy
|
|
124
|
+
it for reasons no error message explains.
|
|
125
|
+
|
|
126
|
+
Eleven tests now pin the emitted schema rather than the fact that a call returned: the required and
|
|
127
|
+
optional split, nested objects and arrays, enums, the primitive type mapping, and both behaviours for
|
|
128
|
+
inputs JSON Schema cannot represent.
|
|
129
|
+
|
|
130
|
+
That last pair is the wrapper's actual reason to exist, and it was verified against the schema library
|
|
131
|
+
rather than taken from the comment describing it: by default an unrepresentable input degrades to an
|
|
132
|
+
empty schema instead of throwing, which is the opposite of what the underlying library does on its
|
|
133
|
+
own. Callers who ask for the strict behaviour still get the library's own error, message included.
|
|
134
|
+
|
|
135
|
+
- c5e78c1: `HandoffOptions.tools` now restricts the receiving agent, as its name always implied.
|
|
136
|
+
|
|
137
|
+
Nothing in the package read it. A caller passing `{ tools: ["read_file"] }` — the option was
|
|
138
|
+
presented as an allowlist — got no restriction and no warning, which is worse than an absent option
|
|
139
|
+
because it gives false assurance. It is wired to `SendOptions.activeTools`, the same
|
|
140
|
+
`withToolWhitelist` path `Agent.fork`'s `allowedTools` uses: exact name matching, an empty list
|
|
141
|
+
means the empty set (fail-closed), and omitting the option imposes no restriction.
|
|
142
|
+
|
|
143
|
+
Local runtime only — a cloud agent ignores `activeTools`, and the docblock now says so.
|
|
144
|
+
|
|
145
|
+
- aadc9dd: Seventeen more negative-case tests now identify which failure they caught, and a registry test suite
|
|
146
|
+
stops sleeping to make timestamps differ.
|
|
147
|
+
|
|
148
|
+
Most of those assertions turned out to be under-asserting rather than untestable: twelve of them sat
|
|
149
|
+
on errors that were **already typed**, and simply checked that something threw. They now name the
|
|
150
|
+
class, the stable code and a message fragment — which means a change that swaps one failure for
|
|
151
|
+
another is caught, where before any error at all satisfied the test.
|
|
152
|
+
|
|
153
|
+
Four remain matched on a message fragment because the underlying error genuinely has no type yet, and
|
|
154
|
+
one of those is filed separately: a public entry point throwing a plain error gives callers nothing to
|
|
155
|
+
branch on but a string that changes whenever someone improves the wording.
|
|
156
|
+
|
|
157
|
+
Four more were reclassified out of scope after reading the source rather than the name: they raise
|
|
158
|
+
errors owned by Node, by the schema library, or by a database driver, and pinning a third-party class
|
|
159
|
+
buys little.
|
|
160
|
+
|
|
161
|
+
Separately, the live-agent-registry tests slept thirteen times — some to force last-used timestamps
|
|
162
|
+
apart so eviction ordering could be asserted, others to let fire-and-forget cleanup finish. Both are
|
|
163
|
+
now driven by the test clock, a mechanism this same file already used elsewhere and which needed no
|
|
164
|
+
production change. The file runs in a fraction of the time and no longer depends on how busy the
|
|
165
|
+
machine is.
|
|
166
|
+
|
|
167
|
+
- 3724b43: The rule that turns an agent's name into a tool-safe slug existed twice — once in `handoff.ts`,
|
|
168
|
+
once in `tool-injector.ts` — byte-identical apart from a parameter name, and covered by no test at
|
|
169
|
+
all. Two copies of one rule drift the moment either is adjusted, and nothing would have reported
|
|
170
|
+
it: a handoff tool named one way and a dispatcher expecting another.
|
|
171
|
+
|
|
172
|
+
It is now one function with tests describing the behaviour it already had: the `agent-` prefix
|
|
173
|
+
stripped case-insensitively, runs of unsafe characters collapsed to one underscore, underscores
|
|
174
|
+
trimmed from both ends, `"anonymous"` when nothing survives, and a 64-character cap. No result
|
|
175
|
+
changes.
|
|
176
|
+
|
|
177
|
+
The input is now bounded before the slug rules run. CodeQL flags one of those expressions as
|
|
178
|
+
polynomial backtracking; stated plainly, the quadratic cost **could not be reproduced** — V8
|
|
179
|
+
resolves 100,000 characters of the worst-case shape in under a millisecond. The bound is defence
|
|
180
|
+
in depth against an engine that does not optimise it, not a fix for a demonstrated exploit, and
|
|
181
|
+
nothing beyond the bound could have reached the 64-character result anyway.
|
|
182
|
+
|
|
183
|
+
- e699569: **The repository moved to the official `usetheokit` organization.** Every `repository`, `bugs` and `homepage` field now points there, along with the README, `CONTRIBUTING.md`, `SECURITY.md` and the issue templates. Existing clones and any URL already published keep working — GitHub redirects a transferred repository permanently — so this is a correctness fix for the metadata npm renders, not a break.
|
|
184
|
+
|
|
185
|
+
**The Apache-2.0 text every package ships was replaced with the official one.** The copy distributed until now had paragraph 4(d) truncated: it read "except as required for describing the origin of the Work and reproducing the content of the NOTICE file", dropping "reasonable and customary use" from the licensed clause. §4(d) governs what a redistributor must do with attribution notices, and the omission narrowed it.
|
|
186
|
+
|
|
187
|
+
That matters more than a typo would. The manifests declare the SPDX identifier `Apache-2.0`, which is an assertion that the terms are _the_ Apache-2.0 terms — a licence scanner resolves the identifier and never reads the file. A consumer's compliance review, which does read the file, would find a body that no longer matches the identifier and has no name of its own. Every `LICENSE` in this repository is now byte-identical to the canonical text, with the appendix filled in.
|
|
188
|
+
|
|
189
|
+
Nothing else about the terms changed: the licence is the same licence it has always been meant to be, and no package changes what it grants.
|
|
190
|
+
|
|
191
|
+
- e3f2a82: `@opentelemetry/api` is now declared as an optional peer dependency, so the spans these two
|
|
192
|
+
packages emit can actually reach a collector.
|
|
193
|
+
|
|
194
|
+
Both lazily `require("@opentelemetry/api")` from their own directory, but neither manifest
|
|
195
|
+
declared it in any dependency field. Under an isolated `node_modules` layout the specifier is
|
|
196
|
+
therefore not linked under the package, the require throws, the loader caches a `null` tracer,
|
|
197
|
+
and every span degrades to a no-op — silently, with no warning, unlike `@theokit/sdk`, which
|
|
198
|
+
prints one when telemetry is enabled and OTel is absent. For `sdk-cache` that covered both of
|
|
199
|
+
its main paths (`cache.lookup` on every send, `cache.store` on every reply), so an operator
|
|
200
|
+
reading a trace saw no cache activity at all and had no way to tell that from a cache that was
|
|
201
|
+
never consulted.
|
|
202
|
+
|
|
203
|
+
The declaration matches `@theokit/sdk`'s: `peerDependencies` plus `peerDependenciesMeta.optional`,
|
|
204
|
+
so nothing is installed for anyone who does not want OTel, and users who do want it get their
|
|
205
|
+
copy linked where the require can find it.
|
|
206
|
+
|
|
207
|
+
- e3f2a82: Every symbol these packages declare in `exports` now reaches the `.d.ts` they publish.
|
|
208
|
+
|
|
209
|
+
Sixty-six declarations across twenty-three published files did not compile, and four entry
|
|
210
|
+
points silently omitted names their own barrel exports — `@theokit/sdk/internal/security`
|
|
211
|
+
dropped seven at once. Runtime was never affected; this is types-only. A consumer with
|
|
212
|
+
`skipLibCheck` on saw nothing, and a consumer running type-aware lint saw every type reached
|
|
213
|
+
through one of them degrade to `error`.
|
|
214
|
+
|
|
215
|
+
The cause was `stripInternal`, which deletes a declaration when the literal `@internal`
|
|
216
|
+
appears in ANY leading comment range of it. The tag was being used here to mean "outside the
|
|
217
|
+
semver contract" — `internal/persistence/sqlite-open.ts` said so in those words, on a subpath
|
|
218
|
+
the manifest publishes and a back-compat test pins. The compiler reads it as "erase this", and
|
|
219
|
+
the two meanings only diverge in the published artifact. It now says the semver exemption in
|
|
220
|
+
prose, and the tag is gone from the symbols that are published.
|
|
221
|
+
|
|
222
|
+
Two further mechanisms had the same cause and a wider blast radius. A tag in a BARREL header
|
|
223
|
+
deleted the first `export … from` beneath it; a tag in a MODULE header deleted the following
|
|
224
|
+
`import`, so `import { z } from "zod"` vanished and every type it bound became
|
|
225
|
+
`Cannot find name`. Nothing was added to any `exports` map and no `export` line changed — a
|
|
226
|
+
deleted import was never privacy, only a broken declaration.
|
|
227
|
+
|
|
228
|
+
`@theokit/sdk-handoff`'s `./internal` entry left `SDKAgent` and `CustomTool` unbound, from a
|
|
229
|
+
different defect: the declaration repair only ever looked at `exports["."]`, so it fixed each
|
|
230
|
+
package's main entry and shipped the rest unrepaired. It now covers every declared subpath, and
|
|
231
|
+
binds the side-effect import form (`import '@theokit/sdk';`) the rollup emits with the names
|
|
232
|
+
stripped out.
|
|
233
|
+
|
|
234
|
+
Three gates were widened or added so this cannot return silently: the declaration typecheck
|
|
235
|
+
now covers all 45 published entries rather than 12, a new export-parity check fails when a
|
|
236
|
+
source barrel exports a name the emit omits, and public-API documentation coverage is gated at
|
|
237
|
+
100%.
|
|
238
|
+
|
|
239
|
+
Two consequences worth naming rather than discovering. `coerceToKnownAgentRunErrorCode` — the
|
|
240
|
+
boundary helper the 4.x release notes point at as the migration path off the open
|
|
241
|
+
`AgentRunErrorCode` union — was tagged internal and therefore absent from the published types; it
|
|
242
|
+
is now exported and documented, which is a small addition to the public surface. And
|
|
243
|
+
`packages/sdk/typedoc.json` sets `excludeInternal: true`, so the generated API reference gains the
|
|
244
|
+
~57 symbols whose tags were removed. That is the intended direction: those symbols are published,
|
|
245
|
+
and the reference now says so.
|
|
246
|
+
|
|
247
|
+
- c7385d2: Test runs no longer claim every core on the host.
|
|
248
|
+
|
|
249
|
+
None of the package configs capped `maxWorkers`, so vitest's default applied: `os.availableParallelism()`,
|
|
250
|
+
one fork per core, each booting a full test environment. The repo's `test` script is
|
|
251
|
+
`turbo run test --filter='./packages/*'`, so that default is paid once per package _concurrently_ —
|
|
252
|
+
nproc forks times turbo's concurrency, on nproc cores. Measured on a 12-thread machine during an
|
|
253
|
+
unrelated investigation, two vitest pools alone were enough to reach load average 33.89 with the
|
|
254
|
+
desktop unusable; a full fan-out is several times that.
|
|
255
|
+
|
|
256
|
+
`@theokit/sdk` is the interesting case. B-104 recorded on 2026-08-19 that the `poolOptions.forks.*`
|
|
257
|
+
block was 100% dead in Vitest 4, deleted it, and noted that `fileParallelism: false` was forcing
|
|
258
|
+
`maxWorkers` to 1 unconditionally, so a fork-count knob could not act. B-059 then flipped
|
|
259
|
+
`fileParallelism` to `true` on 2026-08-20, which made the knob able to act again — and nothing
|
|
260
|
+
reintroduced one, so the package silently went back to the uncapped default. That comment has been
|
|
261
|
+
corrected along with the config; it claimed no knob existed, which is no longer true.
|
|
262
|
+
|
|
263
|
+
The cap leaves 4 cores free (`Math.max(2, cpus().length - 4)`), scaling with the runner rather than
|
|
264
|
+
hard-coding one machine's core count. It costs no wall-clock: measured in `theokit-ui`, the full
|
|
265
|
+
suite ran 73.96s at 4 workers against 74.36s at 12, so the parallelism above the cap was already
|
|
266
|
+
noise. Verified as resolved config rather than as file contents — `createVitest` reports
|
|
267
|
+
`maxWorkers: 8` on a 12-thread host, which is the formula, not the default.
|
|
268
|
+
|
|
269
|
+
This changes no published behaviour; it is test tooling only. Refs usetheokit/theokit-ui#51.
|
|
270
|
+
|
|
3
271
|
## 0.1.2
|
|
4
272
|
|
|
5
273
|
### Patch Changes
|
package/LICENSE
CHANGED
|
@@ -137,8 +137,8 @@
|
|
|
137
137
|
|
|
138
138
|
6. Trademarks. This License does not grant permission to use the trade
|
|
139
139
|
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
-
except as required for
|
|
141
|
-
reproducing the content of the NOTICE file.
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
142
|
|
|
143
143
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
144
|
agreed to in writing, Licensor provides the Work (and each
|
package/README.md
CHANGED
|
@@ -91,6 +91,19 @@ const support = await Agent.create({
|
|
|
91
91
|
|
|
92
92
|
See the monorepo `CHANGELOG.md` for the 1.x → 2.0 package-split migration notes.
|
|
93
93
|
|
|
94
|
+
## API reference
|
|
95
|
+
|
|
96
|
+
Every symbol this package exports, with the exact specifier to import it from, is in the generated
|
|
97
|
+
capability map that ships inside `@theokit/sdk`:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
node_modules/@theokit/sdk/docs/harness-capability-map.md # symbol -> import specifier
|
|
101
|
+
node_modules/@theokit/sdk/docs/error-codes.md # every `code` an error can carry
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Both are generated from the built type declarations, so they describe the version you installed
|
|
105
|
+
rather than the version someone wrote a page about.
|
|
106
|
+
|
|
94
107
|
## License
|
|
95
108
|
|
|
96
109
|
Apache-2.0.
|
|
@@ -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 };
|