toolnexus 0.21.0 → 0.23.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/README.md +50 -4
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/client.d.ts +77 -2
- package/dist/client.js +304 -61
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/toolkit.d.ts +2 -2
- package/dist/toolkit.js +2 -2
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +4 -3
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +330 -66
- package/src/index.ts +5 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/toolkit.ts +2 -2
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/dist/client.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { pendingOf } from "./types.js";
|
|
2
|
+
import { toOpenAI, toAnthropic } from "./adapters.js";
|
|
3
|
+
import { applyPromptCache } from "./promptcache.js";
|
|
4
|
+
import { buildToolPlan, toolSearch, unwrapCallTool } from "./tooltiers.js";
|
|
2
5
|
import { isRetryableStatus, retryAfterMs } from "./retry.js";
|
|
3
6
|
import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
|
|
4
7
|
import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
|
|
@@ -74,14 +77,29 @@ export async function llmHttpError(res) {
|
|
|
74
77
|
/** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
|
|
75
78
|
export class InMemoryConversationStore {
|
|
76
79
|
map = new Map();
|
|
80
|
+
updated = new Map();
|
|
77
81
|
async get(id) {
|
|
78
82
|
const m = this.map.get(id);
|
|
79
83
|
return m ? [...m] : undefined;
|
|
80
84
|
}
|
|
81
85
|
async save(id, messages) {
|
|
82
86
|
this.map.set(id, [...messages]);
|
|
87
|
+
this.updated.set(id, new Date());
|
|
88
|
+
}
|
|
89
|
+
/** Every stored conversation, newest first (ties by id ascending). */
|
|
90
|
+
async list() {
|
|
91
|
+
return sortEntries([...this.updated].map(([id, updatedAt]) => ({ id, updatedAt })));
|
|
92
|
+
}
|
|
93
|
+
/** Remove a conversation; absent ids are not an error. */
|
|
94
|
+
async delete(id) {
|
|
95
|
+
this.map.delete(id);
|
|
96
|
+
this.updated.delete(id);
|
|
83
97
|
}
|
|
84
98
|
}
|
|
99
|
+
/** @internal Newest first, ties by id ascending (SPEC.md "Conversation memory"). */
|
|
100
|
+
export function sortEntries(entries) {
|
|
101
|
+
return entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
102
|
+
}
|
|
85
103
|
function delay(ms, signal) {
|
|
86
104
|
return new Promise((resolve, reject) => {
|
|
87
105
|
const t = setTimeout(resolve, ms);
|
|
@@ -91,12 +109,19 @@ function delay(ms, signal) {
|
|
|
91
109
|
function addUsage(acc, raw, style) {
|
|
92
110
|
if (!raw)
|
|
93
111
|
return;
|
|
112
|
+
const cache = (key, n) => {
|
|
113
|
+
if (typeof n === "number" && n > 0)
|
|
114
|
+
acc[key] = (acc[key] ?? 0) + n;
|
|
115
|
+
};
|
|
94
116
|
if (style === "anthropic") {
|
|
95
117
|
acc.promptTokens += raw.input_tokens ?? 0;
|
|
96
118
|
acc.completionTokens += raw.output_tokens ?? 0;
|
|
97
119
|
acc.totalTokens += (raw.input_tokens ?? 0) + (raw.output_tokens ?? 0);
|
|
120
|
+
cache("cacheReadTokens", raw.cache_read_input_tokens);
|
|
121
|
+
cache("cacheWriteTokens", raw.cache_creation_input_tokens);
|
|
98
122
|
}
|
|
99
123
|
else {
|
|
124
|
+
cache("cacheReadTokens", raw.prompt_tokens_details?.cached_tokens);
|
|
100
125
|
acc.promptTokens += raw.prompt_tokens ?? 0;
|
|
101
126
|
acc.completionTokens += raw.completion_tokens ?? 0;
|
|
102
127
|
acc.totalTokens += raw.total_tokens ?? (raw.prompt_tokens ?? 0) + (raw.completion_tokens ?? 0);
|
|
@@ -110,18 +135,45 @@ function perCall(raw, style) {
|
|
|
110
135
|
return { prompt: raw.input_tokens ?? 0, completion: raw.output_tokens ?? 0 };
|
|
111
136
|
return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 };
|
|
112
137
|
}
|
|
113
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Pick the API key (SPEC 4C, security). An explicit `apiKey` is used as-is; else `apiKeyEnv`;
|
|
140
|
+
* else ONLY the env var that belongs to the `baseUrl` host. There is no cross-provider fallback:
|
|
141
|
+
* any other host needs `apiKey` or `apiKeyEnv`. The value is never logged.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveKey(opts) {
|
|
114
144
|
if (opts.apiKey)
|
|
115
145
|
return opts.apiKey;
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
(
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
const envName = opts.apiKeyEnv || keyEnvForBaseUrl(opts.baseUrl);
|
|
147
|
+
if (!envName) {
|
|
148
|
+
throw new Error(`No API key: set apiKey or apiKeyEnv (provider env vars are only used for https://api.openai.com, https://api.anthropic.com and https://openrouter.ai, never for ${JSON.stringify(opts.baseUrl)})`);
|
|
149
|
+
}
|
|
150
|
+
const key = process.env[envName];
|
|
121
151
|
if (!key)
|
|
122
|
-
throw new Error(
|
|
152
|
+
throw new Error(`No API key: set apiKey or ${envName}`);
|
|
123
153
|
return key;
|
|
124
154
|
}
|
|
155
|
+
/** The env var whose key may be sent to `baseUrl`'s host, or "" when none may. */
|
|
156
|
+
export function keyEnvForBaseUrl(baseUrl) {
|
|
157
|
+
const h = hostOf(baseUrl);
|
|
158
|
+
if (h === "api.openai.com")
|
|
159
|
+
return "OPENAI_API_KEY";
|
|
160
|
+
if (h === "api.anthropic.com")
|
|
161
|
+
return "ANTHROPIC_API_KEY";
|
|
162
|
+
if (h === "openrouter.ai" || h.endsWith(".openrouter.ai"))
|
|
163
|
+
return "OPENROUTER_API_KEY";
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
function hostOf(baseUrl) {
|
|
167
|
+
try {
|
|
168
|
+
const u = new URL(baseUrl);
|
|
169
|
+
if (u.protocol !== "https:")
|
|
170
|
+
return ""; // a provider key is never sent over plain http
|
|
171
|
+
return u.hostname.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
125
177
|
// ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
|
|
126
178
|
/** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
|
|
127
179
|
const DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
@@ -217,6 +269,8 @@ export class Client {
|
|
|
217
269
|
store;
|
|
218
270
|
/** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
|
|
219
271
|
registry = new MetricsRegistry();
|
|
272
|
+
/** The frozen ToolTiers plan per toolkit (SPEC 8 Tool tiers). */
|
|
273
|
+
tierPlans = new WeakMap();
|
|
220
274
|
constructor(opts) {
|
|
221
275
|
this.opts = opts;
|
|
222
276
|
this.store = opts.store ?? new InMemoryConversationStore();
|
|
@@ -235,6 +289,8 @@ export class Client {
|
|
|
235
289
|
* every LLM body-build site so the ordering contract holds identically on all four paths.
|
|
236
290
|
*/
|
|
237
291
|
finalizeBody(body) {
|
|
292
|
+
if (this.opts.promptCache)
|
|
293
|
+
applyPromptCache(body, this.opts.style, this.opts.promptCache);
|
|
238
294
|
const rp = this.opts.requestParams;
|
|
239
295
|
if (rp) {
|
|
240
296
|
for (const [k, v] of Object.entries(rp)) {
|
|
@@ -489,9 +545,24 @@ export class Client {
|
|
|
489
545
|
}
|
|
490
546
|
if (!ctx?.id)
|
|
491
547
|
return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal });
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
await this.store.
|
|
548
|
+
checkPromptParts(prompt, this.wireOpts());
|
|
549
|
+
const id = ctx.id;
|
|
550
|
+
const history = (await this.store.get(id)) ?? [];
|
|
551
|
+
// §8: an interrupted turn is saved too — the loop hands back its transcript, repaired.
|
|
552
|
+
let partial;
|
|
553
|
+
const onInterrupt = (m) => { partial = m; };
|
|
554
|
+
let result;
|
|
555
|
+
try {
|
|
556
|
+
result = this.opts.style === "anthropic"
|
|
557
|
+
? await this.runAnthropic(prompt, ctx.toolkit, ctx.signal, history, onInterrupt)
|
|
558
|
+
: await this.runOpenAI(prompt, ctx.toolkit, ctx.signal, history, onInterrupt);
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
if (partial)
|
|
562
|
+
await this.store.save(id, partial);
|
|
563
|
+
throw e;
|
|
564
|
+
}
|
|
565
|
+
await this.store.save(id, result.messages);
|
|
495
566
|
return result;
|
|
496
567
|
}
|
|
497
568
|
/** A stateful multi-turn conversation that retains history across sends. */
|
|
@@ -506,13 +577,23 @@ export class Client {
|
|
|
506
577
|
async *stream(prompt, ctx) {
|
|
507
578
|
checkPromptParts(prompt, this.wireOpts());
|
|
508
579
|
const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
|
|
580
|
+
// §8: with an id, an interrupted stream saves its repaired transcript before the error surfaces.
|
|
581
|
+
let partial;
|
|
582
|
+
const onInterrupt = ctx?.id ? (m) => { partial = m; } : undefined;
|
|
509
583
|
const gen = this.opts.style === "anthropic"
|
|
510
|
-
? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
|
|
511
|
-
: this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
584
|
+
? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt)
|
|
585
|
+
: this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt);
|
|
586
|
+
try {
|
|
587
|
+
for await (const ev of gen) {
|
|
588
|
+
if (ev.type === "done" && ctx?.id)
|
|
589
|
+
await this.store.save(ctx.id, ev.result.messages);
|
|
590
|
+
yield ev;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
if (partial && ctx?.id)
|
|
595
|
+
await this.store.save(ctx.id, partial);
|
|
596
|
+
throw e;
|
|
516
597
|
}
|
|
517
598
|
}
|
|
518
599
|
/** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
|
|
@@ -587,13 +668,32 @@ export class Client {
|
|
|
587
668
|
throw lastErr;
|
|
588
669
|
}
|
|
589
670
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
590
|
-
async runTool(toolkit, name, args, id, turn) {
|
|
671
|
+
async runTool(toolkit, name, args, id, turn, signal) {
|
|
672
|
+
if (this.opts.toolTiers && name === "tool_search") {
|
|
673
|
+
// Engine-internal and read-only: no hooks (SPEC 8 Tool tiers).
|
|
674
|
+
const t0 = Date.now();
|
|
675
|
+
const result = toolSearch(toolkit?.tools() ?? [], (n) => toolkit?.get(n), args ?? {});
|
|
676
|
+
this.emitTool(name, "builtin", result, t0);
|
|
677
|
+
return { args, result };
|
|
678
|
+
}
|
|
679
|
+
// call_tool is unwrapped HERE, so hooks, hints and permission gates see the real tool. The
|
|
680
|
+
// returned args keep the wrapper shape (with any beforeTool rewrite inside), so a 10
|
|
681
|
+
// re-run unwraps the same way.
|
|
682
|
+
const inner = this.unwrapCall(name, args);
|
|
683
|
+
if (inner) {
|
|
684
|
+
const r = await this.runToolDirect(toolkit, inner.name, inner.args, id, turn, signal);
|
|
685
|
+
return { args: { name: inner.name, arguments: r.args }, result: r.result };
|
|
686
|
+
}
|
|
687
|
+
return this.runToolDirect(toolkit, name, args, id, turn, signal);
|
|
688
|
+
}
|
|
689
|
+
async runToolDirect(toolkit, name, args, id, turn, signal) {
|
|
591
690
|
const h = this.opts.hooks;
|
|
592
|
-
const
|
|
691
|
+
const tool = toolkit?.get(name);
|
|
692
|
+
const source = tool?.source ?? "custom";
|
|
593
693
|
const t0 = Date.now();
|
|
594
694
|
let a = args;
|
|
595
695
|
if (h?.beforeTool) {
|
|
596
|
-
const ov = await h.beforeTool({ name, args: a, id, turn });
|
|
696
|
+
const ov = await h.beforeTool({ name, args: a, id, turn, ...(tool?.hints ? { hints: tool.hints } : {}) });
|
|
597
697
|
if (ov?.result) {
|
|
598
698
|
// short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
|
|
599
699
|
// call for metrics, but a suspension is classified pending, never an error.
|
|
@@ -605,7 +705,7 @@ export class Client {
|
|
|
605
705
|
}
|
|
606
706
|
// No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
|
|
607
707
|
let result = toolkit
|
|
608
|
-
? await toolkit.execute(name, a)
|
|
708
|
+
? await toolkit.execute(name, a, { ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
|
|
609
709
|
: { output: `unknown tool: ${name}`, isError: true };
|
|
610
710
|
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
611
711
|
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
@@ -618,6 +718,66 @@ export class Client {
|
|
|
618
718
|
this.emitTool(name, source, result, t0);
|
|
619
719
|
return { args: a, result };
|
|
620
720
|
}
|
|
721
|
+
/** ToolTiers `call_tool {name, arguments}` -> the real call; undefined when tiers are off. */
|
|
722
|
+
unwrapCall(name, args) {
|
|
723
|
+
return this.opts.toolTiers ? unwrapCallTool(name, args) : undefined;
|
|
724
|
+
}
|
|
725
|
+
/** The RunResult.toolCalls entry: a `call_tool` call is recorded as the real tool with its
|
|
726
|
+
* inner arguments, the name hooks and stream events see (the transcript keeps the wrapper). */
|
|
727
|
+
toolCallRecord(name, args, result) {
|
|
728
|
+
const inner = this.unwrapCall(name, args);
|
|
729
|
+
return { name: inner ? inner.name : name, args: inner ? inner.args : args, output: result.output, isError: result.isError, metadata: result.metadata };
|
|
730
|
+
}
|
|
731
|
+
/** The stream's tool_call event, naming the real tool for a `call_tool` call. */
|
|
732
|
+
callEvent(id, name, args) {
|
|
733
|
+
const inner = this.unwrapCall(name, args);
|
|
734
|
+
return inner ? { type: "tool_call", id, name: inner.name, args: inner.args, via: "call_tool" } : { type: "tool_call", id, name, args };
|
|
735
|
+
}
|
|
736
|
+
resultEvent(id, name, args, result) {
|
|
737
|
+
const inner = this.unwrapCall(name, args);
|
|
738
|
+
return {
|
|
739
|
+
type: "tool_result", id, name: inner ? inner.name : name, output: result.output, isError: result.isError,
|
|
740
|
+
...(result.metadata ? { metadata: result.metadata } : {}),
|
|
741
|
+
...(inner ? { via: "call_tool" } : {}),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
plan(toolkit) {
|
|
745
|
+
if (!this.opts.toolTiers || !toolkit)
|
|
746
|
+
return undefined;
|
|
747
|
+
let p = this.tierPlans.get(toolkit);
|
|
748
|
+
if (!p) {
|
|
749
|
+
p = buildToolPlan(this.opts.toolTiers, toolkit.tools());
|
|
750
|
+
this.tierPlans.set(toolkit, p);
|
|
751
|
+
}
|
|
752
|
+
return p;
|
|
753
|
+
}
|
|
754
|
+
/** The tools array for a request: core tools (sorted) then tool_search and call_tool. Without
|
|
755
|
+
* tiers it is every tool, as before. */
|
|
756
|
+
requestTools(toolkit) {
|
|
757
|
+
if (!toolkit)
|
|
758
|
+
return [];
|
|
759
|
+
const p = this.plan(toolkit);
|
|
760
|
+
if (!p)
|
|
761
|
+
return toolkit.tools();
|
|
762
|
+
const out = [];
|
|
763
|
+
for (const n of p.core) {
|
|
764
|
+
const t = toolkit.get(n);
|
|
765
|
+
if (t)
|
|
766
|
+
out.push(t);
|
|
767
|
+
}
|
|
768
|
+
return [...out, p.search, p.call];
|
|
769
|
+
}
|
|
770
|
+
/** The frozen tier plan for `toolkit` (built on first use): tool name -> tier. Undefined when
|
|
771
|
+
* `toolTiers` is not set. For a host UI or log. */
|
|
772
|
+
toolTierPlan(toolkit) {
|
|
773
|
+
const p = this.plan(toolkit);
|
|
774
|
+
return p ? Object.fromEntries(p.tier) : undefined;
|
|
775
|
+
}
|
|
776
|
+
/** Forget `toolkit`'s plan so the next request rebuilds it - call at a new session start.
|
|
777
|
+
* Mid-session it breaks the cached prefix. */
|
|
778
|
+
resetToolTiers(toolkit) {
|
|
779
|
+
this.tierPlans.delete(toolkit);
|
|
780
|
+
}
|
|
621
781
|
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
622
782
|
emitTool(name, source, result, t0) {
|
|
623
783
|
const req = pendingOf(result);
|
|
@@ -642,44 +802,44 @@ export class Client {
|
|
|
642
802
|
}
|
|
643
803
|
// The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
|
|
644
804
|
// run leaves: return, throw, or a consumer abandoning the stream half-way.
|
|
645
|
-
async runOpenAI(prompt, toolkit, external, history) {
|
|
805
|
+
async runOpenAI(prompt, toolkit, external, history, onInterrupt) {
|
|
646
806
|
const signal = this.makeSignal(external);
|
|
647
807
|
try {
|
|
648
|
-
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history);
|
|
808
|
+
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt);
|
|
649
809
|
}
|
|
650
810
|
finally {
|
|
651
811
|
this.releaseSignal(signal);
|
|
652
812
|
}
|
|
653
813
|
}
|
|
654
|
-
async runAnthropic(prompt, toolkit, external, history) {
|
|
814
|
+
async runAnthropic(prompt, toolkit, external, history, onInterrupt) {
|
|
655
815
|
const signal = this.makeSignal(external);
|
|
656
816
|
try {
|
|
657
|
-
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history);
|
|
817
|
+
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt);
|
|
658
818
|
}
|
|
659
819
|
finally {
|
|
660
820
|
this.releaseSignal(signal);
|
|
661
821
|
}
|
|
662
822
|
}
|
|
663
|
-
async *streamOpenAI(prompt, toolkit, external, history) {
|
|
823
|
+
async *streamOpenAI(prompt, toolkit, external, history, onInterrupt) {
|
|
664
824
|
const signal = this.makeSignal(external);
|
|
665
825
|
try {
|
|
666
|
-
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history);
|
|
826
|
+
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt);
|
|
667
827
|
}
|
|
668
828
|
finally {
|
|
669
829
|
this.releaseSignal(signal);
|
|
670
830
|
}
|
|
671
831
|
}
|
|
672
|
-
async *streamAnthropic(prompt, toolkit, external, history) {
|
|
832
|
+
async *streamAnthropic(prompt, toolkit, external, history, onInterrupt) {
|
|
673
833
|
const signal = this.makeSignal(external);
|
|
674
834
|
try {
|
|
675
|
-
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history);
|
|
835
|
+
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt);
|
|
676
836
|
}
|
|
677
837
|
finally {
|
|
678
838
|
this.releaseSignal(signal);
|
|
679
839
|
}
|
|
680
840
|
}
|
|
681
841
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
682
|
-
async runOpenAIWithSignal(signal, prompt, toolkit, history) {
|
|
842
|
+
async runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
|
|
683
843
|
const key = resolveKey(this.opts);
|
|
684
844
|
const runStart = Date.now();
|
|
685
845
|
let messages = history && history.length ? [...history] : [];
|
|
@@ -689,12 +849,14 @@ export class Client {
|
|
|
689
849
|
messages.push({ role: "system", content: system });
|
|
690
850
|
}
|
|
691
851
|
messages.push({ role: "user", content: prompt });
|
|
692
|
-
let tools =
|
|
852
|
+
let tools = toOpenAI(this.requestTools(toolkit));
|
|
693
853
|
const toolCalls = [];
|
|
694
854
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
695
855
|
let turns = 0;
|
|
696
856
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
697
857
|
let lastModel = this.opts.model;
|
|
858
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
859
|
+
const done = new Map();
|
|
698
860
|
try {
|
|
699
861
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
700
862
|
turns++;
|
|
@@ -723,22 +885,25 @@ export class Client {
|
|
|
723
885
|
if (calls.length === 0)
|
|
724
886
|
return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage);
|
|
725
887
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
726
|
-
|
|
727
|
-
|
|
888
|
+
done.clear();
|
|
889
|
+
const settled = await untilAborted(signal, Promise.all(calls.map(async (call) => {
|
|
890
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal);
|
|
728
891
|
let halted;
|
|
729
892
|
const req = pendingOf(result);
|
|
730
893
|
if (req) {
|
|
731
|
-
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
|
|
894
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn, signal);
|
|
732
895
|
result = r.result;
|
|
733
896
|
halted = r.halted;
|
|
734
897
|
}
|
|
898
|
+
if (!halted && call.id)
|
|
899
|
+
done.set(call.id, result);
|
|
735
900
|
return { call, args, result, halted };
|
|
736
|
-
}));
|
|
901
|
+
})));
|
|
737
902
|
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
738
903
|
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
739
904
|
// on resume). Mirrors the streaming path.
|
|
740
905
|
for (const s of settled) {
|
|
741
|
-
toolCalls.push(
|
|
906
|
+
toolCalls.push(this.toolCallRecord(s.call.function.name, s.args, s.result));
|
|
742
907
|
messages.push(toolMessage(s.call.id, s.call.function.name, s.result));
|
|
743
908
|
if (s.halted)
|
|
744
909
|
return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage);
|
|
@@ -748,6 +913,7 @@ export class Client {
|
|
|
748
913
|
}
|
|
749
914
|
catch (e) {
|
|
750
915
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
|
|
916
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
|
|
751
917
|
throw e;
|
|
752
918
|
}
|
|
753
919
|
}
|
|
@@ -768,15 +934,18 @@ export class Client {
|
|
|
768
934
|
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
769
935
|
* no `waitFor` is configured (the run should stop and surface the request).
|
|
770
936
|
*/
|
|
771
|
-
async resolvePending(toolkit, name, args, request, id, turn) {
|
|
937
|
+
async resolvePending(toolkit, name, args, request, id, turn, signal) {
|
|
938
|
+
const inner = this.unwrapCall(name, args);
|
|
939
|
+
if (inner)
|
|
940
|
+
({ name, args } = inner);
|
|
772
941
|
if (!this.opts.waitFor)
|
|
773
942
|
return { result: { output: request.prompt, isError: true }, halted: request };
|
|
774
|
-
const answer = await this.opts.waitFor(request);
|
|
943
|
+
const answer = await this.opts.waitFor(request, { signal: signal ?? new AbortController().signal });
|
|
775
944
|
if (!answer?.ok)
|
|
776
945
|
return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
|
|
777
946
|
const t0 = Date.now();
|
|
778
947
|
let result = toolkit
|
|
779
|
-
? await toolkit.execute(name, args, { answer })
|
|
948
|
+
? await toolkit.execute(name, args, { answer, ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
|
|
780
949
|
: { output: `unknown tool: ${name}`, isError: true };
|
|
781
950
|
if (this.opts.hooks?.afterTool) {
|
|
782
951
|
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
|
|
@@ -789,19 +958,21 @@ export class Client {
|
|
|
789
958
|
return { result };
|
|
790
959
|
}
|
|
791
960
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
792
|
-
async runAnthropicWithSignal(signal, prompt, toolkit, history) {
|
|
961
|
+
async runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt) {
|
|
793
962
|
const key = resolveKey(this.opts);
|
|
794
963
|
const base = this.opts.baseUrl.replace(/\/$/, "");
|
|
795
964
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
796
965
|
const system = this.system(toolkit);
|
|
797
966
|
const runStart = Date.now();
|
|
798
967
|
let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
|
|
799
|
-
let tools =
|
|
968
|
+
let tools = toAnthropic(this.requestTools(toolkit));
|
|
800
969
|
const toolCalls = [];
|
|
801
970
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
802
971
|
let turns = 0;
|
|
803
972
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
804
973
|
let lastModel = this.opts.model;
|
|
974
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
975
|
+
const done = new Map();
|
|
805
976
|
try {
|
|
806
977
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
807
978
|
turns++;
|
|
@@ -836,24 +1007,27 @@ export class Client {
|
|
|
836
1007
|
return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
|
|
837
1008
|
}
|
|
838
1009
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
839
|
-
|
|
840
|
-
|
|
1010
|
+
done.clear();
|
|
1011
|
+
const settled = await untilAborted(signal, Promise.all(uses.map(async (use) => {
|
|
1012
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal);
|
|
841
1013
|
let halted;
|
|
842
1014
|
const req = pendingOf(result);
|
|
843
1015
|
if (req) {
|
|
844
|
-
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
|
|
1016
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn, signal);
|
|
845
1017
|
result = r.result;
|
|
846
1018
|
halted = r.halted;
|
|
847
1019
|
}
|
|
1020
|
+
if (!halted && use.id)
|
|
1021
|
+
done.set(use.id, result);
|
|
848
1022
|
return { use, args, result, halted };
|
|
849
|
-
}));
|
|
1023
|
+
})));
|
|
850
1024
|
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
851
1025
|
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
852
1026
|
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
853
1027
|
const content = [];
|
|
854
1028
|
let halted;
|
|
855
1029
|
for (const s of settled) {
|
|
856
|
-
toolCalls.push(
|
|
1030
|
+
toolCalls.push(this.toolCallRecord(s.use.name, s.args, s.result));
|
|
857
1031
|
content.push(toolResultBlock(s.use.id, s.result));
|
|
858
1032
|
if (s.halted) {
|
|
859
1033
|
halted = s.halted;
|
|
@@ -868,11 +1042,12 @@ export class Client {
|
|
|
868
1042
|
}
|
|
869
1043
|
catch (e) {
|
|
870
1044
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
|
|
1045
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
|
|
871
1046
|
throw e;
|
|
872
1047
|
}
|
|
873
1048
|
}
|
|
874
1049
|
// ---- Streaming: OpenAI-style ----
|
|
875
|
-
async *streamOpenAIWithSignal(signal, prompt, toolkit, history) {
|
|
1050
|
+
async *streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
|
|
876
1051
|
const key = resolveKey(this.opts);
|
|
877
1052
|
const runStart = Date.now();
|
|
878
1053
|
let messages = history && history.length ? [...history] : [];
|
|
@@ -882,12 +1057,14 @@ export class Client {
|
|
|
882
1057
|
messages.push({ role: "system", content: system });
|
|
883
1058
|
}
|
|
884
1059
|
messages.push({ role: "user", content: prompt });
|
|
885
|
-
let tools =
|
|
1060
|
+
let tools = toOpenAI(this.requestTools(toolkit));
|
|
886
1061
|
const toolCalls = [];
|
|
887
1062
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
888
1063
|
let turns = 0;
|
|
889
1064
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
890
1065
|
let lastModel = this.opts.model;
|
|
1066
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1067
|
+
const done = new Map();
|
|
891
1068
|
try {
|
|
892
1069
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
893
1070
|
turns++;
|
|
@@ -957,49 +1134,58 @@ export class Client {
|
|
|
957
1134
|
}
|
|
958
1135
|
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
|
|
959
1136
|
for (const c of calls)
|
|
960
|
-
yield
|
|
961
|
-
|
|
1137
|
+
yield this.callEvent(c.id, c.name, safeJson(c.args));
|
|
1138
|
+
done.clear();
|
|
1139
|
+
const settled = await untilAborted(signal, Promise.all(calls.map(async (c) => {
|
|
1140
|
+
const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal);
|
|
1141
|
+
if (!pendingOf(r.result) && c.id)
|
|
1142
|
+
done.set(c.id, r.result);
|
|
1143
|
+
return r;
|
|
1144
|
+
})));
|
|
962
1145
|
for (let i = 0; i < calls.length; i++) {
|
|
963
1146
|
const c = calls[i];
|
|
964
1147
|
let { args, result } = settled[i];
|
|
965
1148
|
const req = pendingOf(result);
|
|
966
1149
|
if (req) {
|
|
967
1150
|
yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
|
|
968
|
-
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
|
|
1151
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal);
|
|
969
1152
|
result = r.result;
|
|
970
1153
|
if (r.halted) {
|
|
971
|
-
toolCalls.push(
|
|
1154
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result));
|
|
972
1155
|
messages.push(toolMessage(c.id, c.name, result));
|
|
973
1156
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) };
|
|
974
1157
|
return;
|
|
975
1158
|
}
|
|
976
1159
|
}
|
|
977
|
-
toolCalls.push(
|
|
1160
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result));
|
|
978
1161
|
messages.push(toolMessage(c.id, c.name, result));
|
|
979
|
-
yield
|
|
1162
|
+
yield this.resultEvent(c.id, c.name, safeJson(c.args), result);
|
|
980
1163
|
}
|
|
981
1164
|
}
|
|
982
1165
|
yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) };
|
|
983
1166
|
}
|
|
984
1167
|
catch (e) {
|
|
985
1168
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
|
|
1169
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
|
|
986
1170
|
throw e;
|
|
987
1171
|
}
|
|
988
1172
|
}
|
|
989
1173
|
// ---- Streaming: Anthropic-style ----
|
|
990
|
-
async *streamAnthropicWithSignal(signal, prompt, toolkit, history) {
|
|
1174
|
+
async *streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt) {
|
|
991
1175
|
const key = resolveKey(this.opts);
|
|
992
1176
|
const runStart = Date.now();
|
|
993
1177
|
const base = this.opts.baseUrl.replace(/\/$/, "");
|
|
994
1178
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
995
1179
|
const system = this.system(toolkit);
|
|
996
1180
|
let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
|
|
997
|
-
let tools =
|
|
1181
|
+
let tools = toAnthropic(this.requestTools(toolkit));
|
|
998
1182
|
const toolCalls = [];
|
|
999
1183
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
1000
1184
|
let turns = 0;
|
|
1001
1185
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1002
1186
|
let lastModel = this.opts.model;
|
|
1187
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1188
|
+
const done = new Map();
|
|
1003
1189
|
try {
|
|
1004
1190
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
1005
1191
|
turns++;
|
|
@@ -1068,8 +1254,14 @@ export class Client {
|
|
|
1068
1254
|
return;
|
|
1069
1255
|
}
|
|
1070
1256
|
for (const u of uses)
|
|
1071
|
-
yield
|
|
1072
|
-
|
|
1257
|
+
yield this.callEvent(u.id, u.name, u.input);
|
|
1258
|
+
done.clear();
|
|
1259
|
+
const settled = await untilAborted(signal, Promise.all(uses.map(async (u) => {
|
|
1260
|
+
const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal);
|
|
1261
|
+
if (!pendingOf(r.result) && u.id)
|
|
1262
|
+
done.set(u.id, r.result);
|
|
1263
|
+
return r;
|
|
1264
|
+
})));
|
|
1073
1265
|
const results = [];
|
|
1074
1266
|
for (let i = 0; i < uses.length; i++) {
|
|
1075
1267
|
const u = uses[i];
|
|
@@ -1077,19 +1269,19 @@ export class Client {
|
|
|
1077
1269
|
const req = pendingOf(result);
|
|
1078
1270
|
if (req) {
|
|
1079
1271
|
yield { type: "pending", request: req };
|
|
1080
|
-
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
|
|
1272
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal);
|
|
1081
1273
|
result = r.result;
|
|
1082
1274
|
if (r.halted) {
|
|
1083
|
-
toolCalls.push(
|
|
1275
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result));
|
|
1084
1276
|
results.push(toolResultBlock(u.id, result));
|
|
1085
1277
|
messages.push({ role: "user", content: results });
|
|
1086
1278
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) };
|
|
1087
1279
|
return;
|
|
1088
1280
|
}
|
|
1089
1281
|
}
|
|
1090
|
-
toolCalls.push(
|
|
1282
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result));
|
|
1091
1283
|
results.push(toolResultBlock(u.id, result));
|
|
1092
|
-
yield
|
|
1284
|
+
yield this.resultEvent(u.id, u.name, u.input, result);
|
|
1093
1285
|
}
|
|
1094
1286
|
messages.push({ role: "user", content: results });
|
|
1095
1287
|
}
|
|
@@ -1097,6 +1289,7 @@ export class Client {
|
|
|
1097
1289
|
}
|
|
1098
1290
|
catch (e) {
|
|
1099
1291
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
|
|
1292
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
|
|
1100
1293
|
throw e;
|
|
1101
1294
|
}
|
|
1102
1295
|
}
|
|
@@ -1276,6 +1469,56 @@ function toolResultBlock(id, result) {
|
|
|
1276
1469
|
b.parts = result.parts;
|
|
1277
1470
|
return b;
|
|
1278
1471
|
}
|
|
1472
|
+
const INTERRUPTED = { output: "interrupted: the turn ended before this tool returned. Whether its side effects happened is unknown. Do not re-run it automatically; check first.", isError: true };
|
|
1473
|
+
/** Settle with `p`, or reject with the abort reason as soon as `signal` aborts. */
|
|
1474
|
+
function untilAborted(signal, p) {
|
|
1475
|
+
if (signal.aborted)
|
|
1476
|
+
return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
1477
|
+
return new Promise((resolve, reject) => {
|
|
1478
|
+
const onAbort = () => reject(signal.reason ?? new Error("aborted"));
|
|
1479
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1480
|
+
p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* §8 "Interrupted turns are saved": a copy of `messages` in which every tool call of the LAST
|
|
1485
|
+
* assistant message has a result — the finished one from `done`, else `interrupted` (isError) —
|
|
1486
|
+
* in the dialect's own shape, so the saved transcript replays on either API.
|
|
1487
|
+
*/
|
|
1488
|
+
function interruptedTranscript(messages, anthropic, done) {
|
|
1489
|
+
const out = [...messages];
|
|
1490
|
+
let ai = -1;
|
|
1491
|
+
for (let i = out.length - 1; i >= 0; i--)
|
|
1492
|
+
if (out[i]?.role === "assistant") {
|
|
1493
|
+
ai = i;
|
|
1494
|
+
break;
|
|
1495
|
+
}
|
|
1496
|
+
if (ai < 0)
|
|
1497
|
+
return out;
|
|
1498
|
+
const resultFor = (id) => done.get(id) ?? INTERRUPTED;
|
|
1499
|
+
if (!anthropic) {
|
|
1500
|
+
const calls = out[ai].tool_calls ?? [];
|
|
1501
|
+
const have = new Set(out.slice(ai + 1).filter((m) => m?.role === "tool").map((m) => m.tool_call_id));
|
|
1502
|
+
for (const c of calls)
|
|
1503
|
+
if (!have.has(c.id))
|
|
1504
|
+
out.push(toolMessage(c.id, c.function?.name ?? "", resultFor(c.id)));
|
|
1505
|
+
return out;
|
|
1506
|
+
}
|
|
1507
|
+
const uses = Array.isArray(out[ai].content) ? out[ai].content.filter((b) => b?.type === "tool_use") : [];
|
|
1508
|
+
if (!uses.length)
|
|
1509
|
+
return out;
|
|
1510
|
+
const next = out[ai + 1];
|
|
1511
|
+
const blocks = next?.role === "user" && Array.isArray(next.content) ? [...next.content] : [];
|
|
1512
|
+
const have = new Set(blocks.filter((b) => b?.type === "tool_result").map((b) => b.tool_use_id));
|
|
1513
|
+
for (const u of uses)
|
|
1514
|
+
if (!have.has(u.id))
|
|
1515
|
+
blocks.push(toolResultBlock(u.id, resultFor(u.id)));
|
|
1516
|
+
if (next?.role === "user" && Array.isArray(next.content))
|
|
1517
|
+
out[ai + 1] = { ...next, content: blocks };
|
|
1518
|
+
else
|
|
1519
|
+
out.splice(ai + 1, 0, { role: "user", content: blocks });
|
|
1520
|
+
return out;
|
|
1521
|
+
}
|
|
1279
1522
|
function lastText(messages) {
|
|
1280
1523
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1281
1524
|
if (messages[i].role === "assistant" && typeof messages[i].content === "string")
|