taskflow-mcp-core 0.1.8 → 0.2.1
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 +248 -808
- package/dist/mcp/jsonrpc.d.ts +12 -1
- package/dist/mcp/jsonrpc.d.ts.map +1 -1
- package/dist/mcp/jsonrpc.js +148 -19
- package/dist/mcp/jsonrpc.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +336 -26
- package/dist/mcp/server.js.map +1 -1
- package/package.json +4 -8
package/dist/mcp/server.js
CHANGED
|
@@ -21,13 +21,93 @@
|
|
|
21
21
|
* - taskflow_verify : statically verify a flow (no execution)
|
|
22
22
|
* - taskflow_compile : render a flow as a DAG diagram (SVG image) + status line
|
|
23
23
|
* - taskflow_peek : inspect a stored run's intermediate phase output
|
|
24
|
+
* - taskflow_trace : read a run's append-only event trace
|
|
25
|
+
* - taskflow_replay : re-evaluate a recorded trace under alternate knobs (zero tokens)
|
|
26
|
+
* - taskflow_why_stale / taskflow_recompute / taskflow_reconcile_workspace
|
|
27
|
+
* - taskflow_save / taskflow_search
|
|
24
28
|
*/
|
|
25
29
|
import { RpcError, RPC, serveStdio } from "./jsonrpc.js";
|
|
26
30
|
import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.js";
|
|
27
|
-
import {
|
|
31
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
32
|
+
import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { discoverAgents, executeTaskflow, getFlowDiagnosed, listFlows, newRunId, peekRun, saveRun, DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, readDefineFile, describeLoadFailure, compileTaskflow, verifyTaskflow, desugar, isShorthand, validateTaskflow, resolveArgs, cwdBridgeModeFromEnv, directoryIdentity, readSubagentSettings, readMeta, saveFlowWithMeta, bumpReuseInSidecar, deriveMeta, searchLibrary, replayRun, upgradeTraceEvent, reconcileResolveOnlyWorkspace, WORKSPACE_RECONCILE_ACKNOWLEDGEMENT, workspaceReconcileAllowedFromEnv, } from "taskflow-core";
|
|
28
36
|
import { runsDir, traceFilePath, FileTraceSink, readTrace, loadRunDiagnosed, readMapOf, declaredReadMapOfDef, formatWhyStale, recomputeTaskflow, } from "taskflow-core";
|
|
29
37
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
30
|
-
const
|
|
38
|
+
const SERVER_VERSION = readServerVersion();
|
|
39
|
+
const SERVER_INFO = { name: "taskflow", title: "Taskflow", version: SERVER_VERSION };
|
|
40
|
+
function readServerVersion() {
|
|
41
|
+
try {
|
|
42
|
+
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
43
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
44
|
+
if (typeof pkg === "object" && pkg !== null && "version" in pkg) {
|
|
45
|
+
const version = pkg.version;
|
|
46
|
+
if (typeof version === "string" && version)
|
|
47
|
+
return version;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Keep the handshake available even in unusual embedded/bundled layouts.
|
|
52
|
+
}
|
|
53
|
+
return "0.2.1";
|
|
54
|
+
}
|
|
55
|
+
function validateToolValue(value, schema, path) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
|
|
58
|
+
if (enumValues && !enumValues.some((candidate) => Object.is(candidate, value))) {
|
|
59
|
+
errors.push(`${path} must be one of ${enumValues.map(String).join(", ")}`);
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
const type = schema.type;
|
|
63
|
+
if (type === "object") {
|
|
64
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
65
|
+
return [`${path} must be an object`];
|
|
66
|
+
}
|
|
67
|
+
const object = value;
|
|
68
|
+
const properties = (schema.properties ?? {});
|
|
69
|
+
for (const required of Array.isArray(schema.required) ? schema.required : []) {
|
|
70
|
+
if (typeof required === "string" && !(required in object))
|
|
71
|
+
errors.push(`${path}.${required} is required`);
|
|
72
|
+
}
|
|
73
|
+
if (schema.additionalProperties === false) {
|
|
74
|
+
for (const key of Object.keys(object)) {
|
|
75
|
+
if (!(key in properties))
|
|
76
|
+
errors.push(`${path}.${key} is not allowed`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
80
|
+
if (key in object)
|
|
81
|
+
errors.push(...validateToolValue(object[key], childSchema, `${path}.${key}`));
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
if (type === "array") {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
return [`${path} must be an array`];
|
|
88
|
+
const itemSchema = schema.items;
|
|
89
|
+
if (typeof itemSchema === "object" && itemSchema !== null) {
|
|
90
|
+
value.forEach((item, index) => errors.push(...validateToolValue(item, itemSchema, `${path}[${index}]`)));
|
|
91
|
+
}
|
|
92
|
+
return errors;
|
|
93
|
+
}
|
|
94
|
+
if (type === "string" && typeof value !== "string")
|
|
95
|
+
errors.push(`${path} must be a string`);
|
|
96
|
+
if (type === "number" && (typeof value !== "number" || !Number.isFinite(value)))
|
|
97
|
+
errors.push(`${path} must be a finite number`);
|
|
98
|
+
if (type === "integer" && (typeof value !== "number" || !Number.isSafeInteger(value)))
|
|
99
|
+
errors.push(`${path} must be an integer`);
|
|
100
|
+
if (type === "boolean" && typeof value !== "boolean")
|
|
101
|
+
errors.push(`${path} must be a boolean`);
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
function validateToolArguments(tool, value) {
|
|
105
|
+
const args = value ?? {};
|
|
106
|
+
const errors = validateToolValue(args, tool.inputSchema, "arguments");
|
|
107
|
+
if (errors.length > 0)
|
|
108
|
+
throw new RpcError(RPC.INVALID_PARAMS, `Invalid ${tool.name} arguments: ${errors.join("; ")}`);
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
31
111
|
/**
|
|
32
112
|
* MCP tools/call result with a single text block.
|
|
33
113
|
*
|
|
@@ -116,8 +196,43 @@ function count(n, noun) {
|
|
|
116
196
|
* Output text is truncated so a fan-out's full transcripts don't flood the
|
|
117
197
|
* host context — pass json:true for the complete record. Mirrors the pi
|
|
118
198
|
* adapter's formatTrace. */
|
|
119
|
-
|
|
199
|
+
const TRACE_DEFAULT_LIMIT = 200;
|
|
200
|
+
const TRACE_MAX_LIMIT = 1000;
|
|
201
|
+
const TRACE_MAX_RESPONSE_CHARS = 120_000;
|
|
202
|
+
const TRACE_JSON_STRING_LIMIT = 4_000;
|
|
203
|
+
function traceLimit(value) {
|
|
204
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
205
|
+
return TRACE_DEFAULT_LIMIT;
|
|
206
|
+
return Math.max(1, Math.min(TRACE_MAX_LIMIT, Math.floor(value)));
|
|
207
|
+
}
|
|
208
|
+
function boundedTraceEvents(events, requested) {
|
|
209
|
+
const limit = traceLimit(requested);
|
|
210
|
+
const bounded = events.slice(-limit).map((event) => JSON.parse(JSON.stringify(event, (_key, value) => typeof value === "string" && value.length > TRACE_JSON_STRING_LIMIT
|
|
211
|
+
? `${value.slice(0, TRACE_JSON_STRING_LIMIT)}… (+${value.length - TRACE_JSON_STRING_LIMIT} chars)`
|
|
212
|
+
: value)));
|
|
213
|
+
while (bounded.length > 1) {
|
|
214
|
+
const candidate = JSON.stringify({ total: events.length, returned: bounded.length, truncated: bounded.length < events.length, events: bounded }, null, 2);
|
|
215
|
+
if (candidate.length <= TRACE_MAX_RESPONSE_CHARS)
|
|
216
|
+
break;
|
|
217
|
+
bounded.shift();
|
|
218
|
+
}
|
|
219
|
+
return { total: events.length, events: bounded };
|
|
220
|
+
}
|
|
221
|
+
export function formatTraceJsonMcp(events, requested) {
|
|
222
|
+
const bounded = boundedTraceEvents(events, requested);
|
|
223
|
+
return JSON.stringify({
|
|
224
|
+
total: bounded.total,
|
|
225
|
+
returned: bounded.events.length,
|
|
226
|
+
truncated: bounded.events.length < bounded.total,
|
|
227
|
+
events: bounded.events,
|
|
228
|
+
}, null, 2);
|
|
229
|
+
}
|
|
230
|
+
function formatTraceMcp(events, runId, flowName, requested) {
|
|
231
|
+
const bounded = boundedTraceEvents(events, requested);
|
|
232
|
+
events = bounded.events;
|
|
120
233
|
const lines = [`Trace — ${flowName} / ${runId} (${events.length} events)`];
|
|
234
|
+
if (events.length < bounded.total)
|
|
235
|
+
lines.push(`Showing newest ${events.length} of ${bounded.total} events.`);
|
|
121
236
|
lines.push("");
|
|
122
237
|
const out = (text, limit = 400) => {
|
|
123
238
|
if (!text)
|
|
@@ -149,9 +264,55 @@ function formatTraceMcp(events, runId, flowName) {
|
|
|
149
264
|
}
|
|
150
265
|
}
|
|
151
266
|
lines.push("");
|
|
152
|
-
lines.push("(Pass json:true for
|
|
267
|
+
lines.push("(Pass json:true for a bounded machine-readable envelope; total/returned/truncated report omitted events.)");
|
|
153
268
|
return lines.join("\n");
|
|
154
269
|
}
|
|
270
|
+
/** Human-readable offline replay report (MCP). Zero tokens — re-folds the
|
|
271
|
+
* recorded trace under alternate decision knobs. */
|
|
272
|
+
function formatReplayMcp(r, runId, flowName) {
|
|
273
|
+
const lines = [
|
|
274
|
+
`Replay — ${flowName} / ${runId} (${r.decisions.length} phase decision(s), zero tokens)`,
|
|
275
|
+
];
|
|
276
|
+
lines.push("");
|
|
277
|
+
if (r.needsLiveRerun)
|
|
278
|
+
lines.push("⚠ Some phases need a live re-run (model/args override).");
|
|
279
|
+
lines.push(`Recorded usage cost ≈ $${r.totalUsage.cost.toFixed(4)} tokens in=${r.totalUsage.input} out=${r.totalUsage.output}`);
|
|
280
|
+
lines.push("");
|
|
281
|
+
for (const d of r.decisions) {
|
|
282
|
+
const prior = d.priorOutcome ? ` prior=${d.priorOutcome}` : "";
|
|
283
|
+
const next = d.replayedOutcome ? ` → ${d.replayedOutcome}` : "";
|
|
284
|
+
lines.push(` • ${d.phaseId}: [${d.outcome}]${prior}${next} — ${d.reason}`);
|
|
285
|
+
}
|
|
286
|
+
lines.push("");
|
|
287
|
+
lines.push("(Pass json:true for the full ReplayReport machine-readable record.)");
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
function parseReplayOverrides(args) {
|
|
291
|
+
const o = {};
|
|
292
|
+
if (typeof args.budgetMaxUSD === "number")
|
|
293
|
+
o.budgetMaxUSD = args.budgetMaxUSD;
|
|
294
|
+
if (typeof args.budgetMaxTokens === "number")
|
|
295
|
+
o.budgetMaxTokens = args.budgetMaxTokens;
|
|
296
|
+
if (args.thresholds && typeof args.thresholds === "object" && !Array.isArray(args.thresholds)) {
|
|
297
|
+
const t = {};
|
|
298
|
+
for (const [k, v] of Object.entries(args.thresholds)) {
|
|
299
|
+
if (typeof v === "number")
|
|
300
|
+
t[k] = v;
|
|
301
|
+
}
|
|
302
|
+
if (Object.keys(t).length)
|
|
303
|
+
o.thresholds = t;
|
|
304
|
+
}
|
|
305
|
+
if (args.models && typeof args.models === "object" && !Array.isArray(args.models)) {
|
|
306
|
+
const m = {};
|
|
307
|
+
for (const [k, v] of Object.entries(args.models)) {
|
|
308
|
+
if (typeof v === "string")
|
|
309
|
+
m[k] = v;
|
|
310
|
+
}
|
|
311
|
+
if (Object.keys(m).length)
|
|
312
|
+
o.models = m;
|
|
313
|
+
}
|
|
314
|
+
return o;
|
|
315
|
+
}
|
|
155
316
|
/** Human-readable recompute dry-run report (MCP). Mirrors the pi adapter's
|
|
156
317
|
* formatRecompute. */
|
|
157
318
|
function formatRecomputeMcp(r) {
|
|
@@ -252,13 +413,40 @@ const TOOLS = [
|
|
|
252
413
|
{
|
|
253
414
|
name: "taskflow_trace",
|
|
254
415
|
title: "Show a run's deterministic-replay event trace",
|
|
255
|
-
description: "Read the append-only event trace a run recorded (each subagent call's input/output +
|
|
416
|
+
description: "Read the append-only event trace a run recorded (each subagent call's input/output + runtime decisions). Foundation for taskflow_replay. Responses are bounded; use limit to select up to 1000 newest events.",
|
|
256
417
|
inputSchema: {
|
|
257
418
|
type: "object",
|
|
258
419
|
additionalProperties: false,
|
|
259
420
|
properties: {
|
|
260
421
|
runId: { type: "string", description: "The run to inspect (from a prior taskflow_run or the runs index)." },
|
|
261
|
-
json: { type: "boolean", description: "Return
|
|
422
|
+
json: { type: "boolean", description: "Return a bounded machine-readable envelope instead of a human timeline. The envelope reports total/returned/truncated; oversized strings are truncated." },
|
|
423
|
+
limit: { type: "number", minimum: 1, maximum: 1000, description: "Maximum newest trace events to return (default 200, max 1000). Large string fields and total response size are also bounded." },
|
|
424
|
+
},
|
|
425
|
+
required: ["runId"],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: "taskflow_replay",
|
|
430
|
+
title: "Replay a recorded run under alternate decision knobs (zero tokens)",
|
|
431
|
+
description: "Re-evaluate a stored run's event trace offline against changed gate thresholds, budget caps, or model routes — without calling any model (zero tokens). Reports per-phase outcomes: reused, would-block, verdict-flipped, would-exceed-budget, needs-live-rerun, etc. Use after taskflow_trace when you want counterfactual analysis of a finished run.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
type: "object",
|
|
434
|
+
additionalProperties: false,
|
|
435
|
+
properties: {
|
|
436
|
+
runId: { type: "string", description: "The run whose trace to replay (from a prior taskflow_run)." },
|
|
437
|
+
json: { type: "boolean", description: "Return the full ReplayReport as JSON." },
|
|
438
|
+
budgetMaxUSD: { type: "number", description: "Alternate max USD budget for would-exceed-budget checks." },
|
|
439
|
+
budgetMaxTokens: { type: "number", description: "Alternate max token budget." },
|
|
440
|
+
thresholds: {
|
|
441
|
+
type: "object",
|
|
442
|
+
additionalProperties: { type: "number" },
|
|
443
|
+
description: "Map of phaseId → new score threshold (re-judges recorded gate-score events).",
|
|
444
|
+
},
|
|
445
|
+
models: {
|
|
446
|
+
type: "object",
|
|
447
|
+
additionalProperties: { type: "string" },
|
|
448
|
+
description: "Map of phaseId → model id (currently marks needs-live-rerun; quality cannot be re-judged offline).",
|
|
449
|
+
},
|
|
262
450
|
},
|
|
263
451
|
required: ["runId"],
|
|
264
452
|
},
|
|
@@ -291,6 +479,24 @@ const TOOLS = [
|
|
|
291
479
|
required: ["runId", "phaseId"],
|
|
292
480
|
},
|
|
293
481
|
},
|
|
482
|
+
{
|
|
483
|
+
name: "taskflow_reconcile_workspace",
|
|
484
|
+
title: "Acknowledge and reconcile the invocation workspace",
|
|
485
|
+
description: "Explicitly accept the current external filesystem state after a resolve-only writer failed with an unknown outcome. This does not restore files or prove correctness: inspect or repair the workspace first. The host operator must also launch Taskflow with TASKFLOW_WORKSPACE_RECONCILE_MODE=explicit. Reconciliation takes an exclusive whole-root lease, durably clears dirty-unknown intents, and advances the workspace generation.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: "object",
|
|
488
|
+
additionalProperties: false,
|
|
489
|
+
properties: {
|
|
490
|
+
acknowledgement: {
|
|
491
|
+
type: "string",
|
|
492
|
+
enum: [WORKSPACE_RECONCILE_ACKNOWLEDGEMENT],
|
|
493
|
+
description: `Must exactly equal: ${WORKSPACE_RECONCILE_ACKNOWLEDGEMENT}`,
|
|
494
|
+
},
|
|
495
|
+
reason: { type: "string", description: "Short audit reason describing what was inspected or repaired." },
|
|
496
|
+
},
|
|
497
|
+
required: ["acknowledgement"],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
294
500
|
{
|
|
295
501
|
name: "taskflow_save",
|
|
296
502
|
title: "Save a reusable taskflow",
|
|
@@ -328,9 +534,43 @@ const TOOLS = [
|
|
|
328
534
|
},
|
|
329
535
|
];
|
|
330
536
|
/** Resolve a flow from params: inline `define` (desugared), `defineFile` (disk), or saved `name`. */
|
|
537
|
+
function resolvePermittedDefineFile(cwd, requested) {
|
|
538
|
+
const lexical = resolve(cwd, requested);
|
|
539
|
+
let candidate = lexical;
|
|
540
|
+
try {
|
|
541
|
+
candidate = realpathSync(lexical);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
// Keep the lexical path so a permitted-but-missing file still receives the
|
|
545
|
+
// normal "not found" diagnostic from readDefineFile. Resolve its existing
|
|
546
|
+
// parent so aliases such as macOS /tmp -> /private/tmp remain contained.
|
|
547
|
+
try {
|
|
548
|
+
candidate = join(realpathSync(dirname(lexical)), basename(lexical));
|
|
549
|
+
}
|
|
550
|
+
catch { /* parent missing */ }
|
|
551
|
+
}
|
|
552
|
+
const rootCandidates = [cwd, tmpdir(), ...(process.platform === "win32" ? [] : ["/tmp"])]
|
|
553
|
+
.map((root) => {
|
|
554
|
+
try {
|
|
555
|
+
return realpathSync(resolve(root));
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return resolve(root);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
const allowed = rootCandidates.some((root) => {
|
|
562
|
+
const rel = relative(root, candidate);
|
|
563
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
564
|
+
});
|
|
565
|
+
if (!allowed) {
|
|
566
|
+
throw new RpcError(RPC.INVALID_PARAMS, `defineFile must be contained in the server cwd or OS temp directory: ${requested}`);
|
|
567
|
+
}
|
|
568
|
+
return candidate;
|
|
569
|
+
}
|
|
331
570
|
function resolveFlow(cwd, params) {
|
|
332
571
|
if (params.define === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) {
|
|
333
|
-
const
|
|
572
|
+
const filePath = resolvePermittedDefineFile(cwd, params.defineFile);
|
|
573
|
+
const fromFile = readDefineFile(filePath);
|
|
334
574
|
if (!fromFile.ok)
|
|
335
575
|
throw new RpcError(RPC.INVALID_PARAMS, describeLoadFailure(fromFile, "defineFile"));
|
|
336
576
|
params = { ...params, define: fromFile.value };
|
|
@@ -357,8 +597,18 @@ function mkRunState(def, args, cwd) {
|
|
|
357
597
|
createdAt: Date.now(),
|
|
358
598
|
updatedAt: Date.now(),
|
|
359
599
|
cwd,
|
|
600
|
+
invocationRootSnapshot: directoryIdentity(cwd),
|
|
360
601
|
};
|
|
361
602
|
}
|
|
603
|
+
export function persistTerminalRun(state, cleanupConfig, write = saveRun) {
|
|
604
|
+
try {
|
|
605
|
+
write(state, cleanupConfig);
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
return error instanceof Error ? error.message : String(error);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
362
612
|
/**
|
|
363
613
|
* Build the per-call tool handlers. `cwd` is the directory the server was
|
|
364
614
|
* launched in (where saved flows + agents are discovered, and where subagents
|
|
@@ -367,11 +617,28 @@ function mkRunState(def, args, cwd) {
|
|
|
367
617
|
*/
|
|
368
618
|
export function makeToolHandlers(cwd, runner) {
|
|
369
619
|
return {
|
|
370
|
-
taskflow_run: async (args) => {
|
|
620
|
+
taskflow_run: async (args, context) => {
|
|
621
|
+
const reusedSavedName = args.define === undefined && args.defineFile === undefined && typeof args.name === "string" && args.name.trim()
|
|
622
|
+
? args.name.trim()
|
|
623
|
+
: undefined;
|
|
371
624
|
const def = resolveFlow(cwd, args);
|
|
372
|
-
const
|
|
373
|
-
if (!
|
|
374
|
-
return textContent(`Flow is invalid:\n- ${
|
|
625
|
+
const structural = validateTaskflow(def);
|
|
626
|
+
if (!structural.ok)
|
|
627
|
+
return textContent(`Flow is invalid:\n- ${structural.errors.join("\n- ")}`, true);
|
|
628
|
+
const providedArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args)
|
|
629
|
+
? args.args
|
|
630
|
+
: {};
|
|
631
|
+
const resolvedArgs = resolveArgs(def, providedArgs);
|
|
632
|
+
const invocation = validateTaskflow(def, { args: resolvedArgs, cwd });
|
|
633
|
+
if (!invocation.ok)
|
|
634
|
+
return textContent(`Flow invocation is invalid:\n- ${invocation.errors.join("\n- ")}`, true);
|
|
635
|
+
const usageAccounting = runner.usageAccounting;
|
|
636
|
+
if (def.budget && usageAccounting === "unavailable") {
|
|
637
|
+
return textContent("This host does not report token or cost usage, so taskflow refuses to run a budgeted flow: the declared ceiling could not be enforced. Remove `budget` only if unmetered execution is intentional, or use a host with usage accounting.", true);
|
|
638
|
+
}
|
|
639
|
+
if (def.budget?.maxUSD !== undefined && usageAccounting === "tokens-only") {
|
|
640
|
+
return textContent("This host reports token usage but not cost, so taskflow refuses budget.maxUSD: the declared dollar ceiling could not be enforced. Use budget.maxTokens or a host with cost accounting.", true);
|
|
641
|
+
}
|
|
375
642
|
// Resolve model roles (e.g. {{fast}} -> a real model id) so the built-in
|
|
376
643
|
// agents' placeholder models map to something the host can launch. This is
|
|
377
644
|
// the same lookup the pi adapter does; without it every phase fails with
|
|
@@ -382,8 +649,11 @@ export function makeToolHandlers(cwd, runner) {
|
|
|
382
649
|
cwd,
|
|
383
650
|
agents,
|
|
384
651
|
runTask: runner.runTask,
|
|
652
|
+
signal: context?.signal,
|
|
653
|
+
usageAccounting,
|
|
654
|
+
cwdBridgeMode: cwdBridgeModeFromEnv(),
|
|
385
655
|
};
|
|
386
|
-
const state = mkRunState(def,
|
|
656
|
+
const state = mkRunState(def, resolvedArgs, cwd);
|
|
387
657
|
// Deterministic-replay trace (best-effort, fail-open).
|
|
388
658
|
deps.trace = new FileTraceSink(traceFilePath(runsDir(cwd), state.flowName, state.runId));
|
|
389
659
|
if (args.incremental === true)
|
|
@@ -399,19 +669,18 @@ export function makeToolHandlers(cwd, runner) {
|
|
|
399
669
|
saveRun(s, cleanupConfig);
|
|
400
670
|
}
|
|
401
671
|
};
|
|
672
|
+
let terminalPersistError;
|
|
402
673
|
const res = await executeTaskflow(state, deps).finally(() => {
|
|
403
674
|
// Terminal persist must survive a throwing runtime ("never lose work") —
|
|
404
675
|
// and persistence itself must never sink a completed run.
|
|
405
|
-
|
|
406
|
-
saveRun(state, cleanupConfig);
|
|
407
|
-
}
|
|
408
|
-
catch {
|
|
409
|
-
/* fail-open */
|
|
410
|
-
}
|
|
676
|
+
terminalPersistError = persistTerminalRun(state, cleanupConfig);
|
|
411
677
|
});
|
|
412
|
-
if (
|
|
678
|
+
if (terminalPersistError !== undefined) {
|
|
679
|
+
return textContent(`Taskflow execution finished, but terminal run persistence failed; no durable run ID can be promised: ${terminalPersistError}`, true);
|
|
680
|
+
}
|
|
681
|
+
if (res.ok && args.reusedFromSearch === true && reusedSavedName) {
|
|
413
682
|
try {
|
|
414
|
-
bumpReuseInSidecar(cwd,
|
|
683
|
+
bumpReuseInSidecar(cwd, reusedSavedName);
|
|
415
684
|
}
|
|
416
685
|
catch {
|
|
417
686
|
/* fail-open: reuse bookkeeping is best-effort */
|
|
@@ -446,8 +715,25 @@ export function makeToolHandlers(cwd, runner) {
|
|
|
446
715
|
if (events.length === 0)
|
|
447
716
|
return textContent(`No trace recorded for run "${runId}" (the run predates tracing, or no trace sink was injected).`, true);
|
|
448
717
|
if (args.json === true)
|
|
449
|
-
return textContent(
|
|
450
|
-
return textContent(formatTraceMcp(events, run.runId, run.flowName));
|
|
718
|
+
return textContent(formatTraceJsonMcp(events, args.limit));
|
|
719
|
+
return textContent(formatTraceMcp(events, run.runId, run.flowName, args.limit));
|
|
720
|
+
},
|
|
721
|
+
taskflow_replay: async (args) => {
|
|
722
|
+
const runId = String(args.runId ?? "");
|
|
723
|
+
if (!runId)
|
|
724
|
+
return textContent("taskflow_replay requires `runId`.", true);
|
|
725
|
+
const runR = loadRunDiagnosed(cwd, runId);
|
|
726
|
+
if (!runR.ok)
|
|
727
|
+
return textContent(describeLoadFailure(runR, `Run "${runId}"`), true);
|
|
728
|
+
const run = runR.value;
|
|
729
|
+
const raw = readTrace(traceFilePath(runsDir(cwd), run.flowName, run.runId));
|
|
730
|
+
if (raw.length === 0)
|
|
731
|
+
return textContent(`No trace recorded for run "${runId}" (the run predates tracing, or no trace sink was injected).`, true);
|
|
732
|
+
const events = raw.map((e) => upgradeTraceEvent(e));
|
|
733
|
+
const report = replayRun(events, parseReplayOverrides(args));
|
|
734
|
+
if (args.json === true)
|
|
735
|
+
return textContent(JSON.stringify(report, null, 2));
|
|
736
|
+
return textContent(formatReplayMcp(report, run.runId, run.flowName));
|
|
451
737
|
},
|
|
452
738
|
taskflow_why_stale: async (args) => {
|
|
453
739
|
const runId = String(args.runId ?? "");
|
|
@@ -462,7 +748,7 @@ export function makeToolHandlers(cwd, runner) {
|
|
|
462
748
|
const seeds = typeof args.phaseId === "string" ? [args.phaseId] : [];
|
|
463
749
|
return textContent(formatWhyStale(run.runId, run.flowName, reads, seeds, declared));
|
|
464
750
|
},
|
|
465
|
-
taskflow_recompute: async (args) => {
|
|
751
|
+
taskflow_recompute: async (args, context) => {
|
|
466
752
|
// MCP exposes recompute as DRY-RUN ONLY (never spends tokens). To actually
|
|
467
753
|
// re-execute, hosts use the Pi adapter's /tf recompute --apply.
|
|
468
754
|
const runId = String(args.runId ?? "");
|
|
@@ -475,10 +761,30 @@ export function makeToolHandlers(cwd, runner) {
|
|
|
475
761
|
const run = runR.value;
|
|
476
762
|
const settings = readSubagentSettings();
|
|
477
763
|
const { agents } = discoverAgents(cwd, "both", settings.modelRoles, settings.taskflow);
|
|
478
|
-
const deps = { cwd, agents, runTask: runner.runTask };
|
|
764
|
+
const deps = { cwd, agents, runTask: runner.runTask, signal: context?.signal };
|
|
479
765
|
const { report } = await recomputeTaskflow(run, deps, [phaseId], { dryRun: true });
|
|
480
766
|
return textContent(formatRecomputeMcp(report));
|
|
481
767
|
},
|
|
768
|
+
taskflow_reconcile_workspace: async (args, context) => {
|
|
769
|
+
try {
|
|
770
|
+
const result = await reconcileResolveOnlyWorkspace({
|
|
771
|
+
invocationRoot: cwd,
|
|
772
|
+
signal: context?.signal,
|
|
773
|
+
allowReconcile: workspaceReconcileAllowedFromEnv(),
|
|
774
|
+
}, {
|
|
775
|
+
acknowledgement: String(args.acknowledgement ?? ""),
|
|
776
|
+
reason: typeof args.reason === "string" ? args.reason : undefined,
|
|
777
|
+
signal: context?.signal,
|
|
778
|
+
});
|
|
779
|
+
const changed = result.reconciledIntentIds.length;
|
|
780
|
+
return textContent(changed === 0
|
|
781
|
+
? `Workspace is already clean at generation ${result.generation}; no dirty intent was changed.`
|
|
782
|
+
: `Workspace reconciled: ${changed} dirty intent(s) accepted; generation ${result.previousGeneration} → ${result.generation}.`);
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
return textContent(`Workspace reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
786
|
+
}
|
|
787
|
+
},
|
|
482
788
|
taskflow_list: async () => {
|
|
483
789
|
const flows = listFlows(cwd);
|
|
484
790
|
if (flows.length === 0)
|
|
@@ -672,13 +978,17 @@ export function makeMcpHandlers(cwd, runner) {
|
|
|
672
978
|
},
|
|
673
979
|
ping: () => ({}),
|
|
674
980
|
"tools/list": () => ({ tools: TOOLS }),
|
|
675
|
-
"tools/call": async (params) => {
|
|
981
|
+
"tools/call": async (params, context) => {
|
|
676
982
|
const p = (params ?? {});
|
|
677
983
|
const tool = tools[p.name ?? ""];
|
|
678
984
|
if (!tool)
|
|
679
985
|
throw new RpcError(RPC.INVALID_PARAMS, `Unknown tool: ${p.name}`);
|
|
986
|
+
const descriptor = TOOLS.find((candidate) => candidate.name === p.name);
|
|
987
|
+
if (!descriptor)
|
|
988
|
+
throw new RpcError(RPC.INVALID_PARAMS, `Unknown tool schema: ${p.name}`);
|
|
989
|
+
const args = validateToolArguments(descriptor, p.arguments);
|
|
680
990
|
void initialized; // tolerant: we don't hard-gate on initialize ordering
|
|
681
|
-
return await tool(
|
|
991
|
+
return await tool(args, context);
|
|
682
992
|
},
|
|
683
993
|
};
|
|
684
994
|
}
|