toolnexus 0.22.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/dist/types.d.ts CHANGED
@@ -9,7 +9,7 @@ export type JSONSchema = {
9
9
  [k: string]: unknown;
10
10
  };
11
11
  import type { ContentPart } from "./content.js";
12
- export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
12
+ export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "media" | "custom";
13
13
  export interface ToolResult {
14
14
  output: string;
15
15
  isError: boolean;
@@ -96,6 +96,9 @@ export interface Tool {
96
96
  /** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
97
97
  * server sends annotations. Advisory: a server can mislabel its tools. */
98
98
  hints?: ToolHints;
99
+ /** Decides per call whether this tool may run alongside other safe calls of the same model
100
+ * response (SPEC §8 "Tool scheduling"). Absent ⇒ safe only when `hints.readOnly`. */
101
+ concurrencySafe?: (args: Record<string, unknown>) => boolean;
99
102
  }
100
103
  export type McpStatus = "connected" | "disabled" | "failed";
101
104
  /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.22.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,12 @@
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",
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
- "example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
23
+ "example:multimodal": "node --experimental-strip-types examples/multimodal.ts",
24
+ "example:media": "node --experimental-strip-types examples/media.ts",
25
+ "example:prompt-cache": "node --experimental-strip-types examples/prompt_cache.ts"
24
26
  },
