agentpaas.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import { tool } from "@opencode-ai/plugin"
  2. import {
  3. recoverPendingRequests,
  4. recoverRequest,
  5. observation,
  6. saveObservation,
  7. cancelAndObserve,
  8. cancellationStatus,
  9. } from "../lib/recovery"
  10. import { mkdir, open, realpath, stat } from "node:fs/promises"
  11. import path from "node:path"
  12. import { setTimeout } from "node:timers/promises"
  13. import { pathToFileURL } from "node:url"
  14. import {
  15. asRecord,
  16. digest,
  17. formatRunResult,
  18. getString,
  19. isSupportedAgentID,
  20. listAgents,
  21. loadAgentPaaSConfig,
  22. loadConversationState,
  23. saveConversationState,
  24. requestJSON,
  25. resolveAgentID,
  26. verifyAgent,
  27. trackRun,
  28. trackedRuns,
  29. } from "../lib/agentpaas"
  30. const selector = {
  31. agent_id: tool.schema.string().optional().describe("目录返回的智能体 ID,优先使用"),
  32. agent_name: tool.schema.string().optional().describe("智能体名称;同名时必须用 ID"),
  33. }
  34. export const agents = tool({
  35. description: "检查 PaaS 连接并列出当前账号可用的全部智能体、用途和本地/远端运行位置。不执行任务。",
  36. args: {},
  37. async execute(_args, context) {
  38. const config = await loadAgentPaaSConfig()
  39. const catalog = await listAgents(config, context.abort)
  40. return {
  41. title: "PaaS 智能体目录",
  42. output: [
  43. `已连接 ${config.url};发现状态:${getString(catalog.discovery.status)}`,
  44. ...catalog.agents.map(
  45. (agent) =>
  46. `${agent.name} | ${agent.id} | ${agent.runtime_mode ?? "unknown"} | ${agent.status ?? "unknown"}\n${getString(agent.description)}`,
  47. ),
  48. ...(!catalog.agents.length ? ["当前列表为空;远端发现未完成或不可用时,不能据此判断平台没有智能体。"] : []),
  49. ].join("\n"),
  50. metadata: { server: config.url, ...catalog },
  51. }
  52. },
  53. })
  54. export const check = tool({
  55. description:
  56. "检查智能体的执行服务、skill、MCP 与知识绑定。不创建研究任务。默认不调用模型;check_model=true 会产生一次或多次小额模型请求,knowledge_query 可验证检索。未验证不等于不可用。",
  57. args: {
  58. ...selector,
  59. check_model: tool.schema.boolean().optional(),
  60. knowledge_query: tool.schema.string().max(500).optional(),
  61. },
  62. async execute(args, context) {
  63. const config = await loadAgentPaaSConfig()
  64. const agentID = await resolveAgentID(config, args.agent_id || args.agent_name || "", context.abort)
  65. const report = await verifyAgent(config, agentID, context.abort, {
  66. check_model: args.check_model ?? false,
  67. knowledge_query: args.knowledge_query ?? "",
  68. })
  69. return { title: "PaaS 能力检查", output: report.summary, metadata: { agent_id: agentID, preflight: report } }
  70. },
  71. })
  72. export const tasks = tool({
  73. description:
  74. "找回本机此 PaaS 账号登记过的最近任务,跨 OpenCode 会话刷新服务端真实状态。只观察,不重跑、不取消、不自动批准。未登记的更早任务用 task/history 查询。",
  75. args: { limit: tool.schema.number().int().min(1).max(50).optional() },
  76. async execute(args, context) {
  77. const config = await loadAgentPaaSConfig()
  78. const warnings = await recoverPendingRequests(config, context.abort)
  79. const saved = await trackedRuns(config, args.limit ?? 20)
  80. const results: Array<ReturnType<typeof formatRunResult>> = []
  81. // Bound concurrency so reopening a client cannot flood the control plane.
  82. for (let offset = 0; offset < saved.length; offset += 5) {
  83. results.push(
  84. ...(await Promise.all(
  85. saved.slice(offset, offset + 5).map(async (row) => {
  86. const snapshot = await requestJSON(
  87. config,
  88. `/agents/${encodeURIComponent(row.agent_id)}/runs/${encodeURIComponent(row.run_id)}`,
  89. context.abort,
  90. ).catch((error): Record<string, unknown> => {
  91. if (context.abort.aborted) throw error
  92. return {
  93. status: "unknown",
  94. lookup_warning: `状态读取失败:${String(error)};已保留任务编号,不会重新执行。`,
  95. }
  96. })
  97. // A task list should not copy every research report or approval payload
  98. // into the model context. Read full materials through task/status.
  99. const confirmation = asRecord(snapshot.pending_confirmation)
  100. const local = await observation(config, row.agent_id, row.run_id)
  101. const count = Array.isArray(snapshot.artifacts) ? snapshot.artifacts.length : 0
  102. return formatRunResult(row.agent_name, {
  103. agent_id: row.agent_id,
  104. run_id: row.run_id,
  105. status: snapshot.status,
  106. output:
  107. getString(snapshot.lookup_warning) ||
  108. `已登记产物:${count}。完整结果和审批材料请用 agentpaas_task status 查看。`,
  109. pending_confirmation: confirmation
  110. ? {
  111. status: confirmation.status,
  112. approval_id: confirmation.approval_id,
  113. expires_at: confirmation.expires_at,
  114. }
  115. : undefined,
  116. pending_approval: snapshot.pending_approval
  117. ? {
  118. approval_id: asRecord(snapshot.pending_approval)?.approval_id,
  119. expires_at: asRecord(snapshot.pending_approval)?.expires_at,
  120. }
  121. : undefined,
  122. cancellation_status: cancellationStatus(local, snapshot.status),
  123. usage: snapshot.usage,
  124. checkpoint_id: snapshot.checkpoint_id,
  125. artifact_count: count,
  126. total_tokens: snapshot.total_tokens ?? asRecord(snapshot.usage)?.total_tokens,
  127. cost_usd: snapshot.cost_usd ?? asRecord(snapshot.usage)?.cost_usd,
  128. })
  129. }),
  130. )),
  131. )
  132. }
  133. return {
  134. title: "PaaS 最近任务",
  135. output: results.length
  136. ? [results.map((result) => `${result.title}\n${result.output}`).join("\n\n"), ...warnings].join("\n")
  137. : ["此账号尚无本机任务索引;可先用 agentpaas_task history 查询指定智能体的服务端历史。", ...warnings].join(
  138. "\n",
  139. ),
  140. metadata: {
  141. runs: results.map((result) => result.metadata),
  142. scope: "locally_tracked_runs",
  143. limit: args.limit ?? 20,
  144. warnings,
  145. },
  146. }
  147. },
  148. })
  149. export const task = tool({
  150. description:
  151. "查询、跟踪或控制已有 PaaS 任务,不创建新任务。history 查看历史;select 选中历史任务作为当前对话的后续上下文;status 查询;watch 恢复进度;pause/resume/cancel 控制原任务;approve/reject 处理审批。",
  152. args: {
  153. ...selector,
  154. action: tool.schema.enum([
  155. "history",
  156. "recover",
  157. "select",
  158. "status",
  159. "watch",
  160. "pause",
  161. "resume",
  162. "cancel",
  163. "approve",
  164. "reject",
  165. ]),
  166. run_id: tool.schema.string().optional().describe("省略时使用本 OpenCode 会话中该智能体的最近任务"),
  167. idempotency_key: tool.schema.string().optional().describe("recover 按原提交凭据找回任务,不重新提交"),
  168. checkpoint_id: tool.schema.string().optional(),
  169. approval_id: tool.schema.string().optional(),
  170. note: tool.schema.string().optional().describe("用户的审批意见,原样记录"),
  171. after: tool.schema.number().int().min(0).optional().describe("事件游标;省略时使用本机已保存进度,显式 0 重放"),
  172. wait_seconds: tool.schema
  173. .number()
  174. .int()
  175. .min(0)
  176. .max(120)
  177. .optional()
  178. .describe("watch 最长观察时间,默认 30 秒;到期不取消任务"),
  179. },
  180. async execute(args, context) {
  181. const config = await loadAgentPaaSConfig()
  182. const agentID = args.agent_id || (await resolveAgentID(config, args.agent_name || "", context.abort))
  183. if (!isSupportedAgentID(agentID)) throw new Error("无效 agent_id")
  184. const base = `/agents/${encodeURIComponent(agentID)}/runs`
  185. if (args.action === "recover") {
  186. if (!args.idempotency_key) throw new Error("recover 需要原 idempotency_key")
  187. const found = await recoverRequest(config, agentID, args.idempotency_key, context.abort)
  188. if (!found)
  189. return {
  190. title: "受理结果尚未确认",
  191. output: "尚未查到此提交;不能据此断定未受理。稍后再次 recover,不要换键重跑。",
  192. metadata: { agent_id: agentID, admission_status: "unknown" },
  193. }
  194. return formatRunResult(args.agent_name || agentID, found, { agent_id: agentID, admission_recovered: true })
  195. }
  196. if (args.action === "history") {
  197. const history = await requestJSON(config, base, context.abort)
  198. return {
  199. title: "PaaS 任务历史",
  200. output: JSON.stringify(history, null, 2),
  201. metadata: { agent_id: agentID, ...history },
  202. }
  203. }
  204. const conversationKey = `${config.url}\n${agentID}\n${digest(config.apiKey)}`
  205. const saved = await loadConversationState(context.sessionID, conversationKey)
  206. if (args.action === "select" && !args.run_id) throw new Error("select 必须明确指定要继续的 run_id")
  207. const runID = args.run_id || saved?.runID
  208. if (!runID) throw new Error("没有最近任务,请先查询 history 并选择 run_id;不会自动提交新任务。")
  209. const route = `${base}/${encodeURIComponent(runID)}`
  210. if (args.action === "cancel") {
  211. const result = await cancelAndObserve(config, agentID, runID)
  212. return formatRunResult(args.agent_name || agentID, result, { agent_id: agentID })
  213. }
  214. let snapshot = await requestJSON(config, route, context.abort)
  215. const local = await observation(config, agentID, runID)
  216. snapshot.cancellation_status = cancellationStatus(local, snapshot.status)
  217. if (args.action === "select") {
  218. const threadID = getString(snapshot.thread_id) || getString(snapshot.session_id)
  219. if (!threadID) throw new Error("此历史任务没有可恢复的会话 ID,未改变当前对话")
  220. await saveConversationState(context.sessionID, conversationKey, { threadID, runID }, true)
  221. const trackingWarning = await trackRun(config, {
  222. agent_id: agentID,
  223. agent_name: args.agent_name || agentID,
  224. run_id: runID,
  225. thread_id: threadID,
  226. }).then(
  227. () => "",
  228. () => "最近任务索引保存失败,请保留此 run_id;当前对话选择已保存。\n",
  229. )
  230. const result = formatRunResult(args.agent_name || agentID, snapshot, {
  231. agent_id: agentID,
  232. run_id: runID,
  233. selected: true,
  234. })
  235. return {
  236. ...result,
  237. output: `已选中此任务,后续修订将沿用会话和父版本。运行中先 watch,暂停用 resume,等待确认先处理审批。\n${trackingWarning}${result.output}`,
  238. }
  239. }
  240. if (["pause", "resume", "cancel", "approve", "reject"].includes(args.action)) {
  241. const approval = asRecord(snapshot.pending_approval)
  242. const confirmation = asRecord(snapshot.pending_confirmation)
  243. const checkpoint = args.checkpoint_id || getString(snapshot.checkpoint_id)
  244. if (args.action === "resume" && (snapshot.status !== "paused" || !checkpoint))
  245. throw new Error("原任务未暂停或缺少 checkpoint_id,不能通过新建任务代替恢复。")
  246. if (args.action === "resume" && snapshot.checkpoint_id && checkpoint !== snapshot.checkpoint_id)
  247. throw new Error("checkpoint_id 已过期,请查询原任务。")
  248. const confirming = args.action === "approve" || args.action === "reject"
  249. const currentApprovalID = getString(approval?.approval_id) || getString(confirmation?.approval_id)
  250. const approvalID = args.approval_id || currentApprovalID
  251. if (confirming) {
  252. if (
  253. !["waiting_human", "waiting_confirmation"].includes(getString(snapshot.status)) &&
  254. confirmation?.status !== "pending"
  255. )
  256. throw new Error("原任务当前没有等待确认;请检查状态,不会自动批准或重跑。")
  257. if (!currentApprovalID) throw new Error("缺少 approval_id,请升级执行服务并查询最新审批材料。")
  258. if (approvalID !== currentApprovalID) throw new Error("approval_id 已过期,请查询最新审批材料。")
  259. if (args.action === "approve")
  260. await context.ask({
  261. permission: "agentpaas_confirm",
  262. patterns: [`${agentID}:${runID}:${approvalID}`],
  263. always: [],
  264. metadata: {
  265. agent_id: agentID,
  266. run_id: runID,
  267. approval_id: approvalID,
  268. pending_approval: approval,
  269. pending_confirmation: confirmation,
  270. note: args.note ?? "",
  271. },
  272. })
  273. }
  274. const body = confirming
  275. ? {
  276. approved: args.action === "approve",
  277. approval_id: approvalID,
  278. note: args.note ?? "",
  279. idempotency_key: `opencode-control-${digest(JSON.stringify([config.url, digest(config.apiKey), agentID, runID, approvalID, args.action, args.note ?? ""]))}`,
  280. }
  281. : args.action === "resume"
  282. ? { checkpoint_id: checkpoint }
  283. : {}
  284. // Mutations are sent once. An ambiguous response must be resolved by status.
  285. await requestJSON(config, `${route}/${confirming ? "confirm" : args.action}`, context.abort, {
  286. method: "POST",
  287. headers: { "Content-Type": "application/json" },
  288. body: JSON.stringify(body),
  289. })
  290. snapshot = await requestJSON(config, route, context.abort)
  291. }
  292. if (args.action !== "watch")
  293. return formatRunResult(args.agent_name || agentID, snapshot, { agent_id: agentID, run_id: runID })
  294. let cursor = args.after ?? (typeof local.event_cursor === "number" ? local.event_cursor : 0)
  295. const warnings: string[] = []
  296. const stages = new Map<string, Record<string, unknown>>()
  297. if (args.after === undefined && Array.isArray(local.stages))
  298. for (const item of local.stages) {
  299. const row = asRecord(item)
  300. if (row && getString(row.stage_id)) stages.set(getString(row.stage_id), row)
  301. }
  302. let idle = 0
  303. const deadline = Date.now() + (args.action === "watch" ? (args.wait_seconds ?? 30) * 1000 : 0)
  304. do {
  305. const before = cursor
  306. const page = await requestJSON(config, `${route}/events?after=${cursor}&source=service`, context.abort).catch(
  307. (error) => {
  308. if (context.abort.aborted) throw error
  309. warnings.push(`事件读取暂不可用,已保留任务快照与产物:${String(error)}`)
  310. return { events: [] }
  311. },
  312. )
  313. for (const item of Array.isArray(page.events) ? page.events : []) {
  314. const event = asRecord(item)
  315. if (!event || typeof event.sequence !== "number" || event.sequence <= cursor) continue
  316. cursor = event.sequence
  317. const data = asRecord(event.data)
  318. const stageID = getString(data?.stage_id)
  319. const kind = getString(event.event_type) || getString(event.event)
  320. if (stageID && ["stage_started", "stage_passed", "stage_completed", "stage_failed"].includes(kind))
  321. stages.set(stageID, {
  322. ...data,
  323. status: kind === "stage_started" ? "running" : kind === "stage_failed" ? "failed" : "completed",
  324. })
  325. }
  326. snapshot = await requestJSON(config, route, context.abort).catch((error) => {
  327. if (context.abort.aborted) throw error
  328. warnings.push(`状态刷新暂不可用,以下为最近一次快照:${String(error)}`)
  329. return snapshot
  330. })
  331. idle = cursor === before ? idle + 1 : 0
  332. snapshot.cancellation_status = cancellationStatus(local, snapshot.status)
  333. await saveObservation(config, agentID, runID, { event_cursor: cursor, stages: [...stages.values()] }).catch(
  334. () => {
  335. warnings.push("进度游标保存失败,重新打开时可能重放旧事件;任务不会重跑。")
  336. },
  337. )
  338. context.metadata({
  339. title: `PaaS ${agentID} · ${snapshot.status}`,
  340. metadata: {
  341. agent_id: agentID,
  342. run_id: runID,
  343. status: snapshot.status,
  344. event_cursor: cursor,
  345. stages: [...stages.values()],
  346. },
  347. })
  348. if (
  349. warnings.length ||
  350. asRecord(snapshot.pending_confirmation)?.status === "pending" ||
  351. !["queued", "running", "resuming"].includes(getString(snapshot.status)) ||
  352. Date.now() >= deadline
  353. )
  354. break
  355. // Aborting observation never cancels the worker. cancel is a separate action.
  356. await setTimeout(
  357. Math.min(Math.min(500 * 2 ** Math.min(idle, 4), 5000), Math.max(0, deadline - Date.now())),
  358. undefined,
  359. { signal: context.abort },
  360. )
  361. } while (Date.now() <= deadline)
  362. const result = formatRunResult(args.agent_name || agentID, snapshot, {
  363. agent_id: agentID,
  364. run_id: runID,
  365. event_cursor: cursor,
  366. stages: [...stages.values()],
  367. warnings,
  368. })
  369. return { ...result, output: [result.output, ...warnings].join("\n") }
  370. },
  371. })
  372. const MAX_FILE_BYTES = 256 * 1024 * 1024
  373. export const upload = tool({
  374. description:
  375. "把用户指定的本机文件上传到 PaaS,返回 file_id。随后将这些 ID 传入 agentpaas_run.input_file_ids。仅上传用户明确选择的文件。",
  376. args: { paths: tool.schema.array(tool.schema.string()).min(1).max(20) },
  377. async execute(args, context) {
  378. const config = await loadAgentPaaSConfig()
  379. const capabilities = await requestJSON(config, "/files/capabilities", context.abort)
  380. const serverLimit = capabilities.direct_upload_max_bytes
  381. if (typeof serverLimit !== "number" || !Number.isSafeInteger(serverLimit) || serverLimit < 0)
  382. throw new Error("PaaS 未返回有效上传限制,请更新 PaaS 后再上传")
  383. const limit = Math.min(MAX_FILE_BYTES, serverLimit)
  384. const paths = await Promise.all(args.paths.map((name) => realpath(path.resolve(context.directory, name))))
  385. for (const filename of paths) {
  386. const info = await stat(filename)
  387. if (!info.isFile() || info.size > limit) throw new Error(`文件不可上传或超过当前上限 ${limit} 字节:${filename}`)
  388. }
  389. await context.ask({
  390. permission: "agentpaas_upload",
  391. patterns: paths,
  392. always: [],
  393. metadata: { server: config.url, paths },
  394. })
  395. const files: Record<string, unknown>[] = []
  396. for (const filename of paths) {
  397. const file = Bun.file(filename)
  398. const bytes = await file.arrayBuffer()
  399. if (bytes.byteLength > limit) throw new Error(`文件超过当前上限 ${limit} 字节:${filename}`)
  400. const query = new URLSearchParams({
  401. name: path.basename(filename),
  402. mime_type: file.type || "application/octet-stream",
  403. })
  404. const result = await requestJSON(config, `/files?${query}`, context.abort, {
  405. method: "POST",
  406. headers: {
  407. "Content-Type": file.type || "application/octet-stream",
  408. "X-File-SHA256": new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),
  409. },
  410. body: bytes,
  411. })
  412. if (!getString(result.file_id) || result.status !== "available") throw new Error("PaaS 未返回可用的 file_id")
  413. files.push({ file_id: result.file_id, name: result.name, size_bytes: result.size_bytes })
  414. context.metadata({ title: `已上传 ${files.length}/${paths.length} 个文件`, metadata: { files: [...files] } })
  415. }
  416. return { title: "输入文件已上传", output: JSON.stringify(files, null, 2), metadata: { files } }
  417. },
  418. })
  419. export const download = tool({
  420. description:
  421. "通过 PaaS 身份验证下载已有任务的产物到本机,校验大小/摘要并返回可打开的文件。不会读取服务端文件路径,也不会覆盖已有文件。",
  422. args: {
  423. ...selector,
  424. run_id: tool.schema.string(),
  425. artifact_id: tool.schema.string(),
  426. output_path: tool.schema.string().optional().describe("本机保存路径;省略时放入当前项目 .agentpaas-artifacts 目录"),
  427. },
  428. async execute(args, context) {
  429. const config = await loadAgentPaaSConfig()
  430. const agentID = args.agent_id || (await resolveAgentID(config, args.agent_name || "", context.abort))
  431. if (!isSupportedAgentID(agentID)) throw new Error("无效 agent_id")
  432. const route = `/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(args.run_id)}/artifacts`
  433. const manifest = await requestJSON(config, route, context.abort)
  434. const artifact = (Array.isArray(manifest.artifacts) ? manifest.artifacts : [])
  435. .map(asRecord)
  436. .find((item) => item?.artifact_id === args.artifact_id)
  437. if (!artifact) throw new Error("当前任务没有该产物")
  438. const name =
  439. path.basename(getString(artifact.name).replaceAll("\\", "/")).replace(/[<>:\x00-\x1f]/g, "_") || "artifact"
  440. const base = await realpath(context.directory)
  441. const destination = args.output_path
  442. ? path.resolve(base, args.output_path)
  443. : path.join(
  444. base,
  445. ".agentpaas-artifacts",
  446. digest(`${config.url}:${agentID}:${args.run_id}`).slice(0, 24),
  447. `${digest(args.artifact_id).slice(0, 12)}-${name}`,
  448. )
  449. await context.ask({
  450. permission: "agentpaas_download",
  451. patterns: [destination],
  452. always: [],
  453. metadata: {
  454. server: config.url,
  455. agent_id: agentID,
  456. run_id: args.run_id,
  457. artifact_id: args.artifact_id,
  458. destination,
  459. },
  460. })
  461. const response = await fetch(`${config.url}/api/v1${route}/${encodeURIComponent(args.artifact_id)}/download`, {
  462. headers: { Authorization: `Bearer ${config.apiKey}` },
  463. redirect: "error",
  464. signal: AbortSignal.any([context.abort, AbortSignal.timeout(60000)]),
  465. })
  466. if (!response.ok) throw new Error(`产物下载失败:HTTP ${response.status}`)
  467. if (Number(response.headers.get("Content-Length")) > MAX_FILE_BYTES) {
  468. await response.body?.cancel()
  469. throw new Error("产物超过 256 MiB,请通过平台文件传输接口获取")
  470. }
  471. const chunks: Uint8Array[] = []
  472. let size = 0
  473. const reader = response.body?.getReader()
  474. if (!reader) throw new Error("产物下载没有响应内容")
  475. try {
  476. while (true) {
  477. const chunk = await reader.read()
  478. if (chunk.done) break
  479. size += chunk.value.length
  480. if (size > MAX_FILE_BYTES) throw new Error("产物超过 256 MiB")
  481. chunks.push(chunk.value)
  482. }
  483. } finally {
  484. await reader.cancel().catch(() => undefined)
  485. reader.releaseLock()
  486. }
  487. const content = Buffer.concat(chunks)
  488. const hash = new Bun.CryptoHasher("sha256").update(content).digest("hex")
  489. if (artifact.sha256 && artifact.sha256 !== hash) throw new Error("产物摘要不匹配,未写入本机")
  490. if (typeof artifact.size_bytes === "number" && artifact.size_bytes > 0 && artifact.size_bytes !== size)
  491. throw new Error("产物大小不匹配,未写入本机")
  492. await mkdir(path.dirname(destination), { recursive: true })
  493. const parent = await realpath(path.dirname(destination))
  494. if (!args.output_path && !parent.startsWith(base + path.sep)) throw new Error("默认产物目录指向项目之外,拒绝写入")
  495. const handle = await open(path.join(parent, path.basename(destination)), "wx", 0o600)
  496. try {
  497. await handle.writeFile(content)
  498. } finally {
  499. await handle.close()
  500. }
  501. return {
  502. title: "产物已下载",
  503. output: `[${name.replace(/[\[\]]/g, "_")}](<${destination}>)`,
  504. metadata: {
  505. agent_id: agentID,
  506. run_id: args.run_id,
  507. artifact_id: args.artifact_id,
  508. local_path: destination,
  509. size_bytes: size,
  510. sha256: hash,
  511. },
  512. attachments: [
  513. {
  514. type: "file" as const,
  515. filename: name,
  516. mime: getString(artifact.mime_type) || "application/octet-stream",
  517. url: pathToFileURL(destination).href,
  518. },
  519. ],
  520. }
  521. },
  522. })