Quellcode durchsuchen

feat(agentpaas): 支持Research67连续对话

为 OpenCode 增加 Research67 主入口,并按 CLI session 自动续用 AgentPaaS 对话线程和工作区。

- 新增 research67 primary agent 门面,区分讨论、执行与新课题

- 持久化 thread_id 和 run_id,并允许显式 conversation=new

- 避免向 Docker 误传宿主机工作目录,运行时解析用户配置路径

- 新增连续会话回归测试;Bun 单测 4 项通过,tsgo 类型检查通过
邵勃源 vor 4 Tagen
Ursprung
Commit
584813804b

+ 31 - 0
.opencode/agent/research67.md

@@ -0,0 +1,31 @@
+---
+mode: primary
+model: opencode/mimo-v2.5-free
+steps: 3
+color: "#0F766E"
+description: 对话式 Research67 入口,在同一 CLI 会话中持续讨论、执行和追问跨学科科研任务。
+tools:
+  "*": false
+  "agentpaas_run": true
+---
+
+你是 Docker 版对话式 Research67 入口(research67)。
+
+每个用户回合使用一次 agentpaas_run,把原始请求转发给远端 AgentPaaS:
+
+- agent_name: research67
+- mode: 问候、澄清、概念讨论和产物追问用 chat;用户明确要求检索、分析、实验、写作或生成文件时用 iterate
+- conversation: 默认 continue;只有用户明确切换到不相关的新课题或要求新会话时用 new
+- input: 用户任务原文;可以补充用户明确给出的时间、成本和交付物边界,但不得改变研究问题
+
+每个用户回合只允许调用一次 agentpaas_run。后续用户消息可以再次调用,工具会按当前
+OpenCode session 自动复用 AgentPaaS thread 和上一轮 workspace,形成连续对话。
+工具返回后,无论成功、暂停、取消或失败,都必须立即忠实返回结果;不得自行改用其他
+Agent 名称,不得在同一回合重复调用,也不得在 OpenCode 一侧伪造研究产物。
+
+若用户引用 125 Questions 中的问题,保留原问题文本和学科语境;背景摘要只能作为待核验
+线索,不得当作可靠证据。用户只给编号而未给正文时,应让远端 Research67 请求补充正文,
+不得猜测编号对应的问题。
+
+当工具返回 `status=completed` 或标题为“任务已完成”时,明确写“任务已完成”,原样保留
+工作目录和文件表格;禁止改写成“已提交”“已接收”“开始执行”或“完成后再返回”。

+ 100 - 11
.opencode/tool/agentpaas_run.ts

@@ -1,13 +1,14 @@
 /// <reference path="../env.d.ts" />
 import { tool } from "@opencode-ai/plugin"
+import { mkdir } from "node:fs/promises"
 import { homedir } from "node:os"
 import path from "node:path"
 
