toolnexus 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/home.js +4 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +76 -6
- package/dist/agents/runtime.js +92 -22
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +342 -58
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +33 -25
- package/dist/client.d.ts +116 -16
- package/dist/client.js +197 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/home.ts +4 -2
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +154 -29
- package/src/builtin.ts +379 -55
- package/src/classifier.ts +45 -27
- package/src/client.ts +220 -51
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/src/order.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic ordering for everything the library lists (addenda A1a, A15, A22, A24).
|
|
3
|
+
*
|
|
4
|
+
* INTERNAL — deliberately not re-exported from `index.ts`. These are not API; they are the one
|
|
5
|
+
* place the ordering rules live so no caller has to remember them.
|
|
6
|
+
*
|
|
7
|
+
* The rules, and why each exists:
|
|
8
|
+
*
|
|
9
|
+
* - **Never `localeCompare`.** It orders by the machine's ICU data, so the same build emits a
|
|
10
|
+
* different order on a different machine. A cross-port byte-identity claim cannot survive it.
|
|
11
|
+
* - **Never a bare `Array.sort()`.** JS's default compares UTF-16 CODE UNITS, which disagrees
|
|
12
|
+
* with code point above U+FFFF — the same divergence C#'s `StringComparer.Ordinal` has.
|
|
13
|
+
* - **Never an ABSENT sort over a directory read.** `readdirSync` is sorted on most platforms,
|
|
14
|
+
* but Node guarantees nothing, and a walk that breaks at a cap turns that accident into a
|
|
15
|
+
* CONTENT difference: which files appear, not merely their order.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Unicode CODE-POINT comparison — the one string ordering rule, identical in all seven ports.
|
|
20
|
+
* Iterates code points rather than code units, so a surrogate pair sorts after U+E000–U+FFFF
|
|
21
|
+
* (where `<`, `Array.sort()` and `StringComparer.Ordinal` all disagree with it).
|
|
22
|
+
*/
|
|
23
|
+
export function compareCodePoints(a: string, b: string): number {
|
|
24
|
+
if (a === b) return 0
|
|
25
|
+
const ai = a[Symbol.iterator]()
|
|
26
|
+
const bi = b[Symbol.iterator]()
|
|
27
|
+
for (;;) {
|
|
28
|
+
const x = ai.next()
|
|
29
|
+
const y = bi.next()
|
|
30
|
+
if (x.done) return y.done ? 0 : -1
|
|
31
|
+
if (y.done) return 1
|
|
32
|
+
const cx = x.value.codePointAt(0)!
|
|
33
|
+
const cy = y.value.codePointAt(0)!
|
|
34
|
+
if (cx !== cy) return cx < cy ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The SKILL DISCOVERY order (addendum A15): DEPTH (path segments) ascending, then code point,
|
|
40
|
+
* over a path relative to the root. First-wins then keeps the shallowest copy of a duplicated
|
|
41
|
+
* name, uniformly — code point alone makes the winner depend on the skill's own first letter
|
|
42
|
+
* (`docx` beats `synced/…`, but `xlsx` loses to it).
|
|
43
|
+
*/
|
|
44
|
+
export function compareDiscovery(a: string, b: string, sep: string): number {
|
|
45
|
+
const da = a.split(sep).length
|
|
46
|
+
const db = b.split(sep).length
|
|
47
|
+
if (da !== db) return da < db ? -1 : 1
|
|
48
|
+
return compareCodePoints(a, b)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sort a directory's entries BY NAME, in code-point order, in place.
|
|
53
|
+
*
|
|
54
|
+
* By NAME at each level, never by full path: that is what makes ports walking the same tree with
|
|
55
|
+
* the same stack produce the same sequence, which matters because a capped walk's cap decides
|
|
56
|
+
* WHICH entries are reached.
|
|
57
|
+
*/
|
|
58
|
+
export function sortEntriesByName<T extends { name: string }>(entries: T[]): T[] {
|
|
59
|
+
return entries.sort((x, y) => compareCodePoints(x.name, y.name))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The listing path form (addendum A28): a relative path with `/` as the separator ON EVERY
|
|
64
|
+
* PLATFORM. Every capped or ordered listing EMITS this string and SORTS ON THE SAME STRING —
|
|
65
|
+
* ordering by one thing and showing another is how a listing ends up looking unsorted to its
|
|
66
|
+
* reader while passing its own test. Takes an already-relative path (`path.relative(...)`).
|
|
67
|
+
*/
|
|
68
|
+
export function toPosixPath(rel: string, sep: string): string {
|
|
69
|
+
return sep === "/" ? rel : rel.split(sep).join("/")
|
|
70
|
+
}
|
package/src/skill.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
|
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import { pathToFileURL } from "node:url"
|
|
15
15
|
import { parse as parseYaml } from "yaml"
|
|
16
|
+
import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js"
|
|
16
17
|
import type { Tool, ToolResult } from "./types.js"
|
|
17
18
|
|
|
18
19
|
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
@@ -59,6 +60,12 @@ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplic
|
|
|
59
60
|
export interface SkillSkip {
|
|
60
61
|
location: string
|
|
61
62
|
reason: SkillSkipReason
|
|
63
|
+
/**
|
|
64
|
+
* The NATIVE parser's own message ("Tabs are not allowed as indentation at line 3"), which is
|
|
65
|
+
* one keystroke from fixed and used to be discarded. Kept as a SEPARATE field so `reason`
|
|
66
|
+
* stays byte-identical across ports while this stays native — never compare it for parity.
|
|
67
|
+
*/
|
|
68
|
+
detail?: string
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
@@ -78,37 +85,80 @@ export interface LoadSkillsOptions {
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
|
-
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML
|
|
82
|
-
*
|
|
83
|
-
*
|
|
88
|
+
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
|
|
89
|
+
* (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
|
|
90
|
+
* Scalar values are coerced to strings.
|
|
84
91
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
92
|
+
* LENIENT FALLBACK (ADR 0028, issue #93) — **YAML FIRST**. A line-wise `key: rest-of-line` read
|
|
93
|
+
* of `name`/`description` runs ONLY on frontmatter a real YAML parser has ALREADY refused.
|
|
94
|
+
* The order is the whole point: line-wise FIRST misparses legitimate YAML (`description: |`
|
|
95
|
+
* becomes `"|"`, a continued plain scalar truncates to its first line), and running it only
|
|
96
|
+
* after a refusal means it can never see a file whose YAML semantics matter — by construction
|
|
97
|
+
* those files have none left to preserve. No file that parses today changes value.
|
|
98
|
+
*
|
|
99
|
+
* `malformed` is true only when fences are present, YAML refused, AND the rescue found no name.
|
|
89
100
|
*/
|
|
90
|
-
|
|
101
|
+
const LENIENT_KEYS = ["name", "description"] as const
|
|
102
|
+
/** A value opening one of these is a construct the rescue does not implement — refused, never
|
|
103
|
+
* guessed, so a half-broken block scalar degrades to "no description", not to `"|"`. */
|
|
104
|
+
const LENIENT_REFUSED_OPENERS = new Set(["|", ">", "&", "*", "[", "{", "!"])
|
|
105
|
+
/** Column 0, ordinary key characters, one `:`; the value is the rest of the line. */
|
|
106
|
+
const LENIENT_LINE = /^([A-Za-z0-9_][A-Za-z0-9_.-]*):[ \t]*(.*)$/
|
|
107
|
+
|
|
108
|
+
function unquoteScalar(v: string): string {
|
|
109
|
+
const t = v.trim()
|
|
110
|
+
if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) return t.slice(1, -1)
|
|
111
|
+
return t
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Rescue `name`/`description` from a frontmatter block YAML refused. First-wins, column 0. */
|
|
115
|
+
function lineWiseFrontmatter(block: string): Record<string, string> {
|
|
116
|
+
const data: Record<string, string> = {}
|
|
117
|
+
for (const raw of block.split(/\r?\n/)) {
|
|
118
|
+
const head = raw.slice(0, 1)
|
|
119
|
+
// indented continuation, comment, or blank — not a top-level key
|
|
120
|
+
if (head === " " || head === "\t" || head === "#" || head === "") continue
|
|
121
|
+
const m = LENIENT_LINE.exec(raw)
|
|
122
|
+
if (!m) continue
|
|
123
|
+
const key = m[1]
|
|
124
|
+
const value = m[2].trim()
|
|
125
|
+
if (!(LENIENT_KEYS as readonly string[]).includes(key) || key in data) continue
|
|
126
|
+
if (!value || LENIENT_REFUSED_OPENERS.has(value[0])) continue
|
|
127
|
+
data[key] = unquoteScalar(value)
|
|
128
|
+
}
|
|
129
|
+
return data
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseFrontmatter(text: string): { data: Record<string, string>; content: string; malformed: boolean; detail?: string } {
|
|
91
133
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
92
134
|
if (!match) return { data: {}, content: text, malformed: false }
|
|
93
135
|
const data: Record<string, string> = {}
|
|
94
136
|
let parsed: unknown
|
|
95
|
-
let
|
|
137
|
+
let detail: string | undefined
|
|
96
138
|
try {
|
|
97
139
|
parsed = parseYaml(match[1])
|
|
98
|
-
} catch {
|
|
140
|
+
} catch (e) {
|
|
99
141
|
parsed = null
|
|
100
|
-
|
|
142
|
+
detail = e instanceof Error ? e.message : String(e)
|
|
101
143
|
}
|
|
102
|
-
if (parsed && typeof parsed === "object") {
|
|
144
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
103
145
|
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
104
146
|
// Trim so block-scalar trailing newlines (chomping differs subtly between
|
|
105
|
-
// YAML libs) don't leak — keeps the
|
|
147
|
+
// YAML libs) don't leak — keeps the ports byte-identical.
|
|
106
148
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
107
149
|
data[key] = String(value).trim()
|
|
108
150
|
}
|
|
109
151
|
}
|
|
152
|
+
return { data, content: match[2], malformed: false }
|
|
153
|
+
}
|
|
154
|
+
// YAML refused (threw, or produced something that is not a mapping): rescue the two scalars.
|
|
155
|
+
const rescued = lineWiseFrontmatter(match[1])
|
|
156
|
+
return {
|
|
157
|
+
data: rescued,
|
|
158
|
+
content: match[2],
|
|
159
|
+
malformed: !rescued.name,
|
|
160
|
+
detail: detail ?? (rescued.name ? undefined : "frontmatter is not a YAML mapping"),
|
|
110
161
|
}
|
|
111
|
-
return { data, content: match[2], malformed }
|
|
112
162
|
}
|
|
113
163
|
|
|
114
164
|
function walkSkillFiles(root: string): string[] {
|
|
@@ -121,10 +171,15 @@ function walkSkillFiles(root: string): string[] {
|
|
|
121
171
|
const dir = stack.pop()!
|
|
122
172
|
let entries
|
|
123
173
|
try {
|
|
124
|
-
|
|
174
|
+
// EXPLICIT (A24): `readdirSync` is sorted on most platforms, but Node guarantees
|
|
175
|
+
// nothing. An absent sort over a directory read is the same defect class as a wrong one,
|
|
176
|
+
// and it is invisible to a grep for comparators.
|
|
177
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
|
|
125
178
|
} catch {
|
|
126
179
|
continue
|
|
127
180
|
}
|
|
181
|
+
// Subdirectories are pushed REVERSED so the LIFO stack pops them in name order.
|
|
182
|
+
for (const entry of [...entries].reverse().filter((e) => e.isDirectory() || e.isSymbolicLink()).reverse().concat([])) void entry
|
|
128
183
|
for (const entry of entries) {
|
|
129
184
|
const full = path.join(dir, entry.name)
|
|
130
185
|
let isDir = entry.isDirectory()
|
|
@@ -158,11 +213,23 @@ function walkSkillFiles(root: string): string[] {
|
|
|
158
213
|
return out
|
|
159
214
|
}
|
|
160
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The `<skill_files>` SAMPLE LIST: sibling resources shown to the model when a skill is loaded.
|
|
218
|
+
*
|
|
219
|
+
* This is SHIPPED OUTPUT, so its order is part of the cross-port contract (addendum A22). It
|
|
220
|
+
* used to be raw `readdirSync` order over a LIFO `stack.pop()` walk with the cap applied MID
|
|
221
|
+
* TRAVERSAL — so the filesystem decided not merely how the sample was ordered but WHICH files
|
|
222
|
+
* appeared in it at all. Now: collect everything, then order by DEPTH ascending then Unicode
|
|
223
|
+
* CODE POINT over the path relative to the skill dir (the same `compareDiscovery` rule as
|
|
224
|
+
* discovery), and only then apply the cap. The sample is therefore a function of the tree and
|
|
225
|
+
* the cap alone — never of readdir order, and stable across machines and platforms. The
|
|
226
|
+
* ordering is PLAIN Unicode code point over the relative path (A25).
|
|
227
|
+
*/
|
|
161
228
|
function sampleSiblingFiles(dir: string, limit = 10): string[] {
|
|
162
|
-
const
|
|
229
|
+
const found: string[] = []
|
|
163
230
|
const stack = [dir]
|
|
164
231
|
const seen = new Set<string>()
|
|
165
|
-
while (stack.length
|
|
232
|
+
while (stack.length) {
|
|
166
233
|
const cur = stack.pop()!
|
|
167
234
|
let entries
|
|
168
235
|
try {
|
|
@@ -171,7 +238,6 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
|
|
|
171
238
|
continue
|
|
172
239
|
}
|
|
173
240
|
for (const entry of entries) {
|
|
174
|
-
if (out.length >= limit) break
|
|
175
241
|
const full = path.join(cur, entry.name)
|
|
176
242
|
let isDir = entry.isDirectory()
|
|
177
243
|
let isFile = entry.isFile()
|
|
@@ -196,11 +262,17 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
|
|
|
196
262
|
seen.add(real)
|
|
197
263
|
stack.push(full)
|
|
198
264
|
} else if (isFile && entry.name !== "SKILL.md") {
|
|
199
|
-
|
|
265
|
+
found.push(full)
|
|
200
266
|
}
|
|
201
267
|
}
|
|
202
268
|
}
|
|
203
|
-
|
|
269
|
+
// Order FIRST, cap SECOND: capping during the walk let readdir order pick the sample.
|
|
270
|
+
// PLAIN code point over the path relative to the skill dir (A25) — NOT the discovery rule.
|
|
271
|
+
// A15's depth rule exists to decide WHICH FILE a duplicated skill NAME resolves to; it has no
|
|
272
|
+
// business ordering a flat listing, where it would interleave `a-root.txt, z-root.txt,
|
|
273
|
+
// alpha/f.txt` instead of `a-root.txt, alpha/f.txt, z-root.txt`.
|
|
274
|
+
found.sort((a, b) => compareCodePoints(path.relative(dir, a), path.relative(dir, b)))
|
|
275
|
+
return found.slice(0, limit)
|
|
204
276
|
}
|
|
205
277
|
|
|
206
278
|
/** One raw candidate before cross-source dedupe: a parsed skill OR a typed skip. */
|
|
@@ -222,17 +294,26 @@ function candidatesFromDir(root: string): RawCandidate[] {
|
|
|
222
294
|
console.warn(`[toolnexus] skills dir not found: ${root}`)
|
|
223
295
|
return out
|
|
224
296
|
}
|
|
225
|
-
|
|
297
|
+
// DISCOVERY ORDER (addendum A1/A1a/A15): dirs in the order the CALLER passed them; within a
|
|
298
|
+
// dir, by DEPTH ASCENDING, then Unicode CODE-POINT comparison of the path relative to that dir.
|
|
299
|
+
// A symlink sorts at its DISCOVERED path, never its target.
|
|
300
|
+
//
|
|
301
|
+
// Depth comes FIRST, and that is the whole rule: code point alone makes the winner depend on
|
|
302
|
+
// the skill's own first letter. `docx`/`pdf`/`pptx` beat `synced/<uuid>/…` because d/p < s —
|
|
303
|
+
// but `xlsx` LOSES to it, because x > s. Depth-first makes "a shallower path beats a nested
|
|
304
|
+
// copy of the same name" true for EVERY name, which is what SPEC §3 already claims.
|
|
305
|
+
const files = walkSkillFiles(root).sort((a, b) => compareDiscovery(path.relative(root, a), path.relative(root, b), path.sep))
|
|
306
|
+
for (const file of files) {
|
|
226
307
|
let text: string
|
|
227
308
|
try {
|
|
228
309
|
text = readFileSync(file, "utf8")
|
|
229
|
-
} catch {
|
|
230
|
-
out.push({ skip: { location: file, reason: "unreadable" } })
|
|
310
|
+
} catch (e) {
|
|
311
|
+
out.push({ skip: { location: file, reason: "unreadable", detail: e instanceof Error ? e.message : String(e) } })
|
|
231
312
|
continue
|
|
232
313
|
}
|
|
233
|
-
const { data, content, malformed } = parseFrontmatter(text)
|
|
314
|
+
const { data, content, malformed, detail } = parseFrontmatter(text)
|
|
234
315
|
if (malformed) {
|
|
235
|
-
out.push({ skip: { location: file, reason: "malformed-frontmatter" } })
|
|
316
|
+
out.push({ skip: { location: file, reason: "malformed-frontmatter", ...(detail ? { detail } : {}) } })
|
|
236
317
|
continue
|
|
237
318
|
}
|
|
238
319
|
if (!data.name) {
|
|
@@ -321,6 +402,13 @@ export interface SkillSource {
|
|
|
321
402
|
tool: Tool
|
|
322
403
|
/** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
|
|
323
404
|
prompt(): string
|
|
405
|
+
/**
|
|
406
|
+
* Candidate SKILL.md files that did NOT become skills, with the typed reason and the native
|
|
407
|
+
* parser `detail` (ADR 0028). Previously only `listSkills` reported these, so the path every
|
|
408
|
+
* README and example uses — `loadSkills` — could load 69 of 75 and say nothing. An omission
|
|
409
|
+
* that stops being reported is indistinguishable from something that was finished.
|
|
410
|
+
*/
|
|
411
|
+
skipped: SkillSkip[]
|
|
324
412
|
}
|
|
325
413
|
|
|
326
414
|
/**
|
|
@@ -343,6 +431,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
343
431
|
const merged = mergeCandidates(collectCandidates(opts))
|
|
344
432
|
const skills = applySkillsFilter(merged.skills, opts.filter)
|
|
345
433
|
|
|
434
|
+
const skipped = merged.skipped
|
|
346
435
|
const tool: Tool = {
|
|
347
436
|
name: "skill",
|
|
348
437
|
description: SKILL_TOOL_DESCRIPTION,
|
|
@@ -358,7 +447,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
358
447
|
const info = skills[name]
|
|
359
448
|
if (!info) {
|
|
360
449
|
return {
|
|
361
|
-
output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
|
|
450
|
+
output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort(compareCodePoints).join(", ") || "none"}`,
|
|
362
451
|
isError: true,
|
|
363
452
|
}
|
|
364
453
|
}
|
|
@@ -409,10 +498,15 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
409
498
|
return {
|
|
410
499
|
skills,
|
|
411
500
|
tool,
|
|
501
|
+
skipped,
|
|
412
502
|
prompt() {
|
|
413
503
|
const described = Object.values(skills)
|
|
414
504
|
.filter((s) => s.description !== undefined)
|
|
415
|
-
|
|
505
|
+
// Unicode CODE POINT over the skill name, never `localeCompare` (addendum A22): a
|
|
506
|
+
// locale collation orders by the machine's ICU data, so the same build emitted a
|
|
507
|
+
// DIFFERENTLY ORDERED prompt on two machines. SPEC §0.10 pins this prompt as
|
|
508
|
+
// byte-identical across ports; it was not even stable within js.
|
|
509
|
+
.sort((a, b) => compareCodePoints(a.name, b.name))
|
|
416
510
|
if (described.length === 0) return "No skills are currently available."
|
|
417
511
|
return [
|
|
418
512
|
SKILLS_PROMPT_PREAMBLE,
|
package/src/types.ts
CHANGED
|
@@ -60,6 +60,28 @@ export interface Answer {
|
|
|
60
60
|
reason?: "declined" | "cancelled" | "expired"
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Typed constructor for the common resume payload: "here is the tool's output" (ADR 0026 D2).
|
|
65
|
+
* Hand-building `{ id, ok: true, data: { output } }` is where hosts get the key wrong; this
|
|
66
|
+
* removes the key from the host's hands entirely.
|
|
67
|
+
*
|
|
68
|
+
* `output` MUST be a string. A non-string is an ERROR, never a silent degrade to `""` — a
|
|
69
|
+
* fabricated empty result is indistinguishable from an answer the human never gave.
|
|
70
|
+
*/
|
|
71
|
+
export function answerOutput(id: string, output: string): Answer {
|
|
72
|
+
if (typeof output !== "string") {
|
|
73
|
+
throw new TypeError(`answerOutput: output must be a string, got ${output === null ? "null" : typeof output}`)
|
|
74
|
+
}
|
|
75
|
+
if (!id) throw new TypeError("answerOutput: id is required (it must match the Request's id)")
|
|
76
|
+
return { id, ok: true, data: { output } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The matching refusal: `ok:false` with an advisory reason the loop does not branch on. */
|
|
80
|
+
export function answerDeclined(id: string, reason: Answer["reason"] = "declined"): Answer {
|
|
81
|
+
if (!id) throw new TypeError("answerDeclined: id is required (it must match the Request's id)")
|
|
82
|
+
return { id, ok: false, reason }
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
let _pendingSeq = 0
|
|
64
86
|
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
65
87
|
export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {
|