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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.23.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": [
@@ -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,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
- return { name: inner ? inner.name : name, args: inner ? inner.args : args, output: result.output, isError: result.isError, metadata: result.metadata }
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, Promise.all(
1161
- 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) => {
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, Promise.all(
1279
- 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) => {
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, Promise.all(calls.map(async (c) => {
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
- 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)
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, 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) => {
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
@@ -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"