///
import { tool } from "@opencode-ai/plugin"
import { mkdir } 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"
type ProgressMilestone = {
id: string
label: string
status: ProgressStatus
done?: Set
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(默认,复用本 OpenCode session 的远端 thread/workspace)/ 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("复用当前对话或开始新课题"),
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}`
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 || ""
: ""
// 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",
},
body: JSON.stringify({
input: args.input,
mode: args.mode,
target_subagent: args.target_subagent ?? "",
thread_id: threadID,
parameters: args.parameters ?? {},
context: {
...(workDir ? { work_dir: workDir } : {}),
...(continueRunID ? { run_id: continueRunID } : {}),
},
}),
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 = {
agent_name: args.agent_name,
agent_id: agentID,
status: "running",
}
let done: Record | null = null
let startedRunID = ""
let sawCancelled = false
let lastThinkUpdate = 0
const progress = createResearch67Progress()
const updateMetadata = (title: string, next: Record) => {
Object.assign(liveMetadata, next)
const snapshot = progress.snapshot()
context.metadata({
title: snapshot?.title ?? title,
metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
})
}
let remoteCancellation: Promise | undefined
const cancelOnAbort = () => {
if (!startedRunID || remoteCancellation) return
remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
}
context.abort.addEventListener("abort", cancelOnAbort, { once: true })
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)
if (remoteThreadID && 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, runID, approved, context.abort)
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 === "cancelled") {
sawCancelled = true
progress.cancel()
updateMetadata("Research67 已取消", { latest_event: ev.event, status: "cancelled" })
} else if (ev.event === "done" && data) {
done = data
break
}
}
context.abort.removeEventListener("abort", cancelOnAbort)
if (!done) {
const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
throw new Error(`AgentPaaS 任务未正常结束${hint}${errors.length ? ":" + errors.join("; ") : ""}`)
}
const status = typeof done.status === "string" ? done.status : "unknown"
const progressMetadata = progress.snapshot()
const metadata = {
agent_name: args.agent_name,
agent_id: agentID,
run_id: done.run_id ?? liveMetadata.run_id,
thread_id: done.thread_id ?? liveMetadata.thread_id,
status,
steps: done.steps,
total_tokens: done.total_tokens,
cost_usd: done.cost_usd,
workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
error_code: liveMetadata.error_code,
error_message: liveMetadata.error_message,
...(progressMetadata ? { research67_progress: progressMetadata } : {}),
}
if (status === "completed")
return { title: `AgentPaaS ${args.agent_name}`, output: String(done.output ?? ""), metadata }
if (status === "cancelled") return { title: `AgentPaaS ${args.agent_name}`, output: "任务已被取消。", 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,
}
},
})
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 (!matches[0].id.startsWith("ag_")) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
return matches[0].id
}
const conversationCache = new Map()
async function loadConversationState(sessionID: string, key: string): Promise {
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)[key]
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
const threadID = getString((value as Record).thread_id)
const runID = getString((value as Record).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 = {}
if (await file.exists()) {
try {
const parsed: unknown = await file.json()
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) saved = parsed as Record
} 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 = {}
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
} 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 path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
}
async function resolveConfirmation(
config: { url: string; apiKey: string },
runID: string,
approved: boolean,
signal: AbortSignal,
) {
const resp = await fetch(`${config.url}/api/v1/traces/${encodeURIComponent(runID)}/confirm`, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ approved }),
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 || resp.status === 409) return
const detail = await resp.text().catch(() => "")
throw new Error(`AgentPaaS 取消请求失败: HTTP ${resp.status}: ${detail.slice(0, 500)}`)
}
async function* parseSSE(body: ReadableStream): AsyncGenerator {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
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 }
}
}
}
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).message)
return String(data)
}
function asRecord(value: unknown): Record | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
const result: Record = {}
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 {
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 },
],
},
]
let current: ProgressStage | undefined
let active: ProgressMilestone | undefined
const pendingArtifacts = new Map>>()
let searchCalls = 0
let note = ""
let activeSince = Date.now()
let activeAttempt = 0
let timerRunning = false
const aliases: Record = {
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) return stage
stage = {
id: normalized,
label: `${stageID || "remote-stage"} · 远端阶段`,
status: "pending",
milestones: genericMilestones(),
}
stages.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 < 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
for (const item of stages) {
if (item === current) break
if (item.status === "pending") item.status = "completed"
}
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() {
for (const stage of stages) {
stage.status = "completed"
for (const milestone of stage.milestones) milestone.status = "completed"
}
active = undefined
timerRunning = false
note = "全部阶段已完成"
},
cancel() {
if (current?.status === "running") current.status = "failed"
if (active?.status === "running") active.status = "failed"
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 = stages.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),
}
},
}
}