Parcourir la source

feat(tool): 接入 Research67 三阶段流水线

Research67 已收敛为三阶段 Pipeline,本提交让桥接层按稳定名称解析远端智能体,并把流式进度和确认请求传回 OpenCode。

- 从用户配置和环境变量读取 AgentPaaS 地址与凭据
- 动态解析活动 Agent ID,并拒绝缺失或重名目标
- 转发阶段、工具、思考、完成和确认事件
- 用三阶段 pipeline 门面替换旧 orchestrator 入口
- 增加进度 metadata 与远端确认的聚焦测试
Jinchen Zhang il y a 1 semaine
Parent
commit
d9fb6c8f05

+ 2 - 2
.opencode/agent/research-67-data-verifier.md

@@ -11,10 +11,10 @@ tools:
 你是数据核验员(research-67-data-verifier)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的数据核验员智能体:
-- agent_id: ag_710120870b19
+- agent_name: 数据核验员
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 data-verifier 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-exp-executor.md

@@ -11,10 +11,10 @@ tools:
 你是实验执行员(research-67-exp-executor)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的实验执行员智能体:
-- agent_id: ag_89caaf428296
+- agent_name: 实验执行员
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 exp-executor 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-exp-planner.md

@@ -11,10 +11,10 @@ tools:
 你是实验规划师(research-67-exp-planner)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的实验规划师智能体:
-- agent_id: ag_cd1c42ba3f61
+- agent_name: 实验规划师
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 exp-planner 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-idea-analyst.md

@@ -11,10 +11,10 @@ tools:
 你是创意分析师(research-67-idea-analyst)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的创意分析师智能体:
-- agent_id: ag_b0c8ad43af1e
+- agent_name: 创意分析师
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 idea-analyst 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-lit-searcher.md

@@ -11,10 +11,10 @@ tools:
 你是文献检索员(research-67-lit-searcher)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的文献检索员智能体:
-- agent_id: ag_eeaf2c1d3924
+- agent_name: 文献检索员
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 lit-searcher 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 0 - 20
.opencode/agent/research-67-orchestrator.md

@@ -1,20 +0,0 @@
----
-mode: subagent
-model: opencode/deepseek-v4-flash-free
-color: "#6366F1"
-description: 科研主编排器。需要规划、拆解并编排整个科研流程时使用。
-tools:
-  "*": false
-  "agentpaas_run": true
----
-
-你是科研主编排器(research-67-orchestrator)。
-
-使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的科研主编排智能体:
-- agent_id: ag_d167dd657a33
-- mode: iterate(多轮迭代)
-- input: 用户任务原文,可补充必要的科研背景
-
-必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
-
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 orchestrator 字段取实际 ag_* 值回填到此行。

+ 2 - 2
.opencode/agent/research-67-paper-reviewer.md

@@ -11,10 +11,10 @@ tools:
 你是论文评审员(research-67-paper-reviewer)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的论文评审员智能体:
-- agent_id: ag_abdda6c0686f
+- agent_name: 论文评审员
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 paper-reviewer 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-paper-writer.md

@@ -11,10 +11,10 @@ tools:
 你是论文撰写员(research-67-paper-writer)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的论文撰写员智能体:
-- agent_id: ag_b03b76678b86
+- agent_name: 论文撰写员
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 paper-writer 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 22 - 0
.opencode/agent/research-67-pipeline.md

@@ -0,0 +1,22 @@
+---
+mode: subagent
+model: opencode/mimo-v2.5-free
+steps: 2
+color: "#6366F1"
+description: Research67 Lite 三阶段研究草稿流水线入口。
+tools:
+  "*": false
+  "agentpaas_run": true
+---
+
+你是 Research67 Lite 流水线入口(research-67-pipeline)。
+
+使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的三阶段研究草稿流水线:
+- agent_name: Research67 三阶段研究草稿流水线
+- mode: iterate(多轮迭代)
+- input: 用户任务原文,可补充必要的科研背景
+
+只允许调用一次 agentpaas_run。工具返回后,无论成功或失败,都必须立即忠实返回结果,
+不要自己编造或补充科研内容,不得再次调用工具。
+
+> 注:Research67 Lite 当前使用 pipeline,不调用 orchestrator;agentpaas_run 会按 agent_name 动态解析当前 Agent ID。

