toolnexus 0.6.0 → 0.8.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/builtin.js +26 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +46 -22
- package/dist/mcp.d.ts +9 -2
- package/dist/mcp.js +36 -2
- package/dist/serve.js +24 -7
- package/dist/skill.d.ts +44 -2
- package/dist/skill.js +160 -45
- package/dist/toolkit.d.ts +16 -0
- package/dist/toolkit.js +24 -2
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/builtin.ts +25 -3
- package/src/client.ts +44 -20
- package/src/mcp.ts +40 -4
- package/src/serve.ts +22 -6
- package/src/skill.ts +221 -51
- package/src/toolkit.ts +39 -3
- package/src/types.ts +6 -0
package/dist/toolkit.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* single uniform list, with provider adapters and a single execute() router.
|
|
4
4
|
*/
|
|
5
5
|
import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js";
|
|
6
|
+
import { type SkillDef } from "./skill.js";
|
|
6
7
|
import { type BuiltinsConfig } from "./builtin.js";
|
|
7
8
|
import { type Agent } from "./a2a.js";
|
|
8
9
|
import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
|
|
@@ -11,11 +12,26 @@ import type { Client } from "./client.js";
|
|
|
11
12
|
export interface ToolkitOptions {
|
|
12
13
|
mcpConfig?: string | object;
|
|
13
14
|
skillsDir?: string | string[];
|
|
15
|
+
/** Skills supplied as data, bypassing the filesystem (§3, S1). */
|
|
16
|
+
skills?: SkillDef[];
|
|
17
|
+
/** Lazy provider of data-supplied skills, resolved once at toolkit build (§3, S1). */
|
|
18
|
+
skillProvider?: () => SkillDef[] | Promise<SkillDef[]>;
|
|
19
|
+
/** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
|
|
20
|
+
skillsFilter?: Record<string, boolean>;
|
|
21
|
+
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
|
|
22
|
+
skillSampleLimit?: number;
|
|
14
23
|
extraTools?: Tool[];
|
|
15
24
|
/** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
|
|
16
25
|
builtins?: BuiltinsConfig;
|
|
17
26
|
/** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
|
|
18
27
|
agents?: Agent[];
|
|
28
|
+
/**
|
|
29
|
+
* Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
|
|
30
|
+
* from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
|
|
31
|
+
* URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
|
|
32
|
+
* elicitation is not advertised.
|
|
33
|
+
*/
|
|
34
|
+
waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
|
|
19
35
|
}
|
|
20
36
|
export declare class Toolkit {
|
|
21
37
|
private readonly mcp;
|
package/dist/toolkit.js
CHANGED
|
@@ -34,8 +34,30 @@ export class Toolkit {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
static async create(opts) {
|
|
37
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined;
|
|
38
|
-
|
|
37
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
|
|
38
|
+
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
39
|
+
// S1). The provider is resolved here (create() is async) and merged with the
|
|
40
|
+
// data list; a provider failure is isolated so other sources still load.
|
|
41
|
+
let skill;
|
|
42
|
+
if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
|
|
43
|
+
let providerDefs = [];
|
|
44
|
+
if (opts.skillProvider) {
|
|
45
|
+
try {
|
|
46
|
+
providerDefs = await opts.skillProvider();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
console.warn(`[toolnexus] skill provider failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
50
|
+
providerDefs = [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const dataDefs = [...(opts.skills ?? []), ...providerDefs];
|
|
54
|
+
skill = loadSkills({
|
|
55
|
+
dirs: opts.skillsDir,
|
|
56
|
+
skills: dataDefs.length > 0 ? dataDefs : undefined,
|
|
57
|
+
filter: opts.skillsFilter,
|
|
58
|
+
sampleLimit: opts.skillSampleLimit,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
39
61
|
// The toggle comes from the `builtins` option, or a top-level `builtins`
|
|
40
62
|
// key on a parsed config object — same precedence as MCP's isEnabled.
|
|
41
63
|
let builtinsCfg = opts.builtins;
|
package/dist/types.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface Answer {
|
|
|
37
37
|
id: string;
|
|
38
38
|
ok: boolean;
|
|
39
39
|
data?: Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* When `ok` is false, why (advisory — the loop rule branches only on `ok`). Distinguishes an
|
|
42
|
+
* explicit refusal from a dismissal/timeout; the MCP elicitation bridge maps `declined`→`decline`
|
|
43
|
+
* and everything else→`cancel`. (R1)
|
|
44
|
+
*/
|
|
45
|
+
reason?: "declined" | "cancelled" | "expired";
|
|
40
46
|
}
|
|
41
47
|
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
42
48
|
export declare function pending(request: Omit<Request, "id"> & {
|
package/package.json
CHANGED
package/src/builtin.ts
CHANGED
|
@@ -12,6 +12,7 @@ import fs from "node:fs/promises"
|
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs"
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
15
|
+
import { pending } from "./types.js"
|
|
15
16
|
|
|
16
17
|
/** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
|
|
17
18
|
export type BuiltinsConfig =
|
|
@@ -412,10 +413,27 @@ function webfetchTool(): Tool {
|
|
|
412
413
|
)
|
|
413
414
|
}
|
|
414
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
|
|
418
|
+
* each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
|
|
419
|
+
* joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
|
|
420
|
+
*/
|
|
421
|
+
function renderQuestionPrompt(questions: unknown[]): string {
|
|
422
|
+
return questions
|
|
423
|
+
.map((q) => {
|
|
424
|
+
const item = q as { question?: unknown; options?: unknown }
|
|
425
|
+
let line = typeof item?.question === "string" ? item.question : ""
|
|
426
|
+
const opts = Array.isArray(item?.options) ? item.options : []
|
|
427
|
+
if (opts.length > 0) line += ` (options: ${opts.join(", ")})`
|
|
428
|
+
return line
|
|
429
|
+
})
|
|
430
|
+
.join("\n")
|
|
431
|
+
}
|
|
432
|
+
|
|
415
433
|
function questionTool(): Tool {
|
|
416
434
|
return builtin(
|
|
417
435
|
"question",
|
|
418
|
-
"Ask the host one or more questions.
|
|
436
|
+
"Ask the host one or more questions. Suspends via a kind:\"question\" Request (§10); the host's waitFor resolves it and the answer is returned to the model.",
|
|
419
437
|
{
|
|
420
438
|
type: "object",
|
|
421
439
|
properties: {
|
|
@@ -437,9 +455,13 @@ function questionTool(): Tool {
|
|
|
437
455
|
required: ["questions"],
|
|
438
456
|
additionalProperties: false,
|
|
439
457
|
},
|
|
440
|
-
async (args) => {
|
|
458
|
+
async (args, ctx) => {
|
|
441
459
|
const questions = Array.isArray(args.questions) ? args.questions : []
|
|
442
|
-
|
|
460
|
+
// Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
|
|
461
|
+
// answer, as with kind:"input" — forward it verbatim to the model.
|
|
462
|
+
if (ctx?.answer) return ok(JSON.stringify(ctx.answer.data ?? {}))
|
|
463
|
+
// First call: suspend. A question is just a §10 Request with kind:"question".
|
|
464
|
+
return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } })
|
|
443
465
|
},
|
|
444
466
|
)
|
|
445
467
|
}
|
package/src/client.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface ClientOptions {
|
|
|
44
44
|
*/
|
|
45
45
|
export type MetricEvent =
|
|
46
46
|
| { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
|
|
47
|
-
| { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
|
|
47
|
+
| { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
|
|
48
48
|
| { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -227,7 +227,7 @@ class MetricsRegistry {
|
|
|
227
227
|
}
|
|
228
228
|
observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
|
|
229
229
|
} else if (ev.event === "tool") {
|
|
230
|
-
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]))
|
|
230
|
+
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]))
|
|
231
231
|
observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
|
|
232
232
|
} else if (ev.event === "run") {
|
|
233
233
|
if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
|
|
@@ -402,21 +402,31 @@ export class Client {
|
|
|
402
402
|
if (h?.beforeTool) {
|
|
403
403
|
const ov = await h.beforeTool({ name, args: a, id, turn })
|
|
404
404
|
if (ov?.result) {
|
|
405
|
-
// short-circuit (deny/cache/dry-run
|
|
406
|
-
|
|
405
|
+
// short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
|
|
406
|
+
// call for metrics, but a suspension is classified pending, never an error.
|
|
407
|
+
this.emitTool(name, source, ov.result, t0)
|
|
407
408
|
return { args: a, result: ov.result }
|
|
408
409
|
}
|
|
409
410
|
if (ov?.args) a = ov.args
|
|
410
411
|
}
|
|
411
412
|
let result = await toolkit.execute(name, a)
|
|
412
|
-
|
|
413
|
+
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
414
|
+
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
415
|
+
// a tool error.
|
|
416
|
+
if (h?.afterTool && !pendingOf(result)) {
|
|
413
417
|
const ov = await h.afterTool({ name, args: a, result, id, turn })
|
|
414
418
|
if (ov?.result) result = ov.result
|
|
415
419
|
}
|
|
416
|
-
this.
|
|
420
|
+
this.emitTool(name, source, result, t0)
|
|
417
421
|
return { args: a, result }
|
|
418
422
|
}
|
|
419
423
|
|
|
424
|
+
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
425
|
+
private emitTool(name: string, source: string, result: ToolResult, t0: number) {
|
|
426
|
+
const req = pendingOf(result)
|
|
427
|
+
this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) })
|
|
428
|
+
}
|
|
429
|
+
|
|
420
430
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
421
431
|
private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
|
|
422
432
|
const t0 = Date.now()
|
|
@@ -469,22 +479,27 @@ export class Client {
|
|
|
469
479
|
const calls = msg.tool_calls ?? []
|
|
470
480
|
if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
471
481
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
472
|
-
|
|
473
|
-
const results = await Promise.all(
|
|
482
|
+
const settled = await Promise.all(
|
|
474
483
|
calls.map(async (call: any) => {
|
|
475
484
|
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
485
|
+
let halted: Request | undefined
|
|
476
486
|
const req = pendingOf(result)
|
|
477
487
|
if (req) {
|
|
478
488
|
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
|
|
479
489
|
result = r.result
|
|
480
|
-
|
|
490
|
+
halted = r.halted
|
|
481
491
|
}
|
|
482
|
-
|
|
483
|
-
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
492
|
+
return { call, args, result, halted }
|
|
484
493
|
}),
|
|
485
494
|
)
|
|
486
|
-
|
|
487
|
-
|
|
495
|
+
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
496
|
+
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
497
|
+
// on resume). Mirrors the streaming path.
|
|
498
|
+
for (const s of settled) {
|
|
499
|
+
toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
500
|
+
messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output })
|
|
501
|
+
if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
|
|
502
|
+
}
|
|
488
503
|
}
|
|
489
504
|
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
|
|
490
505
|
} catch (e) {
|
|
@@ -520,7 +535,7 @@ export class Client {
|
|
|
520
535
|
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
521
536
|
if (ov?.result) result = ov.result
|
|
522
537
|
}
|
|
523
|
-
this.
|
|
538
|
+
this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
|
|
524
539
|
if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
|
|
525
540
|
return { result }
|
|
526
541
|
}
|
|
@@ -566,21 +581,30 @@ export class Client {
|
|
|
566
581
|
return this.endRun(runStart, text, messages, toolCalls, turns, usage)
|
|
567
582
|
}
|
|
568
583
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
569
|
-
|
|
570
|
-
const results = await Promise.all(
|
|
584
|
+
const settled = await Promise.all(
|
|
571
585
|
uses.map(async (use: any) => {
|
|
572
586
|
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
587
|
+
let halted: Request | undefined
|
|
573
588
|
const req = pendingOf(result)
|
|
574
589
|
if (req) {
|
|
575
590
|
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
|
|
576
591
|
result = r.result
|
|
577
|
-
|
|
592
|
+
halted = r.halted
|
|
578
593
|
}
|
|
579
|
-
|
|
580
|
-
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
|
|
594
|
+
return { use, args, result, halted }
|
|
581
595
|
}),
|
|
582
596
|
)
|
|
583
|
-
|
|
597
|
+
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
598
|
+
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
599
|
+
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
600
|
+
const content: any[] = []
|
|
601
|
+
let halted: Request | undefined
|
|
602
|
+
for (const s of settled) {
|
|
603
|
+
toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
604
|
+
content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError })
|
|
605
|
+
if (s.halted) { halted = s.halted; break }
|
|
606
|
+
}
|
|
607
|
+
messages.push({ role: "user", content })
|
|
584
608
|
if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
|
|
585
609
|
}
|
|
586
610
|
return this.endRun(runStart, "", messages, toolCalls, turns, usage)
|
package/src/mcp.ts
CHANGED
|
@@ -8,8 +8,36 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
9
9
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
10
10
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
11
|
-
import type { Tool as McpToolDef } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
-
import {
|
|
11
|
+
import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
+
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
|
+
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
17
|
+
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
18
|
+
// URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
let _elcSeq = 0
|
|
22
|
+
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
23
|
+
export function elicitationToRequest(params: ElicitRequest["params"]): Request {
|
|
24
|
+
const p = params as { message?: string; requestedSchema?: unknown; mode?: string; url?: string }
|
|
25
|
+
const isUrl = p.mode === "url"
|
|
26
|
+
const req: Request = {
|
|
27
|
+
id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
|
|
28
|
+
kind: isUrl ? "authorization" : "input",
|
|
29
|
+
prompt: String(p.message ?? ""),
|
|
30
|
+
}
|
|
31
|
+
if (isUrl && p.url) req.url = p.url
|
|
32
|
+
if (!isUrl && p.requestedSchema) req.data = { schema: p.requestedSchema }
|
|
33
|
+
return req
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
37
|
+
export function answerToElicitResult(answer: Answer): ElicitResult {
|
|
38
|
+
if (answer.ok) return { action: "accept", content: (answer.data ?? {}) as ElicitResult["content"] }
|
|
39
|
+
return { action: answer.reason === "declined" ? "decline" : "cancel" }
|
|
40
|
+
}
|
|
13
41
|
|
|
14
42
|
const DEFAULT_TIMEOUT = 30_000
|
|
15
43
|
const MAX_LIST_PAGES = 1_000
|
|
@@ -155,11 +183,12 @@ export interface McpSource {
|
|
|
155
183
|
}
|
|
156
184
|
|
|
157
185
|
/** Connect to every enabled server, list + convert tools. Failures are isolated. */
|
|
158
|
-
export async function loadMcp(input: string | object): Promise<McpSource> {
|
|
186
|
+
export async function loadMcp(input: string | object, opts?: { waitFor?: (request: Request) => Promise<Answer> }): Promise<McpSource> {
|
|
159
187
|
const config = parseMcpConfig(input)
|
|
160
188
|
const tools: Tool[] = []
|
|
161
189
|
const status: Record<string, McpStatus> = {}
|
|
162
190
|
const clients: Client[] = []
|
|
191
|
+
const waitFor = opts?.waitFor
|
|
163
192
|
|
|
164
193
|
await Promise.all(
|
|
165
194
|
Object.entries(config).map(async ([name, cfg]) => {
|
|
@@ -168,7 +197,14 @@ export async function loadMcp(input: string | object): Promise<McpSource> {
|
|
|
168
197
|
return
|
|
169
198
|
}
|
|
170
199
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
171
|
-
|
|
200
|
+
// Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
|
|
201
|
+
// a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
|
|
202
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
|
|
203
|
+
if (waitFor) {
|
|
204
|
+
client.setRequestHandler(ElicitRequestSchema, async (request) =>
|
|
205
|
+
answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
172
208
|
try {
|
|
173
209
|
if (isRemote(cfg)) {
|
|
174
210
|
const url = new URL(cfg.url)
|
package/src/serve.ts
CHANGED
|
@@ -62,7 +62,13 @@ export class FileTaskStore implements TaskStore {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
async save(task: A2ATask): Promise<void> {
|
|
65
|
-
|
|
65
|
+
// Atomic write: a concurrent get() must never read a half-written file (that
|
|
66
|
+
// would surface as a spurious "Task not found" mid-poll). Write a temp file in
|
|
67
|
+
// the same dir, then rename over the target.
|
|
68
|
+
const target = this.file(task.id)
|
|
69
|
+
const tmp = `${target}.tmp`
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(task))
|
|
71
|
+
fs.renameSync(tmp, target)
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -331,12 +337,22 @@ async function fulfil(
|
|
|
331
337
|
// contextId groups a peer's turns into one conversation — thread it so the
|
|
332
338
|
// client's ConversationStore remembers across tasks in the same context.
|
|
333
339
|
result = await runTask(text, contextId)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
340
|
+
if (result.status === "pending" && result.pending) {
|
|
341
|
+
// §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
|
|
342
|
+
// protocol's `input-required` state carrying the request prompt — never a false `completed`.
|
|
343
|
+
task = {
|
|
344
|
+
id,
|
|
345
|
+
status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
|
|
346
|
+
}
|
|
347
|
+
state = "input-required"
|
|
348
|
+
} else {
|
|
349
|
+
const artifact: A2AArtifact = {
|
|
350
|
+
artifactId: randomUUID(),
|
|
351
|
+
parts: [{ kind: "text", text: result.text }],
|
|
352
|
+
}
|
|
353
|
+
task = { id, status: { state: "completed" }, artifacts: [artifact] }
|
|
354
|
+
state = "completed"
|
|
337
355
|
}
|
|
338
|
-
task = { id, status: { state: "completed" }, artifacts: [artifact] }
|
|
339
|
-
state = "completed"
|
|
340
356
|
} catch (e) {
|
|
341
357
|
const detail = e instanceof Error ? e.message : String(e)
|
|
342
358
|
task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } }
|