toolnexus 0.5.0 → 0.6.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 CHANGED
@@ -61,6 +61,41 @@ console.log(text)
61
61
  await tk.close() // disconnect MCP servers
62
62
  ```
63
63
 
64
+ ## Suspension — login / approval / input (`Pending` / `waitFor`)
65
+
66
+ Some tools can't finish in one shot: they need an **out-of-band, async resolution** — a human
67
+ logs in, approves a trade, types a value. That's one idea, not one feature: *login* is just the
68
+ `kind:"authorization"` case. A tool returns **`Pending(request)`**; the client calls your
69
+ **`waitFor(request) → answer`**, then **retries the tool** with `ctx.answer`, and the model gets
70
+ the real answer. Everything crossing a boundary is plain data, so it works the same across
71
+ processes, agents, and restarts — and identically in all five language ports.
72
+
73
+ ```ts
74
+ import { authRequired } from "toolnexus"
75
+
76
+ // a tool that needs login the first time:
77
+ defineTool({
78
+ name: "get_balance", description: "…", inputSchema: { type: "object", properties: {} },
79
+ run: (_args, ctx) =>
80
+ authed ? `balance: ₹67,417` : authRequired("https://example.com/login", "Log in to continue"),
81
+ })
82
+
83
+ // the host supplies ONE function — how the link reaches the human is your call:
84
+ const agent = createClient({
85
+ baseUrl, style: "openai", model,
86
+ waitFor: async (request) => { // interactive: open a browser + poll
87
+ open(request.url); await pollUntilLoggedIn(); return { id: request.id, ok: true }
88
+ },
89
+ // headless desk: waitFor = send request.url to your channel + poll
90
+ // another agent: waitFor = forward the request over A2A, await its answer
91
+ })
92
+ ```
93
+
94
+ Omit `waitFor` for a **durable** host: `run()` doesn't hang — it returns
95
+ `{ status: "pending", pending: request }`, so you can deliver the link out-of-band (file, HTTP,
96
+ channel) and resume later, even in another process. `stream()` emits a `{ type: "pending", request }`
97
+ event so a channel can push the link in real time. Runnable demo: `examples/pending.ts`.
98
+
64
99
  ## Conversations / memory
65
100
 
66
101
  `run()` is stateless — each call starts fresh. To keep a thread's history, use `ask()` with an
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Toolkit. See ../../SPEC.md §8.
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js";
7
- import type { ToolResult } from "./types.js";
7
+ import type { ToolResult, Request, Answer } from "./types.js";
8
8
  export type ClientStyle = "openai" | "anthropic";
9
9
  export interface ClientOptions {
10
10
  baseUrl: string;
@@ -27,6 +27,11 @@ export interface ClientOptions {
27
27
  /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
28
28
  * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
29
29
  onMetric?: (ev: MetricEvent) => void;
30
+ /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
31
+ * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
32
+ * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
33
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
34
+ waitFor?: (request: Request) => Promise<Answer>;
30
35
  }
31
36
  /**
32
37
  * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
@@ -148,6 +153,10 @@ export interface RunResult {
148
153
  usage: Usage;
149
154
  /** The model used. */
150
155
  model: string;
156
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
157
+ status: "done" | "pending";
158
+ /** The unresolved suspension — present iff status === "pending" (§10). */
159
+ pending?: Request;
151
160
  }
152
161
  /** Events yielded by client.stream(). */
153
162
  export type StreamEvent = {
@@ -167,6 +176,9 @@ export type StreamEvent = {
167
176
  } | {
168
177
  type: "usage";
169
178
  usage: Usage;
179
+ } | {
180
+ type: "pending";
181
+ request: Request;
170
182
  } | {
171
183
  type: "done";
172
184
  result: RunResult;
@@ -229,6 +241,14 @@ export declare class Client {
229
241
  private llmCallJson;
230
242
  private runOpenAI;
231
243
  private result;
244
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
245
+ private pendingRun;
246
+ /**
247
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
248
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
249
+ * no `waitFor` is configured (the run should stop and surface the request).
250
+ */
251
+ private resolvePending;
232
252
  private runAnthropic;
233
253
  private streamOpenAI;
234
254
  private streamAnthropic;
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { pendingOf } from "./types.js";
1
2
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
2
3
  export class InMemoryConversationStore {
3
4
  map = new Map();
@@ -342,12 +343,22 @@ export class Client {
342
343
  if (calls.length === 0)
343
344
  return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
344
345
  // execute all tool calls in this turn concurrently (true parallel tool calling)
346
+ let halted;
345
347
  const results = await Promise.all(calls.map(async (call) => {
346
- const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
348
+ let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
349
+ const req = pendingOf(result);
350
+ if (req) {
351
+ const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
352
+ result = r.result;
353
+ if (r.halted)
354
+ halted = r.halted;
355
+ }
347
356
  toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
348
357
  return { role: "tool", tool_call_id: call.id, content: result.output };
349
358
  }));
350
359
  messages.push(...results);
360
+ if (halted)
361
+ return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
351
362
  }
352
363
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
353
364
  }
@@ -357,7 +368,35 @@ export class Client {
357
368
  }
358
369
  }
359
370
  result(text, messages, toolCalls, turns, usage) {
360
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model };
371
+ return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
372
+ }
373
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
374
+ pendingRun(runStart, request, messages, toolCalls, turns, usage) {
375
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
376
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request };
377
+ }
378
+ /**
379
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
380
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
381
+ * no `waitFor` is configured (the run should stop and surface the request).
382
+ */
383
+ async resolvePending(toolkit, name, args, request, id, turn) {
384
+ if (!this.opts.waitFor)
385
+ return { result: { output: request.prompt, isError: true }, halted: request };
386
+ const answer = await this.opts.waitFor(request);
387
+ if (!answer?.ok)
388
+ return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
389
+ const t0 = Date.now();
390
+ let result = await toolkit.execute(name, args, { answer });
391
+ if (this.opts.hooks?.afterTool) {
392
+ const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
393
+ if (ov?.result)
394
+ result = ov.result;
395
+ }
396
+ this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 });
397
+ if (pendingOf(result))
398
+ result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
399
+ return { result };
361
400
  }
362
401
  // ---- Anthropic-style: POST {baseUrl}/messages ----
363
402
  async runAnthropic(prompt, toolkit, external, history) {
@@ -402,12 +441,22 @@ export class Client {
402
441
  return this.endRun(runStart, text, messages, toolCalls, turns, usage);
403
442
  }
404
443
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
444
+ let halted;
405
445
  const results = await Promise.all(uses.map(async (use) => {
406
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
446
+ let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
447
+ const req = pendingOf(result);
448
+ if (req) {
449
+ const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
450
+ result = r.result;
451
+ if (r.halted)
452
+ halted = r.halted;
453
+ }
407
454
  toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
408
455
  return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
409
456
  }));
410
457
  messages.push({ role: "user", content: results });
458
+ if (halted)
459
+ return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
411
460
  }
412
461
  return this.endRun(runStart, "", messages, toolCalls, turns, usage);
413
462
  }
@@ -500,7 +549,20 @@ export class Client {
500
549
  yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
501
550
  const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
502
551
  for (let i = 0; i < calls.length; i++) {
503
- const c = calls[i], { args, result } = settled[i];
552
+ const c = calls[i];
553
+ let { args, result } = settled[i];
554
+ const req = pendingOf(result);
555
+ if (req) {
556
+ yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
557
+ const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
558
+ result = r.result;
559
+ if (r.halted) {
560
+ toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
561
+ messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
562
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
563
+ return;
564
+ }
565
+ }
504
566
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
505
567
  messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
506
568
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
@@ -594,7 +656,21 @@ export class Client {
594
656
  const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
595
657
  const results = [];
596
658
  for (let i = 0; i < uses.length; i++) {
597
- const u = uses[i], { args, result } = settled[i];
659
+ const u = uses[i];
660
+ let { args, result } = settled[i];
661
+ const req = pendingOf(result);
662
+ if (req) {
663
+ yield { type: "pending", request: req };
664
+ const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
665
+ result = r.result;
666
+ if (r.halted) {
667
+ toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
668
+ results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
669
+ messages.push({ role: "user", content: results });
670
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
671
+ return;
672
+ }
673
+ }
598
674
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
599
675
  results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
600
676
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
package/dist/types.d.ts CHANGED
@@ -17,7 +17,35 @@ export interface ToolResult {
17
17
  export interface ToolContext {
18
18
  signal?: AbortSignal;
19
19
  timeout?: number;
20
+ /** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
21
+ answer?: Answer;
20
22
  }
23
+ /**
24
+ * §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
25
+ * input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
26
+ * wire data — it crosses languages, processes, and agents unchanged.
27
+ */
28
+ export interface Request {
29
+ id: string;
30
+ kind: string;
31
+ prompt: string;
32
+ url?: string;
33
+ data?: Record<string, unknown>;
34
+ expiresAt?: string;
35
+ }
36
+ export interface Answer {
37
+ id: string;
38
+ ok: boolean;
39
+ data?: Record<string, unknown>;
40
+ }
41
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
42
+ export declare function pending(request: Omit<Request, "id"> & {
43
+ id?: string;
44
+ }): ToolResult;
45
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
46
+ export declare function authRequired(url: string, prompt?: string): ToolResult;
47
+ /** Read the suspension off a result, if any. */
48
+ export declare function pendingOf(result: ToolResult): Request | undefined;
21
49
  export interface Tool {
22
50
  name: string;
23
51
  description: string;
package/dist/types.js CHANGED
@@ -1,5 +1,24 @@
1
1
  /**
2
2
  * Shared contract types. See ../../SPEC.md.
3
3
  */
4
+ let _pendingSeq = 0;
5
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
6
+ export function pending(request) {
7
+ const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`;
8
+ const req = { ...request, id };
9
+ return {
10
+ output: req.prompt + (req.url ? `\n${req.url}` : ""),
11
+ isError: true,
12
+ metadata: { pending: req },
13
+ };
14
+ }
15
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
16
+ export function authRequired(url, prompt = "Authorization required to continue") {
17
+ return pending({ kind: "authorization", prompt, url });
18
+ }
19
+ /** Read the suspension off a result, if any. */
20
+ export function pendingOf(result) {
21
+ return result.metadata?.pending;
22
+ }
4
23
  /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
