homegraph 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +268 -243
  3. package/dist/arkts/ohos-api-index.d.ts +15 -0
  4. package/dist/arkts/ohos-api-index.d.ts.map +1 -0
  5. package/dist/arkts/ohos-api-index.js +190 -0
  6. package/dist/arkts/ohos-api-index.js.map +1 -0
  7. package/dist/arkts/ohos-sdk-input.d.ts +36 -0
  8. package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
  9. package/dist/arkts/ohos-sdk-input.js +214 -0
  10. package/dist/arkts/ohos-sdk-input.js.map +1 -0
  11. package/dist/bin/command-supervision.d.ts +12 -0
  12. package/dist/bin/command-supervision.d.ts.map +1 -0
  13. package/dist/bin/command-supervision.js +86 -0
  14. package/dist/bin/command-supervision.js.map +1 -0
  15. package/dist/bin/homegraph.d.ts +1 -1
  16. package/dist/bin/homegraph.js +971 -71
  17. package/dist/bin/homegraph.js.map +1 -1
  18. package/dist/db/index.d.ts +20 -0
  19. package/dist/db/index.d.ts.map +1 -1
  20. package/dist/db/index.js +39 -0
  21. package/dist/db/index.js.map +1 -1
  22. package/dist/db/migrations.d.ts +1 -1
  23. package/dist/db/migrations.d.ts.map +1 -1
  24. package/dist/db/migrations.js +58 -19
  25. package/dist/db/migrations.js.map +1 -1
  26. package/dist/db/queries.d.ts +14 -0
  27. package/dist/db/queries.d.ts.map +1 -1
  28. package/dist/db/queries.js +314 -143
  29. package/dist/db/queries.js.map +1 -1
  30. package/dist/db/schema.sql +172 -152
  31. package/dist/directory.d.ts +32 -0
  32. package/dist/directory.d.ts.map +1 -1
  33. package/dist/directory.js +88 -5
  34. package/dist/directory.js.map +1 -1
  35. package/dist/extraction/arkts-batch-worker.d.ts +2 -0
  36. package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
  37. package/dist/extraction/arkts-batch-worker.js +28 -0
  38. package/dist/extraction/arkts-batch-worker.js.map +1 -0
  39. package/dist/extraction/context.d.ts +11 -0
  40. package/dist/extraction/context.d.ts.map +1 -1
  41. package/dist/extraction/context.js +20 -0
  42. package/dist/extraction/context.js.map +1 -1
  43. package/dist/extraction/index.d.ts +16 -2
  44. package/dist/extraction/index.d.ts.map +1 -1
  45. package/dist/extraction/index.js +619 -380
  46. package/dist/extraction/index.js.map +1 -1
  47. package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
  48. package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
  49. package/dist/extraction/languages/arkts-state-decorators.js +26 -0
  50. package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
  51. package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
  52. package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
  53. package/dist/extraction/languages/arkts-viewtree.js +21 -6
  54. package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
  55. package/dist/extraction/languages/arkts.d.ts +98 -2
  56. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  57. package/dist/extraction/languages/arkts.js +1290 -61
  58. package/dist/extraction/languages/arkts.js.map +1 -1
  59. package/dist/extraction/languages/c-cpp.d.ts +56 -0
  60. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  61. package/dist/extraction/languages/c-cpp.js +198 -1
  62. package/dist/extraction/languages/c-cpp.js.map +1 -1
  63. package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
  64. package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
  65. package/dist/extraction/languages/ohos-api-consumer.js +283 -0
  66. package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
  67. package/dist/extraction/parse-pool.d.ts +126 -0
  68. package/dist/extraction/parse-pool.d.ts.map +1 -0
  69. package/dist/extraction/parse-pool.js +319 -0
  70. package/dist/extraction/parse-pool.js.map +1 -0
  71. package/dist/extraction/tree-sitter-types.d.ts +17 -0
  72. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  73. package/dist/extraction/tree-sitter.d.ts +21 -0
  74. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  75. package/dist/extraction/tree-sitter.js +198 -27
  76. package/dist/extraction/tree-sitter.js.map +1 -1
  77. package/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  78. package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  79. package/dist/graph/traversal.d.ts.map +1 -1
  80. package/dist/graph/traversal.js +76 -17
  81. package/dist/graph/traversal.js.map +1 -1
  82. package/dist/index.d.ts +25 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +78 -4
  85. package/dist/index.js.map +1 -1
  86. package/dist/installer/instructions-template.js +9 -9
  87. package/dist/installer/targets/shared.d.ts +5 -6
  88. package/dist/installer/targets/shared.d.ts.map +1 -1
  89. package/dist/installer/targets/shared.js +5 -6
  90. package/dist/installer/targets/shared.js.map +1 -1
  91. package/dist/mcp/daemon-paths.d.ts +30 -3
  92. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  93. package/dist/mcp/daemon-paths.js +50 -10
  94. package/dist/mcp/daemon-paths.js.map +1 -1
  95. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  96. package/dist/mcp/daemon-registry.js +7 -3
  97. package/dist/mcp/daemon-registry.js.map +1 -1
  98. package/dist/mcp/daemon.d.ts +48 -0
  99. package/dist/mcp/daemon.d.ts.map +1 -1
  100. package/dist/mcp/daemon.js +196 -32
  101. package/dist/mcp/daemon.js.map +1 -1
  102. package/dist/mcp/engine.d.ts +17 -0
  103. package/dist/mcp/engine.d.ts.map +1 -1
  104. package/dist/mcp/engine.js +73 -1
  105. package/dist/mcp/engine.js.map +1 -1
  106. package/dist/mcp/index.d.ts.map +1 -1
  107. package/dist/mcp/index.js +25 -43
  108. package/dist/mcp/index.js.map +1 -1
  109. package/dist/mcp/liveness-watchdog.js +16 -16
  110. package/dist/mcp/ppid-watchdog.d.ts +18 -0
  111. package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
  112. package/dist/mcp/ppid-watchdog.js +37 -0
  113. package/dist/mcp/ppid-watchdog.js.map +1 -1
  114. package/dist/mcp/query-cache.d.ts +25 -0
  115. package/dist/mcp/query-cache.d.ts.map +1 -0
  116. package/dist/mcp/query-cache.js +191 -0
  117. package/dist/mcp/query-cache.js.map +1 -0
  118. package/dist/mcp/query-pool.d.ts +94 -0
  119. package/dist/mcp/query-pool.d.ts.map +1 -0
  120. package/dist/mcp/query-pool.js +297 -0
  121. package/dist/mcp/query-pool.js.map +1 -0
  122. package/dist/mcp/query-worker.d.ts +24 -0
  123. package/dist/mcp/query-worker.d.ts.map +1 -0
  124. package/dist/mcp/query-worker.js +87 -0
  125. package/dist/mcp/query-worker.js.map +1 -0
  126. package/dist/mcp/server-instructions.d.ts +5 -7
  127. package/dist/mcp/server-instructions.d.ts.map +1 -1
  128. package/dist/mcp/server-instructions.js +72 -74
  129. package/dist/mcp/server-instructions.js.map +1 -1
  130. package/dist/mcp/tools.d.ts +94 -4
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +877 -71
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/project-config.d.ts +20 -0
  135. package/dist/project-config.d.ts.map +1 -1
  136. package/dist/project-config.js +42 -2
  137. package/dist/project-config.js.map +1 -1
  138. package/dist/reasoning/login.js +1 -1
  139. package/dist/reasoning/login.js.map +1 -1
  140. package/dist/reasoning/reasoner.js +32 -32
  141. package/dist/resolution/c-fnptr-synthesizer.d.ts +0 -28
  142. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  143. package/dist/resolution/c-fnptr-synthesizer.js +765 -79
  144. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  145. package/dist/resolution/callback-synthesizer.d.ts +1 -1
  146. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  147. package/dist/resolution/callback-synthesizer.js +72 -11
  148. package/dist/resolution/callback-synthesizer.js.map +1 -1
  149. package/dist/resolution/cooperative-yield.d.ts +32 -0
  150. package/dist/resolution/cooperative-yield.d.ts.map +1 -0
  151. package/dist/resolution/cooperative-yield.js +42 -0
  152. package/dist/resolution/cooperative-yield.js.map +1 -0
  153. package/dist/resolution/index.d.ts +11 -2
  154. package/dist/resolution/index.d.ts.map +1 -1
  155. package/dist/resolution/index.js +72 -4
  156. package/dist/resolution/index.js.map +1 -1
  157. package/dist/resolution/name-matcher.d.ts +22 -0
  158. package/dist/resolution/name-matcher.d.ts.map +1 -1
  159. package/dist/resolution/name-matcher.js +317 -20
  160. package/dist/resolution/name-matcher.js.map +1 -1
  161. package/dist/spec/config.d.ts +39 -0
  162. package/dist/spec/config.d.ts.map +1 -0
  163. package/dist/spec/config.js +304 -0
  164. package/dist/spec/config.js.map +1 -0
  165. package/dist/spec/db/commit-node.d.ts +23 -0
  166. package/dist/spec/db/commit-node.d.ts.map +1 -0
  167. package/dist/spec/db/commit-node.js +62 -0
  168. package/dist/spec/db/commit-node.js.map +1 -0
  169. package/dist/spec/db/fragment-node.d.ts +24 -0
  170. package/dist/spec/db/fragment-node.d.ts.map +1 -0
  171. package/dist/spec/db/fragment-node.js +128 -0
  172. package/dist/spec/db/fragment-node.js.map +1 -0
  173. package/dist/spec/db/fts.d.ts +74 -0
  174. package/dist/spec/db/fts.d.ts.map +1 -0
  175. package/dist/spec/db/fts.js +324 -0
  176. package/dist/spec/db/fts.js.map +1 -0
  177. package/dist/spec/db/index.d.ts +13 -0
  178. package/dist/spec/db/index.d.ts.map +1 -0
  179. package/dist/spec/db/index.js +50 -0
  180. package/dist/spec/db/index.js.map +1 -0
  181. package/dist/spec/db/relations.d.ts +55 -0
  182. package/dist/spec/db/relations.d.ts.map +1 -0
  183. package/dist/spec/db/relations.js +158 -0
  184. package/dist/spec/db/relations.js.map +1 -0
  185. package/dist/spec/db/schema.d.ts +33 -0
  186. package/dist/spec/db/schema.d.ts.map +1 -0
  187. package/dist/spec/db/schema.js +119 -0
  188. package/dist/spec/db/schema.js.map +1 -0
  189. package/dist/spec/db/schema.sql +117 -0
  190. package/dist/spec/db/spec-node.d.ts +41 -0
  191. package/dist/spec/db/spec-node.d.ts.map +1 -0
  192. package/dist/spec/db/spec-node.js +114 -0
  193. package/dist/spec/db/spec-node.js.map +1 -0
  194. package/dist/spec/evolve/impact-locator.d.ts +13 -0
  195. package/dist/spec/evolve/impact-locator.d.ts.map +1 -0
  196. package/dist/spec/evolve/impact-locator.js +25 -0
  197. package/dist/spec/evolve/impact-locator.js.map +1 -0
  198. package/dist/spec/evolve/llm-client.d.ts +50 -0
  199. package/dist/spec/evolve/llm-client.d.ts.map +1 -0
  200. package/dist/spec/evolve/llm-client.js +176 -0
  201. package/dist/spec/evolve/llm-client.js.map +1 -0
  202. package/dist/spec/evolve/logic-checker.d.ts +12 -0
  203. package/dist/spec/evolve/logic-checker.d.ts.map +1 -0
  204. package/dist/spec/evolve/logic-checker.js +24 -0
  205. package/dist/spec/evolve/logic-checker.js.map +1 -0
  206. package/dist/spec/evolve/pipeline.d.ts +42 -0
  207. package/dist/spec/evolve/pipeline.d.ts.map +1 -0
  208. package/dist/spec/evolve/pipeline.js +567 -0
  209. package/dist/spec/evolve/pipeline.js.map +1 -0
  210. package/dist/spec/evolve/spec-rewriter.d.ts +42 -0
  211. package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -0
  212. package/dist/spec/evolve/spec-rewriter.js +230 -0
  213. package/dist/spec/evolve/spec-rewriter.js.map +1 -0
  214. package/dist/spec/graph/queries.d.ts +155 -0
  215. package/dist/spec/graph/queries.d.ts.map +1 -0
  216. package/dist/spec/graph/queries.js +440 -0
  217. package/dist/spec/graph/queries.js.map +1 -0
  218. package/dist/spec/llm/client.d.ts +29 -0
  219. package/dist/spec/llm/client.d.ts.map +1 -0
  220. package/dist/spec/llm/client.js +123 -0
  221. package/dist/spec/llm/client.js.map +1 -0
  222. package/dist/spec/llm/index.d.ts +3 -0
  223. package/dist/spec/llm/index.d.ts.map +1 -0
  224. package/dist/spec/llm/index.js +11 -0
  225. package/dist/spec/llm/index.js.map +1 -0
  226. package/dist/spec/llm/prompts.d.ts +13 -0
  227. package/dist/spec/llm/prompts.d.ts.map +1 -0
  228. package/dist/spec/llm/prompts.js +75 -0
  229. package/dist/spec/llm/prompts.js.map +1 -0
  230. package/dist/spec/mining/diff-parser.d.ts +33 -0
  231. package/dist/spec/mining/diff-parser.d.ts.map +1 -0
  232. package/dist/spec/mining/diff-parser.js +166 -0
  233. package/dist/spec/mining/diff-parser.js.map +1 -0
  234. package/dist/spec/mining/git-scanner.d.ts +103 -0
  235. package/dist/spec/mining/git-scanner.d.ts.map +1 -0
  236. package/dist/spec/mining/git-scanner.js +307 -0
  237. package/dist/spec/mining/git-scanner.js.map +1 -0
  238. package/dist/spec/mining/pipeline.d.ts +53 -0
  239. package/dist/spec/mining/pipeline.d.ts.map +1 -0
  240. package/dist/spec/mining/pipeline.js +178 -0
  241. package/dist/spec/mining/pipeline.js.map +1 -0
  242. package/dist/spec/mining/scope-resolver.d.ts +45 -0
  243. package/dist/spec/mining/scope-resolver.d.ts.map +1 -0
  244. package/dist/spec/mining/scope-resolver.js +103 -0
  245. package/dist/spec/mining/scope-resolver.js.map +1 -0
  246. package/dist/spec/mining/spec-extractor.d.ts +69 -0
  247. package/dist/spec/mining/spec-extractor.d.ts.map +1 -0
  248. package/dist/spec/mining/spec-extractor.js +369 -0
  249. package/dist/spec/mining/spec-extractor.js.map +1 -0
  250. package/dist/spec/types.d.ts +149 -0
  251. package/dist/spec/types.d.ts.map +1 -0
  252. package/dist/spec/types.js +15 -0
  253. package/dist/spec/types.js.map +1 -0
  254. package/dist/spec/utils.d.ts +167 -0
  255. package/dist/spec/utils.d.ts.map +1 -0
  256. package/dist/spec/utils.js +463 -0
  257. package/dist/spec/utils.js.map +1 -0
  258. package/dist/sync/worktree.d.ts +9 -0
  259. package/dist/sync/worktree.d.ts.map +1 -1
  260. package/dist/sync/worktree.js +40 -0
  261. package/dist/sync/worktree.js.map +1 -1
  262. package/dist/types.d.ts +6 -1
  263. package/dist/types.d.ts.map +1 -1
  264. package/dist/ui/shimmer-progress.d.ts +2 -0
  265. package/dist/ui/shimmer-progress.d.ts.map +1 -1
  266. package/dist/ui/shimmer-progress.js +19 -2
  267. package/dist/ui/shimmer-progress.js.map +1 -1
  268. package/dist/upgrade/index.js +1 -1
  269. package/dist/upgrade/index.js.map +1 -1
  270. package/package.json +58 -57
  271. package/scripts/add-lang/bench.sh +60 -60
  272. package/scripts/add-lang/check-grammar.mjs +75 -75
  273. package/scripts/add-lang/dump-ast.mjs +103 -103
  274. package/scripts/add-lang/verify-extraction.mjs +70 -70
  275. package/scripts/agent-eval/ab-adoption.sh +91 -91
  276. package/scripts/agent-eval/ab-hook.sh +86 -86
  277. package/scripts/agent-eval/ab-impl.sh +78 -78
  278. package/scripts/agent-eval/ab-new-vs-baseline.sh +102 -102
  279. package/scripts/agent-eval/ab-sufficiency.sh +78 -78
  280. package/scripts/agent-eval/arms-F.sh +21 -21
  281. package/scripts/agent-eval/arms-matrix.sh +37 -37
  282. package/scripts/agent-eval/audit.sh +68 -68
  283. package/scripts/agent-eval/bench-readme.sh +28 -28
  284. package/scripts/agent-eval/bench-why-repo.sh +22 -22
  285. package/scripts/agent-eval/block-read-hook.sh +19 -19
  286. package/scripts/agent-eval/hook-settings.json +15 -15
  287. package/scripts/agent-eval/itrun.sh +120 -120
  288. package/scripts/agent-eval/offload-eval-3arm.sh +72 -72
  289. package/scripts/agent-eval/offload-eval-cost.mjs +133 -133
  290. package/scripts/agent-eval/offload-eval-effort.mjs +108 -108
  291. package/scripts/agent-eval/offload-eval-frontload-matrix.sh +25 -25
  292. package/scripts/agent-eval/offload-eval-frontload.sh +47 -47
  293. package/scripts/agent-eval/offload-eval-ground-truth.json +18 -18
  294. package/scripts/agent-eval/offload-eval-hook.mjs +84 -84
  295. package/scripts/agent-eval/offload-eval-judge.mjs +103 -103
  296. package/scripts/agent-eval/offload-eval-matrix.sh +20 -20
  297. package/scripts/agent-eval/offload-eval-metrics.mjs +94 -94
  298. package/scripts/agent-eval/offload-eval-refs1.sh +50 -50
  299. package/scripts/agent-eval/offload-eval-setup.sh +24 -24
  300. package/scripts/agent-eval/offload-eval-styles.sh +71 -71
  301. package/scripts/agent-eval/offload-eval-summarize.mjs +68 -68
  302. package/scripts/agent-eval/offload-eval.md +76 -76
  303. package/scripts/agent-eval/parse-arms.mjs +116 -116
  304. package/scripts/agent-eval/parse-bench-readme.mjs +84 -84
  305. package/scripts/agent-eval/parse-run.mjs +45 -45
  306. package/scripts/agent-eval/parse-session.mjs +93 -93
  307. package/scripts/agent-eval/probe-context.mjs +21 -21
  308. package/scripts/agent-eval/probe-explore.mjs +40 -40
  309. package/scripts/agent-eval/probe-node.mjs +20 -20
  310. package/scripts/agent-eval/probe-sweep.mjs +119 -119
  311. package/scripts/agent-eval/probe-trace.mjs +20 -20
  312. package/scripts/agent-eval/redirect-read-hook.sh +38 -38
  313. package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
  314. package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
  315. package/scripts/agent-eval/run-agent.sh +34 -34
  316. package/scripts/agent-eval/run-all.sh +69 -69
  317. package/scripts/agent-eval/run-arms.sh +56 -56
  318. package/scripts/agent-eval/seq-matrix.mjs +137 -137
  319. package/scripts/build-bundle.sh +118 -118
  320. package/scripts/exp_boundary_eval/README.md +247 -0
  321. package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
  322. package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
  323. package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
  324. package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
  325. package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
  326. package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
  327. package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
  328. package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
  329. package/scripts/exp_boundary_eval/_utils.py +1116 -0
  330. package/scripts/exp_boundary_eval/analyze.py +1313 -0
  331. package/scripts/exp_boundary_eval/data/agents.json +109 -0
  332. package/scripts/exp_boundary_eval/data/experiments.json +140 -0
  333. package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
  334. package/scripts/exp_boundary_eval/run_all.py +378 -0
  335. package/scripts/exp_boundary_eval/run_one.py +165 -0
  336. package/scripts/exp_boundary_eval/run_session.py +158 -0
  337. package/scripts/exp_boundary_eval/setup.py +120 -0
  338. package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
  339. package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
  340. package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
  341. package/scripts/extract-release-notes.mjs +130 -130
  342. package/scripts/local-install.sh +41 -41
  343. package/scripts/npm-sdk.js +75 -75
  344. package/scripts/npm-shim.js +268 -246
  345. package/scripts/ohos-sdk-publish.mjs +133 -0
  346. package/scripts/pack-npm.sh +119 -119
  347. package/scripts/prepare-release.mjs +270 -270
  348. package/scripts/qa_eval/README.md +407 -404
  349. package/scripts/qa_eval/_test_deveco_probe.py +41 -41
  350. package/scripts/qa_eval/agent_runner.py +526 -526
  351. package/scripts/qa_eval/data/.gitignore +4 -4
  352. package/scripts/qa_eval/data/test-set.jsonl +2 -22
  353. package/scripts/qa_eval/eval_metrics.py +274 -233
  354. package/scripts/qa_eval/external_agent.py +976 -671
  355. package/scripts/qa_eval/llm_config.py +92 -92
  356. package/scripts/qa_eval/memory_monitor.py +132 -132
  357. package/scripts/qa_eval/my_answer_accuracy.py +187 -187
  358. package/scripts/qa_eval/requirements.txt +2 -2
  359. package/scripts/qa_eval/run_pipeline.py +804 -711
  360. package/scripts/qa_eval/stats_efficiency.py +279 -279
  361. package/scripts/qa_eval/stats_scores.py +207 -207
