|
@@ -0,0 +1,136 @@
|
|
|
|
|
+/// <reference path="../env.d.ts" />
|
|
|
|
|
+import { tool } from "@opencode-ai/plugin"
|
|
|
|
|
+
|
|
|
|
|
+const AGENTPAAS_URL = process.env.AGENTPAAS_URL ?? "http://127.0.0.1:8000"
|
|
|
|
|
+
|
|
|
|
|
+type SSEEvent = { event: string; data: unknown }
|
|
|
|
|
+
|
|
|
|
|
+export default tool({
|
|
|
|
|
+ description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
|
|
|
|
|
+
|
|
|
|
|
+参数说明:
|
|
|
|
|
+- agent_id:远端 AgentPaaS 智能体 ID(ag_ 开头),必填
|
|
|
|
|
+- input:要执行的用户任务指令
|
|
|
|
|
+- mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
|
|
|
|
|
+- target_subagent:mode=edit 时的目标子智能体 ID
|
|
|
|
|
+- work_dir:覆盖工作目录(默认使用当前 opencode 项目目录)
|
|
|
|
|
+- thread_id:会话线程 ID,不传则新建
|
|
|
|
|
+- parameters:额外参数,原样传给远端智能体
|
|
|
|
|
+
|
|
|
|
|
+适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
|
|
|
|
|
+ args: {
|
|
|
|
|
+ input: tool.schema.string().describe("用户任务指令"),
|
|
|
|
|
+ agent_id: tool.schema.string().describe("远端 AgentPaaS 智能体 ID(ag_ 开头)"),
|
|
|
|
|
+ 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,不传则新建"),
|
|
|
|
|
+ parameters: tool.schema
|
|
|
|
|
+ .record(tool.schema.string(), tool.schema.any())
|
|
|
|
|
+ .optional()
|
|
|
|
|
+ .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")
|
|
|
|
|
+ }
|
|
|
|
|
+ if (args.mode === "edit" && !args.target_subagent) {
|
|
|
|
|
+ throw new Error("mode=edit 时必须提供 target_subagent")
|
|
|
|
|
+ }
|
|
|
|
|
+ const workDir = args.work_dir ?? context.worktree ?? context.directory
|
|
|
|
|
+ const url = `${AGENTPAAS_URL}/api/v1/agents/${args.agent_id}/run/stream`
|
|
|
|
|
+ let resp: Response
|
|
|
|
|
+ try {
|
|
|
|
|
+ resp = await fetch(url, {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { Authorization: `Bearer ${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: args.thread_id ?? "",
|
|
|
|
|
+ parameters: args.parameters ?? {},
|
|
|
|
|
+ context: { work_dir: workDir },
|
|
|
|
|
+ }),
|
|
|
|
|
+ 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[] = []
|
|
|
|
|
+ let done: Record<string, unknown> | null = null
|
|
|
|
|
+ let sawCancelled = false
|
|
|
|
|
+ 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>
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 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,
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 }
|
|
|
|
|
+ return {
|
|
|
|
|
+ title: `AgentPaaS ${args.agent_id}(失败)`,
|
|
|
|
|
+ output: `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
|
|
|
|
|
+ metadata,
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+async function* parseSSE(body: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
|
|
|
|
|
+ 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")
|
|
|
|
|
+ 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<string, unknown>).message)
|
|
|
|
|
+ return String(data)
|
|
|
|
|
+}
|