|
|
@@ -1,14 +1,22 @@
|
|
|
/// <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"
|
|
|
+import { pendingRequest, clearPendingRequest, recoverRequest, cancelAndObserve } from "../lib/recovery"
|
|
|
+import {
|
|
|
+ loadAgentPaaSConfig,
|
|
|
+ resolveAgentID,
|
|
|
+ digest,
|
|
|
+ canonicalJSON,
|
|
|
+ requestContext,
|
|
|
+ loadConversationState,
|
|
|
+ saveConversationState,
|
|
|
+ formatRunResult,
|
|
|
+ scientificLabel,
|
|
|
+ requestJSON,
|
|
|
+ verifyAgent,
|
|
|
+ trackRun,
|
|
|
+} from "../lib/agentpaas"
|
|
|
|
|
|
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
|
|
|
@@ -26,11 +34,12 @@ type ProgressStage = {
|
|
|
}
|
|
|
|
|
|
export default tool({
|
|
|
- description: `把任务转发给远端 AgentPaaS 智能体执行,等待完成后返回最终答案。
|
|
|
+ description: `把任务转发给 PaaS 目录中的智能体执行,返回真实运行状态、答案和产物。
|
|
|
|
|
|
参数说明:
|
|
|
-- agent_name:远端 AgentPaaS 智能体名称,工具会动态解析当前 ID,必填
|
|
|
+- agent_id / agent_name:至少填写一个;优先使用目录返回的 ID,同名时必须使用 ID
|
|
|
- input:要执行的用户任务指令
|
|
|
+- input_file_ids:已通过 agentpaas_upload 上传的文件 ID,不接受客户端路径
|
|
|
- mode:iterate(多轮迭代,默认)/ chat(单轮)/ edit(单轮并指定目标子智能体)
|
|
|
- target_subagent:mode=edit 时的目标子智能体 ID
|
|
|
- work_dir:可选的服务端可见工作目录;留空时由 AgentPaaS 创建隔离 workspace
|
|
|
@@ -38,10 +47,20 @@ export default tool({
|
|
|
- thread_id:可选的远端会话线程 ID;显式值优先于自动映射
|
|
|
- parameters:额外参数,原样传给远端智能体
|
|
|
|
|
|
-适用于调用远端科研流程智能体(如 research-67 系列)的场景。`,
|
|
|
+适用于 PaaS 目录中的本地或远端智能体,包括科研、数据分析、软件工程和问答等已注册能力。
|
|
|
+查询、继续观察或控制已有任务请用 agentpaas_task,不要再次提交。`,
|
|
|
args: {
|
|
|
input: tool.schema.string().describe("用户任务指令"),
|
|
|
- agent_name: tool.schema.string().describe("远端 AgentPaaS 智能体名称"),
|
|
|
+ background: tool.schema
|
|
|
+ .boolean()
|
|
|
+ .optional()
|
|
|
+ .describe("长任务设为 true:受理并保存编号后返回,在服务端继续执行;用 task/watch 查看,用 cancel 停止"),
|
|
|
+ agent_name: tool.schema.string().optional().describe("远端 AgentPaaS 智能体名称;与 agent_id 至少填写一个"),
|
|
|
+ agent_id: tool.schema.string().optional().describe("目录返回的稳定 ID;同名智能体用 ID 选择"),
|
|
|
+ input_file_ids: tool.schema
|
|
|
+ .array(tool.schema.string())
|
|
|
+ .optional()
|
|
|
+ .describe("agentpaas_upload 返回的 file_id;不接受客户端路径"),
|
|
|
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 创建"),
|
|
|
@@ -54,6 +73,12 @@ export default tool({
|
|
|
.optional()
|
|
|
.describe("续跑时选取的上一轮产物 ID;省略时复用上一轮已登记产物"),
|
|
|
idempotency_key: tool.schema.string().optional().describe("同一请求重试时复用;不同请求必须使用不同键"),
|
|
|
+ max_cost_usd: tool.schema
|
|
|
+ .number()
|
|
|
+ .positive()
|
|
|
+ .finite()
|
|
|
+ .optional()
|
|
|
+ .describe("仅支持预检查声明可用的 CEK 单智能体;按服务端计量在调用结算后检查,非供应商硬限额"),
|
|
|
parameters: tool.schema
|
|
|
.record(tool.schema.string(), tool.schema.any())
|
|
|
.optional()
|
|
|
@@ -64,7 +89,13 @@ export default tool({
|
|
|
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 agentID = await resolveAgentID(config, args.agent_id || args.agent_name || "", context.abort)
|
|
|
+ const agentName = args.agent_name || agentID
|
|
|
+ context.metadata({ title: `PaaS ${agentName} · 执行前检查`, metadata: { agent_id: agentID, status: "checking" } })
|
|
|
+ const preflight = await verifyAgent(config, agentID, context.abort, undefined, true)
|
|
|
+ if (preflight.blocked) throw new Error(preflight.summary)
|
|
|
+ if (args.max_cost_usd !== undefined && preflight.client_budget_supported !== true)
|
|
|
+ throw new Error("此智能体未声明支持客户端费用预算,任务未提交;不能承诺限额会生效。")
|
|
|
const conversation = args.conversation ?? "continue"
|
|
|
const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
|
|
|
const savedConversation =
|
|
|
@@ -74,18 +105,37 @@ export default tool({
|
|
|
conversation === "continue" && (!args.thread_id?.trim() || args.thread_id.trim() === savedConversation?.threadID)
|
|
|
? savedConversation?.runID || ""
|
|
|
: ""
|
|
|
- const intent = digest(canonicalJSON({ ...args, idempotency_key: undefined, conversation }))
|
|
|
+ // Observation mode does not change the admitted business task.
|
|
|
+ const intent = digest(canonicalJSON({ ...args, background: undefined, 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 })
|
|
|
+ const rememberRecovered = async (run: Record<string, unknown>) => {
|
|
|
+ const runID = getString(run.run_id),
|
|
|
+ remoteThreadID = getString(run.session_id) || getString(run.thread_id)
|
|
|
+ if (!runID || !remoteThreadID) return
|
|
|
+ const latest = await loadConversationState(context.sessionID, conversationKey)
|
|
|
+ if (!latest || latest.runID === admission.runID || latest.runID === runID)
|
|
|
+ await saveConversationState(context.sessionID, conversationKey, { threadID: remoteThreadID, runID })
|
|
|
+ }
|
|
|
+ const inputFiles = await Promise.all(
|
|
|
+ (args.input_file_ids ?? []).map(async (id) => {
|
|
|
+ const file = await requestJSON(config, `/files/${encodeURIComponent(id)}`, context.abort)
|
|
|
+ if (file.file_id !== id || file.status !== "available") throw new Error(`输入文件不可用:${id}`)
|
|
|
+ return file
|
|
|
+ }),
|
|
|
+ )
|
|
|
// 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
|
|
|
+ await pendingRequest(config, agentID, idempotencyKey)
|
|
|
+ const admissionAbort = new AbortController()
|
|
|
+ const admissionTimer = setTimeout(() => admissionAbort.abort(), 30000)
|
|
|
try {
|
|
|
resp = await fetch(url, {
|
|
|
method: "POST",
|
|
|
@@ -97,10 +147,15 @@ export default tool({
|
|
|
},
|
|
|
body: JSON.stringify({
|
|
|
input: args.input,
|
|
|
+ cancel_on_disconnect: false,
|
|
|
+ ...(inputFiles.length ? { input_files: inputFiles } : {}),
|
|
|
mode: args.mode,
|
|
|
target_subagent: args.target_subagent ?? "",
|
|
|
thread_id: admission.threadID,
|
|
|
- parameters: args.parameters ?? {},
|
|
|
+ parameters: {
|
|
|
+ ...args.parameters,
|
|
|
+ ...(args.max_cost_usd !== undefined ? { client_budget_usd: args.max_cost_usd } : {}),
|
|
|
+ },
|
|
|
context: {
|
|
|
...(workDir ? { work_dir: workDir } : {}),
|
|
|
...(admission.runID ? { run_id: admission.runID } : {}),
|
|
|
@@ -109,49 +164,92 @@ export default tool({
|
|
|
...(args.input_artifact_ids ? { input_artifact_ids: args.input_artifact_ids } : {}),
|
|
|
},
|
|
|
}),
|
|
|
- signal: context.abort,
|
|
|
+ signal: AbortSignal.any([context.abort, admissionAbort.signal]),
|
|
|
+ redirect: "error",
|
|
|
})
|
|
|
} catch (err) {
|
|
|
- throw new Error(`AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)}`)
|
|
|
+ clearTimeout(admissionTimer)
|
|
|
+ if (!context.abort.aborted) {
|
|
|
+ const recovered = await recoverRequest(config, agentID, idempotencyKey, context.abort).catch(() => undefined)
|
|
|
+ if (recovered) {
|
|
|
+ await rememberRecovered(recovered).catch(() => undefined)
|
|
|
+ return formatRunResult(agentName, recovered, { agent_id: agentID, admission_recovered: true })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new Error(
|
|
|
+ `AgentPaaS 请求失败: ${err instanceof Error ? err.message : String(err)};idempotency_key=${idempotencyKey}。可能已受理,请查询原任务;不得换键自动重跑。`,
|
|
|
+ )
|
|
|
}
|
|
|
if (!resp.ok) {
|
|
|
+ clearTimeout(admissionTimer)
|
|
|
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")
|
|
|
+ if (!resp.body) {
|
|
|
+ clearTimeout(admissionTimer)
|
|
|
+ throw new Error("AgentPaaS 响应没有 body")
|
|
|
+ }
|
|
|
|
|
|
const errors: string[] = []
|
|
|
+ const warnings: string[] = []
|
|
|
const liveMetadata: Record<string, unknown> = {
|
|
|
- agent_name: args.agent_name,
|
|
|
+ agent_name: agentName,
|
|
|
agent_id: agentID,
|
|
|
idempotency_key: idempotencyKey,
|
|
|
status: "running",
|
|
|
+ preflight,
|
|
|
}
|
|
|
let done: Record<string, unknown> | null = null
|
|
|
let startedRunID = ""
|
|
|
let sawCancelled = false
|
|
|
let lastThinkUpdate = 0
|
|
|
const progress = createResearch67Progress()
|
|
|
+ const researchUI = /research|科研/i.test(agentName) || context.agent.startsWith("research")
|
|
|
const updateMetadata = (title: string, next: Record<string, unknown>) => {
|
|
|
Object.assign(liveMetadata, next)
|
|
|
- const snapshot = progress.snapshot()
|
|
|
+ const snapshot = researchUI ? progress.snapshot() : undefined
|
|
|
context.metadata({
|
|
|
- title: snapshot?.title ?? title,
|
|
|
+ title: snapshot?.title ?? (researchUI ? title : title.replaceAll("Research67", agentName)),
|
|
|
metadata: { ...liveMetadata, ...(snapshot ? { research67_progress: snapshot } : {}) },
|
|
|
})
|
|
|
}
|
|
|
- let remoteCancellation: Promise<void> | undefined
|
|
|
+ let remoteCancellation: Promise<unknown> | undefined
|
|
|
const cancelOnAbort = () => {
|
|
|
+ if (args.background) return
|
|
|
if (!startedRunID || remoteCancellation) return
|
|
|
- remoteCancellation = cancelRemoteRun(config, agentID, startedRunID).catch(() => undefined)
|
|
|
+ remoteCancellation = cancelAndObserve(config, agentID, startedRunID).then(
|
|
|
+ (result) => {
|
|
|
+ updateMetadata("PaaS 取消状态", result)
|
|
|
+ },
|
|
|
+ (error) => {
|
|
|
+ updateMetadata("PaaS 取消未确认,请查询原任务", {
|
|
|
+ cancellation_status: "unconfirmed",
|
|
|
+ cancellation_error: String(error),
|
|
|
+ })
|
|
|
+ },
|
|
|
+ )
|
|
|
}
|
|
|
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") {
|
|
|
+ clearTimeout(admissionTimer)
|
|
|
startedRunID = getString(data?.run_id)
|
|
|
+ if (!startedRunID) throw new Error("PaaS started 事件缺少 run_id;不能确认任务受理情况")
|
|
|
const remoteThreadID = getString(data?.thread_id) || getString(data?.session_id)
|
|
|
+ await trackRun(config, {
|
|
|
+ agent_id: agentID,
|
|
|
+ agent_name: agentName,
|
|
|
+ run_id: startedRunID,
|
|
|
+ thread_id: remoteThreadID,
|
|
|
+ })
|
|
|
+ .then(() => clearPendingRequest(config, agentID, idempotencyKey))
|
|
|
+ .catch(() => {
|
|
|
+ warnings.push(
|
|
|
+ `本机任务索引写入失败,请保存 agent_id=${agentID}、run_id=${startedRunID},稍后用 status 查询。`,
|
|
|
+ )
|
|
|
+ })
|
|
|
if (remoteThreadID && startedRunID) {
|
|
|
const latest = await loadConversationState(context.sessionID, conversationKey)
|
|
|
if (admission.created || !latest || latest.runID === admission.runID || latest.runID === startedRunID) {
|
|
|
@@ -167,6 +265,11 @@ export default tool({
|
|
|
thread_id: data?.thread_id,
|
|
|
latest_event: ev.event,
|
|
|
})
|
|
|
+ if (args.background) {
|
|
|
+ // Breaking cancels only this reader. The worker owns the durable run.
|
|
|
+ done = { ...data, status: "queued", run_id: startedRunID }
|
|
|
+ break
|
|
|
+ }
|
|
|
} 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))
|
|
|
@@ -216,6 +319,17 @@ export default tool({
|
|
|
const reason = getString(data?.reason)
|
|
|
const input = getString(data?.input)
|
|
|
if (!runID) throw new Error("AgentPaaS confirm_required 事件缺少 run_id")
|
|
|
+ const approvalID = getString(data?.approval_id)
|
|
|
+ if (!approvalID) throw new Error("PaaS 确认事件缺少 approval_id;请升级执行服务,不能批准未绑定的操作。")
|
|
|
+ // Replayed journals can contain already-resolved confirmations.
|
|
|
+ // Only ask about the operation that is still pending on the worker.
|
|
|
+ const current = await requestJSON(
|
|
|
+ config,
|
|
|
+ `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(runID)}`,
|
|
|
+ context.abort,
|
|
|
+ )
|
|
|
+ const pending = asRecord(current.pending_confirmation) || asRecord(current.pending_approval)
|
|
|
+ if (!pending || pending.approval_id !== approvalID || pending.status !== "pending") continue
|
|
|
|
|
|
progress.note(`等待确认 · ${toolName}`)
|
|
|
updateMetadata(`Research67 正在等待确认:${toolName}`, {
|
|
|
@@ -229,14 +343,21 @@ export default tool({
|
|
|
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 },
|
|
|
+ patterns: [`${agentID}:${runID}:${approvalID}`],
|
|
|
+ always: [],
|
|
|
+ metadata: {
|
|
|
+ run_id: runID,
|
|
|
+ approval_id: approvalID,
|
|
|
+ agent_name: agentName,
|
|
|
+ tool: toolName,
|
|
|
+ reason,
|
|
|
+ input,
|
|
|
+ },
|
|
|
})
|
|
|
} catch {
|
|
|
approved = false
|
|
|
}
|
|
|
- await resolveConfirmation(config, agentID, runID, approved, context.abort, getString(data?.approval_id))
|
|
|
+ await resolveConfirmation(config, agentID, runID, approved, context.abort, approvalID)
|
|
|
progress.note(approved ? "确认已通过,继续执行" : "确认被拒绝,等待流程处理")
|
|
|
updateMetadata(`Research67 已${approved ? "批准" : "拒绝"} ${toolName}`, {
|
|
|
latest_event: "confirmation_resolved",
|
|
|
@@ -312,8 +433,36 @@ export default tool({
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
+ } catch (error) {
|
|
|
+ if (context.abort.aborted) throw error
|
|
|
+ errors.push(error instanceof Error ? error.message : String(error))
|
|
|
} finally {
|
|
|
+ clearTimeout(admissionTimer)
|
|
|
context.abort.removeEventListener("abort", cancelOnAbort)
|
|
|
+ if (remoteCancellation) await remoteCancellation
|
|
|
+ }
|
|
|
+ if (!done && !startedRunID && !context.abort.aborted) {
|
|
|
+ done = (await recoverRequest(config, agentID, idempotencyKey, context.abort).catch(() => undefined)) ?? null
|
|
|
+ if (done) {
|
|
|
+ liveMetadata.admission_recovered = true
|
|
|
+ await rememberRecovered(done).catch(() =>
|
|
|
+ warnings.push("任务已找回,会话映射保存失败;继续修订前请 select 此 run_id。"),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if ((!done || args.background) && startedRunID && !context.abort.aborted) {
|
|
|
+ // A dropped transport is not a failed run. Query, never resubmit.
|
|
|
+ const snapshot = await requestJSON(
|
|
|
+ config,
|
|
|
+ `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(startedRunID)}`,
|
|
|
+ context.abort,
|
|
|
+ ).catch(() => null)
|
|
|
+ if (snapshot) done = snapshot
|
|
|
+ if (snapshot && !args.background) liveMetadata.connection_recovered = true
|
|
|
+ if (!snapshot && args.background) {
|
|
|
+ done = { ...done, status: "unknown" }
|
|
|
+ warnings.push("任务已受理,最新状态暂不可读取;请用原 run_id 恢复查看,不要重复提交。")
|
|
|
+ }
|
|
|
}
|
|
|
if (!done) {
|
|
|
const hint = sawCancelled ? "(收到 cancelled 事件)" : "(未收到 done 事件,连接可能中断)"
|
|
|
@@ -322,12 +471,12 @@ export default tool({
|
|
|
)
|
|
|
}
|
|
|
const status = typeof done.status === "string" ? done.status : "unknown"
|
|
|
- if (status === "completed" || status === "failed") progress.stopUnverified()
|
|
|
+ if (["completed", "partial", "needs_clarification", "failed"].includes(status)) progress.stopUnverified()
|
|
|
if (status === "paused") progress.pause()
|
|
|
if (status === "cancelled") progress.cancel()
|
|
|
- const progressMetadata = progress.snapshot()
|
|
|
+ const progressMetadata = researchUI ? progress.snapshot() : undefined
|
|
|
const metadata = {
|
|
|
- agent_name: args.agent_name,
|
|
|
+ agent_name: agentName,
|
|
|
agent_id: agentID,
|
|
|
idempotency_key: idempotencyKey,
|
|
|
run_id: done.run_id ?? liveMetadata.run_id,
|
|
|
@@ -341,254 +490,30 @@ export default tool({
|
|
|
workspace_path: done.workspace_path ?? liveMetadata.workspace_path,
|
|
|
error_code: liveMetadata.error_code,
|
|
|
error_message: liveMetadata.error_message,
|
|
|
+ connection_recovered: liveMetadata.connection_recovered,
|
|
|
+ admission_recovered: liveMetadata.admission_recovered,
|
|
|
checkpoint_id: done.checkpoint_id ?? liveMetadata.checkpoint_id,
|
|
|
pending_approval: done.pending_approval ?? liveMetadata.pending_approval,
|
|
|
+ preflight,
|
|
|
+ background: args.background === true,
|
|
|
+ warnings,
|
|
|
...(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,
|
|
|
- }
|
|
|
+ const result = formatRunResult(agentName, { ...done, ...metadata, output: done.output }, metadata)
|
|
|
return {
|
|
|
- title: `AgentPaaS ${args.agent_name}(失败)`,
|
|
|
+ ...result,
|
|
|
output: [
|
|
|
- `[AgentPaaS 执行失败] ${errors.join("; ") || String(done.output ?? "") || "未知错误"}`,
|
|
|
- metadata.workspace_path
|
|
|
- ? `已有产物保留在:${metadata.workspace_path}`
|
|
|
- : "已有产物已保留;请根据 run_id 查询工作区。",
|
|
|
- "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
|
|
|
- ].join("\n"),
|
|
|
- metadata,
|
|
|
+ args.background ? "后台任务已受理;关闭当前观察不会取消执行。重新打开后用 /paas-tasks 找回。" : "",
|
|
|
+ preflight.summary,
|
|
|
+ result.output,
|
|
|
+ ...warnings,
|
|
|
+ ]
|
|
|
+ .filter(Boolean)
|
|
|
+ .join("\n"),
|
|
|
}
|
|
|
},
|
|
|
})
|
|
|
|
|
|
-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,
|
|
|
@@ -609,29 +534,15 @@ async function resolveConfirmation(
|
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
|
"Content-Type": "application/json",
|
|
|
},
|
|
|
- body: JSON.stringify({ approved, ...(remote && approvalID ? { approval_id: approvalID } : {}) }),
|
|
|
+ body: JSON.stringify({ approved, approval_id: approvalID }),
|
|
|
signal,
|
|
|
+ redirect: "error",
|
|
|
})
|
|
|
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()
|