25
27
  "keywords": [
26
28
  "mcp",
@@ -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
- const judged: RunResult = { ...r, toolCalls: accumulated }
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") {
@@ -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
- return defineTool({
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,11 +1017,42 @@ 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
996
1044
  }
997
1045
 
1046
+ /** The RunResult.toolCalls entry: a `call_tool` call is recorded as the real tool with its
1047
+ * inner arguments, the name hooks and stream events see (the transcript keeps the wrapper). */
1048
+ private toolCallRecord(name: string, args: Record<string, unknown>, result: ToolResult, callId?: string): ToolCallRecord {
1049
+ const inner = this.unwrapCall(name, args)
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
1054
+ }
1055
+
998
1056
  /** The stream's tool_call event, naming the real tool for a `call_tool` call. */
999
1057
  private callEvent(id: string, name: string, args: Record<string, unknown>): StreamEvent {
1000
1058
  const inner = this.unwrapCall(name, args)
@@ -1150,8 +1208,8 @@ export class Client {
1150
1208
  if (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
1151
1209
  // execute all tool calls in this turn concurrently (true parallel tool calling)
1152
1210
  done.clear()
1153
- const settled = await untilAborted(signal, Promise.all(
1154
- calls.map(async (call: any) => {
1211
+ const settled = await untilAborted(signal, this.schedule(toolkit,
1212
+ calls, (call: any) => [call.function.name, safeJson(call.function.arguments)], async (call: any) => {
1155
1213
  let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal)
1156
1214
  let halted: Request | undefined
1157
1215
  const req = pendingOf(result)
@@ -1162,13 +1220,13 @@ export class Client {
1162
1220
  }
1163
1221
  if (!halted && call.id) done.set(call.id, result)
1164
1222
  return { call, args, result, halted }
1165
- }),
1223
+ },
1166
1224
  ))
1167
1225
  // Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
1168
1226
  // and the later suspensions' placeholder results never enter the transcript (they re-suspend
1169
1227
  // on resume). Mirrors the streaming path.
1170
1228
  for (const s of settled) {
1171
- toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
1229
+ toolCalls.push(this.toolCallRecord(s.call.function.name, s.args, s.result, s.call.id))
1172
1230
  messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
1173
1231
  if (s.halted) return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage)
1174
1232
  }
@@ -1183,7 +1241,7 @@ export class Client {
1183
1241
 
1184
1242
  private result(model: string, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
1185
1243
  const incomplete = exhausted && text === ""
1186
- 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" }
1187
1245
  if (incomplete) r.limit = "maxTurns"
1188
1246
  return r
1189
1247
  }
@@ -1191,7 +1249,7 @@ export class Client {
1191
1249
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
1192
1250
  private pendingRun(runStart: number, model: string, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
1193
1251
  this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
1194
- 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 }
1195
1253
  }
1196
1254
 
1197
1255
  /**
@@ -1268,8 +1326,8 @@ export class Client {
1268
1326
  }
1269
1327
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
1270
1328
  done.clear()
1271
- const settled = await untilAborted(signal, Promise.all(
1272
- uses.map(async (use: any) => {
1329
+ const settled = await untilAborted(signal, this.schedule(toolkit,
1330
+ uses, (use: any) => [use.name, use.input ?? {}], async (use: any) => {
1273
1331
  let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal)
1274
1332
  let halted: Request | undefined
1275
1333
  const req = pendingOf(result)
@@ -1280,7 +1338,7 @@ export class Client {
1280
1338
  }
1281
1339
  if (!halted && use.id) done.set(use.id, result)
1282
1340
  return { use, args, result, halted }
1283
- }),
1341
+ },
1284
1342
  ))
1285
1343
  // Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
1286
1344
  // building the content, and surface it — deterministic (G3), later suspensions re-suspend on
@@ -1288,7 +1346,7 @@ export class Client {
1288
1346
  const content: any[] = []
1289
1347
  let halted: Request | undefined
1290
1348
  for (const s of settled) {
1291
- toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
1349
+ toolCalls.push(this.toolCallRecord(s.use.name, s.args, s.result, s.use.id))
1292
1350
  content.push(toolResultBlock(s.use.id, s.result))
1293
1351
  if (s.halted) { halted = s.halted; break }
1294
1352
  }
@@ -1378,11 +1436,11 @@ export class Client {
1378
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 } })) })
1379
1437
  for (const c of calls) yield this.callEvent(c.id, c.name, safeJson(c.args))
1380
1438
  done.clear()
1381
- const settled = await untilAborted(signal, Promise.all(calls.map(async (c) => {
1439
+ const settled = await untilAborted(signal, this.schedule(toolkit, calls, (c) => [c.name, safeJson(c.args)], async (c) => {
1382
1440
  const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal)
1383
- if (!pendingOf(r.result) && c.id) done.set(c.id, r.result)
1441
+ if (!pendingOf(r.result) && c.id) done.set(c.id, r.result)
1384
1442
  return r
1385
- })))
1443
+ }))
1386
1444
  for (let i = 0; i < calls.length; i++) {
1387
1445
  const c = calls[i]
1388
1446
  let { args, result } = settled[i]
@@ -1392,13 +1450,13 @@ export class Client {
1392
1450
  const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal)
1393
1451
  result = r.result
1394
1452
  if (r.halted) {
1395
- toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1453
+ toolCalls.push(this.toolCallRecord(c.name, args, result, c.id))
1396
1454
  messages.push(toolMessage(c.id, c.name, result))
1397
1455
  yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
1398
1456
  return
1399
1457
  }
1400
1458
  }
1401
- toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1459
+ toolCalls.push(this.toolCallRecord(c.name, args, result, c.id))
1402
1460
  messages.push(toolMessage(c.id, c.name, result))
1403
1461
  yield this.resultEvent(c.id, c.name, safeJson(c.args), result)
1404
1462
  }
@@ -1480,11 +1538,11 @@ export class Client {
1480
1538
  }
1481
1539
  for (const u of uses as any[]) yield this.callEvent(u.id, u.name, u.input)
1482
1540
  done.clear()
1483
- const settled = await untilAborted(signal, Promise.all((uses as any[]).map(async (u) => {
1541
+ const settled = await untilAborted(signal, this.schedule(toolkit, uses as any[], (u: any) => [u.name, u.input ?? {}], async (u: any) => {
1484
1542
  const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal)
1485
1543
  if (!pendingOf(r.result) && u.id) done.set(u.id, r.result)
1486
1544
  return r
1487
- })))
1545
+ }))
1488
1546
  const results: any[] = []
1489
1547
  for (let i = 0; i < uses.length; i++) {
1490
1548
  const u = uses[i] as any
@@ -1495,14 +1553,14 @@ export class Client {
1495
1553
  const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal)
1496
1554
  result = r.result
1497
1555
  if (r.halted) {
1498
- toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1556
+ toolCalls.push(this.toolCallRecord(u.name, args, result, u.id))
1499
1557
  results.push(toolResultBlock(u.id, result))
1500
1558
  messages.push({ role: "user", content: results })
1501
1559
  yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
1502
1560
  return
1503
1561
  }
1504
1562
  }
1505
- toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1563
+ toolCalls.push(this.toolCallRecord(u.name, args, result, u.id))
1506
1564
  results.push(toolResultBlock(u.id, result))
1507
1565
  yield this.resultEvent(u.id, u.name, u.input, result)
1508
1566
  }
package/src/index.ts CHANGED
@@ -45,5 +45,7 @@ export {
45
45
  } from "./judge.js"
46
46
  export * from "./promptcache.js"
47
47
  export * from "./tooltiers.js"
48
+ export * from "./toolconcurrency.js"
48
49
  export * from "./models.js"
49
50
  export * from "./builtin_extras.js"
51
+ export * from "./media.js"