@fieldwangai/agentflow 0.1.166 → 0.1.168

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.
@@ -17,6 +17,7 @@ import {
17
17
  isCursorQuotaError,
18
18
  markCursorApiKeyLaneBlocked,
19
19
  recordCursorApiKeyFallbackModel,
20
+ recordCursorApiKeyUsage,
20
21
  } from "./cursor-api-key-pool.mjs";
21
22
  import { discoverCursorModels } from "./cursor-model-catalog.mjs";
22
23
  import { outputNodeBasename } from "../pipeline/get-exec-id.mjs";
@@ -92,6 +93,28 @@ function cursorResultErrorText(event) {
92
93
  return "";
93
94
  }
94
95
 
96
+ export function isCursorAgentLoopingError(error = "") {
97
+ const text = String(error?.message || error || "");
98
+ return /agent looping detected|got stuck in a repeating response pattern/i.test(text);
99
+ }
100
+
101
+ function isCursorReadOnlyToolCall(toolName = "") {
102
+ const name = String(toolName || "").trim();
103
+ if (!name) return false;
104
+ return /^(read|glob|grep|search|semanticSearch|list|find|fetch|webSearch|view|inspect)/i.test(name);
105
+ }
106
+
107
+ function annotateCursorFailure(error, { hadToolActivity = false, hadMutatingToolActivity = false } = {}) {
108
+ const failure = error instanceof Error ? error : new Error(String(error || "Cursor Agent failed."));
109
+ failure.cursorHadToolActivity = Boolean(hadToolActivity);
110
+ failure.cursorHadMutatingToolActivity = Boolean(hadMutatingToolActivity);
111
+ if (isCursorAgentLoopingError(failure)) {
112
+ failure.code = "CURSOR_AGENT_LOOPING";
113
+ failure.agentflowFailureCategory = "agent_looping";
114
+ }
115
+ return failure;
116
+ }
117
+
95
118
  function envFlag(name, defaultValue = false) {
96
119
  const raw = process.env[name];
97
120
  if (raw == null || raw === "") return defaultValue;
@@ -123,7 +146,7 @@ function shouldSkipCodexGitCheck(workspace) {
123
146
  return !hasGitMetadataAncestor(workspace);
124
147
  }
125
148
 
126
- function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [] }) {
149
+ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessagePath, promptText, configArgs = [], sandboxMode = "", allowDanger = true }) {
127
150
  const args = [];
128
151
  for (const cfg of Array.isArray(configArgs) ? configArgs : []) {
129
152
  const value = String(cfg || "").trim();
@@ -137,10 +160,10 @@ function buildCodexExecArgs({ workspace, addDirs = [], model, outputLastMessageP
137
160
  const abs = path.resolve(dir);
138
161
  if (abs && abs !== workspace) args.push("--add-dir", abs);
139
162
  }
140
- if (envFlag("AGENTFLOW_CODEX_DANGER", false)) {
163
+ if (allowDanger && envFlag("AGENTFLOW_CODEX_DANGER", false)) {
141
164
  args.push("--dangerously-bypass-approvals-and-sandbox");
142
165
  } else {
143
- args.push("--sandbox", String(process.env.AGENTFLOW_CODEX_SANDBOX || "workspace-write").trim() || "workspace-write");
166
+ args.push("--sandbox", String(sandboxMode || process.env.AGENTFLOW_CODEX_SANDBOX || "workspace-write").trim() || "workspace-write");
144
167
  }
145
168
  if (shouldSkipCodexGitCheck(workspace)) args.push("--skip-git-repo-check");
146
169
  if (envFlag("AGENTFLOW_CODEX_EPHEMERAL", false)) args.push("--ephemeral");
@@ -432,11 +455,16 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
432
455
  || cursorSelection?.modelSelection
433
456
  || { lane: "auto", modelId: "auto", modelName: "Auto" };
434
457
  const model = hasExplicitModel ? requestedModel : cursorModelSelection.modelId;
458
+ if (cursorSelection) recordCursorApiKeyUsage(cursorSelection, cursorModelSelection);
435
459
  // Web UI Composer 需要能无交互执行本机 curl 等命令来刷新画布。
436
- const args = ["--print", "--output-format", "stream-json", "--trust", "--sandbox", "disabled", "--workspace", ws];
437
- const approveMcps = process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false";
460
+ const args = ["--print", "--output-format", "stream-json"];
461
+ if (options.mode) args.push("--mode", String(options.mode));
462
+ args.push("--trust");
463
+ if (options.sandboxDisabled !== false) args.push("--sandbox", "disabled");
464
+ args.push("--workspace", ws);
465
+ const approveMcps = options.approveMcps ?? (process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "0" && process.env.AGENTFLOW_CURSOR_APPROVE_MCPS !== "false");
438
466
  if (approveMcps) args.push("--approve-mcps");
439
- args.push("--force");
467
+ if (options.force !== false) args.push("--force");
440
468
  if (shouldPassCursorModelArg(model)) args.push("--model", model);
441
469
  args.push(promptText);
442
470
 
@@ -453,6 +481,14 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
453
481
  let lastResult = null;
454
482
  let hadError = false;
455
483
  let hadToolActivity = false;
484
+ let hadMutatingToolActivity = false;
485
+ const annotateFailure = (error) => {
486
+ const failure = annotateCursorFailure(error, { hadToolActivity, hadMutatingToolActivity });
487
+ failure.cursorModelLane = cursorModelSelection.lane;
488
+ failure.cursorModelId = cursorModelSelection.modelId;
489
+ failure.cursorModelName = cursorModelSelection.modelName;
490
+ return failure;
491
+ };
456
492
  const STDERR_CAP_BYTES = 1024 * 1024;
457
493
  const stderrChunks = [];
458
494
  let stderrTotalBytes = 0;
@@ -540,6 +576,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
540
576
  hadToolActivity = true;
541
577
  const toolName =
542
578
  event.tool_call && typeof event.tool_call === "object" ? Object.keys(event.tool_call)[0] ?? "?" : "?";
579
+ if (!isCursorReadOnlyToolCall(toolName)) hadMutatingToolActivity = true;
543
580
  const subtype = event.subtype ?? "";
544
581
  const statusLine = `工具 ${toolName}${subtype ? ` (${subtype})` : ""}`;
545
582
  emit({ type: "status", line: statusLine });
@@ -551,14 +588,18 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
551
588
  if (options.onToolCall) options.onToolCall("thinking", "");
552
589
  } else if (event.type === "result") {
553
590
  lastResult = event;
554
- const resultNl = extractCursorResultNl(event);
591
+ const resultNl = options.includeJsonResult && typeof event.result === "string"
592
+ ? normalizeStreamTextChunk(event.result)
593
+ : extractCursorResultNl(event);
555
594
  if (resultNl) emit({ type: "natural", kind: "result", text: resultNl });
556
595
  if (event.subtype === "success" && !event.is_error) {
557
596
  hadError = false;
558
597
  emit({ type: "status", line: t("runner.completed") });
559
598
  } else {
560
599
  hadError = true;
561
- const errNl = extractCursorResultNl(event);
600
+ const errNl = options.includeJsonResult && typeof event.result === "string"
601
+ ? normalizeStreamTextChunk(event.result)
602
+ : extractCursorResultNl(event);
562
603
  if (errNl) emit({ type: "natural", kind: "error", text: errNl });
563
604
  emit({
564
605
  type: "status",
@@ -573,13 +614,18 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
573
614
  if (line.includes('"type":"tool_call"') || line.includes('"type": "tool_call"')) {
574
615
  hadToolActivity = true;
575
616
  let subtype = "?";
617
+ let toolName = "?";
576
618
  try {
577
619
  const ev = JSON.parse(line);
578
- if (ev && ev.type === "tool_call") subtype = ev.subtype ?? "?";
620
+ if (ev && ev.type === "tool_call") {
621
+ subtype = ev.subtype ?? "?";
622
+ toolName = ev.tool_call && typeof ev.tool_call === "object" ? Object.keys(ev.tool_call)[0] ?? "?" : "?";
623
+ }
579
624
  } catch {
580
625
  const m = line.match(/"subtype"\s*:\s*"([^"]+)"/);
581
626
  if (m) subtype = m[1];
582
627
  }
628
+ if (!isCursorReadOnlyToolCall(toolName)) hadMutatingToolActivity = true;
583
629
  emit({ type: "status", line: t("runner.tool_call", { subtype }) });
584
630
  } else if (isLikelyBase64(line)) {
585
631
  emit({ type: "status", line: t("runner.base64_data", { len: line.length }) });
@@ -614,39 +660,55 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
614
660
  const rest = stderrComposerBuffer.trim();
615
661
  emit({ type: "status", line: `[stderr] ${truncateComposerLine(rest)}` });
616
662
  }
617
- const retryCursorQuota = (errorText) => {
618
- if (!cursorSelection) return false;
619
- if (hadToolActivity) return false;
620
- if (!isCursorQuotaError(errorText)) return false;
621
- const errorCategory = classifyCursorApiKeyLimitError(errorText);
622
- const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv);
623
- markCursorApiKeyLaneBlocked(
624
- cursorSelection,
625
- cursorModelSelection.lane,
626
- cooldownMinutes,
627
- errorText,
628
- );
663
+ const retryCursorFailure = (errorText) => {
664
+ const quotaFailure = isCursorQuotaError(errorText);
665
+ const loopingFailure = isCursorAgentLoopingError(errorText);
666
+ if (!quotaFailure && !loopingFailure) return false;
667
+ if (quotaFailure && hadToolActivity) return false;
668
+ if (loopingFailure && hadMutatingToolActivity) return false;
669
+ const errorCategory = quotaFailure ? classifyCursorApiKeyLimitError(errorText) : "agent_looping";
670
+ if (quotaFailure && cursorSelection) {
671
+ const cooldownMinutes = cursorApiKeyCooldownMinutes(cursorBaseEnv, errorText);
672
+ markCursorApiKeyLaneBlocked(
673
+ cursorSelection,
674
+ cursorModelSelection.lane,
675
+ cooldownMinutes,
676
+ errorText,
677
+ Date.now(),
678
+ {
679
+ modelId: cursorModelSelection.modelId,
680
+ modelName: cursorModelSelection.modelName,
681
+ },
682
+ );
683
+ }
629
684
  const canTryComposer = !hasExplicitModel
630
685
  && cursorModelSelection.lane === "auto"
631
- && errorCategory === "explicit_limit"
632
- && isCursorAutoFallbackEligible(errorText);
633
- const hasNextKey = cursorAttemptIndex < cursorAttempts.length - 1;
686
+ && (
687
+ loopingFailure
688
+ || (errorCategory === "explicit_limit" && isCursorAutoFallbackEligible(errorText))
689
+ );
690
+ const hasNextKey = quotaFailure && Boolean(cursorSelection) && cursorAttemptIndex < cursorAttempts.length - 1;
634
691
  if (!canTryComposer && !hasNextKey) return false;
635
692
 
636
693
  const retry = async () => {
637
694
  if (canTryComposer) {
695
+ const authLabel = cursorSelection
696
+ ? `API Key ${cursorApiKeyLabel(cursorSelection)}`
697
+ : "login session";
638
698
  emit({
639
699
  type: "status",
640
- line: `Cursor Auto on API Key ${cursorApiKeyLabel(cursorSelection)} is out of usage; discovering Composer fallback...`,
700
+ line: loopingFailure
701
+ ? `Cursor Auto on ${authLabel} entered a response loop; discovering Composer fallback...`
702
+ : `Cursor Auto on ${authLabel} is out of usage; discovering Composer fallback...`,
641
703
  });
642
704
  const catalog = await discoverCursorModels({
643
- keyId: cursorSelection.id,
705
+ keyId: cursorSelection?.id || `login:${cursorBaseEnv.AGENTFLOW_USER_ID || "default"}`,
644
706
  cwd: ws,
645
707
  command: agentCmd,
646
708
  env: childEnv(options, cursorApiKeyEnv(cursorSelection)),
647
709
  });
648
710
  if (catalog.fallbackModel) {
649
- recordCursorApiKeyFallbackModel(cursorSelection, catalog.fallbackModel);
711
+ if (cursorSelection) recordCursorApiKeyFallbackModel(cursorSelection, catalog.fallbackModel);
650
712
  const fallbackSelection = {
651
713
  lane: "fallback",
652
714
  modelId: catalog.fallbackModel.id,
@@ -662,6 +724,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
662
724
  stream: "runner",
663
725
  eventType: "model_fallback",
664
726
  text: `auto -> ${fallbackSelection.modelId}`,
727
+ reason: loopingFailure ? "agent_looping" : "usage_limit",
665
728
  });
666
729
  const fallback = runCursorAgentWithPrompt(
667
730
  cliWorkspace,
@@ -690,7 +753,7 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
690
753
  await next.finished;
691
754
  return;
692
755
  }
693
- throw new Error(errorText || "Cursor API Key reached its limit.");
756
+ throw annotateFailure(new Error(errorText || (loopingFailure ? "Cursor Agent entered a response loop." : "Cursor API Key reached its limit.")));
694
757
  };
695
758
  retry().then(resolve).catch(reject);
696
759
  return true;
@@ -698,9 +761,9 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
698
761
  if (code !== 0 && lastResult == null) {
699
762
  const stderr = Buffer.concat(stderrChunks).toString("utf-8");
700
763
  const stderrTail = stderr ? stderr.trim().slice(-1200) : "";
701
- if (retryCursorQuota(stderrTail)) return;
764
+ if (retryCursorFailure(stderrTail)) return;
702
765
  const stderrSummary = summarizeCursorStderr(stderr);
703
- const err = new Error(`Cursor CLI exited ${code}. ${stderrSummary || "No result event received."}`);
766
+ const err = annotateFailure(new Error(`Cursor CLI exited ${code}. ${stderrSummary || "No result event received."}`));
704
767
  err.cursorStderrTail = stderrTail;
705
768
  emit({ type: "status", line: truncateComposerLine(err.message) });
706
769
  reject(err);
@@ -708,9 +771,9 @@ export function runCursorAgentWithPrompt(cliWorkspace, promptText, options = {})
708
771
  }
709
772
  if (hadError || (lastResult && lastResult.is_error)) {
710
773
  const msg = cursorResultErrorText(lastResult) || "Agent reported error.";
711
- if (retryCursorQuota(msg)) return;
774
+ if (retryCursorFailure(msg)) return;
712
775
  emit({ type: "status", line: truncateComposerLine(msg) });
713
- reject(new Error(msg));
776
+ reject(annotateFailure(new Error(msg)));
714
777
  return;
715
778
  }
716
779
  if (cursorSelection) clearCursorApiKeyLaneCooldown(cursorSelection, cursorModelSelection.lane);
@@ -840,6 +903,7 @@ export function runClaudeCodeAgentWithPrompt(cliWorkspace, promptText, options =
840
903
  const model = options.model && String(options.model).trim();
841
904
  const claudeCmd = process.env.CLAUDE_CODE_CMD || "claude";
842
905
  const bypassPermissions =
906
+ options.allowDanger !== false &&
843
907
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "0" &&
844
908
  process.env.AGENTFLOW_CLAUDE_CODE_BYPASS_PERMISSIONS !== "false";
845
909
  const args = ["-p", "--output-format", "stream-json", "--verbose", "--add-dir", ws];
@@ -1033,6 +1097,8 @@ export function runCodexAgentWithPrompt(cliWorkspace, promptText, options = {})
1033
1097
  outputLastMessagePath,
1034
1098
  promptText,
1035
1099
  configArgs: options.codexConfigArgs,
1100
+ sandboxMode: options.sandboxMode,
1101
+ allowDanger: options.allowDanger !== false,
1036
1102
  });
1037
1103
 
1038
1104
  const useStderrInherit =
@@ -0,0 +1,293 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const EXPLORATION_DIR = path.join(".workspace", "agentflow", "explorations");
6
+ const SESSION_ID_RE = /^exp_[a-z0-9_-]{8,80}$/i;
7
+ const EVENT_TYPES = new Set(["run", "turn", "decision", "agent", "tool", "command", "file", "artifact", "status"]);
8
+ const EVENT_STATUSES = new Set(["planned", "running", "success", "error", "blocked", "skipped"]);
9
+ const EVENT_PHASES = new Set(["planned", "simulated", "observed", "materialized"]);
10
+ const SIDE_EFFECTS = new Set(["none", "read", "write", "external"]);
11
+
12
+ function explorationRoot(workspaceRoot) {
13
+ return path.join(path.resolve(workspaceRoot), EXPLORATION_DIR);
14
+ }
15
+
16
+ function sessionDir(workspaceRoot, sessionId) {
17
+ const id = normalizeSessionId(sessionId);
18
+ return path.join(explorationRoot(workspaceRoot), id);
19
+ }
20
+
21
+ function sessionMetadataPath(workspaceRoot, sessionId) {
22
+ return path.join(sessionDir(workspaceRoot, sessionId), "session.json");
23
+ }
24
+
25
+ function sessionEventsPath(workspaceRoot, sessionId) {
26
+ return path.join(sessionDir(workspaceRoot, sessionId), "trace.jsonl");
27
+ }
28
+
29
+ function normalizeSessionId(value) {
30
+ const id = String(value || "").trim();
31
+ if (!SESSION_ID_RE.test(id)) throw new Error("Invalid exploration session id");
32
+ return id;
33
+ }
34
+
35
+ function clip(value, max = 2000) {
36
+ return redactSecrets(String(value ?? "")).trim().slice(0, max);
37
+ }
38
+
39
+ function redactSecrets(value) {
40
+ return String(value || "")
41
+ .replace(/\b(?:sk|key|token|secret)[-_][A-Za-z0-9_.-]{8,}\b/gi, "[redacted]")
42
+ .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s,;]+/gi, "$1[redacted]")
43
+ .replace(/((?:api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]");
44
+ }
45
+
46
+ function writeJsonAtomic(filePath, value) {
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
49
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
50
+ fs.renameSync(tempPath, filePath);
51
+ }
52
+
53
+ function readJson(filePath) {
54
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
55
+ }
56
+
57
+ function sessionSummary(raw = {}) {
58
+ return {
59
+ version: 1,
60
+ id: normalizeSessionId(raw.id),
61
+ title: clip(raw.title || "AI 探索运行", 160),
62
+ goal: clip(raw.goal || "", 4000),
63
+ summary: clip(raw.summary || "", 2000),
64
+ mode: EVENT_PHASES.has(String(raw.mode || "")) ? String(raw.mode) : "planned",
65
+ status: ["draft", "planning", "ready", "running", "completed", "failed"].includes(String(raw.status || ""))
66
+ ? String(raw.status)
67
+ : "draft",
68
+ source: {
69
+ provider: clip(raw.source?.provider || "agentflow", 80),
70
+ agent: clip(raw.source?.agent || "workspace", 120),
71
+ },
72
+ eventCount: Math.max(0, Number(raw.eventCount || 0) || 0),
73
+ createdAt: String(raw.createdAt || new Date().toISOString()),
74
+ updatedAt: String(raw.updatedAt || raw.createdAt || new Date().toISOString()),
75
+ ...(raw.materializedAt ? { materializedAt: String(raw.materializedAt) } : {}),
76
+ };
77
+ }
78
+
79
+ export function createAiExplorationSession(workspaceRoot, input = {}) {
80
+ const now = new Date().toISOString();
81
+ const id = `exp_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
82
+ const session = sessionSummary({
83
+ id,
84
+ title: input.title,
85
+ goal: input.goal,
86
+ mode: input.mode,
87
+ status: input.status || "draft",
88
+ source: input.source,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ });
92
+ const dir = sessionDir(workspaceRoot, id);
93
+ fs.mkdirSync(path.join(dir, "artifacts"), { recursive: true, mode: 0o700 });
94
+ writeJsonAtomic(sessionMetadataPath(workspaceRoot, id), session);
95
+ fs.writeFileSync(sessionEventsPath(workspaceRoot, id), "", { encoding: "utf-8", mode: 0o600 });
96
+ return session;
97
+ }
98
+
99
+ export function listAiExplorationSessions(workspaceRoot, limit = 50) {
100
+ const root = explorationRoot(workspaceRoot);
101
+ if (!fs.existsSync(root)) return [];
102
+ const sessions = [];
103
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
104
+ if (!entry.isDirectory() || !SESSION_ID_RE.test(entry.name)) continue;
105
+ try {
106
+ sessions.push(sessionSummary(readJson(sessionMetadataPath(workspaceRoot, entry.name))));
107
+ } catch {
108
+ // A corrupt exploration is omitted instead of breaking the Workspace.
109
+ }
110
+ }
111
+ return sessions
112
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id))
113
+ .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)));
114
+ }
115
+
116
+ export function readAiExplorationSession(workspaceRoot, sessionId) {
117
+ const session = sessionSummary(readJson(sessionMetadataPath(workspaceRoot, sessionId)));
118
+ const events = [];
119
+ const eventPath = sessionEventsPath(workspaceRoot, session.id);
120
+ if (fs.existsSync(eventPath)) {
121
+ for (const line of fs.readFileSync(eventPath, "utf-8").split("\n")) {
122
+ if (!line.trim()) continue;
123
+ try { events.push(JSON.parse(line)); } catch { /* retain readable events */ }
124
+ }
125
+ }
126
+ return { ...session, events };
127
+ }
128
+
129
+ export function updateAiExplorationSession(workspaceRoot, sessionId, patch = {}) {
130
+ const current = readAiExplorationSession(workspaceRoot, sessionId);
131
+ const next = sessionSummary({
132
+ ...current,
133
+ ...patch,
134
+ source: patch.source ? { ...current.source, ...patch.source } : current.source,
135
+ updatedAt: new Date().toISOString(),
136
+ });
137
+ writeJsonAtomic(sessionMetadataPath(workspaceRoot, next.id), next);
138
+ return next;
139
+ }
140
+
141
+ function normalizeArtifacts(raw) {
142
+ return (Array.isArray(raw) ? raw : []).slice(0, 20).map((artifact) => ({
143
+ kind: clip(artifact?.kind || "file", 40),
144
+ path: clip(artifact?.path || "", 500),
145
+ label: clip(artifact?.label || artifact?.path || "artifact", 160),
146
+ ...(artifact?.sha256 ? { sha256: clip(artifact.sha256, 80) } : {}),
147
+ })).filter((artifact) => artifact.path || artifact.label);
148
+ }
149
+
150
+ export function normalizeAiTraceEvent(raw = {}, defaults = {}) {
151
+ const now = new Date().toISOString();
152
+ const phase = EVENT_PHASES.has(String(raw.phase || defaults.phase || "")) ? String(raw.phase || defaults.phase) : "observed";
153
+ const type = EVENT_TYPES.has(String(raw.type || "")) ? String(raw.type) : "status";
154
+ const status = EVENT_STATUSES.has(String(raw.status || "")) ? String(raw.status) : (phase === "planned" ? "planned" : "running");
155
+ const sideEffect = SIDE_EFFECTS.has(String(raw.sideEffect || "")) ? String(raw.sideEffect) : "none";
156
+ return {
157
+ id: clip(raw.id || `evt_${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`, 100),
158
+ traceId: clip(raw.traceId || defaults.traceId || "", 100),
159
+ spanId: clip(raw.spanId || raw.id || `span_${crypto.randomUUID().replace(/-/g, "").slice(0, 18)}`, 100),
160
+ parentSpanId: clip(raw.parentSpanId || "", 100),
161
+ sequence: Math.max(1, Number(raw.sequence || defaults.sequence || 1) || 1),
162
+ phase,
163
+ type,
164
+ name: clip(raw.name || type, 160),
165
+ summary: clip(raw.summary || raw.description || "", 2000),
166
+ status,
167
+ sideEffect,
168
+ requiresApproval: raw.requiresApproval === true || ["write", "external"].includes(sideEffect),
169
+ startedAt: String(raw.startedAt || now),
170
+ ...(raw.endedAt ? { endedAt: String(raw.endedAt) } : {}),
171
+ ...(raw.inputPreview ? { inputPreview: clip(raw.inputPreview, 2000) } : {}),
172
+ ...(raw.outputPreview ? { outputPreview: clip(raw.outputPreview, 2000) } : {}),
173
+ artifacts: normalizeArtifacts(raw.artifacts),
174
+ };
175
+ }
176
+
177
+ export function appendAiTraceEvents(workspaceRoot, sessionId, rawEvents = [], defaults = {}) {
178
+ const session = readAiExplorationSession(workspaceRoot, sessionId);
179
+ const incoming = Array.isArray(rawEvents) ? rawEvents : [rawEvents];
180
+ if (!incoming.length) return { session, events: [] };
181
+ if (session.eventCount + incoming.length > 5000) throw new Error("Exploration trace exceeds 5000 events");
182
+ const events = incoming.map((event, index) => normalizeAiTraceEvent(event, {
183
+ ...defaults,
184
+ traceId: session.id,
185
+ sequence: session.eventCount + index + 1,
186
+ }));
187
+ fs.appendFileSync(sessionEventsPath(workspaceRoot, session.id), events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf-8");
188
+ const next = updateAiExplorationSession(workspaceRoot, session.id, {
189
+ eventCount: session.eventCount + events.length,
190
+ mode: defaults.phase || session.mode,
191
+ });
192
+ return { session: next, events };
193
+ }
194
+
195
+ export function parseAiPlanResult(text, sessionId) {
196
+ const raw = String(text || "").trim();
197
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
198
+ const candidates = [raw, fenced].filter(Boolean);
199
+ let parsed;
200
+ for (const candidate of candidates) {
201
+ try {
202
+ parsed = JSON.parse(candidate);
203
+ break;
204
+ } catch {
205
+ const start = candidate.indexOf("{");
206
+ const end = candidate.lastIndexOf("}");
207
+ if (start >= 0 && end > start) {
208
+ try { parsed = JSON.parse(candidate.slice(start, end + 1)); break; } catch { /* next */ }
209
+ }
210
+ }
211
+ }
212
+ if (!parsed || typeof parsed !== "object") throw new Error("Plan agent did not return valid JSON");
213
+ const spans = Array.isArray(parsed.spans) ? parsed.spans : Array.isArray(parsed.steps) ? parsed.steps : [];
214
+ if (!spans.length) throw new Error("Plan agent returned no executable spans");
215
+ return {
216
+ title: clip(parsed.title || "AI 执行计划", 160),
217
+ summary: clip(parsed.summary || "", 2000),
218
+ events: spans.slice(0, 120).map((span, index) => normalizeAiTraceEvent({
219
+ ...span,
220
+ id: span.id || `plan_${index + 1}`,
221
+ spanId: span.spanId || span.id || `plan_${index + 1}`,
222
+ parentSpanId: span.parentSpanId || span.parentId || "",
223
+ type: span.type || "turn",
224
+ status: "planned",
225
+ phase: "planned",
226
+ sideEffect: span.sideEffect || "none",
227
+ startedAt: new Date().toISOString(),
228
+ }, { traceId: sessionId, phase: "planned", sequence: index + 1 })),
229
+ };
230
+ }
231
+
232
+ export function materializableAiTraceEvents(session) {
233
+ const events = Array.isArray(session?.events) ? session.events : [];
234
+ const planned = events.filter((event) => event.phase === "planned");
235
+ if (planned.length) return planned;
236
+ return events.filter((event) => event.phase === "observed");
237
+ }
238
+
239
+ export function classifyAiToolSideEffect(toolName, subtype = "") {
240
+ const value = `${toolName || ""} ${subtype || ""}`.trim().toLowerCase();
241
+ if (/(?:^|[_\W])thinking(?:$|[_\W])/.test(value)) return "none";
242
+ if (/(?:^|[_\W])(web|http|curl|fetch|mcp|publish|send|notify|deploy)(?:$|[_\W])/.test(value)) return "external";
243
+ if (/(?:^|[_\W])(read|search|find|grep|glob|list|inspect|status|stat|cat|head|tail)(?:$|[_\W])/.test(value)) return "read";
244
+ return "write";
245
+ }
246
+
247
+ export function aiPlanPrompt({ goal = "", workspaceSource = "" } = {}) {
248
+ return [
249
+ "你是 AgentFlow 的只读 Plan Agent。只规划,不执行工具,不修改文件。",
250
+ "把用户目标转换为一张预计 AI 运行图。只输出合法 JSON,不要 Markdown 代码围栏。",
251
+ "JSON 格式:",
252
+ '{"title":"短标题","summary":"计划摘要","spans":[{"id":"step_1","parentSpanId":"","type":"turn|decision|agent|tool|command|file|artifact","name":"步骤名","summary":"做什么以及为什么","sideEffect":"none|read|write|external","requiresApproval":false,"inputPreview":"预计输入","outputPreview":"预期输出"}]}',
253
+ "要求:id 唯一;parentSpanId 表达父子关系;写文件、发布、发送、删除、外部写请求必须标记 requiresApproval=true;搜索与读取标记 read;不要假装已经得到任何执行结果。",
254
+ workspaceSource ? `\n## 当前 Workspace DSL\n\n${workspaceSource}` : "",
255
+ `\n## 用户目标\n\n${clip(goal, 12000)}`,
256
+ ].filter(Boolean).join("\n");
257
+ }
258
+
259
+ export function aiMaterializationPrompt(session, workspaceSource = "") {
260
+ const events = materializableAiTraceEvents(session);
261
+ const plan = events.map((event) => ({
262
+ spanId: event.spanId,
263
+ parentSpanId: event.parentSpanId,
264
+ type: event.type,
265
+ name: event.name,
266
+ summary: event.summary,
267
+ sideEffect: event.sideEffect,
268
+ requiresApproval: event.requiresApproval,
269
+ }));
270
+ return [
271
+ "你是 AgentFlow 流程固化 Agent。把已审核的 AI Plan 固化到当前 Workspace DSL 调整态。",
272
+ "必须实际编辑 workspace.flow.js;稳定脚本放入 nodes/<name>/index.mjs;不要执行该流程,不要发布。",
273
+ "过滤纯搜索噪声;把输入参数化;把判断映射为 control.if,把受控重复映射为 control.while,把产物映射为 Display;副作用步骤必须保留清晰名称和输入。",
274
+ "修改完成后运行 `agentflow flow dsl lint <当前流程目录>`。最终只简短说明生成了哪些节点以及仍需人工确认的副作用。",
275
+ workspaceSource ? `\n## 当前 Workspace DSL\n\n${workspaceSource}` : "",
276
+ `\n## 探索目标\n\n${clip(session?.goal || "", 8000)}`,
277
+ `\n## 已审核 Plan Trace\n\n${JSON.stringify(plan, null, 2)}`,
278
+ ].filter(Boolean).join("\n");
279
+ }
280
+
281
+ export function writeAiExplorationMaterialization(workspaceRoot, sessionId, value = {}) {
282
+ const session = readAiExplorationSession(workspaceRoot, sessionId);
283
+ const payload = {
284
+ version: 1,
285
+ sessionId: session.id,
286
+ materializedAt: new Date().toISOString(),
287
+ spanIds: materializableAiTraceEvents(session).map((event) => event.spanId).filter(Boolean),
288
+ nodeIds: (Array.isArray(value.nodeIds) ? value.nodeIds : []).map((id) => clip(id, 160)).filter(Boolean),
289
+ ...(value.designRevision ? { designRevision: clip(value.designRevision, 160) } : {}),
290
+ };
291
+ writeJsonAtomic(path.join(sessionDir(workspaceRoot, session.id), "materialization.json"), payload);
292
+ return payload;
293
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as acornParse } from "acorn";
4
+
5
+ import { PACKAGE_ROOT } from "./paths.mjs";
6
+
7
+ const WORKSPACE_RUNTIME_PATH = "bin/lib/workspace-server.mjs";
8
+
9
+ // Only modules explicitly listed here are exposed in full. The runtime adapter is
10
+ // extracted from workspace-server.mjs so the review always follows the code that
11
+ // actually dispatches the built-in node without exposing the whole server file.
12
+ const BUILTIN_NODE_IMPLEMENTATION_FILES = new Map([
13
+ ["tool_wecom_send_group_markdown", ["bin/lib/wecom.mjs"]],
14
+ ["tool_wecom_send_app_markdown", ["bin/lib/wecom.mjs"]],
15
+ ]);
16
+
17
+ const sourceCache = new Map();
18
+ let runtimeAstCache = null;
19
+
20
+ function readPackageSource(relativePath) {
21
+ const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
22
+ if (!normalized || normalized.includes("..")) return "";
23
+ if (sourceCache.has(normalized)) return sourceCache.get(normalized);
24
+ const absolutePath = path.join(PACKAGE_ROOT, normalized);
25
+ let source = "";
26
+ try {
27
+ if (fs.statSync(absolutePath).isFile()) source = fs.readFileSync(absolutePath, "utf8").replace(/\r\n/g, "\n");
28
+ } catch {}
29
+ sourceCache.set(normalized, source);
30
+ return source;
31
+ }
32
+
33
+ function walkAst(node, visit) {
34
+ if (!node || typeof node !== "object") return;
35
+ if (typeof node.type === "string") visit(node);
36
+ for (const [key, value] of Object.entries(node)) {
37
+ if (["start", "end", "loc", "type"].includes(key)) continue;
38
+ if (Array.isArray(value)) {
39
+ for (const child of value) walkAst(child, visit);
40
+ } else if (value && typeof value === "object") {
41
+ walkAst(value, visit);
42
+ }
43
+ }
44
+ }
45
+
46
+ function astContainsString(node, expected) {
47
+ let found = false;
48
+ walkAst(node, (candidate) => {
49
+ if (candidate.type === "Literal" && candidate.value === expected) found = true;
50
+ });
51
+ return found;
52
+ }
53
+
54
+ function runtimeAst() {
55
+ const source = readPackageSource(WORKSPACE_RUNTIME_PATH);
56
+ if (!source) return { source: "", ast: null };
57
+ if (runtimeAstCache?.source === source) return runtimeAstCache;
58
+ let ast = null;
59
+ try {
60
+ ast = acornParse(source, { ecmaVersion: "latest", sourceType: "module", allowHashBang: true });
61
+ } catch {}
62
+ runtimeAstCache = { source, ast };
63
+ return runtimeAstCache;
64
+ }
65
+
66
+ function runtimeAdapterSource(definitionId) {
67
+ const { source, ast } = runtimeAst();
68
+ if (!source || !ast) return "";
69
+ const matches = [];
70
+ walkAst(ast, (node) => {
71
+ if (node.type !== "IfStatement" || !astContainsString(node.test, definitionId)) return;
72
+ matches.push(source.slice(node.start, node.end).trim());
73
+ });
74
+ return matches.join("\n\n");
75
+ }
76
+
77
+ export function builtinNodeReviewSources(definitionId = "") {
78
+ const id = String(definitionId || "").trim();
79
+ if (!id) return [];
80
+ const sources = [];
81
+ const adapter = runtimeAdapterSource(id);
82
+ if (adapter) {
83
+ sources.push({
84
+ sourcePath: `builtin/${id}/runtime-adapter.mjs`,
85
+ title: "内置运行适配器",
86
+ kind: "builtin-adapter",
87
+ content: adapter,
88
+ });
89
+ }
90
+ for (const relativePath of BUILTIN_NODE_IMPLEMENTATION_FILES.get(id) || []) {
91
+ const content = readPackageSource(relativePath);
92
+ if (!content) continue;
93
+ sources.push({
94
+ sourcePath: relativePath,
95
+ title: `内置实现 · ${path.basename(relativePath)}`,
96
+ kind: "builtin-implementation",
97
+ content,
98
+ });
99
+ }
100
+ return sources;
101
+ }