accept-agentpaas.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { mkdir, rename } from "node:fs/promises"
  2. import path from "node:path"
  3. import { parseArgs } from "node:util"
  4. import { asRecord, digest, getString, loadAgentPaaSConfig, canonicalJSON } from "../.opencode/lib/agentpaas"
  5. import run from "../.opencode/tool/agentpaas_run"
  6. import { agents, check, upload, task, download } from "../.opencode/tool/agentpaas"
  7. import { evaluateAcceptance, type AcceptanceCase } from "./lib/agentpaas-acceptance"
  8. const suite = path.resolve(import.meta.dir, "../evals/agentpaas-acceptance")
  9. const options = parseArgs({
  10. options: {
  11. run: { type: "boolean" },
  12. resume: { type: "boolean" },
  13. catalog: { type: "boolean" },
  14. case: { type: "string" },
  15. "agent-id": { type: "string" },
  16. output: { type: "string" },
  17. "model-label": { type: "string" },
  18. "wait-seconds": { type: "string", default: "120" },
  19. "probe-model": { type: "boolean" },
  20. },
  21. }).values
  22. const cases = (await Bun.file(path.join(suite, "cases.json")).json()) as AcceptanceCase[]
  23. const context = {
  24. sessionID: crypto.randomUUID(),
  25. messageID: "acceptance",
  26. agent: "paas",
  27. directory: suite,
  28. worktree: suite,
  29. abort: new AbortController().signal,
  30. metadata() {},
  31. async ask(request: { permission: string }) {
  32. if (request.permission !== "agentpaas_upload" && request.permission !== "agentpaas_download")
  33. throw new Error("验收脚本不自动批准智能体工具操作,请从 OpenCode 查看并明确决定。")
  34. },
  35. }
  36. function structured(result: Awaited<ReturnType<typeof run.execute>>) {
  37. if (typeof result === "string" || !result.metadata) throw new Error("工具未返回结构化状态")
  38. return result
  39. }
  40. if (options.catalog) {
  41. console.log(structured(await agents.execute({}, context)).output)
  42. } else if (!options.run && !options.resume) {
  43. console.log(
  44. JSON.stringify(
  45. {
  46. mode: "plan_only",
  47. cases,
  48. instruction:
  49. "使用 --catalog 选择实际智能体;--run --case data --agent-id ag_... --model-label 实际模型版本 --output 空目录 启动一个用例。--resume --output 原目录 只观察同一任务。",
  50. },
  51. null,
  52. 2,
  53. ),
  54. )
  55. } else {
  56. if (options.run && options.resume) throw new Error("--run 与 --resume 不能同时使用")
  57. if (!options.output) throw new Error("必须指定 --output")
  58. const wait = Number(options["wait-seconds"])
  59. if (!Number.isInteger(wait) || wait < 0 || wait > 900)
  60. throw new Error("wait-seconds 必须为 0–900;到期只停止观察,不取消服务端任务")
  61. const config = await loadAgentPaaSConfig()
  62. const scope = digest(canonicalJSON([config.url, config.apiKey]))
  63. const directory = path.resolve(options.output)
  64. const receiptPath = path.join(directory, "receipt.json")
  65. const previous = options.resume ? asRecord(await Bun.file(receiptPath).json()) : undefined
  66. const sample = cases.find((item) => item.id === (previous?.case_id || options.case))
  67. if (!sample) throw new Error("请选择 data、qa、software 或 research 用例")
  68. if (previous && previous.scope !== scope) throw new Error("当前服务或账号与原任务不同;停止恢复")
  69. if (!previous && (!options["agent-id"] || !options["model-label"]))
  70. throw new Error("首次运行必须指定 --agent-id 和 --model-label;标签需与部署配置核对")
  71. if (!previous) await mkdir(directory, { recursive: false, mode: 0o700 })
  72. const receipt: Record<string, unknown> = previous ?? {
  73. case_id: sample.id,
  74. agent_id: options["agent-id"],
  75. model_label: options["model-label"],
  76. model_label_source: "operator_declared",
  77. execution_mode: sample.mode,
  78. scope,
  79. idempotency_key: `acceptance-${crypto.randomUUID()}`,
  80. session_id: context.sessionID,
  81. started_at: new Date().toISOString(),
  82. phase: "prepared",
  83. }
  84. context.sessionID = getString(receipt.session_id)
  85. const save = async () => {
  86. await Bun.write(`${receiptPath}.tmp`, JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600 })
  87. await rename(`${receiptPath}.tmp`, receiptPath)
  88. }
  89. await save()
  90. try {
  91. const agentID = getString(receipt.agent_id)
  92. if (!previous) {
  93. const probe = structured(
  94. await check.execute({ agent_id: agentID, check_model: options["probe-model"] ?? false }, context),
  95. )
  96. receipt.preflight = probe.metadata?.preflight
  97. await save()
  98. if (asRecord(receipt.preflight)?.blocked) throw new Error(probe.output)
  99. const files = structured(
  100. await upload.execute({ paths: sample.files.map((file) => path.join(suite, file)) }, context),
  101. )
  102. receipt.input_file_ids = (files.metadata?.files as Array<{ file_id: string }>).map((file) => file.file_id)
  103. receipt.phase = "submitting"
  104. await save()
  105. const accepted = structured(
  106. await run.execute(
  107. {
  108. agent_id: agentID,
  109. input: sample.prompt,
  110. input_file_ids: receipt.input_file_ids as string[],
  111. idempotency_key: getString(receipt.idempotency_key),
  112. background: true,
  113. conversation: "new",
  114. mode: sample.mode,
  115. },
  116. context,
  117. ),
  118. )
  119. receipt.run_id = accepted.metadata?.run_id
  120. receipt.phase = "observing"
  121. await save()
  122. }
  123. if (!receipt.run_id && previous && receipt.phase === "submitting") {
  124. const recovered = structured(
  125. await task.execute(
  126. { agent_id: agentID, action: "recover", idempotency_key: getString(receipt.idempotency_key) },
  127. context,
  128. ),
  129. )
  130. if (recovered.metadata?.run_id) {
  131. receipt.run_id = recovered.metadata.run_id
  132. receipt.phase = "observing"
  133. await save()
  134. }
  135. }
  136. const runID = getString(receipt.run_id)
  137. if (!runID) throw new Error("尚无受理编号;请先用 /paas-tasks 或 history 核查提交结果,禁止重新启动用例")
  138. const deadline = Date.now() + wait * 1000
  139. let result = structured(await task.execute({ agent_id: agentID, run_id: runID, action: "status" }, context))
  140. while (["running", "queued", "resuming"].includes(getString(result.metadata?.status)) && Date.now() < deadline) {
  141. result = structured(
  142. await task.execute(
  143. {
  144. agent_id: agentID,
  145. run_id: runID,
  146. action: "watch",
  147. wait_seconds: Math.min(30, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))),
  148. },
  149. context,
  150. ),
  151. )
  152. if (Array.isArray(result.metadata?.warnings) && result.metadata.warnings.length) break
  153. }
  154. const metadata = result.metadata!
  155. const artifacts = (Array.isArray(metadata.artifacts) ? metadata.artifacts : []).flatMap((item) => {
  156. const row = asRecord(item)
  157. return row && getString(row.artifact_id) ? [row] : []
  158. })
  159. const downloads = asRecord(receipt.downloads) ?? {}
  160. receipt.downloads = downloads
  161. for (const item of artifacts.filter((item) => sample.required_artifacts.includes(getString(item.name)))) {
  162. const id = getString(item.artifact_id)
  163. if (typeof downloads[id] === "string" && (await Bun.file(downloads[id]).exists())) continue
  164. const fetched = structured(
  165. await download.execute({ agent_id: agentID, run_id: runID, artifact_id: id }, { ...context, directory }),
  166. )
  167. downloads[id] = fetched.metadata?.local_path
  168. await save()
  169. }
  170. const target = artifacts.filter((item) => item.name === sample.artifact)
  171. const targetPath = target.length === 1 ? getString(downloads[getString(target[0].artifact_id)]) : ""
  172. const status = getString(metadata.status)
  173. const terminal = ["completed", "partial", "failed", "cancelled", "needs_clarification"].includes(status)
  174. const content = targetPath ? await Bun.file(targetPath).text() : undefined
  175. const contents: Record<string, string> = {}
  176. for (const name of sample.required_artifacts) {
  177. const matches = artifacts.filter((item) => item.name === name)
  178. const local = matches.length === 1 ? getString(downloads[getString(matches[0].artifact_id)]) : ""
  179. if (local) contents[name] = await Bun.file(local).text()
  180. }
  181. const inputs = Object.fromEntries(
  182. await Promise.all(sample.files.map(async (name) => [name, await Bun.file(path.join(suite, name)).text()])),
  183. )
  184. const evaluation = terminal
  185. ? evaluateAcceptance(
  186. sample,
  187. artifacts.map((item) => getString(item.name)),
  188. content,
  189. contents,
  190. inputs,
  191. )
  192. : null
  193. const summary = {
  194. case_id: sample.id,
  195. agent_id: agentID,
  196. run_id: runID,
  197. model_label: receipt.model_label,
  198. execution_mode: receipt.execution_mode,
  199. usage_complete: asRecord(metadata.usage)?.usage_complete === true,
  200. cost_complete: asRecord(metadata.usage)?.cost_complete === true,
  201. status,
  202. observation_complete: terminal,
  203. wall_seconds_since_submission: (Date.now() - Date.parse(getString(receipt.started_at))) / 1000,
  204. total_tokens: metadata.total_tokens ?? asRecord(metadata.usage)?.total_tokens ?? null,
  205. cost_usd_reported: metadata.cost_usd ?? asRecord(metadata.usage)?.cost_usd ?? null,
  206. usage_source: "execution_service; null means unreported; zero is not a verified free-cost claim",
  207. automatic_checks: evaluation,
  208. correction_rounds: 0,
  209. quality_score: null,
  210. note: terminal
  211. ? "自动检查只验证文件与预定值;人工项未评审,不计算综合质量总分。"
  212. : "任务未结束或等待操作。到期不取消、不重跑;处理审批后用 --resume 继续观察。",
  213. }
  214. await Bun.write(path.join(directory, "result.json"), JSON.stringify(summary, null, 2) + "\n", { mode: 0o600 })
  215. await Bun.write(path.join(directory, "result.md"), result.output + "\n", { mode: 0o600 })
  216. receipt.phase = terminal ? "finished" : "pending"
  217. await save()
  218. console.log(JSON.stringify(summary, null, 2))
  219. } catch (error) {
  220. receipt.error = String(error).replaceAll(config.apiKey, "[redacted]")
  221. await save()
  222. console.error(receipt.error)
  223. process.exitCode = 1
  224. }
  225. }