agentpaas_run.ts 42 KB

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