5
24
  export const sanitize = (value) => value.replace(/[^a-zA-Z0-9_-]/g, "_");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
package/src/client.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Toolkit. See ../../SPEC.md §8.
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js"
7
- import type { ToolResult } from "./types.js"
7
+ import type { ToolResult, Request, Answer } from "./types.js"
8
+ import { pendingOf } from "./types.js"
8
9
 
9
10
  export type ClientStyle = "openai" | "anthropic"
10
11
 
@@ -29,6 +30,11 @@ export interface ClientOptions {
29
30
  /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
30
31
  * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
31
32
  onMetric?: (ev: MetricEvent) => void
33
+ /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
34
+ * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
35
+ * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
36
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
37
+ waitFor?: (request: Request) => Promise<Answer>
32
38
  }
33
39
 
34
40
  /**
@@ -117,6 +123,10 @@ export interface RunResult {
117
123
  usage: Usage
118
124
  /** The model used. */
119
125
  model: string
126
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
127
+ status: "done" | "pending"
128
+ /** The unresolved suspension — present iff status === "pending" (§10). */
129
+ pending?: Request
120
130
  }
121
131
 
122
132
  /** Events yielded by client.stream(). */
@@ -125,6 +135,7 @@ export type StreamEvent =
125
135
  | { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
126
136
  | { type: "tool_result"; id: string; name: string; output: string; isError: boolean }
127
137
  | { type: "usage"; usage: Usage }
138
+ | { type: "pending"; request: Request }
128
139
  | { type: "done"; result: RunResult }
129
140
 
130
141
  function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
@@ -458,14 +469,22 @@ export class Client {
458
469
  const calls = msg.tool_calls ?? []
459
470
  if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
460
471
  // execute all tool calls in this turn concurrently (true parallel tool calling)
472
+ let halted: Request | undefined
461
473
  const results = await Promise.all(
462
474
  calls.map(async (call: any) => {
463
- const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
475
+ let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
476
+ const req = pendingOf(result)
477
+ if (req) {
478
+ const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
479
+ result = r.result
480
+ if (r.halted) halted = r.halted
481
+ }
464
482
  toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
465
483
  return { role: "tool", tool_call_id: call.id, content: result.output }
466
484
  }),
467
485
  )
468
486
  messages.push(...results)
487
+ if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
469
488
  }
470
489
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
471
490
  } catch (e) {
@@ -475,7 +494,35 @@ export class Client {
475
494
  }
476
495
 
477
496
  private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
478
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model }
497
+ return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" }
498
+ }
499
+
500
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
501
+ private pendingRun(runStart: number, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
502
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
503
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request }
504
+ }
505
+
506
+ /**
507
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
508
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
509
+ * no `waitFor` is configured (the run should stop and surface the request).
510
+ */
511
+ private async resolvePending(
512
+ toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
513
+ ): Promise<{ result: ToolResult; halted?: Request }> {
514
+ if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
515
+ const answer = await this.opts.waitFor(request)
516
+ if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
517
+ const t0 = Date.now()
518
+ let result = await toolkit.execute(name, args, { answer })
519
+ if (this.opts.hooks?.afterTool) {
520
+ const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
521
+ if (ov?.result) result = ov.result
522
+ }
523
+ this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 })
524
+ if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
525
+ return { result }
479
526
  }