@@ -1,671 +1,976 @@
1
- """Run qa_eval dataset through real Agent hosts: Claude Code CLI, DevEco Code (opencode CLI)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import logging
7
- import os
8
- import re
9
- import shutil
10
- import subprocess
11
- import tempfile
12
- import time
13
- from datetime import datetime
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- from memory_monitor import sample_memory
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
- TIME_FMT = "%Y-%m-%d %H:%M:%S.%f"
22
-
23
- HOST_CLAUDE = "claude-code"
24
- HOST_DEVECO = "deveco-code"
25
- SUPPORTED_HOSTS = (HOST_CLAUDE, HOST_DEVECO)
26
-
27
-
28
- def _strip_ansi(text: str) -> str:
29
- return re.sub(r"\x1b\[[0-9;]*m", "", text or "")
30
-
31
-
32
- def _extract_deveco_json_errors(text: str) -> str | None:
33
- """Parse deveco --format json error lines into a short message."""
34
- messages: list[str] = []
35
- for line in _strip_ansi(text).splitlines():
36
- line = line.strip()
37
- if not line.startswith("{"):
38
- continue
39
- try:
40
- ev = json.loads(line)
41
- except json.JSONDecodeError:
42
- continue
43
- if ev.get("type") != "error":
44
- continue
45
- err = ev.get("error") or {}
46
- data = err.get("data") or {}
47
- msg = data.get("message") or err.get("message") or err.get("name")
48
- if msg and msg not in messages:
49
- messages.append(str(msg))
50
- if not messages:
51
- return None
52
- return "; ".join(messages)
53
-
54
-
55
- def _cli_error_summary(stderr: str, stdout: str, *, max_len: int = 400) -> str:
56
- """Pick a short user-facing error from CLI stderr/stdout."""
57
- for raw in (stderr, stdout):
58
- deveco_err = _extract_deveco_json_errors(raw)
59
- if deveco_err:
60
- return deveco_err[:max_len]
61
- for raw in (stderr, stdout):
62
- clean = _strip_ansi(raw).strip()
63
- if not clean:
64
- continue
65
- for line in clean.splitlines():
66
- line = line.strip()
67
- if not line or line.lower().startswith("error:"):
68
- continue
69
- if len(line) > 20:
70
- return line[:max_len]
71
- if clean:
72
- return clean[:max_len]
73
- return ""
74
-
75
-
76
- def auth_failure_reason(text: str) -> str | None:
77
- """Return a user-facing reason if output looks like an auth/login failure."""
78
- t = _strip_ansi(text or "").lower()
79
- if any(h in t for h in ("not logged in", "please run /login")) or (
80
- "/login" in t and "run" in t
81
- ):
82
- return "Claude Code 未登录,请先运行: claude login"
83
- if "credentials cannot be decrypted" in t or "saved provider credentials are unavailable" in t:
84
- return (
85
- "DevEco Code 本地凭证无法解密。请运行: deveco providers reset,"
86
- "然后在 TUI 中重新配置模型,或执行 deveco providers login"
87
- )
88
- if "deveco providers reset" in t or "deveco auth reset" in t:
89
- return (
90
- "DevEco Code 未配置或凭证无效。请运行: deveco providers reset,"
91
- "然后重新登录/配置 provider"
92
- )
93
- if "model not found" in t:
94
- return (
95
- "DevEco 模型名无效。deveco 需要 provider/model 格式(如 zhipuai/glm-4.5-flash),"
96
- "可用 `deveco models` 查看;勿把 Judge --model 直接传给 deveco"
97
- )
98
- return "API Key 无效"
99
- if "authentication required" in t or "unauthorized" in t:
100
- return "未授权,请检查登录或 API Key"
101
- return None
102
-
103
-
104
- def _log_line(log_file: Path | None, msg: str) -> None:
105
- if log_file is None:
106
- return
107
- log_file.parent.mkdir(parents=True, exist_ok=True)
108
- with log_file.open("a", encoding="utf-8") as f:
109
- f.write(f"{datetime.now().strftime(TIME_FMT)} {msg}\n")
110
-
111
-
112
- def _log_memory(log_file: Path | None, mem: dict[str, float | None]) -> None:
113
- if mem.get("peak_rss_mb") is not None:
114
- _log_line(log_file, f"peakRssMb = {mem['peak_rss_mb']}")
115
- if mem.get("avg_rss_mb") is not None:
116
- _log_line(log_file, f"avgRssMb = {mem['avg_rss_mb']}")
117
-
118
-
119
- def find_claude_cli() -> str:
120
- found = shutil.which("claude")
121
- if not found:
122
- raise FileNotFoundError(
123
- "未找到 claude CLI。请安装 Claude Code 并确保 `claude` 在 PATH 中。"
124
- )
125
- return found
126
-
127
-
128
- def find_deveco_cli() -> str:
129
- for name in ("deveco", "opencode"):
130
- found = shutil.which(name)
131
- if found:
132
- return found
133
- raise FileNotFoundError(
134
- "未找到 DevEco Code / opencode CLI。请安装 DevEco Code 或 opencode 并加入 PATH。"
135
- )
136
-
137
-
138
- def _popen_capture(cmd: list[str], *, cwd: str) -> subprocess.Popen:
139
- """Run CLI with UTF-8 stdout/stderr (Windows default locale is often GBK)."""
140
- return subprocess.Popen(
141
- cmd,
142
- cwd=cwd,
143
- stdout=subprocess.PIPE,
144
- stderr=subprocess.PIPE,
145
- text=True,
146
- encoding="utf-8",
147
- errors="replace",
148
- )
149
-
150
-
151
- def write_mcp_config(path: Path, *, hg_command: str, hg_args: list[str]) -> None:
152
- path.write_text(
153
- json.dumps({"mcpServers": {"homegraph": {"command": hg_command, "args": hg_args}}}, indent=2)
154
- + "\n",
155
- encoding="utf-8",
156
- )
157
-
158
-
159
- def _split_hg_bin(hg_bin: str) -> tuple[str, list[str]]:
160
- if hg_bin.startswith("node "):
161
- parts = hg_bin.split(" ", 1)
162
- return parts[0], parts[1].split() + ["serve", "--mcp"]
163
- return hg_bin, ["serve", "--mcp"]
164
-
165
-
166
- def parse_claude_stream_json(raw: str) -> dict[str, Any]:
167
- tool_trace: list[str] = []
168
- answer_parts: list[str] = []
169
- total_tokens = 0
170
- max_turn = 0
171
- duration_ms = 0
172
-
173
- for line in raw.splitlines():
174
- line = line.strip()
175
- if not line:
176
- continue
177
- try:
178
- ev = json.loads(line)
179
- except json.JSONDecodeError:
180
- continue
181
-
182
- if ev.get("type") == "assistant" and ev.get("message", {}).get("content"):
183
- max_turn += 1
184
- for block in ev["message"]["content"]:
185
- if block.get("type") == "text" and block.get("text"):
186
- answer_parts.append(str(block["text"]))
187
- if block.get("type") == "tool_use":
188
- name = block.get("name", "?")
189
- inp = block.get("input") or {}
190
- tool_trace.append(
191
- f"---\n{name}\nargs: {json.dumps(inp, ensure_ascii=False)[:500]}\n---"
192
- )
193
-
194
- if ev.get("type") == "result":
195
- usage = ev.get("usage") or {}
196
- total_tokens = int(
197
- (usage.get("input_tokens") or 0)
198
- + (usage.get("cache_read_input_tokens") or 0)
199
- + (usage.get("cache_creation_input_tokens") or 0)
200
- + (usage.get("output_tokens") or 0)
201
- )
202
- duration_ms = int(ev.get("duration_ms") or 0)
203
- if ev.get("num_turns"):
204
- max_turn = max(max_turn, int(ev["num_turns"]))
205
- if ev.get("result") and not answer_parts:
206
- answer_parts.append(str(ev["result"]))
207
-
208
- answer = "\n".join(answer_parts).strip()
209
- output = "\n\n".join(tool_trace + ([answer] if answer else []))
210
- auth_err = auth_failure_reason(answer) or auth_failure_reason(raw)
211
- if auth_err:
212
- return {
213
- "output_answer": output,
214
- "agent_status": "error",
215
- "agent_error": auth_err,
216
- "agent_turns": max_turn,
217
- "agent_duration_ms": duration_ms,
218
- "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
219
- }
220
- return {
221
- "output_answer": output,
222
- "agent_status": "success" if answer else "error",
223
- "agent_turns": max_turn,
224
- "agent_duration_ms": duration_ms,
225
- "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
226
- }
227
-
228
-
229
- def parse_opencode_json_events(raw: str) -> dict[str, Any]:
230
- tool_trace: list[str] = []
231
- answer_parts: list[str] = []
232
- total_tokens = 0
233
- max_turn = 0
234
- duration_ms = 0
235
- stream_errors: list[str] = []
236
-
237
- for line in raw.splitlines():
238
- line = line.strip()
239
- if not line:
240
- continue
241
- try:
242
- ev = json.loads(line)
243
- except json.JSONDecodeError:
244
- continue
245
-
246
- ev_type = ev.get("type") or ev.get("event")
247
- part = ev.get("part") if isinstance(ev.get("part"), dict) else {}
248
-
249
- if ev_type == "error":
250
- err = ev.get("error") or {}
251
- data = err.get("data") or {}
252
- msg = data.get("message") or err.get("message") or err.get("name")
253
- if msg:
254
- stream_errors.append(str(msg))
255
-
256
- # DevEco / opencode stream-json: {type:"text", part:{type:"text", text:"..."}}
257
- if ev_type == "text" and part.get("text"):
258
- text = str(part["text"]).strip()
259
- if text:
260
- answer_parts.append(text)
261
- max_turn += 1
262
-
263
- part_type = part.get("type") or ""
264
- if ev_type in ("tool", "tool_call", "tool_use") or part_type in (
265
- "tool",
266
- "tool-invocation",
267
- "tool_use",
268
- "tool-call",
269
- ):
270
- max_turn += 1
271
- name = part.get("tool") or part.get("name") or ev.get("tool") or "?"
272
- inp = part.get("input") or part.get("args") or part.get("state") or ev.get("input") or {}
273
- tool_trace.append(
274
- f"---\n{name}\nargs: {json.dumps(inp, ensure_ascii=False)[:500]}\n---"
275
- )
276
-
277
- if ev_type in ("message", "assistant"):
278
- content = ev.get("content") or ev.get("text") or ev.get("message")
279
- if isinstance(content, str) and content.strip():
280
- answer_parts.append(content.strip())
281
- max_turn += 1
282
- elif isinstance(content, list):
283
- max_turn += 1
284
- for block in content:
285
- if isinstance(block, dict):
286
- if block.get("type") == "text" and block.get("text"):
287
- answer_parts.append(str(block["text"]))
288
- if block.get("type") == "tool_use":
289
- tool_trace.append(
290
- f"---\n{block.get('name', '?')}\n"
291
- f"args: {json.dumps(block.get('input') or {}, ensure_ascii=False)[:500]}\n---"
292
- )
293
-
294
- usage = ev.get("usage") or ev.get("tokens")
295
- if isinstance(usage, dict):
296
- total = usage.get("total") or usage.get("total_tokens")
297
- if total:
298
- total_tokens = max(total_tokens, int(total))
299
-
300
- if ev.get("duration_ms"):
301
- duration_ms = max(duration_ms, int(ev["duration_ms"]))
302
-
303
- part_time = part.get("time") if isinstance(part.get("time"), dict) else {}
304
- if part_time.get("start") and part_time.get("end"):
305
- duration_ms = max(duration_ms, int(part_time["end"]) - int(part_time["start"]))
306
-
307
- answer = "\n".join(answer_parts).strip()
308
- output = "\n\n".join(tool_trace + ([answer] if answer else []))
309
- auth_err = auth_failure_reason(answer) or auth_failure_reason(raw)
310
- if auth_err:
311
- return {
312
- "output_answer": output,
313
- "agent_status": "error",
314
- "agent_error": auth_err,
315
- "agent_turns": max_turn,
316
- "agent_duration_ms": duration_ms,
317
- "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
318
- }
319
- if stream_errors and not answer:
320
- return {
321
- "output_answer": output,
322
- "agent_status": "error",
323
- "agent_error": "; ".join(stream_errors),
324
- "agent_turns": max_turn,
325
- "agent_duration_ms": duration_ms,
326
- "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
327
- }
328
- return {
329
- "output_answer": output,
330
- "agent_status": "success" if answer else "error",
331
- "agent_error": None if answer else "deveco 未返回可解析的文本回答",
332
- "agent_turns": max_turn,
333
- "agent_duration_ms": duration_ms,
334
- "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
335
- }
336
-
337
-
338
- def verify_claude_login() -> None:
339
- """Fail fast before burning the whole dataset on 'Not logged in'."""
340
- claude = find_claude_cli()
341
- proc = subprocess.run(
342
- [claude, "-p", "ping", "--output-format", "text", "--max-turns", "1"],
343
- capture_output=True,
344
- text=True,
345
- timeout=45,
346
- cwd=os.getcwd(),
347
- )
348
- combined = f"{proc.stdout}\n{proc.stderr}"
349
- reason = auth_failure_reason(combined)
350
- if reason:
351
- raise RuntimeError(f"{reason}\n(探测输出: {combined.strip()[:200]})")
352
-
353
-
354
- def run_claude_query(
355
- repo: Path,
356
- query: str,
357
- *,
358
- arm: str,
359
- hg_bin: str,
360
- log_file: Path | None,
361
- task_id: int,
362
- timeout_sec: int = 600,
363
- ) -> dict[str, Any]:
364
- claude = find_claude_cli()
365
- backend = f"claude-code-{'with' if arm == 'with' else 'without'}-homegraph"
366
-
367
- with tempfile.TemporaryDirectory(prefix="qa-eval-mcp-") as tmp:
368
- mcp_path = Path(tmp) / "mcp.json"
369
- if arm == "with":
370
- cmd, base_args = _split_hg_bin(hg_bin)
371
- write_mcp_config(
372
- mcp_path,
373
- hg_command=cmd,
374
- hg_args=[*base_args, "--path", str(repo.resolve())],
375
- )
376
- mcp_args = ["--strict-mcp-config", "--mcp-config", str(mcp_path)]
377
- else:
378
- mcp_path.write_text('{"mcpServers":{}}\n', encoding="utf-8")
379
- mcp_args = ["--strict-mcp-config", "--mcp-config", str(mcp_path)]
380
-
381
- cmd = [
382
- claude,
383
- "-p",
384
- query,
385
- "--output-format",
386
- "stream-json",
387
- "--verbose",
388
- "--permission-mode",
389
- "bypassPermissions",
390
- *mcp_args,
391
- ]
392
-
393
- _log_line(log_file, f"Evaluate {task_id}:")
394
- _log_line(log_file, "the 1 turn")
395
- t0 = time.time()
396
-
397
- proc = _popen_capture(cmd, cwd=str(repo))
398
- with sample_memory(proc.pid) as sampler:
399
- try:
400
- stdout, stderr = proc.communicate(timeout=timeout_sec)
401
- except subprocess.TimeoutExpired:
402
- proc.kill()
403
- stdout, stderr = proc.communicate()
404
- mem = sampler.last_stats
405
- _log_memory(log_file, mem)
406
- return {
407
- "output_answer": "",
408
- "agent_status": "error",
409
- "agent_error": f"timeout after {timeout_sec}s",
410
- "agent_backend": backend,
411
- "agent_memory_mb": mem,
412
- }
413
- mem = sampler.last_stats
414
-
415
- if proc.returncode != 0 and not (stdout or "").strip():
416
- _log_memory(log_file, mem)
417
- return {
418
- "output_answer": (stderr or "")[:2000],
419
- "agent_status": "error",
420
- "agent_error": f"claude exit {proc.returncode}",
421
- "agent_backend": backend,
422
- "agent_memory_mb": mem,
423
- }
424
-
425
- _log_line(log_file, "first token")
426
- combined = f"{stdout}\n{stderr}"
427
- auth_err = auth_failure_reason(combined)
428
- if auth_err:
429
- _log_memory(log_file, mem)
430
- return {
431
- "output_answer": (stdout or stderr or "")[:2000],
432
- "agent_status": "error",
433
- "agent_error": auth_err,
434
- "agent_backend": backend,
435
- "agent_host": HOST_CLAUDE,
436
- "agent_memory_mb": mem,
437
- }
438
- parsed = parse_claude_stream_json(stdout or "")
439
- tokens = (parsed.get("agent_usage") or {}).get("total_tokens") or 0
440
- if tokens:
441
- _log_line(log_file, f"totalTokenCount = {tokens}")
442
- _log_memory(log_file, mem)
443
-
444
- duration_ms = parsed.get("agent_duration_ms") or int((time.time() - t0) * 1000)
445
- return {
446
- **parsed,
447
- "agent_duration_ms": duration_ms,
448
- "agent_backend": backend,
449
- "agent_host": HOST_CLAUDE,
450
- "ab_arm": "with-homegraph" if arm == "with" else "without-homegraph",
451
- "agent_memory_mb": mem,
452
- }
453
-
454
-
455
- def _deveco_project_config_dir(repo: Path) -> Path:
456
- """Project-level deveco config (read before ~/.config/deveco/deveco.jsonc)."""
457
- return repo / ".deveco"
458
-
459
-
460
- def _write_deveco_project_mcp(repo: Path, *, arm: str, hg_bin: str) -> Path:
461
- """Write repo/.deveco/deveco.jsonc for MCP only; credentials stay in ~/.config/deveco."""
462
- config_dir = _deveco_project_config_dir(repo)
463
- config_dir.mkdir(parents=True, exist_ok=True)
464
- config_path = config_dir / "deveco.jsonc"
465
- if arm == "with":
466
- if not hg_bin:
467
- from agent_runner import find_homegraph_bin
468
-
469
- hg_bin = find_homegraph_bin(None)
470
- cmd, base_args = _split_hg_bin(hg_bin)
471
- body = {
472
- "$schema": "https://opencode.ai/config.json",
473
- "mcp": {
474
- "homegraph": {
475
- "type": "local",
476
- "command": [cmd, *base_args, "--path", str(repo.resolve())],
477
- "enabled": True,
478
- }
479
- },
480
- }
481
- else:
482
- body = {"$schema": "https://opencode.ai/config.json", "mcp": {}}
483
- config_path.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
484
- return config_path
485
-
486
-
487
- DEFAULT_DEVECO_MODEL = "zhipuai/glm-4.5-flash"
488
-
489
-
490
- def run_deveco_query(
491
- repo: Path,
492
- query: str,
493
- *,
494
- arm: str,
495
- hg_bin: str,
496
- log_file: Path | None,
497
- task_id: int,
498
- model: str | None = None,
499
- timeout_sec: int = 600,
500
- ) -> dict[str, Any]:
501
- cli = find_deveco_cli()
502
- backend = f"deveco-code-{'with' if arm == 'with' else 'without'}-homegraph"
503
-
504
- _write_deveco_project_mcp(repo, arm=arm, hg_bin=hg_bin)
505
-
506
- run_cmd = [
507
- cli,
508
- "run",
509
- query,
510
- "--format",
511
- "json",
512
- "--dir",
513
- str(repo),
514
- "--dangerously-skip-permissions",
515
- ]
516
- if model:
517
- run_cmd.extend(["--model", model])
518
- else:
519
- run_cmd.extend(["--model", DEFAULT_DEVECO_MODEL])
520
-
521
- _log_line(log_file, f"Evaluate {task_id}:")
522
- _log_line(log_file, "the 1 turn")
523
- t0 = time.time()
524
-
525
- proc = _popen_capture(run_cmd, cwd=str(repo))
526
- with sample_memory(proc.pid) as sampler:
527
- try:
528
- stdout, stderr = proc.communicate(timeout=timeout_sec)
529
- except subprocess.TimeoutExpired:
530
- proc.kill()
531
- stdout, stderr = proc.communicate()
532
- mem = sampler.last_stats
533
- _log_memory(log_file, mem)
534
- return {
535
- "output_answer": "",
536
- "agent_status": "error",
537
- "agent_error": f"timeout after {timeout_sec}s",
538
- "agent_backend": backend,
539
- "agent_memory_mb": mem,
540
- }
541
- mem = sampler.last_stats
542
- combined = f"{stdout or ''}\n{stderr or ''}"
543
-
544
- if proc.returncode != 0:
545
- auth_err = auth_failure_reason(combined)
546
- detail = auth_err or _cli_error_summary(stderr, stdout)
547
- err_msg = detail or f"{cli} exit {proc.returncode}"
548
- _log_memory(log_file, mem)
549
- return {
550
- "output_answer": _strip_ansi(stderr or stdout or "")[:2000],
551
- "agent_status": "error",
552
- "agent_error": err_msg,
553
- "agent_backend": backend,
554
- "agent_host": HOST_DEVECO,
555
- "agent_memory_mb": mem,
556
- }
557
-
558
- _log_line(log_file, "first token")
559
- out = (stdout or "").strip()
560
- err = (stderr or "").strip()
561
- parsed = parse_opencode_json_events(out if out else err)
562
- tokens = (parsed.get("agent_usage") or {}).get("total_tokens") or 0
563
- if tokens:
564
- _log_line(log_file, f"totalTokenCount = {tokens}")
565
- _log_memory(log_file, mem)
566
-
567
- duration_ms = parsed.get("agent_duration_ms") or int((time.time() - t0) * 1000)
568
- result = {
569
- **parsed,
570
- "agent_duration_ms": duration_ms,
571
- "agent_backend": backend,
572
- "agent_host": HOST_DEVECO,
573
- "ab_arm": "with-homegraph" if arm == "with" else "without-homegraph",
574
- "agent_memory_mb": mem,
575
- }
576
- if result.get("agent_status") != "success" and not result.get("agent_error"):
577
- result["agent_error"] = _extract_deveco_json_errors(combined) or "deveco 未返回可解析的文本回答"
578
- return result
579
-
580
-
581
- def run_external_dataset(
582
- host: str,
583
- repo: Path,
584
- dataset: list[dict[str, Any]],
585
- *,
586
- arm: str,
587
- output: Path,
588
- log_file: Path | None,
589
- hg_bin: str,
590
- model: str | None = None,
591
- ) -> list[dict[str, Any]]:
592
- if host not in SUPPORTED_HOSTS:
593
- raise ValueError(f"unknown agent host: {host}")
594
-
595
- from agent_runner import find_homegraph_bin, print_agent_progress, require_index, _arm_short
596
-
597
- if arm == "with":
598
- require_index(repo)
599
- hg = find_homegraph_bin(hg_bin) if arm == "with" else ""
600
-
601
- output.parent.mkdir(parents=True, exist_ok=True)
602
- if log_file:
603
- log_file.write_text("", encoding="utf-8")
604
-
605
- results: list[dict[str, Any]] = []
606
- total = len(dataset)
607
- print(f" → [{host}] {_arm_short(arm)} 臂:共 {total} 题", flush=True)
608
-
609
- auth_abort: str | None = None
610
- with output.open("w", encoding="utf-8") as f:
611
- for i, item in enumerate(dataset, 1):
612
- if auth_abort:
613
- meta = {
614
- "output_answer": "",
615
- "agent_status": "error",
616
- "agent_error": auth_abort,
617
- "agent_backend": f"{host}-{arm}",
618
- "agent_host": host,
619
- }
620
- print_agent_progress(arm, i, total, str(item.get("id") or i), f"跳过: {auth_abort[:80]}")
621
- row = {**item, **meta}
622
- results.append(row)
623
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
624
- f.flush()
625
- continue
626
- q = str(item["query"])
627
- item_id = str(item.get("id") or i)
628
- print_agent_progress(arm, i, total, item_id, f"[{host}] 开始…")
629
- logger.info("[%s/%s] %s/%s %s", host, arm, i, total, item_id)
630
- try:
631
- common = dict(
632
- arm=arm,
633
- hg_bin=hg,
634
- log_file=log_file,
635
- task_id=i,
636
- )
637
- if host == HOST_CLAUDE:
638
- meta = run_claude_query(repo, q, **common)
639
- else:
640
- meta = run_deveco_query(repo, q, model=model, **common)
641
- except Exception as e:
642
- logger.error("External agent failed %s: %s", item.get("id"), e)
643
- meta = {
644
- "output_answer": "",
645
- "agent_status": "error",
646
- "agent_error": str(e),
647
- "agent_backend": f"{host}-{arm}",
648
- "agent_host": host,
649
- }
650
- row = {**item, **meta}
651
- results.append(row)
652
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
653
- f.flush()
654
- if meta.get("agent_status") == "success":
655
- dur_ms = meta.get("agent_duration_ms")
656
- dur_s = f"{dur_ms / 1000:.1f}s" if isinstance(dur_ms, (int, float)) else "?"
657
- print_agent_progress(arm, i, total, item_id, f"[{host}] 完成 ({dur_s})")
658
- else:
659
- err = str(meta.get("agent_error") or meta.get("agent_status") or "error")
660
- print_agent_progress(arm, i, total, item_id, f"[{host}] 失败: {err[:100]}")
661
- if i == 1 and meta.get("agent_error") and (
662
- "未登录" in str(meta["agent_error"])
663
- or "DevEco Code" in str(meta["agent_error"])
664
- or "DevEco 模型" in str(meta["agent_error"])
665
- or "Model not found" in str(meta["agent_error"])
666
- ):
667
- auth_abort = str(meta["agent_error"])
668
- print(f"\n ✗ {auth_abort} — 后续题目跳过", flush=True)
669
- ok = sum(1 for r in results if r.get("agent_status") == "success")
670
- print(f" [{host}] {_arm_short(arm)} 臂结束:{ok}/{total} 成功", flush=True)
671
- return results
1
+ """Run qa_eval dataset through real Agent hosts: Claude Code CLI, DevEco Code (opencode CLI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from memory_monitor import sample_memory
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _QA_EVAL_DIR = Path(__file__).resolve().parent
22
+
23
+ TIME_FMT = "%Y-%m-%d %H:%M:%S.%f"
24
+
25
+ HOST_CLAUDE = "claude-code"
26
+ HOST_DEVECO = "deveco-code"
27
+ SUPPORTED_HOSTS = (HOST_CLAUDE, HOST_DEVECO)
28
+
29
+ # DevEco / opencode MCP tool names (server "homegraph" may prefix once or twice).
30
+ _HOMEGRAPH_TOOL_RE = re.compile(r"homegraph(?:_homegraph)?_(?:explore|node|search|callers|callees)", re.I)
31
+
32
+
33
+ def trace_tool_names(output_answer: str) -> list[str]:
34
+ return re.findall(r"---\n([^\n]+)\n", str(output_answer or ""))
35
+
36
+
37
+ def is_homegraph_tool(name: str) -> bool:
38
+ n = str(name or "").strip()
39
+ if not n:
40
+ return False
41
+ if _HOMEGRAPH_TOOL_RE.search(n):
42
+ return True
43
+ return n.lower() in ("homegraph_explore", "homegraph_node")
44
+
45
+
46
+ def output_used_homegraph(output_answer: str) -> bool:
47
+ return any(is_homegraph_tool(n) for n in trace_tool_names(output_answer))
48
+
49
+
50
+ def _tools_from_session_export(raw: str) -> list[str]:
51
+ tools: list[str] = []
52
+ try:
53
+ data = json.loads(raw.strip())
54
+ except json.JSONDecodeError:
55
+ return tools
56
+ if not isinstance(data, dict):
57
+ return tools
58
+ for msg in data.get("messages") or []:
59
+ if not isinstance(msg, dict):
60
+ continue
61
+ for part in msg.get("parts") or []:
62
+ if not isinstance(part, dict) or part.get("type") != "tool":
63
+ continue
64
+ t = part.get("tool")
65
+ if t:
66
+ tools.append(str(t))
67
+ return tools
68
+
69
+
70
+ def _deveco_with_query(query: str) -> str:
71
+ return (
72
+ "【homegraph 评测臂】请优先用 MCP 工具 homegraph_explore(query 用原问题里的符号名,"
73
+ "如 getColorString);只回答题目问的那一个定义所在文件,不要列举其它同名函数。"
74
+ "若 homegraph 结果不足,再用 grep/read 补充。"
75
+ f"问题:{query}"
76
+ )
77
+
78
+
79
+ _DEVECO_WITH_AGENT_PROMPT = (
80
+ "你是鸿蒙 ArkTS 代码仓库问答 Agent(homegraph 评测臂)。"
81
+ "回答代码定位/理解问题时,优先调用 MCP 工具 homegraph_explore;"
82
+ "仅当 homegraph 查不到或信息不够时,再用 grep/read/glob。"
83
+ "基于仓库事实作答,中文简洁准确。"
84
+ )
85
+
86
+ _DEVECO_WITHOUT_AGENT_PROMPT = (
87
+ "你是鸿蒙 ArkTS 代码仓库问答 Agent(baseline 臂,无 homegraph)。"
88
+ " grep、read 等内置工具探索仓库后作答,中文简洁准确。"
89
+ )
90
+
91
+
92
+ def _strip_ansi(text: str) -> str:
93
+ return re.sub(r"\x1b\[[0-9;]*m", "", text or "")
94
+
95
+
96
+ def _extract_deveco_json_errors(text: str) -> str | None:
97
+ """Parse deveco --format json error lines into a short message."""
98
+ messages: list[str] = []
99
+ for line in _strip_ansi(text).splitlines():
100
+ line = line.strip()
101
+ if not line.startswith("{"):
102
+ continue
103
+ try:
104
+ ev = json.loads(line)
105
+ except json.JSONDecodeError:
106
+ continue
107
+ if ev.get("type") != "error":
108
+ continue
109
+ err = ev.get("error") or {}
110
+ data = err.get("data") or {}
111
+ msg = data.get("message") or err.get("message") or err.get("name")
112
+ if msg and msg not in messages:
113
+ messages.append(str(msg))
114
+ if not messages:
115
+ return None
116
+ return "; ".join(messages)
117
+
118
+
119
+ def _cli_error_summary(stderr: str, stdout: str, *, max_len: int = 400) -> str:
120
+ """Pick a short user-facing error from CLI stderr/stdout."""
121
+ for raw in (stderr, stdout):
122
+ deveco_err = _extract_deveco_json_errors(raw)
123
+ if deveco_err:
124
+ return deveco_err[:max_len]
125
+ for raw in (stderr, stdout):
126
+ clean = _strip_ansi(raw).strip()
127
+ if not clean:
128
+ continue
129
+ for line in clean.splitlines():
130
+ line = line.strip()
131
+ if not line or line.lower().startswith("error:"):
132
+ continue
133
+ if len(line) > 20:
134
+ return line[:max_len]
135
+ if clean:
136
+ return clean[:max_len]
137
+ return ""
138
+
139
+
140
+ def auth_failure_reason(text: str) -> str | None:
141
+ """Return a user-facing reason if output looks like an auth/login failure."""
142
+ t = _strip_ansi(text or "").lower()
143
+ if any(h in t for h in ("not logged in", "please run /login")) or (
144
+ "/login" in t and "run" in t
145
+ ):
146
+ return "Claude Code 未登录,请先运行: claude login"
147
+ if "credentials cannot be decrypted" in t or "saved provider credentials are unavailable" in t:
148
+ return (
149
+ "DevEco Code 本地凭证无法解密。请运行: deveco providers reset,"
150
+ "然后在 TUI 中重新配置模型,或执行 deveco providers login"
151
+ )
152
+ if "deveco providers reset" in t or "deveco auth reset" in t:
153
+ return (
154
+ "DevEco Code 未配置或凭证无效。请运行: deveco providers reset,"
155
+ "然后重新登录/配置 provider"
156
+ )
157
+ if "model not found" in t:
158
+ return (
159
+ "DevEco 模型名无效。deveco 需要 provider/model 格式(如 zhipuai/glm-4.5-flash),"
160
+ "可用 `deveco models` 查看;勿把 Judge 的 --model 直接传给 deveco"
161
+ )
162
+ return "API Key 无效"
163
+ if "authentication required" in t or "unauthorized" in t:
164
+ return "未授权,请检查登录或 API Key"
165
+ return None
166
+
167
+
168
+ def _log_line(log_file: Path | None, msg: str) -> None:
169
+ if log_file is None:
170
+ return
171
+ log_file.parent.mkdir(parents=True, exist_ok=True)
172
+ with log_file.open("a", encoding="utf-8") as f:
173
+ f.write(f"{datetime.now().strftime(TIME_FMT)} {msg}\n")
174
+
175
+
176
+ def _log_memory(log_file: Path | None, mem: dict[str, float | None]) -> None:
177
+ if mem.get("peak_rss_mb") is not None:
178
+ _log_line(log_file, f"peakRssMb = {mem['peak_rss_mb']}")
179
+ if mem.get("avg_rss_mb") is not None:
180
+ _log_line(log_file, f"avgRssMb = {mem['avg_rss_mb']}")
181
+
182
+
183
+ def find_claude_cli() -> str:
184
+ found = shutil.which("claude")
185
+ if not found:
186
+ raise FileNotFoundError(
187
+ "未找到 claude CLI。请安装 Claude Code 并确保 `claude` 在 PATH 中。"
188
+ )
189
+ return found
190
+
191
+
192
+ def find_deveco_cli() -> str:
193
+ for name in ("deveco", "opencode"):
194
+ found = shutil.which(name)
195
+ if found:
196
+ return found
197
+ raise FileNotFoundError(
198
+ "未找到 DevEco Code / opencode CLI。请安装 DevEco Code 或 opencode 并加入 PATH。"
199
+ )
200
+
201
+
202
+ def _popen_capture(cmd: list[str], *, cwd: str) -> subprocess.Popen:
203
+ """Run CLI with UTF-8 stdout/stderr (Windows default locale is often GBK)."""
204
+ return subprocess.Popen(
205
+ cmd,
206
+ cwd=cwd,
207
+ stdout=subprocess.PIPE,
208
+ stderr=subprocess.PIPE,
209
+ text=True,
210
+ encoding="utf-8",
211
+ errors="replace",
212
+ )
213
+
214
+
215
+ def write_mcp_config(path: Path, *, hg_command: str, hg_args: list[str]) -> None:
216
+ path.write_text(
217
+ json.dumps({"mcpServers": {"homegraph": {"command": hg_command, "args": hg_args}}}, indent=2)
218
+ + "\n",
219
+ encoding="utf-8",
220
+ )
221
+
222
+
223
+ def _split_hg_bin(hg_bin: str) -> tuple[str, list[str]]:
224
+ if hg_bin.startswith("node "):
225
+ parts = hg_bin.split(" ", 1)
226
+ return parts[0], parts[1].split() + ["serve", "--mcp"]
227
+ return hg_bin, ["serve", "--mcp"]
228
+
229
+
230
+ def parse_claude_stream_json(raw: str) -> dict[str, Any]:
231
+ tool_trace: list[str] = []
232
+ answer_parts: list[str] = []
233
+ total_tokens = 0
234
+ max_turn = 0
235
+ duration_ms = 0
236
+
237
+ for line in raw.splitlines():
238
+ line = line.strip()
239
+ if not line:
240
+ continue
241
+ try:
242
+ ev = json.loads(line)
243
+ except json.JSONDecodeError:
244
+ continue
245
+
246
+ if ev.get("type") == "assistant" and ev.get("message", {}).get("content"):
247
+ max_turn += 1
248
+ for block in ev["message"]["content"]:
249
+ if block.get("type") == "text" and block.get("text"):
250
+ answer_parts.append(str(block["text"]))
251
+ if block.get("type") == "tool_use":
252
+ name = block.get("name", "?")
253
+ inp = block.get("input") or {}
254
+ tool_trace.append(
255
+ f"---\n{name}\nargs: {json.dumps(inp, ensure_ascii=False)[:500]}\n---"
256
+ )
257
+
258
+ if ev.get("type") == "result":
259
+ usage = ev.get("usage") or {}
260
+ total_tokens = int(
261
+ (usage.get("input_tokens") or 0)
262
+ + (usage.get("cache_read_input_tokens") or 0)
263
+ + (usage.get("cache_creation_input_tokens") or 0)
264
+ + (usage.get("output_tokens") or 0)
265
+ )
266
+ duration_ms = int(ev.get("duration_ms") or 0)
267
+ if ev.get("num_turns"):
268
+ max_turn = max(max_turn, int(ev["num_turns"]))
269
+ if ev.get("result") and not answer_parts:
270
+ answer_parts.append(str(ev["result"]))
271
+
272
+ answer = "\n".join(answer_parts).strip()
273
+ output = "\n\n".join(tool_trace + ([answer] if answer else []))
274
+ auth_err = auth_failure_reason(answer) or auth_failure_reason(raw)
275
+ if auth_err:
276
+ return {
277
+ "output_answer": output,
278
+ "agent_status": "error",
279
+ "agent_error": auth_err,
280
+ "agent_turns": max_turn,
281
+ "agent_duration_ms": duration_ms,
282
+ "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
283
+ }
284
+ return {
285
+ "output_answer": output,
286
+ "agent_status": "success" if answer else "error",
287
+ "agent_turns": max_turn,
288
+ "agent_duration_ms": duration_ms,
289
+ "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
290
+ }
291
+
292
+
293
+ def parse_opencode_json_events(raw: str) -> dict[str, Any]:
294
+ tool_trace: list[str] = []
295
+ answer_parts: list[str] = []
296
+ total_tokens = 0
297
+ max_turn = 0
298
+ duration_ms = 0
299
+ session_id = ""
300
+ stream_errors: list[str] = []
301
+
302
+ def note_session(ev: dict[str, Any], part: dict[str, Any]) -> None:
303
+ nonlocal session_id
304
+ sid = ev.get("sessionID") or ev.get("sessionId") or part.get("sessionID") or part.get("sessionId")
305
+ if sid:
306
+ session_id = str(sid)
307
+
308
+ def append_tool(name: str, inp: Any, outp: Any = "") -> None:
309
+ payload = json.dumps(inp, ensure_ascii=False)[:500] if inp else ""
310
+ block = f"---\n{name}\nargs: {payload}\n---"
311
+ if outp:
312
+ out_s = str(outp)
313
+ block = f"---\n{name}\nargs: {payload}\noutput: {out_s[:800]}\n---"
314
+ tool_trace.append(block)
315
+
316
+ for line in raw.splitlines():
317
+ line = line.strip()
318
+ if not line:
319
+ continue
320
+ try:
321
+ ev = json.loads(line)
322
+ except json.JSONDecodeError:
323
+ continue
324
+
325
+ ev_type = ev.get("type") or ev.get("event")
326
+ part = ev.get("part") if isinstance(ev.get("part"), dict) else {}
327
+ part_type = part.get("type") or ""
328
+ note_session(ev, part)
329
+
330
+ if ev_type == "error":
331
+ err = ev.get("error") or {}
332
+ data = err.get("data") or {}
333
+ msg = data.get("message") or err.get("message") or err.get("name")
334
+ if msg:
335
+ stream_errors.append(str(msg))
336
+
337
+ if ev_type == "text" and part.get("text"):
338
+ text = str(part["text"]).strip()
339
+ if text:
340
+ answer_parts.append(text)
341
+ max_turn += 1
342
+ elif ev_type == "text" and ev.get("text"):
343
+ text = str(ev["text"]).strip()
344
+ if text:
345
+ answer_parts.append(text)
346
+ max_turn += 1
347
+
348
+ if ev_type == "tool_use":
349
+ max_turn += 1
350
+ state = part.get("state") if isinstance(part.get("state"), dict) else {}
351
+ name = part.get("tool") or part.get("name") or "?"
352
+ append_tool(str(name), state.get("input") or part.get("input"), state.get("output"))
353
+ elif ev_type in ("tool", "tool_call") or part_type in (
354
+ "tool",
355
+ "tool-invocation",
356
+ "tool_use",
357
+ "tool-call",
358
+ ):
359
+ max_turn += 1
360
+ name = part.get("tool") or part.get("name") or ev.get("tool") or "?"
361
+ inp = part.get("input") or part.get("args") or part.get("state") or ev.get("input") or {}
362
+ if isinstance(inp, dict) and "input" in inp:
363
+ outp = inp.get("output")
364
+ inp = inp.get("input") or inp
365
+ else:
366
+ outp = ""
367
+ append_tool(str(name), inp, outp)
368
+
369
+ if ev_type in ("message", "assistant"):
370
+ content = ev.get("content") or ev.get("text") or ev.get("message")
371
+ if isinstance(content, str) and content.strip():
372
+ answer_parts.append(content.strip())
373
+ max_turn += 1
374
+ elif isinstance(content, list):
375
+ max_turn += 1
376
+ for block in content:
377
+ if isinstance(block, dict):
378
+ if block.get("type") == "text" and block.get("text"):
379
+ answer_parts.append(str(block["text"]))
380
+ if block.get("type") == "tool_use":
381
+ append_tool(
382
+ str(block.get("name", "?")),
383
+ block.get("input") or {},
384
+ )
385
+
386
+ usage = ev.get("usage") or ev.get("tokens")
387
+ if isinstance(usage, dict):
388
+ total = usage.get("total") or usage.get("total_tokens")
389
+ if total:
390
+ total_tokens = max(total_tokens, int(total))
391
+
392
+ if ev_type == "step_finish":
393
+ tokens = part.get("tokens") if isinstance(part.get("tokens"), dict) else {}
394
+ step_total = tokens.get("total") or tokens.get("total_tokens")
395
+ if step_total:
396
+ total_tokens = max(total_tokens, int(step_total))
397
+
398
+ if ev.get("duration_ms"):
399
+ duration_ms = max(duration_ms, int(ev["duration_ms"]))
400
+
401
+ part_time = part.get("time") if isinstance(part.get("time"), dict) else {}
402
+ if part_time.get("start") and part_time.get("end"):
403
+ duration_ms = max(duration_ms, int(part_time["end"]) - int(part_time["start"]))
404
+
405
+ answer = "\n".join(answer_parts).strip()
406
+ output = "\n\n".join(tool_trace + ([answer] if answer else []))
407
+ base = {
408
+ "output_answer": output,
409
+ "agent_turns": max_turn,
410
+ "agent_duration_ms": duration_ms,
411
+ "agent_usage": {"total_tokens": total_tokens} if total_tokens else {},
412
+ "deveco_session_id": session_id or None,
413
+ }
414
+ auth_err = auth_failure_reason(answer) or auth_failure_reason(raw)
415
+ if auth_err:
416
+ return {
417
+ **base,
418
+ "agent_status": "error",
419
+ "agent_error": auth_err,
420
+ }
421
+ if stream_errors and not answer:
422
+ return {
423
+ **base,
424
+ "agent_status": "error",
425
+ "agent_error": "; ".join(stream_errors),
426
+ }
427
+ return {
428
+ **base,
429
+ "agent_status": "success" if answer else "error",
430
+ "agent_error": None if answer else "deveco 未返回可解析的文本回答",
431
+ }
432
+
433
+
434
+ def _meaningful_text(text: str) -> bool:
435
+ return bool(str(text or "").strip())
436
+
437
+
438
+ def _extract_text_from_export(raw: str) -> list[str]:
439
+ """Pull assistant answer text from `deveco export` (session JSON, JSONL, or array)."""
440
+ texts: list[str] = []
441
+
442
+ def collect_stream_event(ev: dict[str, Any]) -> None:
443
+ if ev.get("type") == "text":
444
+ t = ev.get("text")
445
+ if not t and isinstance(ev.get("part"), dict):
446
+ t = ev["part"].get("text")
447
+ if _meaningful_text(str(t or "")):
448
+ texts.append(str(t).strip())
449
+ return
450
+ part = ev.get("part") if isinstance(ev.get("part"), dict) else {}
451
+ if part.get("type") == "text" and _meaningful_text(str(part.get("text") or "")):
452
+ texts.append(str(part["text"]).strip())
453
+
454
+ def collect_session_export(data: dict[str, Any]) -> None:
455
+ for msg in data.get("messages") or []:
456
+ if not isinstance(msg, dict):
457
+ continue
458
+ info = msg.get("info") if isinstance(msg.get("info"), dict) else {}
459
+ if info.get("role") != "assistant":
460
+ continue
461
+ for part in msg.get("parts") or []:
462
+ if not isinstance(part, dict):
463
+ continue
464
+ if part.get("type") == "text" and _meaningful_text(str(part.get("text") or "")):
465
+ texts.append(str(part["text"]).strip())
466
+
467
+ stripped = raw.strip()
468
+ if not stripped:
469
+ return texts
470
+
471
+ try:
472
+ data = json.loads(stripped)
473
+ except json.JSONDecodeError:
474
+ for line in stripped.splitlines():
475
+ line = line.strip()
476
+ if not line.startswith("{"):
477
+ continue
478
+ try:
479
+ collect_stream_event(json.loads(line))
480
+ except json.JSONDecodeError:
481
+ continue
482
+ return texts
483
+
484
+ if isinstance(data, dict) and isinstance(data.get("messages"), list):
485
+ collect_session_export(data)
486
+ return texts
487
+ if isinstance(data, list):
488
+ for ev in data:
489
+ if isinstance(ev, dict):
490
+ collect_stream_event(ev)
491
+ return texts
492
+
493
+
494
+ def _final_answer_from_export(raw: str) -> str:
495
+ """Best-effort final assistant text from exported session."""
496
+ texts = _extract_text_from_export(raw)
497
+ return texts[-1] if texts else ""
498
+
499
+
500
+ def export_deveco_session(
501
+ session_id: str,
502
+ dest: Path,
503
+ *,
504
+ cwd: str,
505
+ cli: str | None = None,
506
+ timeout_sec: int = 60,
507
+ ) -> bool:
508
+ """Save `deveco export <session_id>` to dest. Returns True if file written."""
509
+ if not session_id:
510
+ return False
511
+ exe = cli or find_deveco_cli()
512
+ dest.parent.mkdir(parents=True, exist_ok=True)
513
+ proc = subprocess.run(
514
+ [exe, "export", session_id],
515
+ cwd=cwd,
516
+ capture_output=True,
517
+ text=True,
518
+ encoding="utf-8",
519
+ errors="replace",
520
+ timeout=timeout_sec,
521
+ )
522
+ body = (proc.stdout or "").strip()
523
+ if proc.returncode != 0 or not body:
524
+ logger.warning(
525
+ "deveco export %s failed (rc=%s): %s",
526
+ session_id,
527
+ proc.returncode,
528
+ (proc.stderr or "")[:200],
529
+ )
530
+ return False
531
+ dest.write_text(body + "\n", encoding="utf-8")
532
+ return True
533
+
534
+
535
+ def supplement_from_session_export(parsed: dict[str, Any], export_path: Path) -> dict[str, Any]:
536
+ """If stdout missed final text, recover from exported session file."""
537
+ if not export_path.is_file():
538
+ return parsed
539
+ raw = export_path.read_text(encoding="utf-8")
540
+ answer = _final_answer_from_export(raw)
541
+ if not answer:
542
+ return parsed
543
+ existing = str(parsed.get("output_answer") or "")
544
+ if answer not in existing:
545
+ parsed = {**parsed, "output_answer": (existing + "\n\n" + answer).strip() if existing else answer}
546
+ if parsed.get("agent_status") != "success":
547
+ parsed = {
548
+ **parsed,
549
+ "agent_status": "success",
550
+ "agent_error": None,
551
+ "agent_answer_source": "session_export",
552
+ }
553
+ return parsed
554
+
555
+
556
+ def verify_claude_login() -> None:
557
+ """Fail fast before burning the whole dataset on 'Not logged in'."""
558
+ claude = find_claude_cli()
559
+ proc = subprocess.run(
560
+ [claude, "-p", "ping", "--output-format", "text", "--max-turns", "1"],
561
+ capture_output=True,
562
+ text=True,
563
+ timeout=45,
564
+ cwd=os.getcwd(),
565
+ )
566
+ combined = f"{proc.stdout}\n{proc.stderr}"
567
+ reason = auth_failure_reason(combined)
568
+ if reason:
569
+ raise RuntimeError(f"{reason}\n(探测输出: {combined.strip()[:200]})")
570
+
571
+
572
+ def run_claude_query(
573
+ repo: Path,
574
+ query: str,
575
+ *,
576
+ arm: str,
577
+ hg_bin: str,
578
+ log_file: Path | None,
579
+ task_id: int,
580
+ timeout_sec: int = 600,
581
+ ) -> dict[str, Any]:
582
+ claude = find_claude_cli()
583
+ backend = f"claude-code-{'with' if arm == 'with' else 'without'}-homegraph"
584
+
585
+ with tempfile.TemporaryDirectory(prefix="qa-eval-mcp-") as tmp:
586
+ mcp_path = Path(tmp) / "mcp.json"
587
+ if arm == "with":
588
+ cmd, base_args = _split_hg_bin(hg_bin)
589
+ write_mcp_config(
590
+ mcp_path,
591
+ hg_command=cmd,
592
+ hg_args=[*base_args, "--path", str(repo.resolve())],
593
+ )
594
+ mcp_args = ["--strict-mcp-config", "--mcp-config", str(mcp_path)]
595
+ else:
596
+ mcp_path.write_text('{"mcpServers":{}}\n', encoding="utf-8")
597
+ mcp_args = ["--strict-mcp-config", "--mcp-config", str(mcp_path)]
598
+
599
+ cmd = [
600
+ claude,
601
+ "-p",
602
+ query,
603
+ "--output-format",
604
+ "stream-json",
605
+ "--verbose",
606
+ "--permission-mode",
607
+ "bypassPermissions",
608
+ *mcp_args,
609
+ ]
610
+
611
+ _log_line(log_file, f"Evaluate {task_id}:")
612
+ _log_line(log_file, "the 1 turn")
613
+ t0 = time.time()
614
+
615
+ proc = _popen_capture(cmd, cwd=str(repo))
616
+ with sample_memory(proc.pid) as sampler:
617
+ try:
618
+ stdout, stderr = proc.communicate(timeout=timeout_sec)
619
+ except subprocess.TimeoutExpired:
620
+ proc.kill()
621
+ stdout, stderr = proc.communicate()
622
+ mem = sampler.last_stats
623
+ _log_memory(log_file, mem)
624
+ return {
625
+ "output_answer": "",
626
+ "agent_status": "error",
627
+ "agent_error": f"timeout after {timeout_sec}s",
628
+ "agent_backend": backend,
629
+ "agent_memory_mb": mem,
630
+ }
631
+ mem = sampler.last_stats
632
+
633
+ if proc.returncode != 0 and not (stdout or "").strip():
634
+ _log_memory(log_file, mem)
635
+ return {
636
+ "output_answer": (stderr or "")[:2000],
637
+ "agent_status": "error",
638
+ "agent_error": f"claude exit {proc.returncode}",
639
+ "agent_backend": backend,
640
+ "agent_memory_mb": mem,
641
+ }
642
+
643
+ _log_line(log_file, "first token")
644
+ combined = f"{stdout}\n{stderr}"
645
+ auth_err = auth_failure_reason(combined)
646
+ if auth_err:
647
+ _log_memory(log_file, mem)
648
+ return {
649
+ "output_answer": (stdout or stderr or "")[:2000],
650
+ "agent_status": "error",
651
+ "agent_error": auth_err,
652
+ "agent_backend": backend,
653
+ "agent_host": HOST_CLAUDE,
654
+ "agent_memory_mb": mem,
655
+ }
656
+ parsed = parse_claude_stream_json(stdout or "")
657
+ tokens = (parsed.get("agent_usage") or {}).get("total_tokens") or 0
658
+ if tokens:
659
+ _log_line(log_file, f"totalTokenCount = {tokens}")
660
+ _log_memory(log_file, mem)
661
+
662
+ wall_ms = int((time.time() - t0) * 1000)
663
+ _log_line(log_file, f"completed ({wall_ms}ms)")
664
+ return {
665
+ **parsed,
666
+ "agent_duration_ms": wall_ms,
667
+ "agent_backend": backend,
668
+ "agent_host": HOST_CLAUDE,
669
+ "ab_arm": "with-homegraph" if arm == "with" else "without-homegraph",
670
+ "agent_memory_mb": mem,
671
+ }
672
+
673
+
674
+ def _deveco_project_config_dir(repo: Path) -> Path:
675
+ """Project-level deveco config (read before ~/.config/deveco/deveco.jsonc)."""
676
+ return repo / ".deveco"
677
+
678
+
679
+ def _write_deveco_project_mcp(repo: Path, *, arm: str, hg_bin: str) -> Path:
680
+ """Write repo/.deveco/deveco.jsonc — MCP, permissions, and agent prompt for qa_eval A/B."""
681
+ config_dir = _deveco_project_config_dir(repo)
682
+ config_dir.mkdir(parents=True, exist_ok=True)
683
+ config_path = config_dir / "deveco.jsonc"
684
+ body: dict[str, Any] = {"$schema": "https://opencode.ai/config.json"}
685
+ if arm == "with":
686
+ if not hg_bin:
687
+ from agent_runner import find_homegraph_bin
688
+
689
+ hg_bin = find_homegraph_bin(None)
690
+ cmd, base_args = _split_hg_bin(hg_bin)
691
+ body["mcp"] = {
692
+ "homegraph": {
693
+ "type": "local",
694
+ "command": [cmd, *base_args, "--path", str(repo.resolve())],
695
+ "enabled": True,
696
+ }
697
+ }
698
+ body["agent"] = {
699
+ "build": {
700
+ "prompt": _DEVECO_WITH_AGENT_PROMPT,
701
+ }
702
+ }
703
+ else:
704
+ body["mcp"] = {}
705
+ body["permission"] = {
706
+ "homegraph_*": "deny",
707
+ "homegraph_homegraph_*": "deny",
708
+ }
709
+ body["agent"] = {
710
+ "build": {
711
+ "prompt": _DEVECO_WITHOUT_AGENT_PROMPT,
712
+ "permission": {
713
+ "homegraph_*": "deny",
714
+ "homegraph_homegraph_*": "deny",
715
+ },
716
+ }
717
+ }
718
+ config_path.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
719
+ return config_path
720
+
721
+
722
+ DEFAULT_DEVECO_MODEL = "zhipuai/glm-4.5-flash"
723
+
724
+
725
+ def run_deveco_query(
726
+ repo: Path,
727
+ query: str,
728
+ *,
729
+ arm: str,
730
+ hg_bin: str,
731
+ log_file: Path | None,
732
+ task_id: int,
733
+ item_id: str | None = None,
734
+ trace_dir: Path | None = None,
735
+ model: str | None = None,
736
+ timeout_sec: int = 600,
737
+ deveco_attach: str | None = None,
738
+ ) -> dict[str, Any]:
739
+ cli = find_deveco_cli()
740
+ backend = f"deveco-code-{'with' if arm == 'with' else 'without'}-homegraph"
741
+ qid = item_id or str(task_id)
742
+ title = f"qa-eval-{arm}-{qid}"
743
+ prompt = _deveco_with_query(query) if arm == "with" else query
744
+
745
+ run_cmd = [
746
+ cli,
747
+ "run",
748
+ prompt,
749
+ "--format",
750
+ "json",
751
+ "--dir",
752
+ str(repo),
753
+ "--title",
754
+ title,
755
+ "--skip-agreement",
756
+ "--dangerously-skip-permissions",
757
+ ]
758
+ if deveco_attach:
759
+ run_cmd.extend(["--attach", deveco_attach])
760
+ if model:
761
+ run_cmd.extend(["--model", model])
762
+ else:
763
+ run_cmd.extend(["--model", DEFAULT_DEVECO_MODEL])
764
+
765
+ _log_line(log_file, f"Evaluate {task_id}:")
766
+ _log_line(log_file, "the 1 turn")
767
+ t0 = time.time()
768
+
769
+ proc = _popen_capture(run_cmd, cwd=str(repo))
770
+ with sample_memory(proc.pid) as sampler:
771
+ try:
772
+ stdout, stderr = proc.communicate(timeout=timeout_sec)
773
+ except subprocess.TimeoutExpired:
774
+ proc.kill()
775
+ stdout, stderr = proc.communicate()
776
+ mem = sampler.last_stats
777
+ _log_memory(log_file, mem)
778
+ wall_ms = int((time.time() - t0) * 1000)
779
+ return {
780
+ "output_answer": "",
781
+ "agent_status": "error",
782
+ "agent_error": f"timeout after {timeout_sec}s",
783
+ "agent_backend": backend,
784
+ "agent_duration_ms": wall_ms,
785
+ "agent_memory_mb": mem,
786
+ }
787
+ mem = sampler.last_stats
788
+ combined = f"{stdout or ''}\n{stderr or ''}"
789
+ wall_ms = int((time.time() - t0) * 1000)
790
+
791
+ if proc.returncode != 0:
792
+ auth_err = auth_failure_reason(combined)
793
+ detail = auth_err or _cli_error_summary(stderr, stdout)
794
+ err_msg = detail or f"{cli} exit {proc.returncode}"
795
+ _log_memory(log_file, mem)
796
+ return {
797
+ "output_answer": _strip_ansi(stderr or stdout or "")[:2000],
798
+ "agent_status": "error",
799
+ "agent_error": err_msg,
800
+ "agent_backend": backend,
801
+ "agent_host": HOST_DEVECO,
802
+ "agent_duration_ms": wall_ms,
803
+ "agent_memory_mb": mem,
804
+ }
805
+
806
+ out = (stdout or "").strip()
807
+ err = (stderr or "").strip()
808
+ parsed = parse_opencode_json_events(out if out else err)
809
+ session_id = parsed.get("deveco_session_id")
810
+ trace_file: Path | None = None
811
+ tools_used: list[str] = trace_tool_names(str(parsed.get("output_answer") or ""))
812
+ if session_id and trace_dir is not None:
813
+ safe_sid = re.sub(r"[^\w.-]", "_", str(session_id))
814
+ trace_file = trace_dir / f"{qid}-{safe_sid}.json"
815
+ if export_deveco_session(str(session_id), trace_file, cwd=str(repo), cli=cli):
816
+ try:
817
+ rel = trace_file.relative_to(_QA_EVAL_DIR)
818
+ except ValueError:
819
+ rel = trace_file
820
+ parsed["agent_trace_file"] = str(rel).replace("\\", "/")
821
+ parsed = supplement_from_session_export(parsed, trace_file)
822
+ export_tools = _tools_from_session_export(trace_file.read_text(encoding="utf-8"))
823
+ for t in export_tools:
824
+ if t not in tools_used:
825
+ tools_used.append(t)
826
+ _log_line(log_file, f"session export → {parsed.get('agent_trace_file')}")
827
+ else:
828
+ parsed["agent_trace_file"] = None
829
+ if session_id:
830
+ _log_line(log_file, f"sessionID = {session_id}")
831
+ if tools_used:
832
+ _log_line(log_file, f"tools = {', '.join(tools_used)}")
833
+ used_hg = any(is_homegraph_tool(t) for t in tools_used)
834
+ parsed["agent_tools_used"] = tools_used
835
+ parsed["agent_used_homegraph"] = used_hg
836
+
837
+ tokens = (parsed.get("agent_usage") or {}).get("total_tokens") or 0
838
+ if tokens:
839
+ _log_line(log_file, f"totalTokenCount = {tokens}")
840
+ _log_memory(log_file, mem)
841
+ _log_line(log_file, f"completed ({wall_ms}ms)")
842
+
843
+ result = {
844
+ **parsed,
845
+ "agent_duration_ms": wall_ms,
846
+ "agent_backend": backend,
847
+ "agent_host": HOST_DEVECO,
848
+ "ab_arm": "with-homegraph" if arm == "with" else "without-homegraph",
849
+ "agent_memory_mb": mem,
850
+ }
851
+ if result.get("agent_status") != "success" and not result.get("agent_error"):
852
+ result["agent_error"] = _extract_deveco_json_errors(combined) or "deveco 未返回可解析的文本回答"
853
+ return result
854
+
855
+
856
+ def run_external_dataset(
857
+ host: str,
858
+ repo: Path,
859
+ dataset: list[dict[str, Any]],
860
+ *,
861
+ arm: str,
862
+ output: Path,
863
+ log_file: Path | None,
864
+ hg_bin: str,
865
+ model: str | None = None,
866
+ deveco_attach: str | None = None,
867
+ ) -> list[dict[str, Any]]:
868
+ if host not in SUPPORTED_HOSTS:
869
+ raise ValueError(f"unknown agent host: {host}")
870
+
871
+ from agent_runner import find_homegraph_bin, print_agent_progress, require_index, _arm_short
872
+
873
+ if arm == "with":
874
+ require_index(repo)
875
+ hg = find_homegraph_bin(hg_bin) if arm == "with" else ""
876
+
877
+ output.parent.mkdir(parents=True, exist_ok=True)
878
+ if log_file:
879
+ log_file.write_text("", encoding="utf-8")
880
+
881
+ results: list[dict[str, Any]] = []
882
+ total = len(dataset)
883
+ print(f" → [{host}] {_arm_short(arm)} 臂:共 {total} 题", flush=True)
884
+ if host == HOST_DEVECO:
885
+ _write_deveco_project_mcp(repo, arm=arm, hg_bin=hg)
886
+ trace_root = output.parent / "traces" / f"{arm}-deveco"
887
+ if deveco_attach:
888
+ print(f" → deveco attach: {deveco_attach}", flush=True)
889
+ print(
890
+ " → 提示: 若 serve 启动报 ServeError,多为端口占用;"
891
+ "可 netstat -ano | findstr :4096 后 taskkill,或不加 --deveco-attach 直接跑",
892
+ flush=True,
893
+ )
894
+ print(f" → 轨迹目录: {trace_root}", flush=True)
895
+ if arm == "with":
896
+ print(" → WITH 臂: 优先 homegraph_explore,不足时可 grep/read", flush=True)
897
+ else:
898
+ trace_root = None
899
+
900
+ auth_abort: str | None = None
901
+ with output.open("w", encoding="utf-8") as f:
902
+ for i, item in enumerate(dataset, 1):
903
+ if auth_abort:
904
+ meta = {
905
+ "output_answer": "",
906
+ "agent_status": "error",
907
+ "agent_error": auth_abort,
908
+ "agent_backend": f"{host}-{arm}",
909
+ "agent_host": host,
910
+ }
911
+ print_agent_progress(arm, i, total, str(item.get("id") or i), f"跳过: {auth_abort[:80]}")
912
+ row = {**item, **meta}
913
+ results.append(row)
914
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
915
+ f.flush()
916
+ continue
917
+ q = str(item["query"])
918
+ item_id = str(item.get("id") or i)
919
+ print_agent_progress(arm, i, total, item_id, f"[{host}] 开始…")
920
+ logger.info("[%s/%s] %s/%s %s", host, arm, i, total, item_id)
921
+ try:
922
+ common = dict(
923
+ arm=arm,
924
+ hg_bin=hg,
925
+ log_file=log_file,
926
+ task_id=i,
927
+ )
928
+ if host == HOST_CLAUDE:
929
+ meta = run_claude_query(repo, q, **common)
930
+ else:
931
+ meta = run_deveco_query(
932
+ repo,
933
+ q,
934
+ model=model,
935
+ deveco_attach=deveco_attach,
936
+ item_id=item_id,
937
+ trace_dir=trace_root,
938
+ **common,
939
+ )
940
+ except Exception as e:
941
+ logger.error("External agent failed %s: %s", item.get("id"), e)
942
+ meta = {
943
+ "output_answer": "",
944
+ "agent_status": "error",
945
+ "agent_error": str(e),
946
+ "agent_backend": f"{host}-{arm}",
947
+ "agent_host": host,
948
+ }
949
+ row = {**item, **meta}
950
+ results.append(row)
951
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
952
+ f.flush()
953
+ if meta.get("agent_status") == "success":
954
+ dur_ms = meta.get("agent_duration_ms")
955
+ dur_s = f"{dur_ms / 1000:.1f}s" if isinstance(dur_ms, (int, float)) else "?"
956
+ extra = ""
957
+ if host == HOST_DEVECO and arm == "with":
958
+ extra = " ✓homegraph" if meta.get("agent_used_homegraph") else " ⚠未用homegraph"
959
+ print_agent_progress(arm, i, total, item_id, f"[{host}] 完成 ({dur_s}){extra}")
960
+ else:
961
+ err = str(meta.get("agent_error") or meta.get("agent_status") or "error")
962
+ extra = ""
963
+ if host == HOST_DEVECO and arm == "with" and meta.get("agent_tools_used"):
964
+ extra = f" tools={','.join(meta['agent_tools_used'][:4])}"
965
+ print_agent_progress(arm, i, total, item_id, f"[{host}] 失败: {err[:100]}{extra}")
966
+ if i == 1 and meta.get("agent_error") and (
967
+ "未登录" in str(meta["agent_error"])
968
+ or "DevEco Code" in str(meta["agent_error"])
969
+ or "DevEco 模型" in str(meta["agent_error"])
970
+ or "Model not found" in str(meta["agent_error"])
971
+ ):
972
+ auth_abort = str(meta["agent_error"])
973
+ print(f"\n ✗ {auth_abort} — 后续题目跳过", flush=True)
974
+ ok = sum(1 for r in results if r.get("agent_status") == "success")
975
+ print(f" → [{host}] {_arm_short(arm)} 臂结束:{ok}/{total} 成功", flush=True)
976
+ return results