agentpaas_run.ts 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227
  1. /// <reference path="../env.d.ts" />
  2. import { tool } from "@opencode-ai/plugin"
  3. import { link, mkdir, rm } from "node:fs/promises"
  4. import { homedir } from "node:os"
  5. import path from "node:path"
  6. const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
  7. type SSEEvent = { event: string; data: unknown }
  8. type AgentListItem = { id: string; name: string }
  9. type ConversationState = { threadID: string; runID: string }
  10. type ProgressStatus = "completed" | "running" | "pending" | "failed" | "paused" | "cancelled" | "skipped" | "unknown"
  11. type ProgressMilestone = {
  12. id: string
  13. label: string
  14. status: ProgressStatus
  15. done?: Set<string>
  16. total?: number
  17. detail?: string
  18. }
  19. type ProgressStage = {
  20. id: string
  21. label: string
  22. status: ProgressStatus
  23. milestones: ProgressMilestone[]
  24. }
  25. export default tool({
  26. description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
  27. 参数说明:
  28. - agent_name:远端 AgentPaaS 智能体名称,工具会动态解析当前 ID,必填
  29. - input:要执行的用户任务指令
  30. - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
  31. - target_subagent:mode=edit 时的目标子智能体 ID
  32. - work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
  33. - conversation:continue(默认,沿用远端 thread 并引用上一轮成果)/ new(新课题)
  34. - thread_id:可选的远端会话线程 ID;显式值优先于自动映射
  35. - parameters:额外参数,原样传给远端智能体
  36. 适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
  37. args: {
  38. input: tool.schema.string().describe("用户任务指令"),
  39. agent_name: tool.schema.string().describe("远端 AgentPaaS 智能体名称"),
  40. mode: tool.schema.enum(["iterate", "chat", "edit"]).default("iterate"),
  41. target_subagent: tool.schema.string().optional().describe("mode=edit 时的目标子智能 ID"),
  42. work_dir: tool.schema.string().optional().describe("服务端可见工作目录;通常留空,由 AgentPaaS 创建"),
  43. thread_id: tool.schema.string().optional().describe("远端会话线程 ID;通常留空,由 OpenCode session 自动维护"),
  44. conversation: tool.schema.enum(["continue", "new"]).default("continue").describe("复用当前对话或开始新课题"),
  45. question_id: tool.schema.number().int().min(1).max(125).optional().describe("Science 125 科学问题编号"),
  46. feedback_kind: tool.schema.enum(["human", "program_check", "execution_result", "evidence"]).optional(),
  47. input_artifact_ids: tool.schema
  48. .array(tool.schema.string())
  49. .optional()
  50. .describe("续跑时选取的上一轮产物 ID;省略时复用上一轮已登记产物"),
  51. idempotency_key: tool.schema.string().optional().describe("同一请求重试时复用;不同请求必须使用不同键"),
  52. parameters: tool.schema
  53. .record(tool.schema.string(), tool.schema.any())
  54. .optional()
  55. .describe("额外参数,原样传给远端智能体"),
  56. },
  57. async execute(args, context) {
  58. const config = await loadAgentPaaSConfig()
  59. if (args.mode === "edit" && !args.target_subagent) {
  60. throw new Error("mode=edit 时必须提供 target_subagent")
  61. }
  62. const agentID = await resolveAgentID(config, args.agent_name, context.abort)
  63. const conversation = args.conversation ?? "continue"
  64. const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
  65. const savedConversation =
  66. conversation === "new" ? undefined : await loadConversationState(context.sessionID, conversationKey)
  67. const threadID = args.thread_id?.trim() || savedConversation?.threadID || ""
  68. const continueRunID =
  69. conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
  70. ? savedConversation?.runID || ""
  71. : ""
  72. const intent = digest(canonicalJSON({ ...args, idempotency_key: undefined, conversation }))
  73. const idempotencyKey =
  74. args.idempotency_key?.trim() ||
  75. `opencode-${digest(canonicalJSON([context.sessionID, context.messageID, agentID, intent]))}`
  76. // Freeze the parent before submitting. Once `started` advances the session,
  77. // retrying this same request must not turn it into a new research revision.
  78. const admission = await requestContext(config, agentID, idempotencyKey, { intent, threadID, runID: continueRunID })
  79. // OpenCode 的宿主机目录不会自动挂载进 AgentPaaS Docker。只有调用方
  80. // 明确给出服务端可见路径时才覆盖,否则让 PaaS 创建并隔离 run workspace。
  81. const workDir = args.work_dir
  82. const url = `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/run/stream`
  83. let resp: Response
  84. try {
  85. resp = await fetch(url, {
  86. method: "POST",
  87. headers: {
  88. Authorization: `Bearer ${config.apiKey}`,
  89. "Content-Type": "application/json",
  90. Accept: "text/event-stream",
  91. "Idempotency-Key": idempotencyKey,
  92. },
  93. body: JSON.stringify({
  94. input: args.input,
  95. mode: args.mode,
  96. target_subagent: args.target_subagent ?? "",
  97. thread_id: admission.threadID,
  98. parameters: args.parameters ?? {},
  99. context: {
  100. ...(workDir ? { work_dir: workDir } : {}),
  101. ...(admission.runID ? { run_id: admission.runID } : {}),
  102. ...(args.question_id ? { question_id: args.question_id } : {}),
  103. ...(args.feedback_kind ? { feedback_kind: args.feedback_kind } : {}),
  104. ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
  105. },
  106. }),
  107. signal: context.abort,
  108. })
  109. } catch (err) {
  110. throw new Error(`AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)}`)
  111. }
  112. if (!resp.ok) {
  113. const detail = await resp.text().catch(() => "")
  114. throw new Error(`AgentPaaS HTTP ${resp.status}: ${detail.slice(0, 500)}`)
  115. }
  116. if (!resp.body) throw new Error("AgentPaaS 响应没有 body")
  117. const errors: string[] = []
  118. const liveMetadata: Record<string, unknown> = {
  119. agent_name: args.agent_name,
  120. agent_id: agentID,
  121. idempotency_key: idempotencyKey,
  122. status: "running",
  123. }
  124. let done: Record<string, unknown> | null = null
  125. let startedRunID = ""
  126. let sawCancelled = false
  127. let lastThinkUpdate = 0
  128. const progress = createResearch67Progress()
  129. const updateMetadata = (title: string, next: Record<string, unknown>) => {
  130. Object.assign(liveMetadata, next)
  131. const snapshot = progress.snapshot()
  132. context.metadata({
  133. title: snapshot?.title ?? title,
  134. metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
  135. })
  136. }
  137. let remoteCancellation: Promise<void> | undefined
  138. const cancelOnAbort = () => {
  139. if (!startedRunID || remoteCancellation) return
  140. remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
  141. }
  142. context.abort.addEventListener("abort", cancelOnAbort, { once: true })
  143. try {
  144. for await (const ev of parseSSE(resp.body)) {
  145. const data = asRecord(ev.data)
  146. if (ev.event === "started") {
  147. startedRunID = getString(data?.run_id)
  148. const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
  149. if (remoteThreadID && startedRunID) {
  150. const latest = await loadConversationState(context.sessionID, conversationKey)
  151. if (admission.created || !latest || latest.runID === admission.runID || latest.runID === startedRunID) {
  152. await saveConversationState(context.sessionID, conversationKey, {
  153. threadID: remoteThreadID,
  154. runID: startedRunID,
  155. })
  156. }
  157. }
  158. if (context.abort.aborted) cancelOnAbort()
  159. updateMetadata("Research67 正在运行", {
  160. run_id: data?.run_id,
  161. thread_id: data?.thread_id,
  162. latest_event: ev.event,
  163. })
  164. } else if (ev.event === "stage_started") {
  165. const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "未知阶段"
  166. progress.startStage(getString(data?.stage_id), getPositiveInteger(data?.attempt))
  167. updateMetadata(`Research67 正在进行 ${stageName}`, {
  168. pipeline_id: data?.pipeline_id,
  169. stage_id: data?.stage_id,
  170. stage_name: stageName,
  171. stage_status: data?.status,
  172. attempt: data?.attempt,
  173. latest_event: ev.event,
  174. })
  175. } else if (ev.event === "stage_passed") {
  176. const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
  177. progress.finishStage(getString(data?.stage_id), "completed")
  178. updateMetadata(`Research67 已通过 ${stageName}`, {
  179. pipeline_id: data?.pipeline_id,
  180. stage_id: data?.stage_id,
  181. stage_name: stageName,
  182. stage_status: data?.status,
  183. attempt: data?.attempt,
  184. latest_event: ev.event,
  185. })
  186. } else if (ev.event === "stage_failed") {
  187. const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
  188. progress.finishStage(getString(data?.stage_id), "failed")
  189. updateMetadata(`Research67 阶段失败:${stageName}`, {
  190. pipeline_id: data?.pipeline_id,
  191. stage_id: data?.stage_id,
  192. stage_name: stageName,
  193. stage_status: data?.status,
  194. stage_error: data?.error,
  195. attempt: data?.attempt,
  196. latest_event: ev.event,
  197. status: "failed",
  198. })
  199. } else if (ev.event === "pipeline_completed") {
  200. progress.finishPipeline()
  201. updateMetadata("Research67 流水线已完成", {
  202. pipeline_id: data?.pipeline_id,
  203. pipeline_status: data?.status,
  204. latest_event: ev.event,
  205. status: "completed",
  206. })
  207. } else if (ev.event === "confirm_required") {
  208. const runID = getString(data?.run_id) || getString(liveMetadata.run_id)
  209. const toolName = getString(data?.tool) || "高风险工具"
  210. const reason = getString(data?.reason)
  211. const input = getString(data?.input)
  212. if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
  213. progress.note(`等待确认 · ${toolName}`)
  214. updateMetadata(`Research67 正在等待确认:${toolName}`, {
  215. latest_event: ev.event,
  216. pending_confirmation: true,
  217. confirmation_tool: toolName,
  218. confirmation_reason: reason,
  219. confirmation_input: input,
  220. })
  221. let approved = true
  222. try {
  223. await context.ask({
  224. permission: "agentpaas_confirm",
  225. patterns: [`${agentID}:${toolName}`],
  226. always: [`${agentID}:${toolName}`],
  227. metadata: { run_id: runID, agent_name: args.agent_name, tool: toolName, reason, input },
  228. })
  229. } catch {
  230. approved = false
  231. }
  232. await resolveConfirmation(config, agentID, runID, approved, context.abort, getString(data?.approval_id))
  233. progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
  234. updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
  235. latest_event: "confirmation_resolved",
  236. pending_confirmation: false,
  237. confirmation_approved: approved,
  238. })
  239. } else if (ev.event === "tool_call") {
  240. const toolName = getString(data?.tool) || "工具"
  241. progress.toolCall(toolName, getString(data?.content))
  242. updateMetadata(withStage("Research67 正在处理当前步骤", liveMetadata), {
  243. latest_event: ev.event,
  244. latest_tool: toolName,
  245. step: data?.step,
  246. })
  247. } else if (ev.event === "tool_result") {
  248. const toolName = getString(data?.tool) || getString(liveMetadata.latest_tool) || "工具"
  249. progress.toolResult(toolName, getString(data?.content))
  250. updateMetadata(withStage("Research67 已更新当前步骤", liveMetadata), {
  251. latest_event: ev.event,
  252. latest_tool: toolName,
  253. step: data?.step,
  254. })
  255. } else if (ev.event === "think" || ev.event === "think_chunk") {
  256. const now = Date.now()
  257. if (ev.event === "think" || now - lastThinkUpdate >= 1000) {
  258. lastThinkUpdate = now
  259. updateMetadata(withStage("Research67 正在思考", liveMetadata), {
  260. latest_event: ev.event,
  261. step: data?.step,
  262. })
  263. }
  264. } else if (ev.event === "answer") {
  265. updateMetadata(withStage("Research67 正在整理结果", liveMetadata), {
  266. latest_event: ev.event,
  267. step: data?.step,
  268. })
  269. } else if (ev.event === "heartbeat") {
  270. updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
  271. } else if (ev.event === "error") {
  272. const message = extractMessage(ev.data)
  273. errors.push(message)
  274. updateMetadata("Research67 执行失败", {
  275. latest_event: ev.event,
  276. status: "failed",
  277. error_code: data?.code,
  278. error_message: message,
  279. workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
  280. })
  281. } else if (ev.event === "paused") {
  282. progress.pause()
  283. updateMetadata("Research67 已暂停", {
  284. latest_event: ev.event,
  285. status: "paused",
  286. checkpoint_id: data?.checkpoint_id,
  287. workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
  288. })
  289. } else if (ev.event === "approval_required") {
  290. // Durable human gates stop the stream. Surface the approval card; a separate
  291. // control action must approve it and re-subscribe to the same run.
  292. updateMetadata("Research67 等待人工反馈", {
  293. latest_event: ev.event,
  294. status: "waiting_human",
  295. pending_approval: data,
  296. })
  297. } else if (ev.event === "cancelled") {
  298. sawCancelled = true
  299. progress.cancel()
  300. updateMetadata("Research67 已取消", { latest_event: ev.event, status: "cancelled" })
  301. } else if (ev.event === "research_status" && data) {
  302. updateMetadata(scientificLabel(data.status), { scientific_status: data.status, latest_event: ev.event })
  303. } else if (ev.event === "done" && data) {
  304. done = data
  305. break
  306. }
  307. }
  308. } finally {
  309. context.abort.removeEventListener("abort", cancelOnAbort)
  310. }
  311. if (!done) {
  312. const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
  313. throw new Error(
  314. `AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}。run_id=${startedRunID || "尚未收到"};idempotency_key=${idempotencyKey}。请先查询原运行状态,不要换键重复执行。`,
  315. )
  316. }
  317. const status = typeof done.status === "string" ? done.status : "unknown"
  318. if (status === "completed" || status === "failed") progress.stopUnverified()
  319. if (status === "paused") progress.pause()
  320. if (status === "cancelled") progress.cancel()
  321. const progressMetadata = progress.snapshot()
  322. const metadata = {
  323. agent_name: args.agent_name,
  324. agent_id: agentID,
  325. idempotency_key: idempotencyKey,
  326. run_id: done.run_id ?? liveMetadata.run_id,
  327. thread_id: done.thread_id ?? done.session_id ?? liveMetadata.thread_id,
  328. status,
  329. steps: done.steps,
  330. total_tokens: done.total_tokens ?? asRecord(done.usage)?.total_tokens,
  331. cost_usd: done.cost_usd ?? asRecord(done.usage)?.cost_usd,
  332. research: done.research,
  333. artifacts: Array.isArray(done.artifacts) ? done.artifacts : [],
  334. workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
  335. error_code: liveMetadata.error_code,
  336. error_message: liveMetadata.error_message,
  337. checkpoint_id: done.checkpoint_id ?? liveMetadata.checkpoint_id,
  338. pending_approval: done.pending_approval ?? liveMetadata.pending_approval,
  339. ...(progressMetadata ? { research67_progress: progressMetadata } : {}),
  340. }
  341. const artifactLinks = metadata.artifacts.flatMap((value) => {
  342. const artifact = asRecord(value)
  343. const id = getString(artifact?.artifact_id)
  344. if (!id) return []
  345. const name = (getString(artifact?.name) || id).replace(/[\[\]\r\n]/g, "_")
  346. return [
  347. `- [${name}](${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(String(metadata.run_id))}/artifacts/${encodeURIComponent(id)}/download)`,
  348. ]
  349. })
  350. const science = asRecord(asRecord(done.research)?.science)
  351. if (status === "completed")
  352. return {
  353. title: science ? scientificLabel(science.status) : `AgentPaaS ${args.agent_name}`,
  354. output: [
  355. ...(science ? [`研究状态:${scientificLabel(science.status)}。执行结束不代表科学结论成立。\n`] : []),
  356. String(done.output ?? ""),
  357. ...(artifactLinks.length ? ["\n研究产物(通过 PaaS 身份验证后下载):", ...artifactLinks] : []),
  358. ].join("\n"),
  359. metadata,
  360. }
  361. if (status === "cancelled") return { title: `AgentPaaS ${args.agent_name}`, output: "任务已被取消。", metadata }
  362. if (status === "paused")
  363. return {
  364. title: `AgentPaaS ${args.agent_name}(已暂停)`,
  365. output: `任务已暂停。run_id=${String(metadata.run_id ?? "")};checkpoint_id=${String(metadata.checkpoint_id ?? "未提供")}。恢复应使用该 checkpoint,不能重新提交任务。`,
  366. metadata,
  367. }
  368. if (status === "waiting_human" || status === "waiting_confirmation")
  369. return {
  370. title: `AgentPaaS ${args.agent_name}(等待确认)`,
  371. output: `任务正在等待人工确认。run_id=${String(metadata.run_id ?? "")}。请检查待确认材料并提交决定;这不是任务失败。`,
  372. metadata,
  373. }
  374. return {
  375. title: `AgentPaaS ${args.agent_name}(失败)`,
  376. output: [
  377. `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
  378. metadata.workspace_path
  379. ? `已有产物保留在:${metadata.workspace_path}`
  380. : "已有产物已保留;请根据 run_id 查询工作区。",
  381. "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
  382. ].join("\n"),
  383. metadata,
  384. }
  385. },
  386. })
  387. function scientificLabel(status: unknown): string {
  388. if (status === "needs_evidence") return "证据不足"
  389. if (status === "needs_revision") return "方案待修订"
  390. if (status === "needs_review") return "方案检查通过,待人工审阅"
  391. return "研究状态待核查"
  392. }
  393. async function resolveAgentID(config: { url: string; apiKey: string }, agentName: string, signal: AbortSignal) {
  394. const name = agentName.trim()
  395. if (!name) throw new Error("agent_name 不能为空")
  396. let resp: Response
  397. try {
  398. resp = await fetch(`${config.url}/api/v1/agents`, {
  399. headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
  400. signal,
  401. })
  402. } catch (err) {
  403. throw new Error(`获取 AgentPaaS Agent 列表失败: ${err instanceof Error ? err.message : String(err)}`)
  404. }
  405. if (!resp.ok) {
  406. const detail = await resp.text().catch(() => "")
  407. throw new Error(`获取 AgentPaaS Agent 列表失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
  408. }
  409. const payload: unknown = await resp.json().catch(() => null)
  410. if (!payload || typeof payload !== "object" || !("agents" in payload) || !Array.isArray(payload.agents)) {
  411. throw new Error("AgentPaaS Agent 列表响应格式无效")
  412. }
  413. const agents = (payload.agents as unknown[]).flatMap((item): AgentListItem[] => {
  414. if (!item || typeof item !== "object" || !("id" in item) || !("name" in item)) return []
  415. if (typeof item.id !== "string" || typeof item.name !== "string") return []
  416. return [{ id: item.id, name: item.name }]
  417. })
  418. const matches = agents.filter((item) => item.name === name)
  419. if (matches.length === 0) throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
  420. if (matches.length > 1)
  421. throw new Error(`AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,无法确定调用目标`)
  422. if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
  423. return matches[0].id
  424. }
  425. // Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
  426. // Every route still encodes the complete ID as one path segment.
  427. function isSupportedAgentID(id: string): boolean {
  428. if (id.length > 512) return false
  429. if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
  430. const parts = id.split(":")
  431. if (parts.length !== 3 || parts[0] !== "locostack") return false
  432. const namespace = parts[1]
  433. const name = parts[2]
  434. const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
  435. return (
  436. namespace.length <= 63 &&
  437. label.test(namespace) &&
  438. name.length <= 253 &&
  439. name.split(".").every((part) => part.length <= 63 && label.test(part))
  440. )
  441. }
  442. const conversationCache = new Map<string, ConversationState>()
  443. function digest(value: string) {
  444. return new Bun.CryptoHasher("sha256").update(value).digest("hex")
  445. }
  446. function canonicalJSON(value: unknown): string {
  447. if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
  448. const record = asRecord(value)
  449. if (record)
  450. return `{${Object.keys(record)
  451. .sort()
  452. .filter((key) => record[key] !== undefined)
  453. .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
  454. .join(",")}}`
  455. return JSON.stringify(value) ?? "null"
  456. }
  457. async function requestContext(
  458. config: { url: string; apiKey: string },
  459. agentID: string,
  460. key: string,
  461. current: ConversationState & { intent: string },
  462. ) {
  463. const directory = path.join(agentPaaSConversationDir(), "requests")
  464. const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
  465. await mkdir(directory, { recursive: true, mode: 0o700 })
  466. const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
  467. await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
  468. // link is atomic and never replaces the first submission's saved context.
  469. const created = await link(temporary, filePath)
  470. .then(
  471. () => true,
  472. (error: NodeJS.ErrnoException) => {
  473. if (error.code === "EEXIST") return false
  474. throw error
  475. },
  476. )
  477. .finally(() => rm(temporary, { force: true }))
  478. const saved = asRecord(await Bun.file(filePath).json())
  479. if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
  480. throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
  481. if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
  482. return { threadID: saved.threadID, runID: saved.runID, created }
  483. }
  484. async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
  485. const cacheKey = `${sessionID}\n${key}`
  486. const cached = conversationCache.get(cacheKey)
  487. if (cached) return cached
  488. const file = Bun.file(conversationStatePath(sessionID))
  489. if (!(await file.exists())) return undefined
  490. try {
  491. const parsed: unknown = await file.json()
  492. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
  493. const value = (parsed as Record<string, unknown>)[key]
  494. if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
  495. const threadID = getString((value as Record<string, unknown>).thread_id)
  496. const runID = getString((value as Record<string, unknown>).run_id)
  497. if (!threadID || !runID) return undefined
  498. const state = { threadID, runID }
  499. conversationCache.set(cacheKey, state)
  500. return state
  501. } catch {
  502. return undefined
  503. }
  504. }
  505. async function saveConversationState(sessionID: string, key: string, state: ConversationState) {
  506. conversationCache.set(`${sessionID}\n${key}`, state)
  507. const filePath = conversationStatePath(sessionID)
  508. const file = Bun.file(filePath)
  509. let saved: Record<string, unknown> = {}
  510. if (await file.exists()) {
  511. try {
  512. const parsed: unknown = await file.json()
  513. if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) saved = parsed as Record<string, unknown>
  514. } catch {
  515. saved = {}
  516. }
  517. }
  518. saved[key] = { thread_id: state.threadID, run_id: state.runID }
  519. try {
  520. await mkdir(agentPaaSConversationDir(), { recursive: true, mode: 0o700 })
  521. await Bun.write(filePath, `${JSON.stringify(saved, null, 2)}\n`)
  522. } catch {
  523. // The in-memory mapping still preserves continuity for this CLI process.
  524. }
  525. }
  526. function conversationStatePath(sessionID: string) {
  527. return path.join(agentPaaSConversationDir(), `${sessionID.replace(/[^A-Za-z0-9._-]/g, "_")}.json`)
  528. }
  529. async function loadAgentPaaSConfig() {
  530. const configPath = agentPaaSConfigPath()
  531. const file = Bun.file(configPath)
  532. let saved: Record<string, unknown> = {}
  533. if (await file.exists()) {
  534. try {
  535. const parsed: unknown = await file.json()
  536. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("根节点必须是 JSON 对象")
  537. saved = parsed as Record<string, unknown>
  538. } catch (err) {
  539. throw new Error(`AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`)
  540. }
  541. }
  542. const savedUrl = typeof saved.server === "string" ? saved.server.trim() : ""
  543. const savedApiKey = typeof saved.api_key === "string" ? saved.api_key.trim() : ""
  544. const url = (process.env.AGENTPAAS_URL?.trim() || savedUrl || DEFAULT_AGENTPAAS_URL).replace(/\/+$/, "")
  545. const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
  546. if (apiKey) return { url, apiKey }
  547. throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
  548. {
  549. "server": "${DEFAULT_AGENTPAAS_URL}",
  550. "api_key": "你的 AgentPaaS API Key"
  551. }
  552. 也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
  553. }
  554. function agentPaaSHome() {
  555. return process.env.HOME?.trim() || homedir()
  556. }
  557. function agentPaaSConfigPath() {
  558. return path.join(agentPaaSHome(), ".agentpaas", "config.json")
  559. }
  560. function agentPaaSConversationDir() {
  561. return (
  562. process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
  563. )
  564. }
  565. async function resolveConfirmation(
  566. config: { url: string; apiKey: string },
  567. agentID: string,
  568. runID: string,
  569. approved: boolean,
  570. signal: AbortSignal,
  571. approvalID: string,
  572. ) {
  573. // Compatibility adapter: legacy local runs keep their existing confirmation API.
  574. // Remote runs must use their owner-aware route; never fall back after a failed POST.
  575. const remote = agentID.startsWith("locostack:")
  576. const endpoint = remote
  577. ? `/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/confirm`
  578. : `/api/v1/traces/${encodeURIComponent(runID)}/confirm`
  579. const resp = await fetch(`${config.url}${endpoint}`, {
  580. method: "POST",
  581. headers: {
  582. Authorization: `Bearer ${config.apiKey}`,
  583. "Content-Type": "application/json",
  584. },
  585. body: JSON.stringify({ approved, ...(remote && approvalID ? { approval_id: approvalID } : {}) }),
  586. signal,
  587. })
  588. if (resp.ok) return
  589. const detail = await resp.text().catch(() => "")
  590. throw new Error(`AgentPaaS 确认请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
  591. }
  592. async function cancelRemoteRun(config: { url: string; apiKey: string }, agentID: string, runID: string) {
  593. const signal = AbortSignal.timeout(5000)
  594. const resp = await fetch(
  595. `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/cancel`,
  596. {
  597. method: "POST",
  598. headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
  599. signal,
  600. },
  601. )
  602. if (resp.ok) return
  603. const detail = await resp.text().catch(() => "")
  604. throw new Error(`AgentPaaS 取消请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
  605. }
  606. async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
  607. const reader = body.getReader()
  608. const decoder = new TextDecoder()
  609. let buffer = ""
  610. try {
  611. while (true) {
  612. const { done, value } = await reader.read()
  613. if (done) break
  614. buffer += decoder.decode(value, { stream: true })
  615. buffer = buffer.replace(/\r\n/g, "\n")
  616. let idx: number
  617. while ((idx = buffer.indexOf("\n\n")) >= 0) {
  618. const chunk = buffer.slice(0, idx)
  619. buffer = buffer.slice(idx + 2)
  620. const event = parseSSEField(chunk, "event")
  621. const dataRaw = parseSSEField(chunk, "data")
  622. if (!event && chunk.split("\n").some((line) => line.trim() === ": heartbeat")) {
  623. yield { event: "heartbeat", data: undefined }
  624. continue
  625. }
  626. let data: unknown = dataRaw
  627. if (dataRaw !== "") {
  628. try {
  629. data = JSON.parse(dataRaw)
  630. } catch {
  631. /* 非 JSON 当纯文本 */
  632. }
  633. }
  634. if (event) yield { event, data }
  635. }
  636. }
  637. } finally {
  638. await reader.cancel().catch(() => undefined)
  639. reader.releaseLock()
  640. }
  641. }
  642. function parseSSEField(chunk: string, field: string): string {
  643. const lines = chunk.split("\n")
  644. const parts: string[] = []
  645. for (const line of lines) {
  646. if (line.startsWith(":")) continue
  647. const m = line.match(new RegExp(`^${field}:\\s?(.*)$`))
  648. if (m) parts.push(m[1])
  649. }
  650. return parts.join("\n")
  651. }
  652. function extractMessage(data: unknown): string {
  653. if (data && typeof data === "object" && "message" in data) return String((data as Record<string, unknown>).message)
  654. return String(data)
  655. }
  656. function asRecord(value: unknown): Record<string, unknown> | undefined {
  657. if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
  658. const result: Record<string, unknown> = {}
  659. for (const [key, item] of Object.entries(value)) result[key] = item
  660. return result
  661. }
  662. function getString(value: unknown): string {
  663. return typeof value === "string" ? value : ""
  664. }
  665. function getPositiveInteger(value: unknown): number {
  666. return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1
  667. }
  668. function getWriteFilePath(content: string): string {
  669. try {
  670. const filePath = getString(asRecord(JSON.parse(content))?.file_path)
  671. if (filePath) return filePath
  672. } catch {}
  673. return content.match(/["']file_path["']\s*:\s*(["'])(.*?)\1/)?.[2] ?? ""
  674. }
  675. function withStage(title: string, metadata: Record<string, unknown>): string {
  676. const stageName = getString(metadata.stage_name)
  677. return stageName ? `${title}(${stageName})` : title
  678. }
  679. function createResearch67Progress() {
  680. const stages: ProgressStage[] = [
  681. {
  682. id: "ontology-seed",
  683. label: "本体构建 · seed ontology",
  684. status: "pending",
  685. milestones: [
  686. { id: "parse", label: "解析问题与学科路由", status: "pending", done: new Set(), total: 1 },
  687. { id: "seed", label: "生成基础本体", status: "pending", done: new Set(), total: 1 },
  688. ],
  689. },
  690. {
  691. id: "ontology-audit",
  692. label: "ontology-auditor · 本体审计",
  693. status: "pending",
  694. milestones: [
  695. { id: "schema", label: "检查 schema 和关系", status: "pending", done: new Set(), total: 1 },
  696. { id: "gaps", label: "识别证据与机制缺口", status: "pending", done: new Set(), total: 1 },
  697. ],
  698. },
  699. {
  700. id: "ontology-enrichment",
  701. label: "ontology-enricher · 知识补全",
  702. status: "pending",
  703. milestones: [
  704. { id: "kb", label: "召回本地知识库与教材锚点", status: "pending", done: new Set(), total: 1 },
  705. { id: "search", label: "检索与来源核验", status: "pending", done: new Set(), detail: "尚未开始" },
  706. { id: "patch", label: "生成本体补丁", status: "pending", done: new Set(), total: 1 },
  707. ],
  708. },
  709. {
  710. id: "hypothesis-build",
  711. label: "hypothesis-builder · 假设构建",
  712. status: "pending",
  713. milestones: [
  714. { id: "hypothesis", label: "生成可证伪假设", status: "pending", done: new Set(), total: 1 },
  715. { id: "falsifiers", label: "生成可证伪条件", status: "pending", done: new Set(), total: 1 },
  716. ],
  717. },
  718. {
  719. id: "research-plan",
  720. label: "plan-builder · 研究计划",
  721. status: "pending",
  722. milestones: [
  723. { id: "plan", label: "形成研究计划本体", status: "pending", done: new Set(), total: 1 },
  724. { id: "dag", label: "编译 Action DAG", status: "pending", done: new Set(), total: 1 },
  725. ],
  726. },
  727. {
  728. id: "evaluation",
  729. label: "action-reasoner · 证据评估",
  730. status: "pending",
  731. milestones: [
  732. { id: "assess", label: "评估假设支持状态", status: "pending", done: new Set(), total: 1 },
  733. { id: "synthesis", label: "形成边界化结论", status: "pending", done: new Set(), total: 1 },
  734. ],
  735. },
  736. {
  737. id: "experiment-design",
  738. label: "exp-planner · 实验规划 / 授权包",
  739. status: "pending",
  740. milestones: [
  741. { id: "protocol", label: "生成实验协议", status: "pending", done: new Set(), total: 1 },
  742. { id: "authorization", label: "生成授权与数据回传要求", status: "pending", done: new Set(), total: 1 },
  743. ],
  744. },
  745. {
  746. id: "experiment-execution",
  747. label: "exp-executor · 授权实验执行",
  748. status: "pending",
  749. milestones: [
  750. { id: "auth", label: "确认授权与执行边界", status: "pending", done: new Set(), total: 1 },
  751. { id: "run", label: "运行计算/数据实验", status: "pending", done: new Set(), total: 1 },
  752. { id: "receipt", label: "保存执行日志与结果", status: "pending", done: new Set(), total: 1 },
  753. ],
  754. },
  755. {
  756. id: "result-analysis",
  757. label: "result-analyzer · 结果分析",
  758. status: "pending",
  759. milestones: [
  760. { id: "analysis", label: "分析实验或回传数据", status: "pending", done: new Set(), total: 1 },
  761. { id: "patch", label: "生成 observation patch", status: "pending", done: new Set(), total: 1 },
  762. ],
  763. },
  764. {
  765. id: "paper-writing",
  766. label: "paper-writer · 论文写作",
  767. status: "pending",
  768. milestones: [
  769. { id: "outline", label: "写作计划与大纲", status: "pending", done: new Set(), total: 2 },
  770. { id: "english", label: "英文稿与参考文献", status: "pending", done: new Set(), total: 8 },
  771. { id: "chinese", label: "中文稿", status: "pending", done: new Set(), total: 7 },
  772. { id: "compile", label: "编译双语 PDF", status: "pending", done: new Set(), total: 2 },
  773. { id: "report", label: "形成交付报告", status: "pending", done: new Set(), total: 2 },
  774. ],
  775. },
  776. {
  777. id: "data-verification",
  778. label: "data-verifier · 数据与主张核验",
  779. status: "pending",
  780. milestones: [
  781. { id: "claims", label: "核验论文主张与来源", status: "pending", done: new Set(), total: 1 },
  782. { id: "numbers", label: "核验数据和表述一致性", status: "pending", done: new Set(), total: 1 },
  783. ],
  784. },
  785. {
  786. id: "paper-review",
  787. label: "paper-reviewer · 论文评审",
  788. status: "pending",
  789. milestones: [
  790. { id: "rubric", label: "按科学性和可复现性评分", status: "pending", done: new Set(), total: 1 },
  791. { id: "gaps", label: "输出研究缺口", status: "pending", done: new Set(), total: 1 },
  792. ],
  793. },
  794. {
  795. id: "review-feedback",
  796. label: "review-feedback · 反馈迭代",
  797. status: "pending",
  798. milestones: [
  799. { id: "compile", label: "编译评审反馈", status: "pending", done: new Set(), total: 1 },
  800. { id: "next", label: "生成下一轮 action 与本体补丁", status: "pending", done: new Set(), total: 1 },
  801. ],
  802. },
  803. {
  804. id: "ontology-reaudit",
  805. label: "ontology-auditor · 本体再审计",
  806. status: "pending",
  807. milestones: [
  808. { id: "audit", label: "检查反馈后的本体", status: "pending", done: new Set(), total: 1 },
  809. { id: "decision", label: "决定完成或进入下一轮", status: "pending", done: new Set(), total: 1 },
  810. ],
  811. },
  812. // 兼容旧三阶段 Lite pipeline 事件。
  813. {
  814. id: "idea-analysis",
  815. label: "idea-analyst · 创意分析",
  816. status: "pending",
  817. milestones: [
  818. { id: "plan", label: "制定分析计划", status: "pending", done: new Set(), total: 1 },
  819. { id: "directions", label: "生成检索方向", status: "pending", done: new Set(), total: 2 },
  820. { id: "report", label: "形成分析报告", status: "pending", done: new Set(), total: 2 },
  821. ],
  822. },
  823. {
  824. id: "literature-review",
  825. label: "lit-searcher · 文献检索",
  826. status: "pending",
  827. milestones: [
  828. { id: "plan", label: "制定检索计划", status: "pending", done: new Set(), total: 1 },
  829. { id: "search", label: "检索候选文献", status: "pending", done: new Set(), detail: "尚未开始" },
  830. { id: "select", label: "筛选入选论文", status: "pending", done: new Set(), total: 1 },
  831. { id: "report", label: "形成文献综述", status: "pending", done: new Set(), total: 2 },
  832. ],
  833. },
  834. ]
  835. const observedStages: ProgressStage[] = []
  836. let current: ProgressStage | undefined
  837. let active: ProgressMilestone | undefined
  838. const pendingArtifacts = new Map<string, Array<Array<{ milestone: ProgressMilestone; key: string }>>>()
  839. let searchCalls = 0
  840. let note = ""
  841. let activeSince = Date.now()
  842. let activeAttempt = 0
  843. let timerRunning = false
  844. const aliases: Record<string, string> = {
  845. ontology_seed: "ontology-seed",
  846. "seed-ontology": "ontology-seed",
  847. "audit-ontology": "ontology-audit",
  848. audit_ontology: "ontology-reaudit",
  849. ontology_audit: "ontology-audit",
  850. "ontology-enrich": "ontology-enrichment",
  851. ontology_enrichment: "ontology-enrichment",
  852. "enrich-evidence": "ontology-enrichment",
  853. enrich_evidence: "ontology-enrichment",
  854. search: "ontology-enrichment",
  855. "build-hypothesis": "hypothesis-build",
  856. build_hypothesis: "hypothesis-build",
  857. "hypothesis-builder": "hypothesis-build",
  858. "plan-build": "research-plan",
  859. "build-research-plan": "research-plan",
  860. build_research_plan: "research-plan",
  861. "action-dag": "research-plan",
  862. synthesis: "evaluation",
  863. "falsification-assessment": "evaluation",
  864. falsification_assessment: "evaluation",
  865. "experiment-plan": "experiment-design",
  866. experiment_design: "experiment-design",
  867. experiment_execution: "experiment-execution",
  868. result_analysis: "result-analysis",
  869. paper_writing: "paper-writing",
  870. data_verification: "data-verification",
  871. paper_review: "paper-review",
  872. review_feedback: "review-feedback",
  873. }
  874. const genericMilestones = (): ProgressMilestone[] => [
  875. { id: "run", label: "远端阶段执行中", status: "pending", done: new Set(), total: 1 },
  876. { id: "receipt", label: "等待 receipt / artifact", status: "pending", done: new Set(), total: 1 },
  877. ]
  878. const normalizeStageID = (stageID: string) => aliases[stageID] ?? aliases[stageID.replaceAll("_", "-")] ?? stageID
  879. const findOrCreateStage = (stageID: string) => {
  880. const normalized = normalizeStageID(stageID || "remote-stage")
  881. let stage = stages.find((item) => item.id === normalized)
  882. if (stage) {
  883. if (!observedStages.includes(stage)) observedStages.push(stage)
  884. return stage
  885. }
  886. stage = {
  887. id: normalized,
  888. label: `${stageID || "remote-stage"} · 远端阶段`,
  889. status: "pending",
  890. milestones: genericMilestones(),
  891. }
  892. stages.push(stage)
  893. observedStages.push(stage)
  894. return stage
  895. }
  896. const startMilestone = (milestone: ProgressMilestone) => {
  897. if (!current) return
  898. if (milestone.status === "completed") return
  899. milestone.status = "running"
  900. if (milestone.detail === "尚未开始") milestone.detail = undefined
  901. if (active !== milestone) activeSince = Date.now()
  902. active = milestone
  903. timerRunning = true
  904. note = ""
  905. }
  906. const resetStage = (stage: ProgressStage) => {
  907. for (const milestone of stage.milestones) {
  908. milestone.status = "pending"
  909. milestone.done?.clear()
  910. milestone.detail = milestone.id === "search" ? "尚未开始" : undefined
  911. }
  912. if (["literature-review", "ontology-enrichment"].includes(stage.id)) searchCalls = 0
  913. pendingArtifacts.clear()
  914. }
  915. const artifact = (filePath: string) => {
  916. if (!current) return undefined
  917. const value = filePath.replaceAll("\\", "/").toLowerCase()
  918. const stage = value
  919. .split("/")
  920. .findLastIndex((part) => part === current?.id || new RegExp(`^${current?.id}_a\\d+$`).test(part))
  921. if (stage < 0) return undefined
  922. const relative = value
  923. .split("/")
  924. .slice(stage + 1)
  925. .join("/")
  926. const match = (milestoneID: string, key: string) => {
  927. const milestone = current?.milestones.find((item) => item.id === milestoneID)
  928. return milestone ? { milestone, key } : undefined
  929. }
  930. if (relative === "work_plan.md") {
  931. return match(current.id === "paper-writing" ? "outline" : "plan", "work_plan")
  932. }
  933. if (current.id === "idea-analysis") {
  934. if (relative === "artifacts/search_queries.json") return match("directions", "search_queries")
  935. if (relative === "artifacts/similar_papers.json") return match("directions", "similar_papers")
  936. if (relative === "report.json") return match("report", "report_json")
  937. if (relative === "report.md") return match("report", "report_md")
  938. }
  939. if (["literature-review", "ontology-enrichment"].includes(current.id)) {
  940. if (relative === "artifacts/papers_selected.json") return match("select", "papers_selected")
  941. if (relative === "report.json") return match("report", "report_json")
  942. if (relative === "report.md") return match("report", "report_md")
  943. if (relative.endsWith("search-log.jsonl")) return match("search", "search_log")
  944. }
  945. if (current.id === "experiment-design") {
  946. if (relative.endsWith("experiment_protocol.json")) return match("protocol", "experiment_protocol")
  947. if (relative.endsWith("external_authorization_package.json"))
  948. return match("authorization", "external_authorization_package")
  949. }
  950. if (current.id !== "paper-writing") return undefined
  951. if (relative === "artifacts/outline.json") return match("outline", "outline")
  952. const section = relative.match(
  953. /^artifacts\/sections\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex$/,
  954. )
  955. if (section) return match("english", `section:${section[1]}`)
  956. if (relative === "artifacts/paper.tex") return match("english", "paper_tex")
  957. if (relative === "artifacts/references.bib") return match("english", "references")
  958. const sectionZh = relative.match(
  959. /^artifacts\/sections_zh\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex$/,
  960. )
  961. if (sectionZh) return match("chinese", `section:${sectionZh[1]}`)
  962. if (relative === "artifacts/paper_zh.tex") return match("chinese", "paper_zh_tex")
  963. if (relative === "report.json") return match("report", "report_json")
  964. if (relative === "report.md") return match("report", "report_md")
  965. return undefined
  966. }
  967. const invalidatePaperBuild = (items: Array<{ milestone: ProgressMilestone; key: string }>) => {
  968. if (current?.id !== "paper-writing") return
  969. if (!items.some((item) => ["english", "chinese"].includes(item.milestone.id))) return
  970. const compile = current.milestones.find((item) => item.id === "compile")!
  971. const report = current.milestones.find((item) => item.id === "report")!
  972. if (compile.status === "pending" && !compile.done?.size && report.status === "pending") return
  973. compile.status = "pending"
  974. compile.done?.clear()
  975. report.status = "pending"
  976. report.done?.clear()
  977. if (active === compile || active === report) active = undefined
  978. activeSince = Date.now()
  979. timerRunning = true
  980. note = "论文源文件已更新,等待重新编译"
  981. }
  982. const completeArtifact = (item: { milestone: ProgressMilestone; key: string }) => {
  983. item.milestone.done?.add(item.key)
  984. if (!item.milestone.total || (item.milestone.done?.size ?? 0) < item.milestone.total) return
  985. item.milestone.status = "completed"
  986. if (active !== item.milestone || !current) return
  987. const next = current.milestones[current.milestones.indexOf(item.milestone) + 1]
  988. if (next) return startMilestone(next)
  989. active = undefined
  990. activeSince = Date.now()
  991. timerRunning = true
  992. note = "正在进行 Guard / 阶段产物验收"
  993. }
  994. const enqueueArtifacts = (toolName: string, items: Array<{ milestone: ProgressMilestone; key: string }>) => {
  995. const queue = pendingArtifacts.get(toolName) ?? []
  996. queue.push(items)
  997. pendingArtifacts.set(toolName, queue)
  998. }
  999. const dequeueArtifacts = (toolName: string) => {
  1000. const queue = pendingArtifacts.get(toolName)
  1001. const items = queue?.shift()
  1002. if (!queue?.length) pendingArtifacts.delete(toolName)
  1003. return items
  1004. }
  1005. return {
  1006. startStage(stageID: string, attempt: number) {
  1007. const stage = findOrCreateStage(stageID)
  1008. if (stage === current && attempt <= activeAttempt) return
  1009. if (stage === current) resetStage(stage)
  1010. current = stage
  1011. activeAttempt = attempt
  1012. // A later stage starting says nothing about whether earlier stages ran.
  1013. current.status = "running"
  1014. startMilestone(current.milestones[0])
  1015. },
  1016. finishStage(stageID: string, status: "completed" | "failed") {
  1017. const stage = findOrCreateStage(stageID)
  1018. stage.status = status
  1019. if (status === "completed") {
  1020. for (const milestone of stage.milestones) milestone.status = "completed"
  1021. } else {
  1022. for (const milestone of stage.milestones) {
  1023. if (milestone.status === "running") milestone.status = "failed"
  1024. }
  1025. }
  1026. current = stage
  1027. active = status === "failed" ? active : undefined
  1028. timerRunning = false
  1029. note = status === "failed" ? "阶段执行失败" : "阶段验收通过"
  1030. },
  1031. finishPipeline() {
  1032. // Terminal pipeline status is not evidence that every predefined stage ran.
  1033. for (const stage of observedStages) {
  1034. if (stage.status === "running") stage.status = "unknown"
  1035. for (const milestone of stage.milestones) {
  1036. if (milestone.status === "running") milestone.status = "unknown"
  1037. }
  1038. }
  1039. active = undefined
  1040. timerRunning = false
  1041. note = "流程已结束;阶段状态以服务端事件为准"
  1042. },
  1043. stopUnverified() {
  1044. for (const stage of observedStages) {
  1045. if (stage.status === "running") stage.status = "unknown"
  1046. for (const milestone of stage.milestones) {
  1047. if (milestone.status === "running") milestone.status = "unknown"
  1048. }
  1049. }
  1050. active = undefined
  1051. timerRunning = false
  1052. },
  1053. pause() {
  1054. if (current?.status === "running") current.status = "paused"
  1055. if (active?.status === "running") active.status = "paused"
  1056. timerRunning = false
  1057. note = "任务已暂停,等待恢复"
  1058. },
  1059. cancel() {
  1060. if (current?.status === "running") current.status = "cancelled"
  1061. if (active?.status === "running") active.status = "cancelled"
  1062. timerRunning = false
  1063. note = "任务已取消"
  1064. },
  1065. note(value: string) {
  1066. note = value
  1067. },
  1068. toolCall(toolName: string, content: string) {
  1069. const lower = toolName.toLowerCase()
  1070. if (
  1071. current?.id === "ontology-enrichment" &&
  1072. ["kbsearch", "websearch", "webfetch", "openalex_search", "crossref_search"].includes(lower)
  1073. ) {
  1074. const milestone = current.milestones.find((item) => item.id === "search")!
  1075. startMilestone(milestone)
  1076. return
  1077. }
  1078. if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
  1079. const milestone = current.milestones.find((item) => item.id === "search")!
  1080. startMilestone(milestone)
  1081. return
  1082. }
  1083. if (current?.id === "paper-writing" && lower === "compilelatex") {
  1084. const milestone = current.milestones.find((item) => item.id === "compile")!
  1085. const key = content.toLowerCase().includes("paper_zh.tex") ? "paper_zh_pdf" : "paper_pdf"
  1086. milestone.done?.delete(key)
  1087. if (milestone.status === "completed") milestone.status = "pending"
  1088. startMilestone(milestone)
  1089. enqueueArtifacts(lower, [{ milestone, key }])
  1090. return
  1091. }
  1092. if (current?.id === "paper-writing" && lower === "buildresearchpaper") {
  1093. const milestone = current.milestones.find((item) => item.id === "compile")!
  1094. milestone.status = "pending"
  1095. milestone.done?.clear()
  1096. startMilestone(milestone)
  1097. enqueueArtifacts(lower, [
  1098. { milestone, key: "paper_pdf" },
  1099. { milestone, key: "paper_zh_pdf" },
  1100. ])
  1101. return
  1102. }
  1103. if (lower !== "writefile") return
  1104. const item = artifact(getWriteFilePath(content))
  1105. if (!item) {
  1106. enqueueArtifacts(lower, [])
  1107. return
  1108. }
  1109. if (
  1110. ["literature-review", "ontology-enrichment"].includes(current?.id ?? "") &&
  1111. item.milestone.id === "select" &&
  1112. searchCalls
  1113. ) {
  1114. const searchMilestone = current?.milestones.find((milestone) => milestone.id === "search")
  1115. if (searchMilestone) searchMilestone.status = "completed"
  1116. }
  1117. startMilestone(item.milestone)
  1118. enqueueArtifacts(lower, [item])
  1119. },
  1120. toolResult(toolName: string, content: string) {
  1121. const lower = toolName.toLowerCase()
  1122. const failed = /\[(?:tool_)?error\]|mcp_error|"status"\s*:\s*"(?:failed|error)"|"passed"\s*:\s*false/i.test(
  1123. content,
  1124. )
  1125. if (
  1126. current?.id === "ontology-enrichment" &&
  1127. ["kbsearch", "websearch", "webfetch", "openalex_search", "crossref_search"].includes(lower)
  1128. ) {
  1129. if (!failed) searchCalls++
  1130. const milestone = current.milestones.find((item) => item.id === "search")!
  1131. milestone.detail = searchCalls ? `已完成 ${searchCalls} 轮检索/核验` : "等待检索重试"
  1132. return
  1133. }
  1134. if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
  1135. if (!failed) searchCalls++
  1136. const milestone = current.milestones.find((item) => item.id === "search")!
  1137. milestone.detail = searchCalls ? `已完成 ${searchCalls} 轮检索` : "等待检索重试"
  1138. return
  1139. }
  1140. const items = dequeueArtifacts(lower)
  1141. if (!items) return
  1142. if (!failed) {
  1143. items.forEach(completeArtifact)
  1144. invalidatePaperBuild(items)
  1145. }
  1146. if (failed) {
  1147. for (const item of items) item.milestone.status = "failed"
  1148. note = "当前操作失败,等待重试"
  1149. }
  1150. },
  1151. snapshot() {
  1152. if (!current) return undefined
  1153. const rows = observedStages.flatMap((stage) => {
  1154. const detail = stage.status === "running" ? active?.label : undefined
  1155. const parent = [{ id: stage.id, label: stage.label, status: stage.status, detail, depth: 0 }]
  1156. if (stage !== current || !["running", "failed"].includes(stage.status)) return parent
  1157. return parent.concat(
  1158. stage.milestones.map((milestone) => ({
  1159. id: `${stage.id}:${milestone.id}`,
  1160. label: milestone.label,
  1161. status: milestone.status,
  1162. detail:
  1163. milestone.detail ??
  1164. (milestone.status !== "completed" && milestone.total && milestone.done?.size
  1165. ? `${milestone.done.size}/${milestone.total}`
  1166. : undefined),
  1167. depth: 1,
  1168. })),
  1169. )
  1170. })
  1171. return {
  1172. kind: "research67",
  1173. title: active ? `Research67 · ${current.label} · ${active.label}` : `Research67 · ${current.label}`,
  1174. rows,
  1175. activeSince: timerRunning ? activeSince : undefined,
  1176. footer: note || (!active ? "进度已更新" : undefined),
  1177. }
  1178. },
  1179. }
  1180. }