| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227 |
- /// <reference path="../env.d.ts" />
- import { tool } from "@opencode-ai/plugin"
- import { link, mkdir, rm } from "node:fs/promises"
- import { homedir } from "node:os"
- import path from "node:path"
- const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
- type SSEEvent = { event: string; data: unknown }
- type AgentListItem = { id: string; name: string }
- type ConversationState = { threadID: string; runID: string }
- type ProgressStatus = "completed" | "running" | "pending" | "failed" | "paused" | "cancelled" | "skipped" | "unknown"
- type ProgressMilestone = {
- id: string
- label: string
- status: ProgressStatus
- done?: Set<string>
- total?: number
- detail?: string
- }
- type ProgressStage = {
- id: string
- label: string
- status: ProgressStatus
- milestones: ProgressMilestone[]
- }
- export default tool({
- description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
- 参数说明:
- - agent_name:远端 AgentPaaS 智能体名称,工具会动态解析当前 ID,必填
- - input:要执行的用户任务指令
- - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
- - target_subagent:mode=edit 时的目标子智能体 ID
- - work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
- - conversation:continue(默认,沿用远端 thread 并引用上一轮成果)/ new(新课题)
- - thread_id:可选的远端会话线程 ID;显式值优先于自动映射
- - parameters:额外参数,原样传给远端智能体
- 适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
- args: {
- input: tool.schema.string().describe("用户任务指令"),
- agent_name: tool.schema.string().describe("远端 AgentPaaS 智能体名称"),
- mode: tool.schema.enum(["iterate", "chat", "edit"]).default("iterate"),
- target_subagent: tool.schema.string().optional().describe("mode=edit 时的目标子智能 ID"),
- work_dir: tool.schema.string().optional().describe("服务端可见工作目录;通常留空,由 AgentPaaS 创建"),
- thread_id: tool.schema.string().optional().describe("远端会话线程 ID;通常留空,由 OpenCode session 自动维护"),
- conversation: tool.schema.enum(["continue", "new"]).default("continue").describe("复用当前对话或开始新课题"),
- question_id: tool.schema.number().int().min(1).max(125).optional().describe("Science 125 科学问题编号"),
- feedback_kind: tool.schema.enum(["human", "program_check", "execution_result", "evidence"]).optional(),
- input_artifact_ids: tool.schema
- .array(tool.schema.string())
- .optional()
- .describe("续跑时选取的上一轮产物 ID;省略时复用上一轮已登记产物"),
- idempotency_key: tool.schema.string().optional().describe("同一请求重试时复用;不同请求必须使用不同键"),
- parameters: tool.schema
- .record(tool.schema.string(), tool.schema.any())
- .optional()
- .describe("额外参数,原样传给远端智能体"),
- },
- async execute(args, context) {
- const config = await loadAgentPaaSConfig()
- if (args.mode === "edit" && !args.target_subagent) {
- throw new Error("mode=edit 时必须提供 target_subagent")
- }
- const agentID = await resolveAgentID(config, args.agent_name, context.abort)
- const conversation = args.conversation ?? "continue"
- const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
- const savedConversation =
- conversation === "new" ? undefined : await loadConversationState(context.sessionID, conversationKey)
- const threadID = args.thread_id?.trim() || savedConversation?.threadID || ""
- const continueRunID =
- conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
- ? savedConversation?.runID || ""
- : ""
- const intent = digest(canonicalJSON({ ...args, idempotency_key: undefined, conversation }))
- const idempotencyKey =
- args.idempotency_key?.trim() ||
- `opencode-${digest(canonicalJSON([context.sessionID, context.messageID, agentID, intent]))}`
- // Freeze the parent before submitting. Once `started` advances the session,
- // retrying this same request must not turn it into a new research revision.
- const admission = await requestContext(config, agentID, idempotencyKey, { intent, threadID, runID: continueRunID })
- // OpenCode 的宿主机目录不会自动挂载进 AgentPaaS Docker。只有调用方
- // 明确给出服务端可见路径时才覆盖,否则让 PaaS 创建并隔离 run workspace。
- const workDir = args.work_dir
- const url = `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/run/stream`
- let resp: Response
- try {
- resp = await fetch(url, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${config.apiKey}`,
- "Content-Type": "application/json",
- Accept: "text/event-stream",
- "Idempotency-Key": idempotencyKey,
- },
- body: JSON.stringify({
- input: args.input,
- mode: args.mode,
- target_subagent: args.target_subagent ?? "",
- thread_id: admission.threadID,
- parameters: args.parameters ?? {},
- context: {
- ...(workDir ? { work_dir: workDir } : {}),
- ...(admission.runID ? { run_id: admission.runID } : {}),
- ...(args.question_id ? { question_id: args.question_id } : {}),
- ...(args.feedback_kind ? { feedback_kind: args.feedback_kind } : {}),
- ...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
- },
- }),
- signal: context.abort,
- })
- } catch (err) {
- throw new Error(`AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)}`)
- }
- if (!resp.ok) {
- const detail = await resp.text().catch(() => "")
- throw new Error(`AgentPaaS HTTP ${resp.status}: ${detail.slice(0, 500)}`)
- }
- if (!resp.body) throw new Error("AgentPaaS 响应没有 body")
- const errors: string[] = []
- const liveMetadata: Record<string, unknown> = {
- agent_name: args.agent_name,
- agent_id: agentID,
- idempotency_key: idempotencyKey,
- status: "running",
- }
- let done: Record<string, unknown> | null = null
- let startedRunID = ""
- let sawCancelled = false
- let lastThinkUpdate = 0
- const progress = createResearch67Progress()
- const updateMetadata = (title: string, next: Record<string, unknown>) => {
- Object.assign(liveMetadata, next)
- const snapshot = progress.snapshot()
- context.metadata({
- title: snapshot?.title ?? title,
- metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
- })
- }
- let remoteCancellation: Promise<void> | undefined
- const cancelOnAbort = () => {
- if (!startedRunID || remoteCancellation) return
- remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
- }
- context.abort.addEventListener("abort", cancelOnAbort, { once: true })
- try {
- for await (const ev of parseSSE(resp.body)) {
- const data = asRecord(ev.data)
- if (ev.event === "started") {
- startedRunID = getString(data?.run_id)
- const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
- if (remoteThreadID && startedRunID) {
- const latest = await loadConversationState(context.sessionID, conversationKey)
- if (admission.created || !latest || latest.runID === admission.runID || latest.runID === startedRunID) {
- await saveConversationState(context.sessionID, conversationKey, {
- threadID: remoteThreadID,
- runID: startedRunID,
- })
- }
- }
- if (context.abort.aborted) cancelOnAbort()
- updateMetadata("Research67 正在运行", {
- run_id: data?.run_id,
- thread_id: data?.thread_id,
- latest_event: ev.event,
- })
- } else if (ev.event === "stage_started") {
- const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "未知阶段"
- progress.startStage(getString(data?.stage_id), getPositiveInteger(data?.attempt))
- updateMetadata(`Research67 正在进行 ${stageName}`, {
- pipeline_id: data?.pipeline_id,
- stage_id: data?.stage_id,
- stage_name: stageName,
- stage_status: data?.status,
- attempt: data?.attempt,
- latest_event: ev.event,
- })
- } else if (ev.event === "stage_passed") {
- const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
- progress.finishStage(getString(data?.stage_id), "completed")
- updateMetadata(`Research67 已通过 ${stageName}`, {
- pipeline_id: data?.pipeline_id,
- stage_id: data?.stage_id,
- stage_name: stageName,
- stage_status: data?.status,
- attempt: data?.attempt,
- latest_event: ev.event,
- })
- } else if (ev.event === "stage_failed") {
- const stageName = getString(data?.stage_name) || getString(data?.stage_id) || "当前阶段"
- progress.finishStage(getString(data?.stage_id), "failed")
- updateMetadata(`Research67 阶段失败:${stageName}`, {
- pipeline_id: data?.pipeline_id,
- stage_id: data?.stage_id,
- stage_name: stageName,
- stage_status: data?.status,
- stage_error: data?.error,
- attempt: data?.attempt,
- latest_event: ev.event,
- status: "failed",
- })
- } else if (ev.event === "pipeline_completed") {
- progress.finishPipeline()
- updateMetadata("Research67 流水线已完成", {
- pipeline_id: data?.pipeline_id,
- pipeline_status: data?.status,
- latest_event: ev.event,
- status: "completed",
- })
- } else if (ev.event === "confirm_required") {
- const runID = getString(data?.run_id) || getString(liveMetadata.run_id)
- const toolName = getString(data?.tool) || "高风险工具"
- const reason = getString(data?.reason)
- const input = getString(data?.input)
- if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
- progress.note(`等待确认 · ${toolName}`)
- updateMetadata(`Research67 正在等待确认:${toolName}`, {
- latest_event: ev.event,
- pending_confirmation: true,
- confirmation_tool: toolName,
- confirmation_reason: reason,
- confirmation_input: input,
- })
- let approved = true
- try {
- await context.ask({
- permission: "agentpaas_confirm",
- patterns: [`${agentID}:${toolName}`],
- always: [`${agentID}:${toolName}`],
- metadata: { run_id: runID, agent_name: args.agent_name, tool: toolName, reason, input },
- })
- } catch {
- approved = false
- }
- await resolveConfirmation(config, agentID, runID, approved, context.abort, getString(data?.approval_id))
- progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
- updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
- latest_event: "confirmation_resolved",
- pending_confirmation: false,
- confirmation_approved: approved,
- })
- } else if (ev.event === "tool_call") {
- const toolName = getString(data?.tool) || "工具"
- progress.toolCall(toolName, getString(data?.content))
- updateMetadata(withStage("Research67 正在处理当前步骤", liveMetadata), {
- latest_event: ev.event,
- latest_tool: toolName,
- step: data?.step,
- })
- } else if (ev.event === "tool_result") {
- const toolName = getString(data?.tool) || getString(liveMetadata.latest_tool) || "工具"
- progress.toolResult(toolName, getString(data?.content))
- updateMetadata(withStage("Research67 已更新当前步骤", liveMetadata), {
- latest_event: ev.event,
- latest_tool: toolName,
- step: data?.step,
- })
- } else if (ev.event === "think" || ev.event === "think_chunk") {
- const now = Date.now()
- if (ev.event === "think" || now - lastThinkUpdate >= 1000) {
- lastThinkUpdate = now
- updateMetadata(withStage("Research67 正在思考", liveMetadata), {
- latest_event: ev.event,
- step: data?.step,
- })
- }
- } else if (ev.event === "answer") {
- updateMetadata(withStage("Research67 正在整理结果", liveMetadata), {
- latest_event: ev.event,
- step: data?.step,
- })
- } else if (ev.event === "heartbeat") {
- updateMetadata(withStage("Research67 仍在运行", liveMetadata), { latest_event: ev.event })
- } else if (ev.event === "error") {
- const message = extractMessage(ev.data)
- errors.push(message)
- updateMetadata("Research67 执行失败", {
- latest_event: ev.event,
- status: "failed",
- error_code: data?.code,
- error_message: message,
- workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
- })
- } else if (ev.event === "paused") {
- progress.pause()
- updateMetadata("Research67 已暂停", {
- latest_event: ev.event,
- status: "paused",
- checkpoint_id: data?.checkpoint_id,
- workspace_path: data?.workspace_path ?? liveMetadata.workspace_path,
- })
- } else if (ev.event === "approval_required") {
- // Durable human gates stop the stream. Surface the approval card; a separate
- // control action must approve it and re-subscribe to the same run.
- updateMetadata("Research67 等待人工反馈", {
- latest_event: ev.event,
- status: "waiting_human",
- pending_approval: data,
- })
- } else if (ev.event === "cancelled") {
- sawCancelled = true
- progress.cancel()
- updateMetadata("Research67 已取消", { latest_event: ev.event, status: "cancelled" })
- } else if (ev.event === "research_status" && data) {
- updateMetadata(scientificLabel(data.status), { scientific_status: data.status, latest_event: ev.event })
- } else if (ev.event === "done" && data) {
- done = data
- break
- }
- }
- } finally {
- context.abort.removeEventListener("abort", cancelOnAbort)
- }
- if (!done) {
- const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
- throw new Error(
- `AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}。run_id=${startedRunID || "尚未收到"};idempotency_key=${idempotencyKey}。请先查询原运行状态,不要换键重复执行。`,
- )
- }
- const status = typeof done.status === "string" ? done.status : "unknown"
- if (status === "completed" || status === "failed") progress.stopUnverified()
- if (status === "paused") progress.pause()
- if (status === "cancelled") progress.cancel()
- const progressMetadata = progress.snapshot()
- const metadata = {
- agent_name: args.agent_name,
- agent_id: agentID,
- idempotency_key: idempotencyKey,
- run_id: done.run_id ?? liveMetadata.run_id,
- thread_id: done.thread_id ?? done.session_id ?? liveMetadata.thread_id,
- status,
- steps: done.steps,
- total_tokens: done.total_tokens ?? asRecord(done.usage)?.total_tokens,
- cost_usd: done.cost_usd ?? asRecord(done.usage)?.cost_usd,
- research: done.research,
- artifacts: Array.isArray(done.artifacts) ? done.artifacts : [],
- workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
- error_code: liveMetadata.error_code,
- error_message: liveMetadata.error_message,
- checkpoint_id: done.checkpoint_id ?? liveMetadata.checkpoint_id,
- pending_approval: done.pending_approval ?? liveMetadata.pending_approval,
- ...(progressMetadata ? { research67_progress: progressMetadata } : {}),
- }
- const artifactLinks = metadata.artifacts.flatMap((value) => {
- const artifact = asRecord(value)
- const id = getString(artifact?.artifact_id)
- if (!id) return []
- const name = (getString(artifact?.name) || id).replace(/[\[\]\r\n]/g, "_")
- return [
- `- [${name}](${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(String(metadata.run_id))}/artifacts/${encodeURIComponent(id)}/download)`,
- ]
- })
- const science = asRecord(asRecord(done.research)?.science)
- if (status === "completed")
- return {
- title: science ? scientificLabel(science.status) : `AgentPaaS ${args.agent_name}`,
- output: [
- ...(science ? [`研究状态:${scientificLabel(science.status)}。执行结束不代表科学结论成立。\n`] : []),
- String(done.output ?? ""),
- ...(artifactLinks.length ? ["\n研究产物(通过 PaaS 身份验证后下载):", ...artifactLinks] : []),
- ].join("\n"),
- metadata,
- }
- if (status === "cancelled") return { title: `AgentPaaS ${args.agent_name}`, output: "任务已被取消。", metadata }
- if (status === "paused")
- return {
- title: `AgentPaaS ${args.agent_name}(已暂停)`,
- output: `任务已暂停。run_id=${String(metadata.run_id ?? "")};checkpoint_id=${String(metadata.checkpoint_id ?? "未提供")}。恢复应使用该 checkpoint,不能重新提交任务。`,
- metadata,
- }
- if (status === "waiting_human" || status === "waiting_confirmation")
- return {
- title: `AgentPaaS ${args.agent_name}(等待确认)`,
- output: `任务正在等待人工确认。run_id=${String(metadata.run_id ?? "")}。请检查待确认材料并提交决定;这不是任务失败。`,
- metadata,
- }
- return {
- title: `AgentPaaS ${args.agent_name}(失败)`,
- output: [
- `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
- metadata.workspace_path
- ? `已有产物保留在:${metadata.workspace_path}`
- : "已有产物已保留;请根据 run_id 查询工作区。",
- "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
- ].join("\n"),
- metadata,
- }
- },
- })
- function scientificLabel(status: unknown): string {
- if (status === "needs_evidence") return "证据不足"
- if (status === "needs_revision") return "方案待修订"
- if (status === "needs_review") return "方案检查通过,待人工审阅"
- return "研究状态待核查"
- }
- async function resolveAgentID(config: { url: string; apiKey: string }, agentName: string, signal: AbortSignal) {
- const name = agentName.trim()
- if (!name) throw new Error("agent_name 不能为空")
- let resp: Response
- try {
- resp = await fetch(`${config.url}/api/v1/agents`, {
- headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
- signal,
- })
- } catch (err) {
- throw new Error(`获取 AgentPaaS Agent 列表失败: ${err instanceof Error ? err.message : String(err)}`)
- }
- if (!resp.ok) {
- const detail = await resp.text().catch(() => "")
- throw new Error(`获取 AgentPaaS Agent 列表失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
- }
- const payload: unknown = await resp.json().catch(() => null)
- if (!payload || typeof payload !== "object" || !("agents" in payload) || !Array.isArray(payload.agents)) {
- throw new Error("AgentPaaS Agent 列表响应格式无效")
- }
- const agents = (payload.agents as unknown[]).flatMap((item): AgentListItem[] => {
- if (!item || typeof item !== "object" || !("id" in item) || !("name" in item)) return []
- if (typeof item.id !== "string" || typeof item.name !== "string") return []
- return [{ id: item.id, name: item.name }]
- })
- const matches = agents.filter((item) => item.name === name)
- if (matches.length === 0) throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
- if (matches.length > 1)
- throw new Error(`AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,无法确定调用目标`)
- if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
- return matches[0].id
- }
- // Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
- // Every route still encodes the complete ID as one path segment.
- function isSupportedAgentID(id: string): boolean {
- if (id.length > 512) return false
- if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
- const parts = id.split(":")
- if (parts.length !== 3 || parts[0] !== "locostack") return false
- const namespace = parts[1]
- const name = parts[2]
- const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
- return (
- namespace.length <= 63 &&
- label.test(namespace) &&
- name.length <= 253 &&
- name.split(".").every((part) => part.length <= 63 && label.test(part))
- )
- }
- const conversationCache = new Map<string, ConversationState>()
- function digest(value: string) {
- return new Bun.CryptoHasher("sha256").update(value).digest("hex")
- }
- function canonicalJSON(value: unknown): string {
- if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
- const record = asRecord(value)
- if (record)
- return `{${Object.keys(record)
- .sort()
- .filter((key) => record[key] !== undefined)
- .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
- .join(",")}}`
- return JSON.stringify(value) ?? "null"
- }
- async function requestContext(
- config: { url: string; apiKey: string },
- agentID: string,
- key: string,
- current: ConversationState & { intent: string },
- ) {
- const directory = path.join(agentPaaSConversationDir(), "requests")
- const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
- await mkdir(directory, { recursive: true, mode: 0o700 })
- const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
- await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
- // link is atomic and never replaces the first submission's saved context.
- const created = await link(temporary, filePath)
- .then(
- () => true,
- (error: NodeJS.ErrnoException) => {
- if (error.code === "EEXIST") return false
- throw error
- },
- )
- .finally(() => rm(temporary, { force: true }))
- const saved = asRecord(await Bun.file(filePath).json())
- if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
- throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
- if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
- return { threadID: saved.threadID, runID: saved.runID, created }
- }
- async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
- const cacheKey = `${sessionID}\n${key}`
- const cached = conversationCache.get(cacheKey)
- if (cached) return cached
- const file = Bun.file(conversationStatePath(sessionID))
- if (!(await file.exists())) return undefined
- try {
- const parsed: unknown = await file.json()
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
- const value = (parsed as Record<string, unknown>)[key]
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
- const threadID = getString((value as Record<string, unknown>).thread_id)
- const runID = getString((value as Record<string, unknown>).run_id)
- if (!threadID || !runID) return undefined
- const state = { threadID, runID }
- conversationCache.set(cacheKey, state)
- return state
- } catch {
- return undefined
- }
- }
- async function saveConversationState(sessionID: string, key: string, state: ConversationState) {
- conversationCache.set(`${sessionID}\n${key}`, state)
- const filePath = conversationStatePath(sessionID)
- const file = Bun.file(filePath)
- let saved: Record<string, unknown> = {}
- if (await file.exists()) {
- try {
- const parsed: unknown = await file.json()
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) saved = parsed as Record<string, unknown>
- } catch {
- saved = {}
- }
- }
- saved[key] = { thread_id: state.threadID, run_id: state.runID }
- try {
- await mkdir(agentPaaSConversationDir(), { recursive: true, mode: 0o700 })
- await Bun.write(filePath, `${JSON.stringify(saved, null, 2)}\n`)
- } catch {
- // The in-memory mapping still preserves continuity for this CLI process.
- }
- }
- function conversationStatePath(sessionID: string) {
- return path.join(agentPaaSConversationDir(), `${sessionID.replace(/[^A-Za-z0-9._-]/g, "_")}.json`)
- }
- async function loadAgentPaaSConfig() {
- const configPath = agentPaaSConfigPath()
- const file = Bun.file(configPath)
- let saved: Record<string, unknown> = {}
- if (await file.exists()) {
- try {
- const parsed: unknown = await file.json()
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("根节点必须是 JSON 对象")
- saved = parsed as Record<string, unknown>
- } catch (err) {
- throw new Error(`AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`)
- }
- }
- const savedUrl = typeof saved.server === "string" ? saved.server.trim() : ""
- const savedApiKey = typeof saved.api_key === "string" ? saved.api_key.trim() : ""
- const url = (process.env.AGENTPAAS_URL?.trim() || savedUrl || DEFAULT_AGENTPAAS_URL).replace(/\/+$/, "")
- const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
- if (apiKey) return { url, apiKey }
- throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
- {
- "server": "${DEFAULT_AGENTPAAS_URL}",
- "api_key": "你的 AgentPaaS API Key"
- }
- 也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
- }
- function agentPaaSHome() {
- return process.env.HOME?.trim() || homedir()
- }
- function agentPaaSConfigPath() {
- return path.join(agentPaaSHome(), ".agentpaas", "config.json")
- }
- function agentPaaSConversationDir() {
- return (
- process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
- )
- }
- async function resolveConfirmation(
- config: { url: string; apiKey: string },
- agentID: string,
- runID: string,
- approved: boolean,
- signal: AbortSignal,
- approvalID: string,
- ) {
- // Compatibility adapter: legacy local runs keep their existing confirmation API.
- // Remote runs must use their owner-aware route; never fall back after a failed POST.
- const remote = agentID.startsWith("locostack:")
- const endpoint = remote
- ? `/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/confirm`
- : `/api/v1/traces/${encodeURIComponent(runID)}/confirm`
- const resp = await fetch(`${config.url}${endpoint}`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${config.apiKey}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ approved, ...(remote && approvalID ? { approval_id: approvalID } : {}) }),
- signal,
- })
- if (resp.ok) return
- const detail = await resp.text().catch(() => "")
- throw new Error(`AgentPaaS 确认请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
- }
- async function cancelRemoteRun(config: { url: string; apiKey: string }, agentID: string, runID: string) {
- const signal = AbortSignal.timeout(5000)
- const resp = await fetch(
- `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}/cancel`,
- {
- method: "POST",
- headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json" },
- signal,
- },
- )
- if (resp.ok) return
- const detail = await resp.text().catch(() => "")
- throw new Error(`AgentPaaS 取消请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
- }
- async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
- const reader = body.getReader()
- const decoder = new TextDecoder()
- let buffer = ""
- try {
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- buffer = buffer.replace(/\r\n/g, "\n")
- let idx: number
- while ((idx = buffer.indexOf("\n\n")) >= 0) {
- const chunk = buffer.slice(0, idx)
- buffer = buffer.slice(idx + 2)
- const event = parseSSEField(chunk, "event")
- const dataRaw = parseSSEField(chunk, "data")
- if (!event && chunk.split("\n").some((line) => line.trim() === ": heartbeat")) {
- yield { event: "heartbeat", data: undefined }
- continue
- }
- let data: unknown = dataRaw
- if (dataRaw !== "") {
- try {
- data = JSON.parse(dataRaw)
- } catch {
- /* 非 JSON 当纯文本 */
- }
- }
- if (event) yield { event, data }
- }
- }
- } finally {
- await reader.cancel().catch(() => undefined)
- reader.releaseLock()
- }
- }
- function parseSSEField(chunk: string, field: string): string {
- const lines = chunk.split("\n")
- const parts: string[] = []
- for (const line of lines) {
- if (line.startsWith(":")) continue
- const m = line.match(new RegExp(`^${field}:\\s?(.*)$`))
- if (m) parts.push(m[1])
- }
- return parts.join("\n")
- }
- function extractMessage(data: unknown): string {
- if (data && typeof data === "object" && "message" in data) return String((data as Record<string, unknown>).message)
- return String(data)
- }
- function asRecord(value: unknown): Record<string, unknown> | undefined {
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
- const result: Record<string, unknown> = {}
- for (const [key, item] of Object.entries(value)) result[key] = item
- return result
- }
- function getString(value: unknown): string {
- return typeof value === "string" ? value : ""
- }
- function getPositiveInteger(value: unknown): number {
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1
- }
- function getWriteFilePath(content: string): string {
- try {
- const filePath = getString(asRecord(JSON.parse(content))?.file_path)
- if (filePath) return filePath
- } catch {}
- return content.match(/["']file_path["']\s*:\s*(["'])(.*?)\1/)?.[2] ?? ""
- }
- function withStage(title: string, metadata: Record<string, unknown>): string {
- const stageName = getString(metadata.stage_name)
- return stageName ? `${title}(${stageName})` : title
- }
- function createResearch67Progress() {
- const stages: ProgressStage[] = [
- {
- id: "ontology-seed",
- label: "本体构建 · seed ontology",
- status: "pending",
- milestones: [
- { id: "parse", label: "解析问题与学科路由", status: "pending", done: new Set(), total: 1 },
- { id: "seed", label: "生成基础本体", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "ontology-audit",
- label: "ontology-auditor · 本体审计",
- status: "pending",
- milestones: [
- { id: "schema", label: "检查 schema 和关系", status: "pending", done: new Set(), total: 1 },
- { id: "gaps", label: "识别证据与机制缺口", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "ontology-enrichment",
- label: "ontology-enricher · 知识补全",
- status: "pending",
- milestones: [
- { id: "kb", label: "召回本地知识库与教材锚点", status: "pending", done: new Set(), total: 1 },
- { id: "search", label: "检索与来源核验", status: "pending", done: new Set(), detail: "尚未开始" },
- { id: "patch", label: "生成本体补丁", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "hypothesis-build",
- label: "hypothesis-builder · 假设构建",
- status: "pending",
- milestones: [
- { id: "hypothesis", label: "生成可证伪假设", status: "pending", done: new Set(), total: 1 },
- { id: "falsifiers", label: "生成可证伪条件", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "research-plan",
- label: "plan-builder · 研究计划",
- status: "pending",
- milestones: [
- { id: "plan", label: "形成研究计划本体", status: "pending", done: new Set(), total: 1 },
- { id: "dag", label: "编译 Action DAG", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "evaluation",
- label: "action-reasoner · 证据评估",
- status: "pending",
- milestones: [
- { id: "assess", label: "评估假设支持状态", status: "pending", done: new Set(), total: 1 },
- { id: "synthesis", label: "形成边界化结论", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "experiment-design",
- label: "exp-planner · 实验规划 / 授权包",
- status: "pending",
- milestones: [
- { id: "protocol", label: "生成实验协议", status: "pending", done: new Set(), total: 1 },
- { id: "authorization", label: "生成授权与数据回传要求", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "experiment-execution",
- label: "exp-executor · 授权实验执行",
- status: "pending",
- milestones: [
- { id: "auth", label: "确认授权与执行边界", status: "pending", done: new Set(), total: 1 },
- { id: "run", label: "运行计算/数据实验", status: "pending", done: new Set(), total: 1 },
- { id: "receipt", label: "保存执行日志与结果", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "result-analysis",
- label: "result-analyzer · 结果分析",
- status: "pending",
- milestones: [
- { id: "analysis", label: "分析实验或回传数据", status: "pending", done: new Set(), total: 1 },
- { id: "patch", label: "生成 observation patch", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "paper-writing",
- label: "paper-writer · 论文写作",
- status: "pending",
- milestones: [
- { id: "outline", label: "写作计划与大纲", status: "pending", done: new Set(), total: 2 },
- { id: "english", label: "英文稿与参考文献", status: "pending", done: new Set(), total: 8 },
- { id: "chinese", label: "中文稿", status: "pending", done: new Set(), total: 7 },
- { id: "compile", label: "编译双语 PDF", status: "pending", done: new Set(), total: 2 },
- { id: "report", label: "形成交付报告", status: "pending", done: new Set(), total: 2 },
- ],
- },
- {
- id: "data-verification",
- label: "data-verifier · 数据与主张核验",
- status: "pending",
- milestones: [
- { id: "claims", label: "核验论文主张与来源", status: "pending", done: new Set(), total: 1 },
- { id: "numbers", label: "核验数据和表述一致性", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "paper-review",
- label: "paper-reviewer · 论文评审",
- status: "pending",
- milestones: [
- { id: "rubric", label: "按科学性和可复现性评分", status: "pending", done: new Set(), total: 1 },
- { id: "gaps", label: "输出研究缺口", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "review-feedback",
- label: "review-feedback · 反馈迭代",
- status: "pending",
- milestones: [
- { id: "compile", label: "编译评审反馈", status: "pending", done: new Set(), total: 1 },
- { id: "next", label: "生成下一轮 action 与本体补丁", status: "pending", done: new Set(), total: 1 },
- ],
- },
- {
- id: "ontology-reaudit",
- label: "ontology-auditor · 本体再审计",
- status: "pending",
- milestones: [
- { id: "audit", label: "检查反馈后的本体", status: "pending", done: new Set(), total: 1 },
- { id: "decision", label: "决定完成或进入下一轮", status: "pending", done: new Set(), total: 1 },
- ],
- },
- // 兼容旧三阶段 Lite pipeline 事件。
- {
- id: "idea-analysis",
- label: "idea-analyst · 创意分析",
- status: "pending",
- milestones: [
- { id: "plan", label: "制定分析计划", status: "pending", done: new Set(), total: 1 },
- { id: "directions", label: "生成检索方向", status: "pending", done: new Set(), total: 2 },
- { id: "report", label: "形成分析报告", status: "pending", done: new Set(), total: 2 },
- ],
- },
- {
- id: "literature-review",
- label: "lit-searcher · 文献检索",
- status: "pending",
- milestones: [
- { id: "plan", label: "制定检索计划", status: "pending", done: new Set(), total: 1 },
- { id: "search", label: "检索候选文献", status: "pending", done: new Set(), detail: "尚未开始" },
- { id: "select", label: "筛选入选论文", status: "pending", done: new Set(), total: 1 },
- { id: "report", label: "形成文献综述", status: "pending", done: new Set(), total: 2 },
- ],
- },
- ]
- const observedStages: ProgressStage[] = []
- let current: ProgressStage | undefined
- let active: ProgressMilestone | undefined
- const pendingArtifacts = new Map<string, Array<Array<{ milestone: ProgressMilestone; key: string }>>>()
- let searchCalls = 0
- let note = ""
- let activeSince = Date.now()
- let activeAttempt = 0
- let timerRunning = false
- const aliases: Record<string, string> = {
- ontology_seed: "ontology-seed",
- "seed-ontology": "ontology-seed",
- "audit-ontology": "ontology-audit",
- audit_ontology: "ontology-reaudit",
- ontology_audit: "ontology-audit",
- "ontology-enrich": "ontology-enrichment",
- ontology_enrichment: "ontology-enrichment",
- "enrich-evidence": "ontology-enrichment",
- enrich_evidence: "ontology-enrichment",
- search: "ontology-enrichment",
- "build-hypothesis": "hypothesis-build",
- build_hypothesis: "hypothesis-build",
- "hypothesis-builder": "hypothesis-build",
- "plan-build": "research-plan",
- "build-research-plan": "research-plan",
- build_research_plan: "research-plan",
- "action-dag": "research-plan",
- synthesis: "evaluation",
- "falsification-assessment": "evaluation",
- falsification_assessment: "evaluation",
- "experiment-plan": "experiment-design",
- experiment_design: "experiment-design",
- experiment_execution: "experiment-execution",
- result_analysis: "result-analysis",
- paper_writing: "paper-writing",
- data_verification: "data-verification",
- paper_review: "paper-review",
- review_feedback: "review-feedback",
- }
- const genericMilestones = (): ProgressMilestone[] => [
- { id: "run", label: "远端阶段执行中", status: "pending", done: new Set(), total: 1 },
- { id: "receipt", label: "等待 receipt / artifact", status: "pending", done: new Set(), total: 1 },
- ]
- const normalizeStageID = (stageID: string) => aliases[stageID] ?? aliases[stageID.replaceAll("_", "-")] ?? stageID
- const findOrCreateStage = (stageID: string) => {
- const normalized = normalizeStageID(stageID || "remote-stage")
- let stage = stages.find((item) => item.id === normalized)
- if (stage) {
- if (!observedStages.includes(stage)) observedStages.push(stage)
- return stage
- }
- stage = {
- id: normalized,
- label: `${stageID || "remote-stage"} · 远端阶段`,
- status: "pending",
- milestones: genericMilestones(),
- }
- stages.push(stage)
- observedStages.push(stage)
- return stage
- }
- const startMilestone = (milestone: ProgressMilestone) => {
- if (!current) return
- if (milestone.status === "completed") return
- milestone.status = "running"
- if (milestone.detail === "尚未开始") milestone.detail = undefined
- if (active !== milestone) activeSince = Date.now()
- active = milestone
- timerRunning = true
- note = ""
- }
- const resetStage = (stage: ProgressStage) => {
- for (const milestone of stage.milestones) {
- milestone.status = "pending"
- milestone.done?.clear()
- milestone.detail = milestone.id === "search" ? "尚未开始" : undefined
- }
- if (["literature-review", "ontology-enrichment"].includes(stage.id)) searchCalls = 0
- pendingArtifacts.clear()
- }
- const artifact = (filePath: string) => {
- if (!current) return undefined
- const value = filePath.replaceAll("\\", "/").toLowerCase()
- const stage = value
- .split("/")
- .findLastIndex((part) => part === current?.id || new RegExp(`^${current?.id}_a\\d+$`).test(part))
- if (stage < 0) return undefined
- const relative = value
- .split("/")
- .slice(stage + 1)
- .join("/")
- const match = (milestoneID: string, key: string) => {
- const milestone = current?.milestones.find((item) => item.id === milestoneID)
- return milestone ? { milestone, key } : undefined
- }
- if (relative === "work_plan.md") {
- return match(current.id === "paper-writing" ? "outline" : "plan", "work_plan")
- }
- if (current.id === "idea-analysis") {
- if (relative === "artifacts/search_queries.json") return match("directions", "search_queries")
- if (relative === "artifacts/similar_papers.json") return match("directions", "similar_papers")
- if (relative === "report.json") return match("report", "report_json")
- if (relative === "report.md") return match("report", "report_md")
- }
- if (["literature-review", "ontology-enrichment"].includes(current.id)) {
- if (relative === "artifacts/papers_selected.json") return match("select", "papers_selected")
- if (relative === "report.json") return match("report", "report_json")
- if (relative === "report.md") return match("report", "report_md")
- if (relative.endsWith("search-log.jsonl")) return match("search", "search_log")
- }
- if (current.id === "experiment-design") {
- if (relative.endsWith("experiment_protocol.json")) return match("protocol", "experiment_protocol")
- if (relative.endsWith("external_authorization_package.json"))
- return match("authorization", "external_authorization_package")
- }
- if (current.id !== "paper-writing") return undefined
- if (relative === "artifacts/outline.json") return match("outline", "outline")
- const section = relative.match(
- /^artifacts\/sections\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex$/,
- )
- if (section) return match("english", `section:${section[1]}`)
- if (relative === "artifacts/paper.tex") return match("english", "paper_tex")
- if (relative === "artifacts/references.bib") return match("english", "references")
- const sectionZh = relative.match(
- /^artifacts\/sections_zh\/(abstract|introduction|related_work|method|experiments|conclusion)\.tex$/,
- )
- if (sectionZh) return match("chinese", `section:${sectionZh[1]}`)
- if (relative === "artifacts/paper_zh.tex") return match("chinese", "paper_zh_tex")
- if (relative === "report.json") return match("report", "report_json")
- if (relative === "report.md") return match("report", "report_md")
- return undefined
- }
- const invalidatePaperBuild = (items: Array<{ milestone: ProgressMilestone; key: string }>) => {
- if (current?.id !== "paper-writing") return
- if (!items.some((item) => ["english", "chinese"].includes(item.milestone.id))) return
- const compile = current.milestones.find((item) => item.id === "compile")!
- const report = current.milestones.find((item) => item.id === "report")!
- if (compile.status === "pending" && !compile.done?.size && report.status === "pending") return
- compile.status = "pending"
- compile.done?.clear()
- report.status = "pending"
- report.done?.clear()
- if (active === compile || active === report) active = undefined
- activeSince = Date.now()
- timerRunning = true
- note = "论文源文件已更新,等待重新编译"
- }
- const completeArtifact = (item: { milestone: ProgressMilestone; key: string }) => {
- item.milestone.done?.add(item.key)
- if (!item.milestone.total || (item.milestone.done?.size ?? 0) < item.milestone.total) return
- item.milestone.status = "completed"
- if (active !== item.milestone || !current) return
- const next = current.milestones[current.milestones.indexOf(item.milestone) + 1]
- if (next) return startMilestone(next)
- active = undefined
- activeSince = Date.now()
- timerRunning = true
- note = "正在进行 Guard / 阶段产物验收"
- }
- const enqueueArtifacts = (toolName: string, items: Array<{ milestone: ProgressMilestone; key: string }>) => {
- const queue = pendingArtifacts.get(toolName) ?? []
- queue.push(items)
- pendingArtifacts.set(toolName, queue)
- }
- const dequeueArtifacts = (toolName: string) => {
- const queue = pendingArtifacts.get(toolName)
- const items = queue?.shift()
- if (!queue?.length) pendingArtifacts.delete(toolName)
- return items
- }
- return {
- startStage(stageID: string, attempt: number) {
- const stage = findOrCreateStage(stageID)
- if (stage === current && attempt <= activeAttempt) return
- if (stage === current) resetStage(stage)
- current = stage
- activeAttempt = attempt
- // A later stage starting says nothing about whether earlier stages ran.
- current.status = "running"
- startMilestone(current.milestones[0])
- },
- finishStage(stageID: string, status: "completed" | "failed") {
- const stage = findOrCreateStage(stageID)
- stage.status = status
- if (status === "completed") {
- for (const milestone of stage.milestones) milestone.status = "completed"
- } else {
- for (const milestone of stage.milestones) {
- if (milestone.status === "running") milestone.status = "failed"
- }
- }
- current = stage
- active = status === "failed" ? active : undefined
- timerRunning = false
- note = status === "failed" ? "阶段执行失败" : "阶段验收通过"
- },
- finishPipeline() {
- // Terminal pipeline status is not evidence that every predefined stage ran.
- for (const stage of observedStages) {
- if (stage.status === "running") stage.status = "unknown"
- for (const milestone of stage.milestones) {
- if (milestone.status === "running") milestone.status = "unknown"
- }
- }
- active = undefined
- timerRunning = false
- note = "流程已结束;阶段状态以服务端事件为准"
- },
- stopUnverified() {
- for (const stage of observedStages) {
- if (stage.status === "running") stage.status = "unknown"
- for (const milestone of stage.milestones) {
- if (milestone.status === "running") milestone.status = "unknown"
- }
- }
- active = undefined
- timerRunning = false
- },
- pause() {
- if (current?.status === "running") current.status = "paused"
- if (active?.status === "running") active.status = "paused"
- timerRunning = false
- note = "任务已暂停,等待恢复"
- },
- cancel() {
- if (current?.status === "running") current.status = "cancelled"
- if (active?.status === "running") active.status = "cancelled"
- timerRunning = false
- note = "任务已取消"
- },
- note(value: string) {
- note = value
- },
- toolCall(toolName: string, content: string) {
- const lower = toolName.toLowerCase()
- if (
- current?.id === "ontology-enrichment" &&
- ["kbsearch", "websearch", "webfetch", "openalex_search", "crossref_search"].includes(lower)
- ) {
- const milestone = current.milestones.find((item) => item.id === "search")!
- startMilestone(milestone)
- return
- }
- if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
- const milestone = current.milestones.find((item) => item.id === "search")!
- startMilestone(milestone)
- return
- }
- if (current?.id === "paper-writing" && lower === "compilelatex") {
- const milestone = current.milestones.find((item) => item.id === "compile")!
- const key = content.toLowerCase().includes("paper_zh.tex") ? "paper_zh_pdf" : "paper_pdf"
- milestone.done?.delete(key)
- if (milestone.status === "completed") milestone.status = "pending"
- startMilestone(milestone)
- enqueueArtifacts(lower, [{ milestone, key }])
- return
- }
- if (current?.id === "paper-writing" && lower === "buildresearchpaper") {
- const milestone = current.milestones.find((item) => item.id === "compile")!
- milestone.status = "pending"
- milestone.done?.clear()
- startMilestone(milestone)
- enqueueArtifacts(lower, [
- { milestone, key: "paper_pdf" },
- { milestone, key: "paper_zh_pdf" },
- ])
- return
- }
- if (lower !== "writefile") return
- const item = artifact(getWriteFilePath(content))
- if (!item) {
- enqueueArtifacts(lower, [])
- return
- }
- if (
- ["literature-review", "ontology-enrichment"].includes(current?.id ?? "") &&
- item.milestone.id === "select" &&
- searchCalls
- ) {
- const searchMilestone = current?.milestones.find((milestone) => milestone.id === "search")
- if (searchMilestone) searchMilestone.status = "completed"
- }
- startMilestone(item.milestone)
- enqueueArtifacts(lower, [item])
- },
- toolResult(toolName: string, content: string) {
- const lower = toolName.toLowerCase()
- const failed = /\[(?:tool_)?error\]|mcp_error|"status"\s*:\s*"(?:failed|error)"|"passed"\s*:\s*false/i.test(
- content,
- )
- if (
- current?.id === "ontology-enrichment" &&
- ["kbsearch", "websearch", "webfetch", "openalex_search", "crossref_search"].includes(lower)
- ) {
- if (!failed) searchCalls++
- const milestone = current.milestones.find((item) => item.id === "search")!
- milestone.detail = searchCalls ? `已完成 ${searchCalls} 轮检索/核验` : "等待检索重试"
- return
- }
- if (current?.id === "literature-review" && ["arxiv_search", "openalex_search"].includes(lower)) {
- if (!failed) searchCalls++
- const milestone = current.milestones.find((item) => item.id === "search")!
- milestone.detail = searchCalls ? `已完成 ${searchCalls} 轮检索` : "等待检索重试"
- return
- }
- const items = dequeueArtifacts(lower)
- if (!items) return
- if (!failed) {
- items.forEach(completeArtifact)
- invalidatePaperBuild(items)
- }
- if (failed) {
- for (const item of items) item.milestone.status = "failed"
- note = "当前操作失败,等待重试"
- }
- },
- snapshot() {
- if (!current) return undefined
- const rows = observedStages.flatMap((stage) => {
- const detail = stage.status === "running" ? active?.label : undefined
- const parent = [{ id: stage.id, label: stage.label, status: stage.status, detail, depth: 0 }]
- if (stage !== current || !["running", "failed"].includes(stage.status)) return parent
- return parent.concat(
- stage.milestones.map((milestone) => ({
- id: `${stage.id}:${milestone.id}`,
- label: milestone.label,
- status: milestone.status,
- detail:
- milestone.detail ??
- (milestone.status !== "completed" && milestone.total && milestone.done?.size
- ? `${milestone.done.size}/${milestone.total}`
- : undefined),
- depth: 1,
- })),
- )
- })
- return {
- kind: "research67",
- title: active ? `Research67 · ${current.label} · ${active.label}` : `Research67 · ${current.label}`,
- rows,
- activeSince: timerRunning ? activeSince : undefined,
- footer: note || (!active ? "进度已更新" : undefined),
- }
- },
- }
- }
|