480
527
 
481
528
  // ---- Anthropic-style: POST {baseUrl}/messages ----
@@ -519,14 +566,22 @@ export class Client {
519
566
  return this.endRun(runStart, text, messages, toolCalls, turns, usage)
520
567
  }
521
568
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
569
+ let halted: Request | undefined
522
570
  const results = await Promise.all(
523
571
  uses.map(async (use: any) => {
524
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
572
+ let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
573
+ const req = pendingOf(result)
574
+ if (req) {
575
+ const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
576
+ result = r.result
577
+ if (r.halted) halted = r.halted
578
+ }
525
579
  toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
526
580
  return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
527
581
  }),
528
582
  )
529
583
  messages.push({ role: "user", content: results })
584
+ if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
530
585
  }
531
586
  return this.endRun(runStart, "", messages, toolCalls, turns, usage)
532
587
  } catch (e) {
@@ -605,7 +660,20 @@ export class Client {
605
660
  for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
606
661
  const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
607
662
  for (let i = 0; i < calls.length; i++) {
608
- const c = calls[i], { args, result } = settled[i]
663
+ const c = calls[i]
664
+ let { args, result } = settled[i]
665
+ const req = pendingOf(result)
666
+ if (req) {
667
+ yield { type: "pending", request: req } // surface BEFORE waitFor so a channel can push the link
668
+ const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn)
669
+ result = r.result
670
+ if (r.halted) {
671
+ toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
672
+ messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
673
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
674
+ return
675
+ }
676
+ }
609
677
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
610
678
  messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
611
679
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
@@ -683,7 +751,21 @@ export class Client {
683
751
  const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
684
752
  const results: any[] = []
685
753
  for (let i = 0; i < uses.length; i++) {
686
- const u = uses[i] as any, { args, result } = settled[i]
754
+ const u = uses[i] as any
755
+ let { args, result } = settled[i]
756
+ const req = pendingOf(result)
757
+ if (req) {
758
+ yield { type: "pending", request: req }
759
+ const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
760
+ result = r.result
761
+ if (r.halted) {
762
+ toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
763
+ results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
764
+ messages.push({ role: "user", content: results })
765
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
766
+ return
767
+ }
768
+ }
687
769
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
688
770
  results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
689
771
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
package/src/types.ts CHANGED
@@ -21,6 +21,50 @@ export interface ToolResult {
21
21
  export interface ToolContext {
22
22
  signal?: AbortSignal
23
23
  timeout?: number
24
+ /** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
25
+ answer?: Answer
26
+ }
27
+
28
+ /**
29
+ * §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
30
+ * input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
31
+ * wire data — it crosses languages, processes, and agents unchanged.
32
+ */
33
+ export interface Request {
34
+ id: string
35
+ kind: string // "authorization" | "approval" | "input" | ... (open vocabulary)
36
+ prompt: string
37
+ url?: string
38
+ data?: Record<string, unknown>
39
+ expiresAt?: string
40
+ }
41
+
42
+ export interface Answer {
43
+ id: string
44
+ ok: boolean
45
+ data?: Record<string, unknown>
46
+ }
47
+
48
+ let _pendingSeq = 0
49
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
50
+ export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {
51
+ const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`
52
+ const req: Request = { ...request, id }
53
+ return {
54
+ output: req.prompt + (req.url ? `\n${req.url}` : ""),
55
+ isError: true,
56
+ metadata: { pending: req },
57
+ }
58
+ }
59
+
60
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
61
+ export function authRequired(url: string, prompt = "Authorization required to continue"): ToolResult {
62
+ return pending({ kind: "authorization", prompt, url })
63
+ }
64
+
65
+ /** Read the suspension off a result, if any. */
66
+ export function pendingOf(result: ToolResult): Request | undefined {
67
+ return (result.metadata as { pending?: Request } | undefined)?.pending
24
68
  }
25
69
 
26
70
  export interface Tool {