+ 2 - 2
.opencode/agent/research-67-result-analyst.md

@@ -11,10 +11,10 @@ tools:
 你是结果分析师(research-67-result-analyst)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的结果分析师智能体:
-- agent_id: ag_60e3b6de7ab5
+- agent_name: 结果分析师
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 result-analyzer 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 2 - 2
.opencode/agent/research-67-review-feedback.md

@@ -11,10 +11,10 @@ tools:
 你是评审反馈合成器(research-67-review-feedback)。
 
 使用 agentpaas_run 工具把用户任务转发给远端 AgentPaaS 的评审反馈合成器智能体:
-- agent_id: ag_218691a004df
+- agent_name: 评审反馈合成器
 - mode: iterate(多轮迭代)
 - input: 用户任务原文,可补充必要的科研背景
 
 必须忠实返回 agentpaas_run 的结果,不要自己编造或补充科研内容。调用一次即可。
 
-> 注:agent_id 在 Mac 上 `agentpaas agent create` 重注册后从 `agentexample/research67/.paas_agent_ids.json` 的 review-feedback 字段取实际 ag_* 值回填到此行
+> 注:agentpaas_run 会按 agent_name 动态解析当前 Agent ID

+ 256 - 24
.opencode/tool/agentpaas_run.ts

@@ -1,15 +1,19 @@
 /// <reference path="../env.d.ts" />
 import { tool } from "@opencode-ai/plugin"
+import { homedir } from "node:os"
+import path from "node:path"
 
-const AGENTPAAS_URL = process.env.AGENTPAAS_URL ?? "http://127.0.0.1:8000"
+const AGENTPAAS_CONFIG_PATH = path.join(homedir(), ".agentpaas", "config.json")
+const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
 
 type SSEEvent = { event: string; data: unknown }