-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 }
+type ConversationState = { threadID: string; runID: string }
 type ProgressStatus = "completed" | "running" | "pending" | "failed"
 type ProgressMilestone = {
   id: string
@@ -32,8 +33,9 @@ export default tool({
 - input:要执行的用户任务指令
 - mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
 - target_subagent:mode=edit 时的目标子智能体 ID
-- work_dir:覆盖工作目录(默认使用当前 opencode 项目目录)
-- thread_id:会话线程 ID,不传则新建
+- work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
+- conversation:continue(默认,复用本 OpenCode session 的远端 thread/workspace)/ new(新课题)
+- thread_id:可选的远端会话线程 ID;显式值优先于自动映射
 - parameters:额外参数,原样传给远端智能体
 
 适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
@@ -42,8 +44,9 @@ export default tool({
     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("覆盖工作目录(默认用当前项目目录)"),
-    thread_id: tool.schema.string().optional().describe("会话线程 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()
@@ -55,7 +58,19 @@ export default tool({
       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 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 {
@@ -70,9 +85,12 @@ export default tool({
           input: args.input,
           mode: args.mode,
           target_subagent: args.target_subagent ?? "",
-          thread_id: args.thread_id ?? "",
+          thread_id: threadID,
           parameters: args.parameters ?? {},
-          context: { work_dir: workDir },
+          context: {
+            ...(workDir ? { work_dir: workDir } : {}),
+            ...(continueRunID ? { run_id: continueRunID } : {}),
+          },
         }),
         signal: context.abort,
       })
@@ -114,6 +132,13 @@ export default tool({
       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,
@@ -320,8 +345,60 @@ async function resolveAgentID(config: { url: string; apiKey: string }, agentName
   return matches[0].id
 }
 
+const conversationCache = new Map<string, ConversationState>()
+
+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 file = Bun.file(AGENTPAAS_CONFIG_PATH)
+  const configPath = agentPaaSConfigPath()
+  const file = Bun.file(configPath)
   let saved: Record<string, unknown> = {}
   if (await file.exists()) {
     try {
@@ -330,7 +407,7 @@ async function loadAgentPaaSConfig() {
       saved = parsed as Record<string, unknown>
     } catch (err) {
       throw new Error(
-        `AgentPaaS 配置文件无效:${AGENTPAAS_CONFIG_PATH}\n${err instanceof Error ? err.message : String(err)}`,
+        `AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`,
       )
     }
   }
@@ -341,7 +418,7 @@ async function loadAgentPaaSConfig() {
   const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
   if (apiKey) return { url, apiKey }
 
-  throw new Error(`AgentPaaS 尚未配置。请创建 ${AGENTPAAS_CONFIG_PATH}:
+  throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
 {
   "server": "${DEFAULT_AGENTPAAS_URL}",
   "api_key": "你的 AgentPaaS API Key"
@@ -349,6 +426,18 @@ async function loadAgentPaaSConfig() {
 也可以通过 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,

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

@@ -678,3 +678,80 @@ test("cancels the remote run when the local request is aborted", async () => {
     await rm(home, { recursive: true, force: true })
   }
 })
+
+test("reuses AgentPaaS thread and workspace across one OpenCode session", async () => {
+  const home = await mkdtemp(path.join(tmpdir(), "agentpaas-conversation-"))
+  const originalHome = process.env.HOME
+  const originalFetch = globalThis.fetch
+  const bodies: Array<Record<string, unknown>> = []
+  let run = 0
+
+  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_research67", name: "research67" }] })
+        }
+        if (pathname === "/api/v1/agents/ag_research67/run/stream") {
+          if (typeof init?.body !== "string") throw new Error("run body must be JSON")
+          bodies.push(JSON.parse(init.body))
+          run++
+          const threadID = run < 3 ? "th_research" : "th_new"
+          const body = [
+            `event: started\ndata: ${JSON.stringify({ run_id: `run_${run}`, thread_id: threadID })}`,
+            `event: done\ndata: ${JSON.stringify({
+              run_id: `run_${run}`,
+              thread_id: threadID,
+              status: "completed",
+              output: `answer ${run}`,
+            })}`,
+          ].join("\n\n")
+          return new Response(`${body}\n\n`, { headers: { "Content-Type": "text/event-stream" } })
+        }
+        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?conversation=${Date.now()}`)).default
+    const context = {
+      sessionID: "session_research",
+      messageID: "message_research",
+      agent: "research67",
+      directory: "/host/workspace",
+      worktree: "/host/workspace",
+      abort: new AbortController().signal,
+      metadata: () => undefined,
+      ask: async () => undefined,
+    }
+
+    await bridge.execute(
+      { input: "先讨论问题", agent_name: "research67", mode: "chat", conversation: "continue" },
+      context,
+    )
+    await bridge.execute(
+      { input: "继续并查看产物", agent_name: "research67", mode: "chat", conversation: "continue" },
+      context,
+    )
+    await bridge.execute(
+      { input: "开始新课题", agent_name: "research67", mode: "chat", conversation: "new" },
+      context,
+    )
+
+    expect(bodies[0]).toMatchObject({ thread_id: "", context: {} })
+    expect(bodies[1]).toMatchObject({ thread_id: "th_research", context: { run_id: "run_1" } })
+    expect(bodies[2]).toMatchObject({ thread_id: "", context: {} })
+    expect(JSON.stringify(bodies)).not.toContain("/host/workspace")
+  } finally {
+    globalThis.fetch = originalFetch
+    process.env.HOME = originalHome
+    await rm(home, { recursive: true, force: true })
+  }
+})