toolnexus 0.23.0 → 0.24.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 +23 -0
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +52 -18
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +3 -0
- package/dist/toolkit.js +1 -1
- package/dist/types.d.ts +4 -1
- package/package.json +3 -2
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +72 -21
- package/src/index.ts +2 -0
- package/src/media.ts +604 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +4 -1
- package/src/types.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/examples.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts",
|
|
24
|
+
"example:media": "node --experimental-strip-types examples/media.ts",
|
|
24
25
|
"example:prompt-cache": "node --experimental-strip-types examples/prompt_cache.ts"
|
|
25
26
|
},
|
|
26
27
|
"keywords": [
|
package/src/agents/loop.ts
CHANGED
|
@@ -141,6 +141,7 @@ export async function runGated(
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
let accumulated: RunResult["toolCalls"] = []
|
|
144
|
+
let accFiles: RunResult["files"] = []
|
|
144
145
|
let last: RunResult | undefined
|
|
145
146
|
let reason = ""
|
|
146
147
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -149,7 +150,9 @@ export async function runGated(
|
|
|
149
150
|
// The gate judges the ACCUMULATED work, so an agent cannot escape it by
|
|
150
151
|
// declining to re-declare its plan on a retry.
|
|
151
152
|
accumulated = [...accumulated, ...r.toolCalls]
|
|
152
|
-
|
|
153
|
+
// RunResult.files is derived from toolCalls, so it accumulates with them (§8).
|
|
154
|
+
accFiles = [...accFiles, ...(r.files ?? [])]
|
|
155
|
+
const judged: RunResult = { ...r, toolCalls: accumulated, files: accFiles }
|
|
153
156
|
last = judged
|
|
154
157
|
|
|
155
158
|
if (r.status && r.status !== "done") {
|
package/src/agents/runtime.ts
CHANGED
|
@@ -1012,7 +1012,7 @@ export class AgentRuntime {
|
|
|
1012
1012
|
const team = [...(parent.def.team ?? [])].sort()
|
|
1013
1013
|
const advertised = team.map((n) => (this.registry[n] ? `${n}: ${this.registry[n].does}` : n)).join("; ")
|
|
1014
1014
|
const rt = this
|
|
1015
|
-
|
|
1015
|
+
const tool = defineTool({
|
|
1016
1016
|
name: "task",
|
|
1017
1017
|
description: `Delegate a subtask to an isolated subagent (it runs on a fresh transcript and returns only its final answer). Available agents — ${advertised}`,
|
|
1018
1018
|
inputSchema: {
|
|
@@ -1068,5 +1068,8 @@ export class AgentRuntime {
|
|
|
1068
1068
|
}
|
|
1069
1069
|
},
|
|
1070
1070
|
})
|
|
1071
|
+
// Each subagent is isolated and the runtime's own gate schedules the children,
|
|
1072
|
+
// so a fan-out of task calls runs concurrently (SPEC §8 "Tool scheduling").
|
|
1073
|
+
return { ...tool, concurrencySafe: () => true }
|
|
1071
1074
|
}
|
|
1072
1075
|
}
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { toOpenAI, toAnthropic } from "./adapters.js"
|
|
|
10
10
|
import { applyPromptCache, type PromptCacheOptions } from "./promptcache.js"
|
|
11
11
|
import { buildToolPlan, toolSearch, unwrapCallTool, type ToolPlan, type ToolTier, type ToolTierOptions } from "./tooltiers.js"
|
|
12
12
|
import { isRetryableStatus, retryAfterMs } from "./retry.js"
|
|
13
|
+
import { isConcurrencySafe, resolveMaxToolConcurrency, scheduleCalls } from "./toolconcurrency.js"
|
|
13
14
|
import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
|
|
14
15
|
import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
|
|
15
16
|
import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
|
|
@@ -41,6 +42,10 @@ export interface ClientOptions {
|
|
|
41
42
|
headers?: Record<string, string>
|
|
42
43
|
systemPrompt?: string
|
|
43
44
|
maxTurns?: number
|
|
45
|
+
/** Cap on concurrency-safe tool calls of one response running at once (SPEC §8 "Tool
|
|
46
|
+
* scheduling"). Absent ⇒ env `TOOLNEXUS_MAX_TOOL_CONCURRENCY` when a positive integer, else 10.
|
|
47
|
+
* Unsafe calls (anything not read-only) always run alone. */
|
|
48
|
+
maxToolConcurrency?: number
|
|
44
49
|
hooks?: Hooks
|
|
45
50
|
/** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
|
|
46
51
|
* Widen the status set with `retryableStatuses`. */
|
|
@@ -318,6 +323,26 @@ export interface ToolCallRecord {
|
|
|
318
323
|
output: string
|
|
319
324
|
isError: boolean
|
|
320
325
|
metadata?: Record<string, unknown>
|
|
326
|
+
/** §8: the ToolResult's parts — present iff the result had parts. */
|
|
327
|
+
parts?: ContentPart[]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** §8 RunResult.files entry: a non-text part a tool call produced this run. */
|
|
331
|
+
export type RunFile = { type: "image" | "file" | "audio"; mimeType: string; url?: string; data?: string; name?: string; from: string; callId: string }
|
|
332
|
+
|
|
333
|
+
/** Model tool-call id of each record, kept off the record itself (§8 shape has no id). */
|
|
334
|
+
const callIds = new WeakMap<ToolCallRecord, string>()
|
|
335
|
+
|
|
336
|
+
/** RunResult.files: every non-text part of every tool call, in call order (§8). */
|
|
337
|
+
function runFiles(toolCalls: ToolCallRecord[]): RunFile[] {
|
|
338
|
+
const out: RunFile[] = []
|
|
339
|
+
for (const r of toolCalls) {
|
|
340
|
+
for (const p of r.parts ?? []) {
|
|
341
|
+
if (p.type === "text") continue
|
|
342
|
+
out.push({ ...p, from: r.name, callId: callIds.get(r) ?? "" })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
321
346
|
}
|
|
322
347
|
|
|
323
348
|
/** Token usage, summed across every LLM round trip in the run. */
|
|
@@ -340,6 +365,8 @@ export interface RunResult {
|
|
|
340
365
|
messages: any[]
|
|
341
366
|
/** Every tool call made, with its output, error flag, and metadata. */
|
|
342
367
|
toolCalls: ToolCallRecord[]
|
|
368
|
+
/** Every non-text part produced by tool calls this run, in call order (§8). Always present. */
|
|
369
|
+
files: RunFile[]
|
|
343
370
|
/** Total number of tool calls (= toolCalls.length). */
|
|
344
371
|
toolCallCount: number
|
|
345
372
|
/** Number of LLM round trips. */
|
|
@@ -990,6 +1017,27 @@ export class Client {
|
|
|
990
1017
|
return { args: a, result }
|
|
991
1018
|
}
|
|
992
1019
|
|
|
1020
|
+
/** Runs one response's calls under the tool schedule (SPEC §8 "Tool scheduling"). */
|
|
1021
|
+
private schedule<T, R>(
|
|
1022
|
+
toolkit: Toolkit | undefined,
|
|
1023
|
+
items: T[],
|
|
1024
|
+
key: (item: T) => [string, Record<string, unknown>],
|
|
1025
|
+
fn: (item: T) => Promise<R>,
|
|
1026
|
+
): Promise<R[]> {
|
|
1027
|
+
return scheduleCalls(items, (item) => {
|
|
1028
|
+
const [name, args] = key(item)
|
|
1029
|
+
return this.callSafe(toolkit, name, args)
|
|
1030
|
+
}, resolveMaxToolConcurrency(this.opts.maxToolConcurrency), fn)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** One call's safety; looks through `call_tool` to the tool it names. Unknown ⇒ unsafe. */
|
|
1034
|
+
private callSafe(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>): boolean {
|
|
1035
|
+
if (this.opts.toolTiers && name === "tool_search") return true
|
|
1036
|
+
const inner = this.unwrapCall(name, args ?? {})
|
|
1037
|
+
const tool = toolkit?.get(inner ? inner.name : name)
|
|
1038
|
+
return tool ? isConcurrencySafe(tool, inner ? inner.args : (args ?? {})) : false
|
|
1039
|
+
}
|
|
1040
|
+
|
|
993
1041
|
/** ToolTiers `call_tool {name, arguments}` -> the real call; undefined when tiers are off. */
|
|
994
1042
|
private unwrapCall(name: string, args: Record<string, unknown>): { name: string; args: Record<string, unknown> } | undefined {
|
|
995
1043
|
return this.opts.toolTiers ? unwrapCallTool(name, args) : undefined
|
|
@@ -997,9 +1045,12 @@ export class Client {
|
|
|
997
1045
|
|
|
998
1046
|
/** The RunResult.toolCalls entry: a `call_tool` call is recorded as the real tool with its
|
|
999
1047
|
* inner arguments, the name hooks and stream events see (the transcript keeps the wrapper). */
|
|
1000
|
-
private toolCallRecord(name: string, args: Record<string, unknown>, result: ToolResult): ToolCallRecord {
|
|
1048
|
+
private toolCallRecord(name: string, args: Record<string, unknown>, result: ToolResult, callId?: string): ToolCallRecord {
|
|
1001
1049
|
const inner = this.unwrapCall(name, args)
|
|
1002
|
-
|
|
1050
|
+
const rec: ToolCallRecord = { name: inner ? inner.name : name, args: inner ? inner.args : args, output: result.output, isError: result.isError, metadata: result.metadata }
|
|
1051
|
+
if (result.parts?.length) rec.parts = result.parts
|
|
1052
|
+
callIds.set(rec, callId ?? "")
|
|
1053
|
+
return rec
|
|
1003
1054
|
}
|
|
1004
1055
|
|
|
1005
1056
|
/** The stream's tool_call event, naming the real tool for a `call_tool` call. */
|
|
@@ -1157,8 +1208,8 @@ export class Client {
|
|
|
1157
1208
|
if (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
1158
1209
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
1159
1210
|
done.clear()
|
|
1160
|
-
const settled = await untilAborted(signal,
|
|
1161
|
-
calls.
|
|
1211
|
+
const settled = await untilAborted(signal, this.schedule(toolkit,
|
|
1212
|
+
calls, (call: any) => [call.function.name, safeJson(call.function.arguments)], async (call: any) => {
|
|
1162
1213
|
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal)
|
|
1163
1214
|
let halted: Request | undefined
|
|
1164
1215
|
const req = pendingOf(result)
|
|
@@ -1169,13 +1220,13 @@ export class Client {
|
|
|
1169
1220
|
}
|
|
1170
1221
|
if (!halted && call.id) done.set(call.id, result)
|
|
1171
1222
|
return { call, args, result, halted }
|
|
1172
|
-
}
|
|
1223
|
+
},
|
|
1173
1224
|
))
|
|
1174
1225
|
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
1175
1226
|
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
1176
1227
|
// on resume). Mirrors the streaming path.
|
|
1177
1228
|
for (const s of settled) {
|
|
1178
|
-
toolCalls.push(this.toolCallRecord(s.call.function.name, s.args, s.result))
|
|
1229
|
+
toolCalls.push(this.toolCallRecord(s.call.function.name, s.args, s.result, s.call.id))
|
|
1179
1230
|
messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
|
|
1180
1231
|
if (s.halted) return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage)
|
|
1181
1232
|
}
|
|
@@ -1190,7 +1241,7 @@ export class Client {
|
|
|
1190
1241
|
|
|
1191
1242
|
private result(model: string, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
|
|
1192
1243
|
const incomplete = exhausted && text === ""
|
|
1193
|
-
const r: RunResult = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" }
|
|
1244
|
+
const r: RunResult = { text, messages, toolCalls, files: runFiles(toolCalls), toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" }
|
|
1194
1245
|
if (incomplete) r.limit = "maxTurns"
|
|
1195
1246
|
return r
|
|
1196
1247
|
}
|
|
@@ -1198,7 +1249,7 @@ export class Client {
|
|
|
1198
1249
|
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
1199
1250
|
private pendingRun(runStart: number, model: string, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
1200
1251
|
this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
|
|
1201
|
-
return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: "pending", pending: request }
|
|
1252
|
+
return { text: request.prompt, messages, toolCalls, files: runFiles(toolCalls), toolCallCount: toolCalls.length, turns, usage, model, status: "pending", pending: request }
|
|
1202
1253
|
}
|
|
1203
1254
|
|
|
1204
1255
|
/**
|
|
@@ -1275,8 +1326,8 @@ export class Client {
|
|
|
1275
1326
|
}
|
|
1276
1327
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
1277
1328
|
done.clear()
|
|
1278
|
-
const settled = await untilAborted(signal,
|
|
1279
|
-
uses.
|
|
1329
|
+
const settled = await untilAborted(signal, this.schedule(toolkit,
|
|
1330
|
+
uses, (use: any) => [use.name, use.input ?? {}], async (use: any) => {
|
|
1280
1331
|
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal)
|
|
1281
1332
|
let halted: Request | undefined
|
|
1282
1333
|
const req = pendingOf(result)
|
|
@@ -1287,7 +1338,7 @@ export class Client {
|
|
|
1287
1338
|
}
|
|
1288
1339
|
if (!halted && use.id) done.set(use.id, result)
|
|
1289
1340
|
return { use, args, result, halted }
|
|
1290
|
-
}
|
|
1341
|
+
},
|
|
1291
1342
|
))
|
|
1292
1343
|
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
1293
1344
|
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
@@ -1295,7 +1346,7 @@ export class Client {
|
|
|
1295
1346
|
const content: any[] = []
|
|
1296
1347
|
let halted: Request | undefined
|
|
1297
1348
|
for (const s of settled) {
|
|
1298
|
-
toolCalls.push(this.toolCallRecord(s.use.name, s.args, s.result))
|
|
1349
|
+
toolCalls.push(this.toolCallRecord(s.use.name, s.args, s.result, s.use.id))
|
|
1299
1350
|
content.push(toolResultBlock(s.use.id, s.result))
|
|
1300
1351
|
if (s.halted) { halted = s.halted; break }
|
|
1301
1352
|
}
|
|
@@ -1385,11 +1436,11 @@ export class Client {
|
|
|
1385
1436
|
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
1386
1437
|
for (const c of calls) yield this.callEvent(c.id, c.name, safeJson(c.args))
|
|
1387
1438
|
done.clear()
|
|
1388
|
-
const settled = await untilAborted(signal,
|
|
1439
|
+
const settled = await untilAborted(signal, this.schedule(toolkit, calls, (c) => [c.name, safeJson(c.args)], async (c) => {
|
|
1389
1440
|
const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal)
|
|
1390
|
-
|
|
1441
|
+
if (!pendingOf(r.result) && c.id) done.set(c.id, r.result)
|
|
1391
1442
|
return r
|
|
1392
|
-
}))
|
|
1443
|
+
}))
|
|
1393
1444
|
for (let i = 0; i < calls.length; i++) {
|
|
1394
1445
|
const c = calls[i]
|
|
1395
1446
|
let { args, result } = settled[i]
|
|
@@ -1399,13 +1450,13 @@ export class Client {
|
|
|
1399
1450
|
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal)
|
|
1400
1451
|
result = r.result
|
|
1401
1452
|
if (r.halted) {
|
|
1402
|
-
toolCalls.push(this.toolCallRecord(c.name, args, result))
|
|
1453
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result, c.id))
|
|
1403
1454
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1404
1455
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
|
|
1405
1456
|
return
|
|
1406
1457
|
}
|
|
1407
1458
|
}
|
|
1408
|
-
toolCalls.push(this.toolCallRecord(c.name, args, result))
|
|
1459
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result, c.id))
|
|
1409
1460
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1410
1461
|
yield this.resultEvent(c.id, c.name, safeJson(c.args), result)
|
|
1411
1462
|
}
|
|
@@ -1487,11 +1538,11 @@ export class Client {
|
|
|
1487
1538
|
}
|
|
1488
1539
|
for (const u of uses as any[]) yield this.callEvent(u.id, u.name, u.input)
|
|
1489
1540
|
done.clear()
|
|
1490
|
-
const settled = await untilAborted(signal,
|
|
1541
|
+
const settled = await untilAborted(signal, this.schedule(toolkit, uses as any[], (u: any) => [u.name, u.input ?? {}], async (u: any) => {
|
|
1491
1542
|
const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal)
|
|
1492
1543
|
if (!pendingOf(r.result) && u.id) done.set(u.id, r.result)
|
|
1493
1544
|
return r
|
|
1494
|
-
}))
|
|
1545
|
+
}))
|
|
1495
1546
|
const results: any[] = []
|
|
1496
1547
|
for (let i = 0; i < uses.length; i++) {
|
|
1497
1548
|
const u = uses[i] as any
|
|
@@ -1502,14 +1553,14 @@ export class Client {
|
|
|
1502
1553
|
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal)
|
|
1503
1554
|
result = r.result
|
|
1504
1555
|
if (r.halted) {
|
|
1505
|
-
toolCalls.push(this.toolCallRecord(u.name, args, result))
|
|
1556
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result, u.id))
|
|
1506
1557
|
results.push(toolResultBlock(u.id, result))
|
|
1507
1558
|
messages.push({ role: "user", content: results })
|
|
1508
1559
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
|
|
1509
1560
|
return
|
|
1510
1561
|
}
|
|
1511
1562
|
}
|
|
1512
|
-
toolCalls.push(this.toolCallRecord(u.name, args, result))
|
|
1563
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result, u.id))
|
|
1513
1564
|
results.push(toolResultBlock(u.id, result))
|
|
1514
1565
|
yield this.resultEvent(u.id, u.name, u.input, result)
|
|
1515
1566
|
}
|
package/src/index.ts
CHANGED