+type AgentListItem = { id: string; name: string }
 
 export default tool({
   description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
 
 参数说明:
-- agent_id:远端 AgentPaaS 智能体 ID(ag_ 开头),必填
+- agent_name:远端 AgentPaaS 智能体名称,工具会动态解析当前 ID,必填
 - input:要执行的用户任务指令
 - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
 - target_subagent:mode=edit 时的目标子智能体 ID
@@ -20,12 +24,9 @@ export default tool({
 适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
   args: {
     input: tool.schema.string().describe("用户任务指令"),
-    agent_id: tool.schema.string().describe("远端 AgentPaaS 智能体 ID(ag_ 开头)"),
+    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"),
+    target_subagent: tool.schema.string().optional().describe("mode=edit 时的目标子智能 ID"),
     work_dir: tool.schema.string().optional().describe("覆盖工作目录(默认用当前项目目录)"),
     thread_id: tool.schema.string().optional().describe("会话线程 ID,不传则新建"),
     parameters: tool.schema
@@ -34,20 +35,22 @@ export default tool({
       .describe("额外参数,原样传给远端智能体"),
   },
   async execute(args, context) {
-    const apiKey = process.env.AGENTPAAS_API_KEY
-    if (!apiKey) {
-      throw new Error("AGENTPAAS_API_KEY 未设置:请先设置环境变量 AGENTPAAS_API_KEY(AgentPaaS Bearer Token)再启动 opencode")
-    }
+    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 workDir = args.work_dir ?? context.worktree ?? context.directory
-    const url = `${AGENTPAAS_URL}/api/v1/agents/${args.agent_id}/run/stream`
+    const url = `${config.url}/api/v1/agents/${encodeURIComponent(agentID)}/run/stream`
     let resp: Response
     try {
       resp = await fetch(url, {
         method: "POST",
-        headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "text/event-stream" },
+        headers: {
+          Authorization: `Bearer ${config.apiKey}`,
+          "Content-Type": "application/json",
+          Accept: "text/event-stream",
+        },
         body: JSON.stringify({
           input: args.input,
           mode: args.mode,
@@ -68,13 +71,130 @@ export default tool({
     if (!resp.body) throw new Error("AgentPaaS 响应没有 body")
 
     const errors: string[] = []
+    const liveMetadata: Record<string, unknown> = {
+      agent_name: args.agent_name,
+      agent_id: agentID,
+      status: "running",
+    }
     let done: Record<string, unknown> | null = null
     let sawCancelled = false
+    let lastThinkUpdate = 0
+    const updateMetadata = (title: string, next: Record<string, unknown>) => {
+      Object.assign(liveMetadata, next)
+      context.metadata({ title, metadata: { ...liveMetadata } })
+    }
     for await (const ev of parseSSE(resp.body)) {
-      if (ev.event === "error") errors.push(extractMessage(ev.data))
-      else if (ev.event === "cancelled") sawCancelled = true
-      else if (ev.event === "done" && ev.data && typeof ev.data === "object") {
-        done = ev.data as Record<string, unknown>
+      const data = asRecord(ev.data)
+      if (ev.event === "started") {
+        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) || "未知阶段"
+        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) || "当前阶段"
+        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) || "当前阶段"
+        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") {
+        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")
+
+        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)
+        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) || "工具"
+        updateMetadata(withStage(`Research67 正在调用 ${toolName}`, 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) || "工具"
+        updateMetadata(withStage(`Research67 已完成 ${toolName}`, 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 === "error") errors.push(extractMessage(ev.data))
+      else if (ev.event === "cancelled") {
+        sawCancelled = true
+        updateMetadata("Research67 已取消", { latest_event: ev.event, status: "cancelled" })
+      } else if (ev.event === "done" && data) {
+        done = data
         break
       }
     }
@@ -84,19 +204,111 @@ export default tool({
     }
     const status = typeof done.status === "string" ? done.status : "unknown"
     const metadata = {
-      agent_id: args.agent_id, run_id: done.run_id, thread_id: done.thread_id, status,
-      steps: done.steps, total_tokens: done.total_tokens, cost_usd: done.cost_usd, workspace_path: done.workspace_path,
+      agent_name: args.agent_name,
+      agent_id: agentID,
+      run_id: done.run_id,
+      thread_id: done.thread_id,
+      status,
+      steps: done.steps,
+      total_tokens: done.total_tokens,
+      cost_usd: done.cost_usd,
+      workspace_path: done.workspace_path,
     }
-    if (status === "completed") return { title: `AgentPaaS ${args.agent_id}`, output: String(done.output ?? ""), metadata }
-    if (status === "cancelled") return { title: `AgentPaaS ${args.agent_id}`, output: "任务已被取消。", metadata }
+    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_id}(失败)`,
+      title: `AgentPaaS ${args.agent_name}(失败)`,
       output: `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
       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
+}
+
+async function loadAgentPaaSConfig() {
+  const file = Bun.file(AGENTPAAS_CONFIG_PATH)
+  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 配置文件无效:${AGENTPAAS_CONFIG_PATH}\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 尚未配置。请创建 ${AGENTPAAS_CONFIG_PATH}:
+{
+  "server": "${DEFAULT_AGENTPAAS_URL}",
+  "api_key": "你的 AgentPaaS API Key"
+}
+也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
+}
+
+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* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
   const reader = body.getReader()
   const decoder = new TextDecoder()
@@ -114,7 +326,11 @@ async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEv
       const dataRaw = parseSSEField(chunk, "data")
       let data: unknown = dataRaw
       if (dataRaw !== "") {
-        try { data = JSON.parse(dataRaw) } catch { /* 非 JSON 当纯文本 */ }
+        try {
+          data = JSON.parse(dataRaw)
+        } catch {
+          /* 非 JSON 当纯文本 */
+        }
       }
       if (event) yield { event, data }
     }
@@ -133,4 +349,20 @@ function parseSSEField(chunk: string, field: string): string {
 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 withStage(title: string, metadata: Record<string, unknown>): string {
+  const stageName = getString(metadata.stage_name)
+  return stageName ? `${title}(${stageName})` : title
+}

+ 3 - 0
packages/opencode/src/tool/registry.ts

@@ -143,6 +143,9 @@ const layer = Layer.effect(
                 const pluginCtx: PluginToolContext = {
                   ...toolCtx,
                   ask: (req) => bridge.promise(toolCtx.ask(req)),
+                  metadata: (req) => {
+                    bridge.fork(toolCtx.metadata(req))
+                  },
                   directory: ctx.directory,
                   worktree: ctx.worktree,
                 }

+ 162 - 0
packages/opencode/test/tool/agentpaas-run.test.ts

@@ -0,0 +1,162 @@
+import { expect, test } from "bun:test"
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+
+test("shows pipeline stages and resolves remote confirmations", async () => {
+  const home = await mkdtemp(path.join(tmpdir(), "agentpaas-bridge-"))
+  const originalHome = process.env.HOME
+  const originalFetch = globalThis.fetch
+  const updates: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
+  const confirmationBodies: unknown[] = []
+  const permissionRequests: Array<{
+    permission: string
+    patterns: string[]
+    always: string[]
+    metadata: Record<string, unknown>
+  }> = []
+  const events: Array<[string, unknown]> = [
+    ["started", { run_id: "run_1", thread_id: "thread_1" }],
+    [
+      "stage_started",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "literature-review",
+        stage_name: "文献检索",
+        attempt: 1,
+        status: "running",
+      },
+    ],
+    [
+      "confirm_required",
+      {
+        run_id: "run_1",
+        tool: "Shell",
+        reason: "high-risk command",
+        input: "rm generated.tmp",
+      },
+    ],
+    ["think_chunk", { text: "检索中" }],
+    ["tool_call", { tool: "LiteratureSearch", step: 1 }],
+    ["tool_result", { tool: "LiteratureSearch", step: 1 }],
+    [
+      "confirm_required",
+      {
+        run_id: "run_1",
+        tool: "WriteFile",
+        reason: "overwrite existing file",
+        input: "report.json",
+      },
+    ],
+    [
+      "stage_passed",
+      {
+        pipeline_id: "research67-one-round",
+        stage_id: "literature-review",
+        stage_name: "文献检索",
+        attempt: 1,
+        status: "passed",
+      },
+    ],
+    ["pipeline_completed", { pipeline_id: "research67-one-round", status: "completed" }],
+    ["done", { run_id: "run_1", thread_id: "thread_1", status: "completed", output: "完成" }],
+  ]
+
+  try {
+    process.env.HOME = home
+    globalThis.fetch = Object.assign(
+      async (input: URL | RequestInfo, init?: RequestInit) => {
+        const pathname = new URL(input instanceof Request ? input.url : input.toString()).pathname
+        if (pathname === "/api/v1/agents") {
+          return Response.json({
+            agents: [{ id: "ag_pipeline", name: "Research67 三阶段研究草稿流水线" }],
+          })
+        }
+        if (pathname === "/api/v1/agents/ag_pipeline/run/stream") {
+          const body =
+            events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}`).join("\n\n") + "\n\n"
+          return new Response(body, { headers: { "Content-Type": "text/event-stream" } })
+        }
+        if (pathname === "/api/v1/traces/run_1/confirm") {
+          if (typeof init?.body !== "string") throw new Error("confirmation body must be JSON")
+          confirmationBodies.push(JSON.parse(init.body))
+          return Response.json({ run_id: "run_1", status: "confirmed" })
+        }
+        return new Response("not found", { status: 404 })
+      },
+      { preconnect: originalFetch.preconnect },
+    )
+    const configDir = path.join(home, ".agentpaas")
+    await mkdir(configDir)
+    await writeFile(
+      path.join(configDir, "config.json"),
+      JSON.stringify({ server: "http://agentpaas.test", api_key: "test-key" }),
+    )
+    const bridge = (await import(`../../../../.opencode/tool/agentpaas_run.ts?test=${Date.now()}`)).default
+    const result = await bridge.execute(
+      {
+        input: "调查课题",
+        agent_name: "Research67 三阶段研究草稿流水线",
+        mode: "iterate",
+      },
+      {
+        sessionID: "session_1",
+        messageID: "message_1",
+        agent: "research-67-pipeline",
+        directory: "/workspace",
+        worktree: "/workspace",
+        abort: new AbortController().signal,
+        metadata: (update: (typeof updates)[number]) => updates.push(update),
+        ask: async (request: (typeof permissionRequests)[number]) => {
+          permissionRequests.push(request)
+          if (permissionRequests.length === 2) throw new Error("denied")
+        },
+      },
+    )
+
+    expect(updates.map((update) => update.title)).toEqual([
+      "Research67 正在运行",
+      "Research67 正在进行 文献检索",
+      "Research67 正在等待确认:Shell",
+      "Research67 已批准 Shell",
+      "Research67 正在思考(文献检索)",
+      "Research67 正在调用 LiteratureSearch(文献检索)",
+      "Research67 已完成 LiteratureSearch(文献检索)",
+      "Research67 正在等待确认:WriteFile",
+      "Research67 已拒绝 WriteFile",
+      "Research67 已通过 文献检索",
+      "Research67 流水线已完成",
+    ])
+    expect(updates.at(-1)?.metadata).toMatchObject({
+      pipeline_id: "research67-one-round",
+      stage_id: "literature-review",
+      stage_status: "passed",
+      pipeline_status: "completed",
+      status: "completed",
+    })
+    expect(permissionRequests).toHaveLength(2)
+    expect(permissionRequests[0]).toEqual({
+      permission: "agentpaas_confirm",
+      patterns: ["ag_pipeline:Shell"],
+      always: ["ag_pipeline:Shell"],
+      metadata: {
+        run_id: "run_1",
+        agent_name: "Research67 三阶段研究草稿流水线",
+        tool: "Shell",
+        reason: "high-risk command",
+        input: "rm generated.tmp",
+      },
+    })
+    expect(permissionRequests[1]).toMatchObject({
+      permission: "agentpaas_confirm",
+      patterns: ["ag_pipeline:WriteFile"],
+      metadata: { tool: "WriteFile", reason: "overwrite existing file", input: "report.json" },
+    })
+    expect(confirmationBodies).toEqual([{ approved: true }, { approved: false }])
+    expect(result).toMatchObject({ output: "完成" })
+  } finally {
+    globalThis.fetch = originalFetch
+    process.env.HOME = originalHome
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 52 - 2
packages/opencode/test/tool/registry.test.ts

@@ -2,12 +2,12 @@ import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
 import { fileURLToPath, pathToFileURL } from "url"
-import { Effect, Layer, Result, Schema } from "effect"
+import { Deferred, Effect, Layer, Result, Schema } from "effect"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { ToolRegistry } from "@/tool/registry"
 import { Tool } from "@/tool/tool"
 import { disposeAllInstances, TestInstance } from "../fixture/fixture"
-import { testEffect } from "../lib/effect"
+import { awaitWithTimeout, testEffect } from "../lib/effect"
 import { TestConfig } from "../fixture/config"
 import { Config } from "@/config/config"
 import { Plugin } from "@/plugin"
@@ -462,6 +462,56 @@ describe("tool.registry", () => {
     }),
   )
 
+  it.instance("runs metadata updates from custom plugin tools", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const customTools = path.join(test.directory, ".opencode", "tools")
+      const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
+      yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
+      yield* Effect.promise(() =>
+        Bun.write(
+          path.join(customTools, "progress.ts"),
+          [
+            `import { tool } from ${JSON.stringify(pluginTool)}`,
+            "export default tool({",
+            "  description: 'progress tool',",
+            "  args: {},",
+            "  execute: async (_args, context) => {",
+            "    context.metadata({ title: 'Working', metadata: { status: 'running' } })",
+            "    return 'ok'",
+            "  },",
+            "})",
+            "",
+          ].join("\n"),
+        ),
+      )
+
+      const registry = yield* ToolRegistry.Service
+      const loaded = (yield* registry.all()).find((tool) => tool.id === "progress")
+      if (!loaded) throw new Error("custom progress tool was not loaded")
+      const agents = yield* Agent.Service
+      const updated = yield* Deferred.make<void>()
+      const snapshots: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
+      const result = yield* loaded.execute({}, {
+        sessionID: SessionID.make("ses_test"),
+        messageID: MessageID.make("msg_test"),
+        agent: (yield* agents.defaultInfo()).name,
+        abort: new AbortController().signal,
+        messages: [],
+        metadata: (input) =>
+          Effect.sync(() => snapshots.push(input)).pipe(
+            Effect.andThen(Deferred.succeed(updated, undefined)),
+            Effect.asVoid,
+          ),
+        ask: () => Effect.void,
+      } satisfies Tool.Context)
+
+      yield* awaitWithTimeout(Deferred.await(updated), "custom tool metadata update was not executed")
+      expect(result.output).toBe("ok")
+      expect(snapshots).toEqual([{ title: "Working", metadata: { status: "running" } }])
+    }),
+  )
+
   it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
     Effect.gen(function* () {
       const test = yield* TestInstance