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/classifier.ts
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* here is a security control.
|
|
11
11
|
*/
|
|
12
12
|
import type { Client } from "./client.js"
|
|
13
|
-
import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
|
|
13
|
+
import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
|
|
14
14
|
import { retryAfterMs, isRetryableStatus } from "./retry.js"
|
|
15
|
-
import type { Toolkit } from "./toolkit.js"
|
|
16
15
|
|
|
17
16
|
// ---------------------------------------------------------------- constants
|
|
18
17
|
|
|
@@ -25,6 +24,19 @@ export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
|
|
|
25
24
|
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
26
25
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
|
|
27
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
|
|
29
|
+
* a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
|
|
30
|
+
* `typesafe/jev-1.13`, and mixing the two is issue #91.
|
|
31
|
+
*/
|
|
32
|
+
export type ClassifierBackend = "typesafe" | "openrouter"
|
|
33
|
+
|
|
34
|
+
/** The two servable pairings, as configurations rather than six independently-choosable cells. */
|
|
35
|
+
export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, { baseUrl: string; model: string; apiKeyEnv: string }>> = {
|
|
36
|
+
typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
|
|
37
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/** Client-side cap on a choice's named options (§8B). */
|
|
29
41
|
export const MAX_CHOICE_OPTIONS = 255
|
|
30
42
|
/** Bounds of a score rubric (§8B). */
|
|
@@ -392,6 +404,11 @@ export type EvaluateFn = (
|
|
|
392
404
|
export interface ClassifierOptions {
|
|
393
405
|
/** Default `"systemone"`. */
|
|
394
406
|
style?: ClassifierStyle
|
|
407
|
+
/** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
|
|
408
|
+
* UNIT. Default `"typesafe"`. The three individual options still win where given, for a
|
|
409
|
+
* self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
|
|
410
|
+
* as an "Unknown model" 400. */
|
|
411
|
+
backend?: ClassifierBackend
|
|
395
412
|
/** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
|
|
396
413
|
baseUrl?: string
|
|
397
414
|
/** Default `"jev-latest"`. Pin it once thresholds are tuned. */
|
|
@@ -456,9 +473,12 @@ export class Classifier {
|
|
|
456
473
|
|
|
457
474
|
constructor(private readonly opts: ClassifierOptions) {
|
|
458
475
|
this.style = opts.style ?? "systemone"
|
|
459
|
-
|
|
460
|
-
this.
|
|
461
|
-
this.
|
|
476
|
+
const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"]
|
|
477
|
+
this.baseUrl = opts.baseUrl ?? preset.baseUrl
|
|
478
|
+
this.model = opts.model ?? preset.model
|
|
479
|
+
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
|
|
480
|
+
const mismatch = backendMismatch(this.baseUrl, this.model)
|
|
481
|
+
if (mismatch) throw new Error(mismatch)
|
|
462
482
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
463
483
|
switch (this.style) {
|
|
464
484
|
case "systemone":
|
|
@@ -630,8 +650,10 @@ export class Classifier {
|
|
|
630
650
|
let lastErr: unknown
|
|
631
651
|
for (let attempt = 0; ; attempt++) {
|
|
632
652
|
const timer = new AbortController()
|
|
653
|
+
// NOT unref'd: this watchdog is the only thing that will end a request an
|
|
654
|
+
// injected `fetch` never settles, and an unref'd timer cannot keep the
|
|
655
|
+
// loop alive long enough to fire. It is cleared on every exit path below.
|
|
633
656
|
const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs)
|
|
634
|
-
;(t as any).unref?.()
|
|
635
657
|
const onOuter = () => timer.abort(signal!.reason)
|
|
636
658
|
if (signal) {
|
|
637
659
|
if (signal.aborted) {
|
|
@@ -706,7 +728,8 @@ export class Classifier {
|
|
|
706
728
|
'{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
|
|
707
729
|
'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
|
|
708
730
|
'"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
|
|
709
|
-
|
|
731
|
+
// No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
|
|
732
|
+
const run = await this.opts.client!.run(prompt, { signal })
|
|
710
733
|
const payload = firstJsonObject(run.text)
|
|
711
734
|
let parsed: unknown
|
|
712
735
|
try {
|
|
@@ -778,9 +801,21 @@ export function degenerateCriteria(criteria: Record<string, string>): string {
|
|
|
778
801
|
*/
|
|
779
802
|
function cause(status: number, body: string): string {
|
|
780
803
|
if (status === 401 || status === 403) return ""
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
return ": " +
|
|
804
|
+
// ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
|
|
805
|
+
const s = capErrorBody(redactErrorBody(body))
|
|
806
|
+
return s ? ": " + s : ""
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
|
|
811
|
+
* pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
|
|
812
|
+
* with, or `""` when the pairing is fine.
|
|
813
|
+
*/
|
|
814
|
+
export function backendMismatch(baseUrl: string, model: string): string {
|
|
815
|
+
const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase()
|
|
816
|
+
if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai")) return ""
|
|
817
|
+
if (!/^jev-/.test(model)) return ""
|
|
818
|
+
return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`
|
|
784
819
|
}
|
|
785
820
|
|
|
786
821
|
/** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
|
|
@@ -813,20 +848,3 @@ function sleep(ms: number): Promise<void> {
|
|
|
813
848
|
})
|
|
814
849
|
}
|
|
815
850
|
|
|
816
|
-
/**
|
|
817
|
-
* The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
|
|
818
|
-
* `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
|
|
819
|
-
* public "no tools" Toolkit constructor just for this path.
|
|
820
|
-
*/
|
|
821
|
-
const EMPTY_TOOLKIT = {
|
|
822
|
-
skillsPrompt: () => "",
|
|
823
|
-
toOpenAI: () => [],
|
|
824
|
-
toAnthropic: () => [],
|
|
825
|
-
toGemini: () => [],
|
|
826
|
-
get: () => undefined,
|
|
827
|
-
list: () => [],
|
|
828
|
-
execute: async () => {
|
|
829
|
-
throw new Error("classifier: the llm style declares no tools")
|
|
830
|
-
},
|
|
831
|
-
close: async () => {},
|
|
832
|
-
} as unknown as Toolkit
|
package/src/client.ts
CHANGED
|
@@ -56,7 +56,18 @@ export interface ClientOptions {
|
|
|
56
56
|
/** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
|
|
57
57
|
* this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
|
|
58
58
|
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
59
|
-
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
|
|
59
|
+
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
|
|
60
|
+
*
|
|
61
|
+
* IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
|
|
62
|
+
* turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
|
|
63
|
+
* REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
|
|
64
|
+
* itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
|
|
65
|
+
* bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
|
|
66
|
+
* side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
|
|
67
|
+
* responsibility. Any tool reachable in a suspendable turn must be idempotent.
|
|
68
|
+
* Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
|
|
69
|
+
* remove this requirement anyway.
|
|
70
|
+
*/
|
|
60
71
|
waitFor?: (request: Request) => Promise<Answer>
|
|
61
72
|
/** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
|
|
62
73
|
* its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
|
|
@@ -84,6 +95,77 @@ export interface ClientOptions {
|
|
|
84
95
|
onUnsupportedPart?: UnsupportedPartMode
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
|
|
100
|
+
* §7D AGENT vocabulary (`agents.TASK_STATUSES`), which also contains `"timeout"`, `"closed"`,
|
|
101
|
+
* `"interrupted"` and `"error"`. Two fields are called `status`; they are not interchangeable.
|
|
102
|
+
* A run that fails at a deadline THROWS here — it never comes back as a `RunResult`.
|
|
103
|
+
*/
|
|
104
|
+
export const RUN_STATUSES = ["done", "pending", "incomplete"] as const
|
|
105
|
+
export type RunStatus = (typeof RUN_STATUSES)[number]
|
|
106
|
+
|
|
107
|
+
/** Account-identifier keys blanked out of a provider error body before it reaches a message,
|
|
108
|
+
* a log or a metric. The shape survives; the value does not (ADR 0027 D3.2). */
|
|
109
|
+
export const REDACTED_BODY_KEYS = ["user_id", "account_id", "org_id", "organization"] as const
|
|
110
|
+
/** What a redacted value is replaced with — identical in all seven ports. */
|
|
111
|
+
export const REDACTION_PLACEHOLDER = "\u00abredacted\u00bb"
|
|
112
|
+
/** A redacted body is capped at this many characters IN THE MESSAGE ONLY. The typed `body`
|
|
113
|
+
* field carries the full redacted body — a host that opted into a typed error asked for the
|
|
114
|
+
* whole thing (ADR 0027 D3 / addendum A5). */
|
|
115
|
+
export const ERROR_BODY_CAP = 200
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Redact known account identifiers, then cap. A CAP IS NOT REDACTION: the body that leaked
|
|
119
|
+
* `user_2…` in issue #92 was 96 bytes, well inside the cap, so both steps are needed and
|
|
120
|
+
* redaction runs FIRST (capping first could split a key/value pair and hide it from the regex).
|
|
121
|
+
*/
|
|
122
|
+
export function redactErrorBody(body: string): string {
|
|
123
|
+
let out = body
|
|
124
|
+
for (const key of REDACTED_BODY_KEYS) {
|
|
125
|
+
// JSON ("key": "value" / "key": 123) and form/query (key=value) spellings alike.
|
|
126
|
+
out = out.replace(new RegExp(`("${key}"\\s*:\\s*)("(?:[^"\\\\]|\\\\.)*"|-?\\d+(?:\\.\\d+)?|true|false|null)`, "g"), `$1"${REDACTION_PLACEHOLDER}"`)
|
|
127
|
+
out = out.replace(new RegExp(`\\b${key}=([^&\\s]+)`, "g"), `${key}=${REDACTION_PLACEHOLDER}`)
|
|
128
|
+
}
|
|
129
|
+
return out.trim()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cap a already-redacted body for interpolation into a MESSAGE. Redaction is not a cap and a
|
|
133
|
+
* cap is not redaction: they are independent and both are needed. */
|
|
134
|
+
export function capErrorBody(body: string): string {
|
|
135
|
+
return body.length > ERROR_BODY_CAP ? body.slice(0, ERROR_BODY_CAP) + "\u2026" : body
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A provider failure carried as a VALUE, not a sentence (ADR 0027 D3.1). A host that must
|
|
140
|
+
* decide what to log can read `status` / `retryAfter` instead of parsing a message.
|
|
141
|
+
*
|
|
142
|
+
* `body` is ALREADY redacted and capped, and is `""` on 401/403 — an auth body routinely
|
|
143
|
+
* reflects the credential or the header that was sent. `rawBody` is never populated by the
|
|
144
|
+
* library; the SPEC credentials guarantee now covers error messages too.
|
|
145
|
+
*/
|
|
146
|
+
export class LlmHttpError extends Error {
|
|
147
|
+
readonly name = "LlmHttpError"
|
|
148
|
+
constructor(
|
|
149
|
+
/** The HTTP status the provider answered with. */
|
|
150
|
+
readonly status: number,
|
|
151
|
+
/** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
|
|
152
|
+
* applies to `message` only. */
|
|
153
|
+
readonly body: string,
|
|
154
|
+
/** The `Retry-After` header verbatim, when the provider sent one. */
|
|
155
|
+
readonly retryAfter?: string,
|
|
156
|
+
) {
|
|
157
|
+
super(`LLM ${status}${body ? ": " + capErrorBody(body) : ""}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Build the typed error from a non-2xx provider response (reads the body once). */
|
|
162
|
+
export async function llmHttpError(res: Response): Promise<LlmHttpError> {
|
|
163
|
+
const raw = await res.text().catch(() => "")
|
|
164
|
+
// 401/403: never echo the body — see `Classifier.cause`, now one policy, not two.
|
|
165
|
+
const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw)
|
|
166
|
+
return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined)
|
|
167
|
+
}
|
|
168
|
+
|
|
87
169
|
/** §8 Resilience. What to do with a failed LLM call. */
|
|
88
170
|
export type ErrorTier = "retry" | "fail"
|
|
89
171
|
|
|
@@ -443,11 +525,17 @@ export class Client {
|
|
|
443
525
|
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
|
|
444
526
|
}
|
|
445
527
|
|
|
446
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
|
|
530
|
+
* COMPLETION (SPEC §0.10). The system message is then the system prompt alone, and the
|
|
531
|
+
* request body carries no `tools` and no `tool_choice` key (not an empty array). An empty
|
|
532
|
+
* toolkit and no toolkit are observably identical on the wire.
|
|
533
|
+
*/
|
|
534
|
+
async run(prompt: PromptInput, ctx?: { toolkit?: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
|
|
447
535
|
checkPromptParts(prompt, this.wireOpts())
|
|
448
536
|
return this.opts.style === "anthropic"
|
|
449
|
-
? this.runAnthropic(prompt, ctx
|
|
450
|
-
: this.runOpenAI(prompt, ctx
|
|
537
|
+
? this.runAnthropic(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
|
|
538
|
+
: this.runOpenAI(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
|
|
451
539
|
}
|
|
452
540
|
|
|
453
541
|
/**
|
|
@@ -487,7 +575,7 @@ export class Client {
|
|
|
487
575
|
if (req.maxTokens) body.max_tokens = req.maxTokens
|
|
488
576
|
|
|
489
577
|
const signal = this.makeSignal(req.signal)
|
|
490
|
-
{
|
|
578
|
+
try {
|
|
491
579
|
const data = await this.llmCallJson(
|
|
492
580
|
`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
|
|
493
581
|
{
|
|
@@ -515,6 +603,8 @@ export class Client {
|
|
|
515
603
|
model: this.opts.model,
|
|
516
604
|
raw: data,
|
|
517
605
|
}
|
|
606
|
+
} finally {
|
|
607
|
+
this.releaseSignal(signal)
|
|
518
608
|
}
|
|
519
609
|
}
|
|
520
610
|
|
|
@@ -549,7 +639,7 @@ export class Client {
|
|
|
549
639
|
if (tc) body.tool_choice = tc
|
|
550
640
|
|
|
551
641
|
const signal = this.makeSignal(req.signal)
|
|
552
|
-
{
|
|
642
|
+
try {
|
|
553
643
|
const data = await this.llmCallJson(
|
|
554
644
|
endpoint,
|
|
555
645
|
{
|
|
@@ -588,6 +678,8 @@ export class Client {
|
|
|
588
678
|
model: this.opts.model,
|
|
589
679
|
raw: data,
|
|
590
680
|
}
|
|
681
|
+
} finally {
|
|
682
|
+
this.releaseSignal(signal)
|
|
591
683
|
}
|
|
592
684
|
}
|
|
593
685
|
|
|
@@ -609,10 +701,10 @@ export class Client {
|
|
|
609
701
|
* the answer — so the next `ask` with the same `id` continues it. Without an
|
|
610
702
|
* `id` it is a stateless one-shot (identical to `run`).
|
|
611
703
|
*/
|
|
612
|
-
async ask(prompt: PromptInput, ctx
|
|
704
|
+
async ask(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
|
|
613
705
|
// Block-style streaming: run the streaming loop, forward text deltas, still return the
|
|
614
706
|
// final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
|
|
615
|
-
if (ctx
|
|
707
|
+
if (ctx?.on_text) {
|
|
616
708
|
let result: RunResult | undefined
|
|
617
709
|
for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
|
|
618
710
|
if (ev.type === "text") ctx.on_text(ev.delta)
|
|
@@ -620,7 +712,7 @@ export class Client {
|
|
|
620
712
|
}
|
|
621
713
|
return result!
|
|
622
714
|
}
|
|
623
|
-
if (!ctx
|
|
715
|
+
if (!ctx?.id) return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal })
|
|
624
716
|
const history = (await this.store.get(ctx.id)) ?? []
|
|
625
717
|
const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
|
|
626
718
|
await this.store.save(ctx.id, result.messages)
|
|
@@ -628,8 +720,8 @@ export class Client {
|
|
|
628
720
|
}
|
|
629
721
|
|
|
630
722
|
/** A stateful multi-turn conversation that retains history across sends. */
|
|
631
|
-
conversation(ctx
|
|
632
|
-
return new Conversation(this, ctx
|
|
723
|
+
conversation(ctx?: { toolkit?: Toolkit; signal?: AbortSignal }): Conversation {
|
|
724
|
+
return new Conversation(this, ctx?.toolkit, ctx?.signal)
|
|
633
725
|
}
|
|
634
726
|
|
|
635
727
|
/**
|
|
@@ -637,28 +729,48 @@ export class Client {
|
|
|
637
729
|
* With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
|
|
638
730
|
* streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
|
|
639
731
|
*/
|
|
640
|
-
async *stream(prompt: PromptInput, ctx
|
|
732
|
+
async *stream(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
|
|
641
733
|
checkPromptParts(prompt, this.wireOpts())
|
|
642
|
-
const history = ctx
|
|
734
|
+
const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined
|
|
643
735
|
const gen = this.opts.style === "anthropic"
|
|
644
|
-
? this.streamAnthropic(prompt, ctx
|
|
645
|
-
: this.streamOpenAI(prompt, ctx
|
|
736
|
+
? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
|
|
737
|
+
: this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history)
|
|
646
738
|
for await (const ev of gen) {
|
|
647
|
-
if (ev.type === "done" && ctx
|
|
739
|
+
if (ev.type === "done" && ctx?.id) await this.store.save(ctx.id, ev.result.messages)
|
|
648
740
|
yield ev
|
|
649
741
|
}
|
|
650
742
|
}
|
|
651
743
|
|
|
652
|
-
|
|
653
|
-
|
|
744
|
+
/** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
|
|
745
|
+
* the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
|
|
746
|
+
private system(toolkit?: Toolkit): string {
|
|
747
|
+
return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n")
|
|
654
748
|
}
|
|
655
749
|
|
|
656
|
-
/**
|
|
750
|
+
/** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
|
|
751
|
+
private readonly runTimers = new WeakMap<AbortSignal, ReturnType<typeof setTimeout>>()
|
|
752
|
+
|
|
753
|
+
/** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
|
|
754
|
+
private releaseSignal(signal: AbortSignal): void {
|
|
755
|
+
const t = this.runTimers.get(signal)
|
|
756
|
+
if (t !== undefined) {
|
|
757
|
+
clearTimeout(t)
|
|
758
|
+
this.runTimers.delete(signal)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
|
|
763
|
+
*
|
|
764
|
+
* The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
|
|
765
|
+
* alive, so a run whose only pending work is a request that never settles — an injected
|
|
766
|
+
* `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
|
|
767
|
+
* fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
|
|
768
|
+
* caller clears it however the run leaves, so a finished run holds nothing open. */
|
|
657
769
|
private makeSignal(external?: AbortSignal): AbortSignal {
|
|
658
770
|
const ctrl = new AbortController()
|
|
659
771
|
if (this.opts.timeoutMs) {
|
|
660
772
|
const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs)
|
|
661
|
-
|
|
773
|
+
this.runTimers.set(ctrl.signal, t)
|
|
662
774
|
}
|
|
663
775
|
if (external) {
|
|
664
776
|
if (external.aborted) ctrl.abort(external.reason)
|
|
@@ -699,9 +811,9 @@ export class Client {
|
|
|
699
811
|
}
|
|
700
812
|
|
|
701
813
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
702
|
-
private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
|
|
814
|
+
private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
|
|
703
815
|
const h = this.opts.hooks
|
|
704
|
-
const source = toolkit
|
|
816
|
+
const source = toolkit?.get(name)?.source ?? "custom"
|
|
705
817
|
const t0 = Date.now()
|
|
706
818
|
let a = args
|
|
707
819
|
if (h?.beforeTool) {
|
|
@@ -714,7 +826,10 @@ export class Client {
|
|
|
714
826
|
}
|
|
715
827
|
if (ov?.args) a = ov.args
|
|
716
828
|
}
|
|
717
|
-
|
|
829
|
+
// No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
|
|
830
|
+
let result: ToolResult = toolkit
|
|
831
|
+
? await toolkit.execute(name, a)
|
|
832
|
+
: { output: `unknown tool: ${name}`, isError: true }
|
|
718
833
|
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
719
834
|
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
720
835
|
// a tool error.
|
|
@@ -737,7 +852,7 @@ export class Client {
|
|
|
737
852
|
const t0 = Date.now()
|
|
738
853
|
try {
|
|
739
854
|
const res = await this.llmFetch(url, init, signal)
|
|
740
|
-
if (!res.ok) throw
|
|
855
|
+
if (!res.ok) throw await llmHttpError(res)
|
|
741
856
|
const data: any = await res.json()
|
|
742
857
|
const tok = perCall(data.usage, style)
|
|
743
858
|
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
|
|
@@ -748,10 +863,47 @@ export class Client {
|
|
|
748
863
|
}
|
|
749
864
|
}
|
|
750
865
|
|
|
866
|
+
// The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
|
|
867
|
+
// run leaves: return, throw, or a consumer abandoning the stream half-way.
|
|
868
|
+
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
869
|
+
const signal = this.makeSignal(external)
|
|
870
|
+
try {
|
|
871
|
+
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
872
|
+
} finally {
|
|
873
|
+
this.releaseSignal(signal)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
878
|
+
const signal = this.makeSignal(external)
|
|
879
|
+
try {
|
|
880
|
+
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
881
|
+
} finally {
|
|
882
|
+
this.releaseSignal(signal)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
887
|
+
const signal = this.makeSignal(external)
|
|
888
|
+
try {
|
|
889
|
+
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
890
|
+
} finally {
|
|
891
|
+
this.releaseSignal(signal)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
896
|
+
const signal = this.makeSignal(external)
|
|
897
|
+
try {
|
|
898
|
+
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
899
|
+
} finally {
|
|
900
|
+
this.releaseSignal(signal)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
751
904
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
752
|
-
private async
|
|
905
|
+
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
753
906
|
const key = resolveKey(this.opts)
|
|
754
|
-
const signal = this.makeSignal(external)
|
|
755
907
|
const runStart = Date.now()
|
|
756
908
|
let messages: any[] = history && history.length ? [...history] : []
|
|
757
909
|
if (!messages.length) {
|
|
@@ -759,7 +911,7 @@ export class Client {
|
|
|
759
911
|
if (system) messages.push({ role: "system", content: system })
|
|
760
912
|
}
|
|
761
913
|
messages.push({ role: "user", content: prompt })
|
|
762
|
-
let tools = toolkit
|
|
914
|
+
let tools = toolkit?.toOpenAI() ?? []
|
|
763
915
|
const toolCalls: ToolCallRecord[] = []
|
|
764
916
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
765
917
|
let turns = 0
|
|
@@ -832,32 +984,33 @@ export class Client {
|
|
|
832
984
|
* no `waitFor` is configured (the run should stop and surface the request).
|
|
833
985
|
*/
|
|
834
986
|
private async resolvePending(
|
|
835
|
-
toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
987
|
+
toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
836
988
|
): Promise<{ result: ToolResult; halted?: Request }> {
|
|
837
989
|
if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
|
|
838
990
|
const answer = await this.opts.waitFor(request)
|
|
839
991
|
if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
|
|
840
992
|
const t0 = Date.now()
|
|
841
|
-
let result =
|
|
993
|
+
let result: ToolResult = toolkit
|
|
994
|
+
? await toolkit.execute(name, args, { answer })
|
|
995
|
+
: { output: `unknown tool: ${name}`, isError: true }
|
|
842
996
|
if (this.opts.hooks?.afterTool) {
|
|
843
997
|
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
844
998
|
if (ov?.result) result = ov.result
|
|
845
999
|
}
|
|
846
|
-
this.emitTool(name, toolkit
|
|
1000
|
+
this.emitTool(name, toolkit?.get(name)?.source ?? "custom", result, t0)
|
|
847
1001
|
if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
|
|
848
1002
|
return { result }
|
|
849
1003
|
}
|
|
850
1004
|
|
|
851
1005
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
852
|
-
private async
|
|
1006
|
+
private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
853
1007
|
const key = resolveKey(this.opts)
|
|
854
|
-
const signal = this.makeSignal(external)
|
|
855
1008
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
856
1009
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
857
1010
|
const system = this.system(toolkit)
|
|
858
1011
|
const runStart = Date.now()
|
|
859
1012
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
860
|
-
let tools = toolkit
|
|
1013
|
+
let tools = toolkit?.toAnthropic() ?? []
|
|
861
1014
|
const toolCalls: ToolCallRecord[] = []
|
|
862
1015
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
863
1016
|
let turns = 0
|
|
@@ -923,9 +1076,8 @@ export class Client {
|
|
|
923
1076
|
}
|
|
924
1077
|
|
|
925
1078
|
// ---- Streaming: OpenAI-style ----
|
|
926
|
-
private async *
|
|
1079
|
+
private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
927
1080
|
const key = resolveKey(this.opts)
|
|
928
|
-
const signal = this.makeSignal(external)
|
|
929
1081
|
const runStart = Date.now()
|
|
930
1082
|
let messages: any[] = history && history.length ? [...history] : []
|
|
931
1083
|
if (!messages.length) {
|
|
@@ -933,7 +1085,7 @@ export class Client {
|
|
|
933
1085
|
if (system) messages.push({ role: "system", content: system })
|
|
934
1086
|
}
|
|
935
1087
|
messages.push({ role: "user", content: prompt })
|
|
936
|
-
let tools = toolkit
|
|
1088
|
+
let tools = toolkit?.toOpenAI() ?? []
|
|
937
1089
|
const toolCalls: ToolCallRecord[] = []
|
|
938
1090
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
939
1091
|
let turns = 0
|
|
@@ -956,7 +1108,7 @@ export class Client {
|
|
|
956
1108
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
957
1109
|
body: JSON.stringify(this.openaiBody(messages, tools, true)),
|
|
958
1110
|
}, signal)
|
|
959
|
-
if (!res.ok || !res.body) throw
|
|
1111
|
+
if (!res.ok || !res.body) throw await llmHttpError(res)
|
|
960
1112
|
for await (const line of sseLines(res.body)) {
|
|
961
1113
|
if (!line.startsWith("data:")) continue
|
|
962
1114
|
const payload = line.slice(5).trim()
|
|
@@ -1019,15 +1171,14 @@ export class Client {
|
|
|
1019
1171
|
}
|
|
1020
1172
|
|
|
1021
1173
|
// ---- Streaming: Anthropic-style ----
|
|
1022
|
-
private async *
|
|
1174
|
+
private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1023
1175
|
const key = resolveKey(this.opts)
|
|
1024
|
-
const signal = this.makeSignal(external)
|
|
1025
1176
|
const runStart = Date.now()
|
|
1026
1177
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
1027
1178
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
1028
1179
|
const system = this.system(toolkit)
|
|
1029
1180
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
1030
|
-
let tools = toolkit
|
|
1181
|
+
let tools = toolkit?.toAnthropic() ?? []
|
|
1031
1182
|
const toolCalls: ToolCallRecord[] = []
|
|
1032
1183
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1033
1184
|
let turns = 0
|
|
@@ -1050,7 +1201,7 @@ export class Client {
|
|
|
1050
1201
|
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
1051
1202
|
body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
|
|
1052
1203
|
}, signal)
|
|
1053
|
-
if (!res.ok || !res.body) throw
|
|
1204
|
+
if (!res.ok || !res.body) throw await llmHttpError(res)
|
|
1054
1205
|
for await (const line of sseLines(res.body)) {
|
|
1055
1206
|
if (!line.startsWith("data:")) continue
|
|
1056
1207
|
const j = safeParse(line.slice(5).trim())
|
|
@@ -1162,19 +1313,18 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
|
|
|
1162
1313
|
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
1163
1314
|
|
|
1164
1315
|
/**
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1316
|
+
* Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
|
|
1317
|
+
* the reusable piece of {@link createInProcessClient} — assembles the wire request,
|
|
1318
|
+
* calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
|
|
1319
|
+
* caller that wants an in-process model behind a `fetch` slot (the top-level client,
|
|
1320
|
+
* or the agent runtime's `inProcess` option) goes through this ONE function, so there
|
|
1321
|
+
* is no second copy of the request/response assembly to drift.
|
|
1171
1322
|
*
|
|
1172
1323
|
* Streaming is refused rather than faked — see the thrown error.
|
|
1173
1324
|
*/
|
|
1174
|
-
export function
|
|
1175
|
-
const { generate, ...rest } = opts
|
|
1325
|
+
export function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch {
|
|
1176
1326
|
if (typeof generate !== "function") {
|
|
1177
|
-
throw new Error("toolnexus:
|
|
1327
|
+
throw new Error("toolnexus: createInProcessFetch requires a `generate` function")
|
|
1178
1328
|
}
|
|
1179
1329
|
|
|
1180
1330
|
const inProcessFetch: typeof fetch = async (_url, init) => {
|
|
@@ -1229,6 +1379,25 @@ export function createInProcessClient(opts: InProcessOptions): Client {
|
|
|
1229
1379
|
)
|
|
1230
1380
|
}
|
|
1231
1381
|
|
|
1382
|
+
return inProcessFetch
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
1387
|
+
* HTTP types to construct.
|
|
1388
|
+
*
|
|
1389
|
+
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
1390
|
+
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
1391
|
+
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
1392
|
+
* The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
|
|
1393
|
+
* is just that adapter wired into a `Client`.
|
|
1394
|
+
*
|
|
1395
|
+
* Streaming is refused rather than faked — see {@link createInProcessFetch}.
|
|
1396
|
+
*/
|
|
1397
|
+
export function createInProcessClient(opts: InProcessOptions): Client {
|
|
1398
|
+
const { generate, ...rest } = opts
|
|
1399
|
+
const inProcessFetch = createInProcessFetch(generate)
|
|
1400
|
+
|
|
1232
1401
|
return new Client({
|
|
1233
1402
|
// Retries default to ZERO here, unlike a network client. There is no wire, so
|
|
1234
1403
|
// there is no transient failure to ride out: whatever `generate` throws will
|
|
@@ -1249,7 +1418,7 @@ export function createInProcessClient(opts: InProcessOptions): Client {
|
|
|
1249
1418
|
export class Conversation {
|
|
1250
1419
|
/** Full running transcript (system + user + assistant + tool messages). */
|
|
1251
1420
|
messages: any[] = []
|
|
1252
|
-
constructor(private readonly client: Client, private readonly toolkit
|
|
1421
|
+
constructor(private readonly client: Client, private readonly toolkit?: Toolkit, private readonly signal?: AbortSignal) {}
|
|
1253
1422
|
|
|
1254
1423
|
/** Send the next user turn; prior history is retained automatically. */
|
|
1255
1424
|
async send(prompt: PromptInput): Promise<RunResult> {
|