agentpaas.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import { link, mkdir, readdir, rename, rm } from "node:fs/promises"
  2. import { homedir } from "node:os"
  3. import path from "node:path"
  4. import { setTimeout } from "node:timers/promises"
  5. const DEFAULT_AGENTPAAS_URL = "http://127.0.0.1:8000"
  6. export type Connection = { url: string; apiKey: string }
  7. export type ConversationState = { threadID: string; runID: string }
  8. type AgentListItem = { id: string; name: string }
  9. const revisions = new Map<string, string>()
  10. const probes = new Map<
  11. string,
  12. { at: number; report: Record<string, unknown> & { blocked: boolean; summary: string } }
  13. >()
  14. export async function requestJSON(config: Connection, route: string, signal: AbortSignal, init: RequestInit = {}) {
  15. const response = await fetch(`${config.url}/api/v1${route}`, {
  16. ...init,
  17. headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json", ...init.headers },
  18. redirect: "error",
  19. signal: AbortSignal.any([signal, AbortSignal.timeout(30000)]),
  20. }).catch((error) => {
  21. if (signal.aborted) throw error
  22. throw new Error(
  23. `无法取得 PaaS 响应(${config.url});请检查服务、地址与网络。已有任务请保留原 run_id 查询,不要重复提交。${getString(asRecord(error)?.code)}`,
  24. )
  25. })
  26. if (!response.ok) {
  27. const detail = (await response.text()).replaceAll(config.apiKey, "[redacted]").slice(0, 500)
  28. throw new Error(`AgentPaaS HTTP ${response.status}: ${detail}`)
  29. }
  30. const result = asRecord(await response.json())
  31. if (!result) throw new Error("AgentPaaS 返回了无效 JSON 对象")
  32. return result
  33. }
  34. export async function verifyAgent(
  35. config: Connection,
  36. agentID: string,
  37. signal: AbortSignal,
  38. options = { check_model: false, knowledge_query: "" },
  39. cached = false,
  40. ): Promise<Record<string, unknown> & { blocked: boolean; summary: string }> {
  41. const scope = digest(canonicalJSON([config.url, config.apiKey, agentID]))
  42. const revision = revisions.get(scope)
  43. const key = `${scope}:${revision}`
  44. const useCache = cached && !options.check_model && !options.knowledge_query
  45. if (!useCache) probes.delete(key)
  46. const previous = probes.get(key)
  47. if (useCache && revision && previous && Date.now() - previous.at < 5000)
  48. return { ...previous.report, cache_hit: true }
  49. const report = await requestJSON(config, `/agents/${encodeURIComponent(agentID)}/verify`, signal, {
  50. method: "POST",
  51. headers: { "Content-Type": "application/json" },
  52. body: JSON.stringify(options),
  53. })
  54. if (
  55. !["verified", "unverified", "degraded", "blocked"].includes(getString(report.status)) ||
  56. !Array.isArray(report.checks)
  57. )
  58. throw new Error("PaaS 能力检查返回无效报告,任务未提交;请更新 PaaS 后重试检查。")
  59. const checks = report.checks.map(asRecord)
  60. if (
  61. checks.some(
  62. (item) =>
  63. !item ||
  64. typeof item.required !== "boolean" ||
  65. !["passed", "failed", "not_checked"].includes(getString(item.status)),
  66. )
  67. )
  68. throw new Error("PaaS 能力检查条目无效,任务未提交。")
  69. const blocked = report.status === "blocked" || checks.some((item) => item?.required && item.status === "failed")
  70. const lines = checks.flatMap((item) =>
  71. item && item.status !== "passed"
  72. ? [
  73. `${item.required ? "必需" : "可选"} ${getString(item.kind)}/${getString(item.name)}:${item.status === "failed" ? "失败" : "未验证"}${item.error_code ? ` (${getString(item.error_code)})` : ""}`,
  74. ]
  75. : [],
  76. )
  77. const result = {
  78. ...report,
  79. blocked,
  80. summary: [blocked ? "必需能力不可用,任务未提交。" : "未发现已确认失效的必需能力。", ...lines].join("\n"),
  81. }
  82. if (useCache && revision && !blocked) {
  83. if (probes.size > 100) probes.clear()
  84. probes.set(key, { at: Date.now(), report: result })
  85. }
  86. return result
  87. }
  88. type TrackedRun = { agent_id: string; agent_name: string; run_id: string; thread_id: string; recorded_at: string }
  89. function trackedRunDirectory(config: Connection) {
  90. return path.join(agentPaaSConversationDir(), "runs", digest(canonicalJSON([config.url, config.apiKey])))
  91. }
  92. export async function trackRun(config: Connection, run: Omit<TrackedRun, "recorded_at">) {
  93. if (!isSupportedAgentID(run.agent_id) || !run.run_id) throw new Error("无法保存无效任务编号")
  94. const directory = trackedRunDirectory(config)
  95. await mkdir(directory, { recursive: true, mode: 0o700 })
  96. const target = path.join(directory, `${digest(canonicalJSON([run.agent_id, run.run_id]))}.json`)
  97. const temporary = `${target}.${crypto.randomUUID()}.tmp`
  98. try {
  99. await Bun.write(temporary, JSON.stringify({ ...run, recorded_at: new Date().toISOString() }), { mode: 0o600 })
  100. await rename(temporary, target)
  101. } finally {
  102. await rm(temporary, { force: true })
  103. }
  104. }
  105. export async function trackedRuns(config: Connection, limit: number) {
  106. const directory = trackedRunDirectory(config)
  107. const files = await readdir(directory).catch((error: NodeJS.ErrnoException) => {
  108. if (error.code === "ENOENT") return []
  109. throw error
  110. })
  111. const rows = await Promise.all(
  112. files
  113. .filter((file) => /^[a-f0-9]{64}\.json$/.test(file))
  114. .map(async (file) => {
  115. const row = asRecord(await Bun.file(path.join(directory, file)).json())
  116. if (
  117. !row ||
  118. !isSupportedAgentID(getString(row.agent_id)) ||
  119. !getString(row.run_id) ||
  120. !getString(row.recorded_at)
  121. )
  122. throw new Error("任务索引损坏,请用 agentpaas_task history 查询服务端历史。")
  123. return row as TrackedRun
  124. }),
  125. )
  126. return rows.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, limit)
  127. }
  128. export async function listAgents(config: Connection, signal: AbortSignal, wanted?: string) {
  129. for (let attempt = 0; ; attempt++) {
  130. const payload = await requestJSON(config, "/agents", signal)
  131. if (!Array.isArray(payload.agents)) throw new Error("AgentPaaS Agent 列表响应格式无效")
  132. const agents = payload.agents.flatMap((item): Array<Record<string, unknown> & AgentListItem> => {
  133. const record = asRecord(item)
  134. return record && typeof record.id === "string" && typeof record.name === "string"
  135. ? [{ ...record, id: record.id, name: record.name }]
  136. : []
  137. })
  138. const status = getString(asRecord(payload.discovery)?.status)
  139. const found = wanted && agents.some((agent) => agent.id === wanted || agent.name === wanted)
  140. if (found || !["pending", "refreshing"].includes(status) || attempt >= 7)
  141. return { agents, discovery: asRecord(payload.discovery) ?? { status: "unknown" } }
  142. // Only read-only discovery is retried. Never replay a control mutation here.
  143. await setTimeout(Math.min(250 * (attempt + 1), 1000), undefined, { signal })
  144. }
  145. }
  146. export async function resolveAgentID(config: Connection, selector: string, signal: AbortSignal) {
  147. const name = selector.trim()
  148. if (!name) throw new Error("必须提供 agent_name 或 agent_id")
  149. const catalog = await listAgents(config, signal, name)
  150. const byID = catalog.agents.filter((item) => item.id === name)
  151. const matches = byID.length ? byID : catalog.agents.filter((item) => item.name === name)
  152. if (!matches.length) {
  153. const status = getString(catalog.discovery.status)
  154. if (["pending", "refreshing", "unavailable"].includes(status))
  155. throw new Error(`AgentPaaS 尚未找到“${name}”,远端发现状态:${status};请稍后刷新目录,任务未提交。`)
  156. throw new Error(`AgentPaaS 中没有名为“${name}”的 active Agent`)
  157. }
  158. if (matches.length > 1)
  159. throw new Error(
  160. `AgentPaaS 中存在 ${matches.length} 个名为“${name}”的 active Agent,请使用目录中的 agent_id:${matches.map((item) => item.id).join(", ")}`,
  161. )
  162. if (!isSupportedAgentID(matches[0].id)) throw new Error(`AgentPaaS 为“${name}”返回了无效 ID: ${matches[0].id}`)
  163. const revision = matches[0].current_version ?? matches[0].agent_version
  164. const scope = digest(canonicalJSON([config.url, config.apiKey, matches[0].id]))
  165. if (revision !== undefined)
  166. revisions.set(scope, canonicalJSON([revision, matches[0].runtime_mode, matches[0].runtime_binding]))
  167. else revisions.delete(scope)
  168. return matches[0].id
  169. }
  170. // Parse the two supported ID namespaces; never accept URL/path syntax or decoded input.
  171. // Every route still encodes the complete ID as one path segment.
  172. export function isSupportedAgentID(id: string): boolean {
  173. if (id.length > 512) return false
  174. if (id.startsWith("ag_")) return /^ag_[A-Za-z0-9_-]+$/.test(id)
  175. const parts = id.split(":")
  176. if (parts.length !== 3 || parts[0] !== "locostack") return false
  177. const namespace = parts[1]
  178. const name = parts[2]
  179. const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
  180. return (
  181. namespace.length <= 63 &&
  182. label.test(namespace) &&
  183. name.length <= 253 &&
  184. name.split(".").every((part) => part.length <= 63 && label.test(part))
  185. )
  186. }
  187. const conversationCache = new Map<string, ConversationState>()
  188. export function digest(value: string) {
  189. return new Bun.CryptoHasher("sha256").update(value).digest("hex")
  190. }
  191. export function canonicalJSON(value: unknown): string {
  192. if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(",")}]`
  193. const record = asRecord(value)
  194. if (record)
  195. return `{${Object.keys(record)
  196. .sort()
  197. .filter((key) => record[key] !== undefined)
  198. .map((key) => `${JSON.stringify(key)}:${canonicalJSON(record[key])}`)
  199. .join(",")}}`
  200. return JSON.stringify(value) ?? "null"
  201. }
  202. export async function requestContext(
  203. config: { url: string; apiKey: string },
  204. agentID: string,
  205. key: string,
  206. current: ConversationState & { intent: string },
  207. ) {
  208. const directory = path.join(agentPaaSConversationDir(), "requests")
  209. const filePath = path.join(directory, `${digest(canonicalJSON([config.url, config.apiKey, agentID, key]))}.json`)
  210. await mkdir(directory, { recursive: true, mode: 0o700 })
  211. const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
  212. await Bun.write(temporary, JSON.stringify(current), { mode: 0o600 })
  213. // link is atomic and never replaces the first submission's saved context.
  214. const created = await link(temporary, filePath)
  215. .then(
  216. () => true,
  217. (error: NodeJS.ErrnoException) => {
  218. if (error.code === "EEXIST") return false
  219. throw error
  220. },
  221. )
  222. .finally(() => rm(temporary, { force: true }))
  223. const saved = asRecord(await Bun.file(filePath).json())
  224. if (!saved || typeof saved.threadID !== "string" || typeof saved.runID !== "string")
  225. throw new Error("AgentPaaS 提交记录损坏,停止提交;请先检查原运行状态。")
  226. if (saved.intent !== current.intent) throw new Error("AgentPaaS 幂等键已用于不同请求;修改后的任务必须使用新键。")
  227. return { threadID: saved.threadID, runID: saved.runID, created }
  228. }
  229. export async function loadConversationState(sessionID: string, key: string): Promise<ConversationState | undefined> {
  230. const cacheKey = `${sessionID}\n${key}`
  231. const cached = conversationCache.get(cacheKey)
  232. if (cached) return cached
  233. const file = Bun.file(conversationStatePath(sessionID))
  234. if (!(await file.exists())) return undefined
  235. try {
  236. const parsed: unknown = await file.json()
  237. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
  238. const value = (parsed as Record<string, unknown>)[key]
  239. if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
  240. const threadID = getString((value as Record<string, unknown>).thread_id)
  241. const runID = getString((value as Record<string, unknown>).run_id)
  242. if (!threadID || !runID) return undefined
  243. const state = { threadID, runID }
  244. conversationCache.set(cacheKey, state)
  245. return state
  246. } catch {
  247. return undefined
  248. }
  249. }
  250. const conversationWrites = new Map<string, Promise<void>>()
  251. export async function saveConversationState(
  252. sessionID: string,
  253. key: string,
  254. state: ConversationState,
  255. required = false,
  256. ) {
  257. const filePath = conversationStatePath(sessionID)
  258. const pending = (conversationWrites.get(filePath) ?? Promise.resolve())
  259. .catch(() => undefined)
  260. .then(async () => {
  261. const file = Bun.file(filePath)
  262. const saved = (await file.exists()) ? asRecord(await file.json()) : {}
  263. if (!saved) throw new Error("当前会话记录损坏,未覆盖已有记录")
  264. saved[key] = { thread_id: state.threadID, run_id: state.runID }
  265. await mkdir(agentPaaSConversationDir(), { recursive: true, mode: 0o700 })
  266. const temporary = `${filePath}.${crypto.randomUUID()}.tmp`
  267. try {
  268. await Bun.write(temporary, `${JSON.stringify(saved, null, 2)}\n`, { mode: 0o600 })
  269. await rename(temporary, filePath)
  270. } finally {
  271. await rm(temporary, { force: true })
  272. }
  273. })
  274. conversationWrites.set(filePath, pending)
  275. try {
  276. await pending
  277. } catch (error) {
  278. if (required) throw error
  279. } finally {
  280. if (conversationWrites.get(filePath) === pending) conversationWrites.delete(filePath)
  281. }
  282. conversationCache.set(`${sessionID}\n${key}`, state)
  283. }
  284. function conversationStatePath(sessionID: string) {
  285. return path.join(agentPaaSConversationDir(), `${sessionID.replace(/[^A-Za-z0-9._-]/g, "_")}.json`)
  286. }
  287. export async function loadAgentPaaSConfig() {
  288. const configPath = agentPaaSConfigPath()
  289. const file = Bun.file(configPath)
  290. let saved: Record<string, unknown> = {}
  291. if (await file.exists()) {
  292. try {
  293. const parsed: unknown = await file.json()
  294. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("根节点必须是 JSON 对象")
  295. saved = parsed as Record<string, unknown>
  296. } catch (err) {
  297. throw new Error(`AgentPaaS 配置文件无效:${configPath}\n${err instanceof Error ? err.message : String(err)}`)
  298. }
  299. }
  300. const savedUrl = typeof saved.server === "string" ? saved.server.trim() : ""
  301. const savedApiKey = typeof saved.api_key === "string" ? saved.api_key.trim() : ""
  302. const url = (process.env.AGENTPAAS_URL?.trim() || savedUrl || DEFAULT_AGENTPAAS_URL).replace(/\/+$/, "")
  303. const apiKey = process.env.AGENTPAAS_API_KEY?.trim() || savedApiKey
  304. const parsedURL = new URL(url)
  305. if (
  306. !["http:", "https:"].includes(parsedURL.protocol) ||
  307. parsedURL.username ||
  308. parsedURL.password ||
  309. parsedURL.search ||
  310. parsedURL.hash
  311. )
  312. throw new Error("AgentPaaS 地址必须是无凭证、查询参数和片段的 HTTP(S) 地址")
  313. if (apiKey) return { url, apiKey }
  314. throw new Error(`AgentPaaS 尚未配置。请创建 ${configPath}:
  315. {
  316. "server": "${DEFAULT_AGENTPAAS_URL}",
  317. "api_key": "你的 AgentPaaS API Key"
  318. }
  319. 也可以通过 AGENTPAAS_URL 和 AGENTPAAS_API_KEY 环境变量临时覆盖。`)
  320. }
  321. function agentPaaSHome() {
  322. return process.env.HOME?.trim() || homedir()
  323. }
  324. function agentPaaSConfigPath() {
  325. return path.join(agentPaaSHome(), ".agentpaas", "config.json")
  326. }
  327. export function agentPaaSConversationDir() {
  328. return (
  329. process.env.AGENTPAAS_BRIDGE_STATE_DIR?.trim() || path.join(agentPaaSHome(), ".agentpaas", "opencode-conversations")
  330. )
  331. }
  332. export function asRecord(value: unknown): Record<string, unknown> | undefined {
  333. if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>
  334. }
  335. export function getString(value: unknown): string {
  336. return typeof value === "string" ? value : ""
  337. }
  338. export function scientificLabel(status: unknown): string {
  339. if (status === "needs_evidence") return "证据不足"
  340. if (status === "needs_revision") return "方案待修订"
  341. if (status === "review_pending") return "审阅未完成,已保留方案"
  342. if (status === "needs_review") return "方案检查通过,待人工审阅"
  343. return "研究状态待核查"
  344. }
  345. export function formatRunResult(agentName: string, run: Record<string, unknown>, extra: Record<string, unknown> = {}) {
  346. const status =
  347. asRecord(run.pending_confirmation)?.status === "pending" && run.status === "running"
  348. ? "waiting_confirmation"
  349. : getString(run.status) || "unknown"
  350. const labels: Record<string, string> = {
  351. completed: "已完成",
  352. partial: "部分完成",
  353. needs_clarification: "需要补充信息",
  354. failed: "失败",
  355. cancelled: "已取消",
  356. paused: "已暂停",
  357. waiting_human: "等待确认",
  358. waiting_confirmation: "等待确认",
  359. queued: "排队中",
  360. running: "运行中",
  361. resuming: "恢复中",
  362. unknown: "状态待核查",
  363. }
  364. const artifacts = Array.isArray(run.artifacts) ? run.artifacts : []
  365. const metadata: Record<string, unknown> = { ...run, ...extra, status, artifacts }
  366. const science = asRecord(asRecord(run.research)?.science)
  367. const hints: Record<string, string> = {
  368. partial: "任务已部分完成,保留已有产物;请查看缺失项后继续补充。",
  369. needs_clarification: "需要补充信息后才能继续;请回答智能体提出的问题,不要重新执行同一请求。",
  370. paused: `任务已暂停。checkpoint_id=${getString(run.checkpoint_id) || "未提供"};使用 agentpaas_task 的 resume 恢复原任务。`,
  371. waiting_human: "任务正在等待人工确认;使用 agentpaas_task 查看审批材料并提交决定,这不是任务失败。",
  372. waiting_confirmation: "任务正在等待工具确认;使用 agentpaas_task 查看并确认原任务。",
  373. cancelled: "任务已被取消。",
  374. queued: "任务已排队;使用 agentpaas_task 的 watch 查看进度。",
  375. running: "任务仍在运行;使用 agentpaas_task 的 watch 恢复查看,不要重新提交。",
  376. resuming: "任务正在恢复;使用 agentpaas_task 的 watch 查看进度。",
  377. failed: "失败严禁自动重跑、拆分任务或调用其他 subagent;必须先向用户报告真实错误和已有产物,等待用户明确指令。",
  378. }
  379. const artifactLines = artifacts.flatMap((item) => {
  380. const artifact = asRecord(item)
  381. if (!artifact || !getString(artifact.artifact_id)) return []
  382. return [`- ${getString(artifact.name).replace(/[\r\n]/g, " ") || "文件"}(artifact_id=${artifact.artifact_id})`]
  383. })
  384. return {
  385. title:
  386. status === "completed"
  387. ? science
  388. ? scientificLabel(science.status)
  389. : `AgentPaaS ${agentName}`
  390. : `AgentPaaS ${agentName}(${labels[status] ?? "状态待核查"})`,
  391. output: [
  392. ...(science ? [`研究状态:${scientificLabel(science.status)}。执行结束不代表科学结论成立。`] : []),
  393. ...(science?.execution_status === "conditional_diagnostic_completed"
  394. ? ["模拟状态:已完成设定模型下的诊断;证据充分性与现实适用性须另行判断。"]
  395. : []),
  396. ...(status === "failed"
  397. ? [`[AgentPaaS 执行失败] ${getString(run.error_message) || getString(asRecord(run.error)?.message)}`]
  398. : []),
  399. getString(run.output),
  400. ...(run.cancellation_status
  401. ? [
  402. {
  403. requested: "取消请求已发送,仍待服务端确认。",
  404. confirmed: "服务端已确认取消。",
  405. unconfirmed: "取消未确认,任务可能仍在执行;请查询原任务。",
  406. already_terminal: "任务已结束,不能将其标记为由本次取消操作停止。",
  407. }[getString(run.cancellation_status)] || "取消状态待核查。",
  408. ]
  409. : []),
  410. hints[status] ?? "",
  411. getString(run.persistence_warning),
  412. ...(metadata.run_id ? [`agent_id=${getString(metadata.agent_id)} · run_id=${metadata.run_id}`] : []),
  413. ...(asRecord(run.research)?.revision !== undefined ? [`研究版本:${asRecord(run.research)?.revision}`] : []),
  414. `用量:tokens=${run.total_tokens ?? asRecord(run.usage)?.total_tokens ?? "未报告"}(${asRecord(run.usage)?.usage_complete === true ? "计量完整" : "完整性未确认"});费用 USD=${run.cost_usd ?? asRecord(run.usage)?.cost_usd ?? "未报告"}(${asRecord(run.usage)?.cost_complete === true ? "计量完整" : "完整性未确认,不能据 0 判断免费"})`,
  415. ...(run.workspace_path ? [`服务端工作目录:${run.workspace_path}`] : []),
  416. ...(artifactLines.length ? ["产物(使用 agentpaas_download 下载到本机):", ...artifactLines] : []),
  417. ...(run.pending_approval ? [`待确认材料:${JSON.stringify(run.pending_approval)}`] : []),
  418. ...(run.pending_confirmation ? [`待确认工具:${JSON.stringify(run.pending_confirmation)}`] : []),
  419. ...(asRecord(run.pending_confirmation)?.expires_at
  420. ? [`工具确认截止:${asRecord(run.pending_confirmation)?.expires_at};超时不会自动批准。`]
  421. : []),
  422. ...(run.pending_approval
  423. ? [
  424. `人工审阅截止:${getString(asRecord(run.pending_approval)?.expires_at) || "服务端未指定"};与普通工具确认分开处理。`,
  425. ]
  426. : []),
  427. ]
  428. .filter(Boolean)
  429. .join("\n"),
  430. metadata,
  431. }
  432. }