diffusion-review 0.1.0__py3-none-any.whl

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 (341) hide show
  1. diffusion/__init__.py +1 -0
  2. diffusion/agent.py +134 -0
  3. diffusion/app.py +333 -0
  4. diffusion/backends/__init__.py +1 -0
  5. diffusion/backends/api.py +206 -0
  6. diffusion/backends/claude_code.py +143 -0
  7. diffusion/backends/codex.py +183 -0
  8. diffusion/backends/tools.py +172 -0
  9. diffusion/chat_agent.py +306 -0
  10. diffusion/classify.py +58 -0
  11. diffusion/cli.py +273 -0
  12. diffusion/config.py +273 -0
  13. diffusion/github.py +201 -0
  14. diffusion/guide_agent.py +161 -0
  15. diffusion/hunks.py +168 -0
  16. diffusion/jobs.py +80 -0
  17. diffusion/launcher.py +137 -0
  18. diffusion/pipeline.py +114 -0
  19. diffusion/prompts/chat.md +39 -0
  20. diffusion/prompts/guide.md +48 -0
  21. diffusion/repo.py +107 -0
  22. diffusion/sample.py +133 -0
  23. diffusion/schema.py +146 -0
  24. diffusion/store.py +54 -0
  25. diffusion/symbols.py +226 -0
  26. diffusion/validate.py +181 -0
  27. diffusion/web/assets/abap-CLvhMVsD.js +1 -0
  28. diffusion/web/assets/actionscript-3-Cs6JJXT5.js +1 -0
  29. diffusion/web/assets/ada-X3Y2mlq2.js +1 -0
  30. diffusion/web/assets/ahk-DwADkxyd.js +1 -0
  31. diffusion/web/assets/ahk2-_6rzW9BS.js +1 -0
  32. diffusion/web/assets/andromeeda-vGVdxbeo.js +1 -0
  33. diffusion/web/assets/angular-html-BvSzluAy.js +1 -0
  34. diffusion/web/assets/angular-ts-BKTucBb8.js +1 -0
  35. diffusion/web/assets/apache-U0d_L8uA.js +1 -0
  36. diffusion/web/assets/apex-KFGFyKwG.js +1 -0
  37. diffusion/web/assets/apl-CEPzvt3v.js +1 -0
  38. diffusion/web/assets/applescript-CCn79oCD.js +1 -0
  39. diffusion/web/assets/ara-4CJ0cIlV.js +1 -0
  40. diffusion/web/assets/asciidoc-SCjQUq34.js +1 -0
  41. diffusion/web/assets/asm-Cmm7eHzH.js +1 -0
  42. diffusion/web/assets/astro-D6R_bz_Q.js +1 -0
  43. diffusion/web/assets/aurora-x-CDeNXAV0.js +1 -0
  44. diffusion/web/assets/awk-BWXHIvNe.js +1 -0
  45. diffusion/web/assets/ayu-dark-DluEY0Gj.js +1 -0
  46. diffusion/web/assets/ayu-light-C3h-C4tm.js +1 -0
  47. diffusion/web/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  48. diffusion/web/assets/ballerina-B7ZEbQpA.js +1 -0
  49. diffusion/web/assets/bat-qdxAdBWQ.js +1 -0
  50. diffusion/web/assets/beancount-C2IN4Vk8.js +1 -0
  51. diffusion/web/assets/berry-DKpUyyne.js +1 -0
  52. diffusion/web/assets/bibtex-Ci_nEsc7.js +1 -0
  53. diffusion/web/assets/bicep-CUHmPFLl.js +1 -0
  54. diffusion/web/assets/bird2-CYIUgYqB.js +1 -0
  55. diffusion/web/assets/blade-C3VNiUq_.js +1 -0
  56. diffusion/web/assets/bsl-_VjlB9mv.js +1 -0
  57. diffusion/web/assets/c-C4up6S_B.js +1 -0
  58. diffusion/web/assets/c3-D0apG41V.js +1 -0
  59. diffusion/web/assets/cadence-CQ2zXKGN.js +1 -0
  60. diffusion/web/assets/cairo-DLTphjLi.js +1 -0
  61. diffusion/web/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
  62. diffusion/web/assets/catppuccin-latte-CtsyrtPq.js +1 -0
  63. diffusion/web/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
  64. diffusion/web/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
  65. diffusion/web/assets/chapel-CGOPr6wc.js +1 -0
  66. diffusion/web/assets/clarity-CtfBBspg.js +1 -0
  67. diffusion/web/assets/clojure-DqKBuwfJ.js +1 -0
  68. diffusion/web/assets/cmake-Bj61d0ZC.js +1 -0
  69. diffusion/web/assets/cobol-BVCc6XsY.js +1 -0
  70. diffusion/web/assets/codeowners-C8r90Shi.js +1 -0
  71. diffusion/web/assets/codeql-oeQT6MSM.js +1 -0
  72. diffusion/web/assets/coffee-DSmvPmJQ.js +1 -0
  73. diffusion/web/assets/common-lisp-Cv5bFMCO.js +1 -0
  74. diffusion/web/assets/coq-CGaSfhwR.js +1 -0
  75. diffusion/web/assets/cpp-DfdMFi5C.js +1 -0
  76. diffusion/web/assets/crystal-BakSq-RC.js +1 -0
  77. diffusion/web/assets/csharp-dmMxLVLW.js +1 -0
  78. diffusion/web/assets/css-CCgNEiC3.js +1 -0
  79. diffusion/web/assets/csv-Dx-8-gkx.js +1 -0
  80. diffusion/web/assets/cue-CE9AQfxI.js +1 -0
  81. diffusion/web/assets/cypher-ClKdZ_lG.js +1 -0
  82. diffusion/web/assets/d-qD-0Kul2.js +1 -0
  83. diffusion/web/assets/dark-plus-Cs2F2srj.js +1 -0
  84. diffusion/web/assets/dart-CHZY9Xhz.js +1 -0
  85. diffusion/web/assets/dax-BkyTk9wS.js +1 -0
  86. diffusion/web/assets/desktop-Dlh5hvp9.js +1 -0
  87. diffusion/web/assets/diff-woXpYk--.js +1 -0
  88. diffusion/web/assets/docker-IyjqRm3v.js +1 -0
  89. diffusion/web/assets/dotenv-CuH5my32.js +1 -0
  90. diffusion/web/assets/dracula-BHWKrbxM.js +1 -0
  91. diffusion/web/assets/dracula-soft-5eyTD99u.js +1 -0
  92. diffusion/web/assets/dream-maker-DW3nJb8Q.js +1 -0
  93. diffusion/web/assets/edge-C4kC8M-1.js +1 -0
  94. diffusion/web/assets/elixir-CoSKdulM.js +1 -0
  95. diffusion/web/assets/elm-Cx8a2zmE.js +1 -0
  96. diffusion/web/assets/emacs-lisp-DR2vtWaj.js +1 -0
  97. diffusion/web/assets/erb-N5z1rmMP.js +1 -0
  98. diffusion/web/assets/erlang-Cphh6RMH.js +1 -0
  99. diffusion/web/assets/everforest-dark-sB-x3p7T.js +1 -0
  100. diffusion/web/assets/everforest-light-Df2xbC6M.js +1 -0
  101. diffusion/web/assets/fennel-DQxkIbk2.js +1 -0
  102. diffusion/web/assets/fish-BJitypiv.js +1 -0
  103. diffusion/web/assets/fluent-C03EYrpw.js +1 -0
  104. diffusion/web/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
  105. diffusion/web/assets/fortran-free-form-CYNrtFtB.js +1 -0
  106. diffusion/web/assets/fsharp-D13ZGOAj.js +1 -0
  107. diffusion/web/assets/gdresource-xMC7jr7x.js +1 -0
  108. diffusion/web/assets/gdscript-DYvRD3hg.js +1 -0
  109. diffusion/web/assets/gdshader-CBce3t8t.js +1 -0
  110. diffusion/web/assets/genie-CV2tkWYe.js +1 -0
  111. diffusion/web/assets/gherkin-DExj1W_8.js +1 -0
  112. diffusion/web/assets/git-commit-BSykSTBG.js +1 -0
  113. diffusion/web/assets/git-rebase-DQ4a8HJE.js +1 -0
  114. diffusion/web/assets/github-dark-C-LZuMrd.js +1 -0
  115. diffusion/web/assets/github-dark-default-DXG-b-1a.js +1 -0
  116. diffusion/web/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  117. diffusion/web/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  118. diffusion/web/assets/github-light-EUqPIrTm.js +1 -0
  119. diffusion/web/assets/github-light-default-BXViO-2h.js +1 -0
  120. diffusion/web/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  121. diffusion/web/assets/gleam-CSRkHgEL.js +1 -0
  122. diffusion/web/assets/glimmer-js-TGQYY7fo.js +1 -0
  123. diffusion/web/assets/glimmer-ts-B9i0hlzk.js +1 -0
  124. diffusion/web/assets/glsl-BdXUvO-Z.js +1 -0
  125. diffusion/web/assets/gn-CAXYAgV-.js +1 -0
  126. diffusion/web/assets/gnuplot-7GGW24-e.js +1 -0
  127. diffusion/web/assets/go-rLFTqkRN.js +1 -0
  128. diffusion/web/assets/graphql-D9EC1XHw.js +1 -0
  129. diffusion/web/assets/groovy-CacY0gHj.js +1 -0
  130. diffusion/web/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  131. diffusion/web/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  132. diffusion/web/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  133. diffusion/web/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  134. diffusion/web/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  135. diffusion/web/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  136. diffusion/web/assets/hack-Cwvcnw04.js +1 -0
  137. diffusion/web/assets/haml-C3Fg1iLd.js +1 -0
  138. diffusion/web/assets/handlebars-DIOTu0Jz.js +1 -0
  139. diffusion/web/assets/haskell-D8IpX4py.js +1 -0
  140. diffusion/web/assets/haxe-C8oCnwJJ.js +1 -0
  141. diffusion/web/assets/hcl-Dh228itO.js +1 -0
  142. diffusion/web/assets/hjson-CxZEssPk.js +1 -0
  143. diffusion/web/assets/hlsl-Cvrh5tZx.js +1 -0
  144. diffusion/web/assets/horizon-CE9ld1lL.js +1 -0
  145. diffusion/web/assets/horizon-bright-DSNQnXHK.js +1 -0
  146. diffusion/web/assets/houston-CsvMBhTu.js +1 -0
  147. diffusion/web/assets/html-EgLiyvGR.js +1 -0
  148. diffusion/web/assets/html-derivative-Bkt5Asag.js +1 -0
  149. diffusion/web/assets/http-Cwnwg4E-.js +1 -0
  150. diffusion/web/assets/hurl-QGMx3cP6.js +1 -0
  151. diffusion/web/assets/hxml-Dcm88srW.js +1 -0
  152. diffusion/web/assets/hy-CZbG8q4J.js +1 -0
  153. diffusion/web/assets/imba-DsUTQ-LC.js +1 -0
  154. diffusion/web/assets/index-C2iJNJ40.css +1 -0
  155. diffusion/web/assets/index-Dr86LksM.js +201 -0
  156. diffusion/web/assets/ini-B5eOa1yu.js +1 -0
  157. diffusion/web/assets/java-Cm8q1_PN.js +1 -0
  158. diffusion/web/assets/javascript-C9QGNj6d.js +1 -0
  159. diffusion/web/assets/jinja-BT2sl2vp.js +1 -0
  160. diffusion/web/assets/jison-EUngFJJ2.js +1 -0
  161. diffusion/web/assets/json-W906IkI3.js +1 -0
  162. diffusion/web/assets/json5-BR5RXkoi.js +1 -0
  163. diffusion/web/assets/jsonc-CYpm1nAK.js +1 -0
  164. diffusion/web/assets/jsonl-CmCQp5Yx.js +1 -0
  165. diffusion/web/assets/jsonnet-CJTPZ8u_.js +1 -0
  166. diffusion/web/assets/jssm-DXw9l8Rf.js +1 -0
  167. diffusion/web/assets/jsx-B9cdzPgv.js +1 -0
  168. diffusion/web/assets/julia-B5Y2TUKn.js +1 -0
  169. diffusion/web/assets/just-DRXz09dU.js +1 -0
  170. diffusion/web/assets/kanagawa-dragon-Bozd7klA.js +1 -0
  171. diffusion/web/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
  172. diffusion/web/assets/kanagawa-wave-BML5mF2J.js +1 -0
  173. diffusion/web/assets/kdl-CsD5j6eV.js +1 -0
  174. diffusion/web/assets/kotlin-DhhofPvG.js +1 -0
  175. diffusion/web/assets/kusto-C7mF5XQf.js +1 -0
  176. diffusion/web/assets/laserwave-C_8bwKvT.js +1 -0
  177. diffusion/web/assets/latex-VVo5HXSJ.js +1 -0
  178. diffusion/web/assets/lean-CewbzKMR.js +1 -0
  179. diffusion/web/assets/less-DVTAwKKz.js +1 -0
  180. diffusion/web/assets/light-plus-DVQuIRkW.js +1 -0
  181. diffusion/web/assets/liquid-43xQo7-_.js +1 -0
  182. diffusion/web/assets/llvm-DqHOMS-s.js +1 -0
  183. diffusion/web/assets/log-BNLmms1o.js +1 -0
  184. diffusion/web/assets/logo-Cluzi2Zq.js +1 -0
  185. diffusion/web/assets/lua-CxNVNmPU.js +1 -0
  186. diffusion/web/assets/luau-VGWVLoqX.js +1 -0
  187. diffusion/web/assets/make-Dixweg8N.js +1 -0
  188. diffusion/web/assets/markdown-BYOwaDjH.js +1 -0
  189. diffusion/web/assets/marko-92hpGoop.js +1 -0
  190. diffusion/web/assets/material-theme-Bm3Qr25_.js +1 -0
  191. diffusion/web/assets/material-theme-darker-2IIEA8gg.js +1 -0
  192. diffusion/web/assets/material-theme-lighter-uhdI0v04.js +1 -0
  193. diffusion/web/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  194. diffusion/web/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  195. diffusion/web/assets/matlab-D7qyCx1q.js +1 -0
  196. diffusion/web/assets/mdc-V0S1pqJ3.js +1 -0
  197. diffusion/web/assets/mdx-DQZ5AkYe.js +1 -0
  198. diffusion/web/assets/mermaid-BxpfTIzt.js +1 -0
  199. diffusion/web/assets/min-dark-BSWPekZh.js +1 -0
  200. diffusion/web/assets/min-light-DDpmG2fV.js +1 -0
  201. diffusion/web/assets/mipsasm-BMqwQI7S.js +1 -0
  202. diffusion/web/assets/mojo-fg40_ntQ.js +1 -0
  203. diffusion/web/assets/monokai-BscAjiQZ.js +1 -0
  204. diffusion/web/assets/moonbit-D9W784E_.js +1 -0
  205. diffusion/web/assets/move-BYnGhZoM.js +1 -0
  206. diffusion/web/assets/narrat-_X_XdTYD.js +1 -0
  207. diffusion/web/assets/nextflow-CSgcYZcx.js +1 -0
  208. diffusion/web/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
  209. diffusion/web/assets/nginx-qyzJEyEJ.js +1 -0
  210. diffusion/web/assets/night-owl-DhmEMT88.js +1 -0
  211. diffusion/web/assets/night-owl-light-eJ-hLW7d.js +1 -0
  212. diffusion/web/assets/nim-Bb_B3hSH.js +1 -0
  213. diffusion/web/assets/nix-IvuFDN5E.js +1 -0
  214. diffusion/web/assets/nord-Cb4Vim4T.js +1 -0
  215. diffusion/web/assets/nsis-CjNLYCLg.js +1 -0
  216. diffusion/web/assets/nushell--0fT3PI1.js +1 -0
  217. diffusion/web/assets/objective-c-D1A_Heim.js +1 -0
  218. diffusion/web/assets/objective-cpp-BsSzOQcm.js +1 -0
  219. diffusion/web/assets/ocaml-O90oeIOV.js +1 -0
  220. diffusion/web/assets/odin-B1RWQWA5.js +1 -0
  221. diffusion/web/assets/one-dark-pro-C6YyR8OD.js +1 -0
  222. diffusion/web/assets/one-light-D7Lr4KcI.js +1 -0
  223. diffusion/web/assets/openscad-BUDT5pXO.js +1 -0
  224. diffusion/web/assets/org-rbGKvz2N.js +1 -0
  225. diffusion/web/assets/pascal-4ZHwLPI5.js +1 -0
  226. diffusion/web/assets/perl-Db0J-8td.js +1 -0
  227. diffusion/web/assets/php-BA7ih3Vo.js +1 -0
  228. diffusion/web/assets/pkl-ot-7Btpt.js +1 -0
  229. diffusion/web/assets/plastic-DQwYfKfQ.js +1 -0
  230. diffusion/web/assets/plsql-DGHpHOYJ.js +1 -0
  231. diffusion/web/assets/po-BiJDBrnU.js +1 -0
  232. diffusion/web/assets/poimandres-DRFjx7u4.js +1 -0
  233. diffusion/web/assets/polar-C7UOKdEL.js +1 -0
  234. diffusion/web/assets/postcss-BXeXVLqQ.js +1 -0
  235. diffusion/web/assets/powerquery-DNMTfnFr.js +1 -0
  236. diffusion/web/assets/powershell-QtSnmXcu.js +1 -0
  237. diffusion/web/assets/prisma-DMgNhnYS.js +1 -0
  238. diffusion/web/assets/prolog-iXnhIJG7.js +1 -0
  239. diffusion/web/assets/proto-DB4EqR-F.js +1 -0
  240. diffusion/web/assets/pug-BHMXe7i1.js +1 -0
  241. diffusion/web/assets/puppet-CDv2pdJW.js +1 -0
  242. diffusion/web/assets/purescript-9MfHhQsQ.js +1 -0
  243. diffusion/web/assets/python-gzcpVVnB.js +1 -0
  244. diffusion/web/assets/qml-q4ftORQG.js +1 -0
  245. diffusion/web/assets/qmldir-DCQb3MpD.js +1 -0
  246. diffusion/web/assets/qss-Fe1Jh2GI.js +1 -0
  247. diffusion/web/assets/r-BJPEt7QB.js +1 -0
  248. diffusion/web/assets/racket-DcIDlBhZ.js +1 -0
  249. diffusion/web/assets/raku-B3gFvitq.js +1 -0
  250. diffusion/web/assets/razor-B6yQWtUA.js +1 -0
  251. diffusion/web/assets/rbs-C0MpOrj4.js +1 -0
  252. diffusion/web/assets/red-CYlAq2b0.js +1 -0
  253. diffusion/web/assets/reg-CRGYupPL.js +1 -0
  254. diffusion/web/assets/regexp-CnUJHIWF.js +1 -0
  255. diffusion/web/assets/rel-BtDbiS_P.js +1 -0
  256. diffusion/web/assets/riscv-Ckw8ddFX.js +1 -0
  257. diffusion/web/assets/ron-VUp2lXgN.js +1 -0
  258. diffusion/web/assets/rose-pine-aMsaMD1D.js +1 -0
  259. diffusion/web/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
  260. diffusion/web/assets/rose-pine-moon-DO-qXg-I.js +1 -0
  261. diffusion/web/assets/rosmsg-CAekHB0j.js +1 -0
  262. diffusion/web/assets/rst-CcVHb0IN.js +1 -0
  263. diffusion/web/assets/ruby-B3xHQYKp.js +1 -0
  264. diffusion/web/assets/rust-DpiIf5JO.js +1 -0
  265. diffusion/web/assets/sas-CGC2ERYn.js +1 -0
  266. diffusion/web/assets/sass-DXrisJhu.js +1 -0
  267. diffusion/web/assets/scala-BiFt_7mu.js +1 -0
  268. diffusion/web/assets/scheme-DQCgrYNe.js +1 -0
  269. diffusion/web/assets/scss-YLxCHbpp.js +1 -0
  270. diffusion/web/assets/sdbl-bTVj8UrX.js +1 -0
  271. diffusion/web/assets/shaderlab-TOUzSsQk.js +1 -0
  272. diffusion/web/assets/shellscript-863OYuGk.js +1 -0
  273. diffusion/web/assets/shellsession-CnnbfYBq.js +1 -0
  274. diffusion/web/assets/slack-dark-DnToyrRv.js +1 -0
  275. diffusion/web/assets/slack-ochin-B2OO5cIa.js +1 -0
  276. diffusion/web/assets/smalltalk-BHpQb5nM.js +1 -0
  277. diffusion/web/assets/smithy-Dgaz7aur.js +1 -0
  278. diffusion/web/assets/snazzy-light-4G7pJPwS.js +1 -0
  279. diffusion/web/assets/solarized-dark-b97czJOk.js +1 -0
  280. diffusion/web/assets/solarized-light-DFPxpIYQ.js +1 -0
  281. diffusion/web/assets/solidity-Bj7E1mRn.js +1 -0
  282. diffusion/web/assets/soy-BVkhjMdg.js +1 -0
  283. diffusion/web/assets/sparql-D_iOobhT.js +1 -0
  284. diffusion/web/assets/splunk-BC2Px7Mm.js +1 -0
  285. diffusion/web/assets/sql-IY_g3nEh.js +1 -0
  286. diffusion/web/assets/ssh-config-BgfXC-Er.js +1 -0
  287. diffusion/web/assets/stata-C53SZfb2.js +1 -0
  288. diffusion/web/assets/stylus-B6D30XZt.js +1 -0
  289. diffusion/web/assets/surrealql-BpOrcm1A.js +1 -0
  290. diffusion/web/assets/svelte-DHgsX77K.js +1 -0
  291. diffusion/web/assets/swift-CyEgAFGc.js +1 -0
  292. diffusion/web/assets/synthwave-84-nFMaYfgc.js +1 -0
  293. diffusion/web/assets/system-verilog-Dh5ihWbf.js +1 -0
  294. diffusion/web/assets/systemd-BxMlprV5.js +1 -0
  295. diffusion/web/assets/talonscript-CohzipZa.js +1 -0
  296. diffusion/web/assets/tasl-DMoTqEGO.js +1 -0
  297. diffusion/web/assets/tcl-CZd0xW_V.js +1 -0
  298. diffusion/web/assets/templ-Dg9rdCmT.js +1 -0
  299. diffusion/web/assets/terraform-DswuEJGm.js +1 -0
  300. diffusion/web/assets/tex-D2KhKtdA.js +1 -0
  301. diffusion/web/assets/tokyo-night-oM2G3aXe.js +1 -0
  302. diffusion/web/assets/toml-CcmNWLt0.js +1 -0
  303. diffusion/web/assets/ts-tags-DW308x0K.js +1 -0
  304. diffusion/web/assets/tsv-sltzmVWM.js +1 -0
  305. diffusion/web/assets/tsx-Pt-nHEB-.js +1 -0
  306. diffusion/web/assets/turtle-ByJddavk.js +1 -0
  307. diffusion/web/assets/twig-DOIe2RSz.js +1 -0
  308. diffusion/web/assets/typescript-s5VfqrsY.js +1 -0
  309. diffusion/web/assets/typespec-BGhR2SqG.js +1 -0
  310. diffusion/web/assets/typst-itG5JigO.js +1 -0
  311. diffusion/web/assets/v-DBj4K9d_.js +1 -0
  312. diffusion/web/assets/vala-zf12oZj6.js +1 -0
  313. diffusion/web/assets/vb-DOwzaHZ0.js +1 -0
  314. diffusion/web/assets/verilog-B2gwz-sJ.js +1 -0
  315. diffusion/web/assets/vesper-DdrHHSXu.js +1 -0
  316. diffusion/web/assets/vhdl-BroJfC0k.js +1 -0
  317. diffusion/web/assets/viml-DvXPmvsu.js +1 -0
  318. diffusion/web/assets/vitesse-black-fwtXNY1n.js +1 -0
  319. diffusion/web/assets/vitesse-dark-BZCL-v6S.js +1 -0
  320. diffusion/web/assets/vitesse-light-VbXTXTou.js +1 -0
  321. diffusion/web/assets/vue-C9aDaE7j.js +1 -0
  322. diffusion/web/assets/vue-html-CfJiG8j6.js +1 -0
  323. diffusion/web/assets/vue-vine-Ez5Goy0L.js +1 -0
  324. diffusion/web/assets/vyper-CgoNMtux.js +1 -0
  325. diffusion/web/assets/wasm-BnjxR4X6.js +1 -0
  326. diffusion/web/assets/wasm-ByWQv1Qj.js +1 -0
  327. diffusion/web/assets/wenyan-C8pVoKbM.js +1 -0
  328. diffusion/web/assets/wgsl-BsKzXJz4.js +1 -0
  329. diffusion/web/assets/wikitext-ClFFjSW2.js +1 -0
  330. diffusion/web/assets/wit-DdvCle-K.js +1 -0
  331. diffusion/web/assets/wolfram-DLL8P-h_.js +1 -0
  332. diffusion/web/assets/xml-UmW_7Zor.js +1 -0
  333. diffusion/web/assets/xsl-BRFCWSO4.js +1 -0
  334. diffusion/web/assets/yaml-_Jl6KO8g.js +1 -0
  335. diffusion/web/assets/zenscript-BnlCZFoB.js +1 -0
  336. diffusion/web/assets/zig-CMLA9XwU.js +1 -0
  337. diffusion/web/index.html +13 -0
  338. diffusion_review-0.1.0.dist-info/METADATA +23 -0
  339. diffusion_review-0.1.0.dist-info/RECORD +341 -0
  340. diffusion_review-0.1.0.dist-info/WHEEL +4 -0
  341. diffusion_review-0.1.0.dist-info/entry_points.txt +2 -0
diffusion/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Local engine that turns a GitHub PR into a guided, linked review walkthrough."""
diffusion/agent.py ADDED
@@ -0,0 +1,134 @@
1
+ """Backend-neutral agent interface: requests, events, custom tools, and the runner seam.
2
+
3
+ A *runner* takes an ``AgentRequest`` and yields ``Event``s. Backends (``backends/``) implement it on top of a
4
+ model API (Pydantic AI) or an agent CLI (Claude Code, Codex). Everything else in the engine only sees this.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncIterator, Awaitable, Callable
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+
14
+ from .config import Config
15
+
16
+
17
+ def inline_refs(schema: dict) -> dict:
18
+ """Inline ``$defs`` so tool schemas are flat (some tool-callers handle ``$ref`` poorly)."""
19
+ defs = schema.get("$defs", {})
20
+
21
+ def walk(node: Any, is_props: bool = False) -> Any:
22
+ if isinstance(node, list):
23
+ return [walk(v) for v in node]
24
+ if not isinstance(node, dict):
25
+ return node
26
+ if is_props: # {property name: schema}: keys are names (e.g. "title"), not keywords
27
+ return {k: walk(v) for k, v in node.items()}
28
+ if "$ref" in node:
29
+ return walk(defs[node["$ref"].rsplit("/", 1)[-1]])
30
+ return {k: walk(v, k == "properties") for k, v in node.items() if k not in ("$defs", "title")}
31
+
32
+ return walk(schema)
33
+
34
+
35
+ # ---------- events surfaced to callers (CLI progress, SSE) ----------
36
+
37
+
38
+ @dataclass
39
+ class ToolEvent:
40
+ name: str
41
+ summary: str
42
+
43
+
44
+ @dataclass
45
+ class TextEvent:
46
+ text: str
47
+
48
+
49
+ @dataclass
50
+ class DoneEvent:
51
+ turns: int
52
+ cost_usd: float | None
53
+ is_error: bool
54
+ session_id: str | None
55
+ result: str | None = None
56
+
57
+
58
+ Event = ToolEvent | TextEvent | DoneEvent
59
+
60
+
61
+ def summarize_tool(name: str, args: dict) -> str:
62
+ short = name.rsplit("__", 1)[-1]
63
+ if short.lower() in ("grep", "glob"):
64
+ return f"{short.capitalize()} {args.get('pattern', '')}".strip()
65
+ if short.lower() in ("read", "read_file"):
66
+ return f"Read {Path(args.get('file_path') or args.get('path') or '').name}"
67
+ if short == "get_hunk":
68
+ ids = args.get("ids") or []
69
+ return f"Hunk {', '.join(ids) if isinstance(ids, list) else ids}"
70
+ if short == "show_code":
71
+ return f"Show {Path(str(args.get('file', ''))).name}:{args.get('start', '')}"
72
+ return short
73
+
74
+
75
+ @dataclass
76
+ class ToolSpec:
77
+ """A custom tool the engine exposes to the agent (e.g. ``get_hunk``, ``submit_guide``).
78
+
79
+ ``terminal`` tools end the task: backends that support structured final output (Codex) may use the tool's
80
+ schema as the output schema and call the handler with the final result instead of exposing it as a tool.
81
+ """
82
+
83
+ name: str
84
+ description: str
85
+ schema: dict
86
+ handler: Callable[[dict], Awaitable[str]]
87
+ terminal: bool = False
88
+
89
+
90
+ @dataclass
91
+ class AgentRequest:
92
+ system_prompt: str
93
+ prompt: str
94
+ model: str
95
+ cwd: Path
96
+ tools: list[ToolSpec] = field(default_factory=list)
97
+ max_turns: int = 40
98
+ resume: str | None = None
99
+ include_partial: bool = False
100
+
101
+
102
+ class Runner(Protocol):
103
+ def __call__(self, req: AgentRequest) -> AsyncIterator[Event]: ...
104
+
105
+
106
+ class SandboxError(ValueError):
107
+ pass
108
+
109
+
110
+ def within(root: Path, path: str | Path) -> Path:
111
+ """Resolve ``path`` (relative to ``root`` unless absolute) and require it to stay inside ``root``."""
112
+ root = root.resolve()
113
+ p = Path(path)
114
+ p = (p if p.is_absolute() else root / p).resolve()
115
+ if p != root and root not in p.parents:
116
+ raise SandboxError(f"{path} is outside the repository")
117
+ return p
118
+
119
+
120
+ def make_runner(cfg: Config) -> Runner:
121
+ """The runner for the configured backend."""
122
+ if cfg.backend == "api":
123
+ from .backends.api import api_runner
124
+
125
+ return api_runner(cfg)
126
+ if cfg.backend == "claude-code":
127
+ from .backends.claude_code import claude_code_runner
128
+
129
+ return claude_code_runner(cfg)
130
+ if cfg.backend == "codex":
131
+ from .backends.codex import codex_runner
132
+
133
+ return codex_runner(cfg)
134
+ raise ValueError(f"unknown backend {cfg.backend!r}")
diffusion/app.py ADDED
@@ -0,0 +1,333 @@
1
+ """HTTP API (localhost only). Guide generation streams progress over SSE."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import time
9
+ import uuid
10
+ from collections.abc import AsyncIterator, Callable
11
+ from dataclasses import dataclass
12
+ from functools import partial
13
+ from pathlib import Path
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.responses import FileResponse, StreamingResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel
19
+
20
+ from .agent import DoneEvent, Runner, ToolEvent, make_runner
21
+ from .chat_agent import ChatDelta, ChatFocus, Question, run_turn
22
+ from .config import Config, load_config
23
+ from .github import GitHubError
24
+ from .guide_agent import GuideDone, GuideError, generate_guide
25
+ from .jobs import Emit, Jobs
26
+ from .pipeline import Prepared, prepare, target_ref
27
+ from .repo import RepoError
28
+ from .store import Store
29
+ from .symbols import NAME, SymbolIndex
30
+
31
+ PrepareFn = Callable[[Config, str], Prepared]
32
+ MAX_FILE_CHARS = 2_000_000
33
+
34
+
35
+ @dataclass
36
+ class Deps:
37
+ cfg: Config
38
+ runner: Runner
39
+ prepare: PrepareFn
40
+ store: Store
41
+ jobs: Jobs
42
+
43
+
44
+ def sse(event: str, data: dict) -> str:
45
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
46
+
47
+
48
+ def sse_response(events: AsyncIterator[tuple[str, dict]]) -> StreamingResponse:
49
+ async def body():
50
+ async for ev, data in events:
51
+ yield sse(ev, data)
52
+
53
+ return StreamingResponse(
54
+ body(),
55
+ media_type="text/event-stream",
56
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
57
+ )
58
+
59
+
60
+ class GuideReq(BaseModel):
61
+ url: str
62
+ force: bool = False
63
+
64
+
65
+ class ChatCreate(BaseModel):
66
+ url: str
67
+
68
+
69
+ class TurnReq(BaseModel):
70
+ url: str
71
+ questions: list[Question]
72
+
73
+
74
+ def chat_summary(c: dict) -> dict:
75
+ qs = [q for t in c.get("turns", []) for q in t["questions"]]
76
+ return {
77
+ "id": c["id"],
78
+ "title": qs[0]["text"][:80] if qs else "",
79
+ "questions": len(qs),
80
+ "anchored": sum(1 for q in qs if q.get("anchor")),
81
+ "created_at": c["created_at"],
82
+ "updated_at": c["updated_at"],
83
+ "head_sha": c.get("head_sha"),
84
+ }
85
+
86
+
87
+ def create_app(deps: Deps | None = None) -> FastAPI:
88
+ app = FastAPI(title="diffusion")
89
+ _deps: list[Deps] = [deps] if deps else []
90
+
91
+ def d() -> Deps:
92
+ if not _deps:
93
+ cfg = load_config()
94
+ _deps.append(Deps(cfg, make_runner(cfg), prepare, Store(cfg.cache_dir), Jobs()))
95
+ return _deps[0]
96
+
97
+ _prepared: dict[str, Prepared] = {}
98
+ chat_locks: dict[str, asyncio.Lock] = {}
99
+ symbols: dict[Path, SymbolIndex] = {}
100
+ symbol_locks: dict[Path, asyncio.Lock] = {}
101
+
102
+ async def get_prepared(url: str, refresh: bool = False) -> Prepared:
103
+ try:
104
+ key = target_ref(url).key
105
+ except GitHubError as e:
106
+ raise HTTPException(400, str(e)) from e
107
+ if refresh or key not in _prepared:
108
+ try:
109
+ _prepared[key] = await asyncio.to_thread(d().prepare, d().cfg, url)
110
+ except (GitHubError, RepoError) as e:
111
+ raise HTTPException(502, str(e)) from e
112
+ except FileNotFoundError as e:
113
+ raise HTTPException(400, str(e)) from e
114
+ return _prepared[key]
115
+
116
+ @app.get("/api/health")
117
+ async def health() -> dict:
118
+ cfg = d().cfg
119
+ return {
120
+ "ok": True,
121
+ "profile": cfg.profile,
122
+ "backend": cfg.backend,
123
+ "guide_model": cfg.guide_model,
124
+ "chat_model": cfg.chat_model,
125
+ }
126
+
127
+ @app.get("/api/pr")
128
+ async def pr(url: str) -> dict:
129
+ prep = await get_prepared(url, refresh=True)
130
+ cached = d().store.load_guide(prep.meta.ref.key, prep.meta.head_sha)
131
+ return {**prep.to_dict(), "guide": cached}
132
+
133
+ @app.post("/api/guide")
134
+ async def guide(req: GuideReq) -> StreamingResponse:
135
+ deps = d()
136
+ prep = await get_prepared(req.url)
137
+ key, sha = prep.meta.ref.key, prep.meta.head_sha
138
+ job_key = f"guide:{key}:{sha}"
139
+
140
+ if not req.force and (cached := deps.store.load_guide(key, sha)):
141
+
142
+ async def replay():
143
+ yield "guide", cached
144
+
145
+ return sse_response(replay())
146
+
147
+ if req.force:
148
+ deps.jobs.drop(job_key)
149
+ job = deps.jobs.start(job_key, partial(run_guide, deps, prep))
150
+ return sse_response(job.follow())
151
+
152
+ @app.get("/api/file")
153
+ async def file(url: str, path: str) -> dict:
154
+ """A changed file at the PR head, so the diff can show lines around and between hunks."""
155
+ prep = await get_prepared(url)
156
+ if path not in {f.path for f in prep.index.files}:
157
+ raise HTTPException(404, f"{path} isn't in this diff")
158
+ try:
159
+ text = await asyncio.to_thread(prep.checkout.file_at_head, path)
160
+ except UnicodeDecodeError as e:
161
+ raise HTTPException(415, f"{path} isn't text") from e
162
+ if text is None:
163
+ raise HTTPException(404, f"{path} doesn't exist at the head commit")
164
+ if len(text) > MAX_FILE_CHARS:
165
+ raise HTTPException(413, f"{path} is too large to show in full")
166
+ return {"path": path, "text": text}
167
+
168
+ @app.get("/api/definition")
169
+ async def definition(url: str, symbol: str, path: str) -> dict:
170
+ """Where `symbol` is defined at the PR head, nearest to `path` first, with each definition's source."""
171
+ if not NAME.match(symbol):
172
+ raise HTTPException(400, "Not a name")
173
+ prep = await get_prepared(url)
174
+ root = prep.checkout.worktree
175
+ async with symbol_locks.setdefault(root, asyncio.Lock()):
176
+ if root not in symbols:
177
+ symbols[root] = await asyncio.to_thread(SymbolIndex, root)
178
+ index = symbols[root]
179
+ found = index.lookup(symbol, path)
180
+ return {
181
+ "symbol": symbol,
182
+ "matches": [
183
+ {"path": m.path, "line": m.line, "end": m.end, "text": index.snippet(m)} for m in found
184
+ ],
185
+ }
186
+
187
+ # ---------- chats ----------
188
+
189
+ def pr_key(url: str) -> str:
190
+ try:
191
+ return target_ref(url).key
192
+ except GitHubError as e:
193
+ raise HTTPException(400, str(e)) from e
194
+
195
+ @app.get("/api/chats")
196
+ async def list_chats(url: str) -> list[dict]:
197
+ return [chat_summary(c) for c in d().store.list_chats(pr_key(url)) if c.get("turns")]
198
+
199
+ @app.post("/api/chats")
200
+ async def create_chat(req: ChatCreate) -> dict:
201
+ now = time.time()
202
+ chat = {
203
+ "id": uuid.uuid4().hex[:12],
204
+ "created_at": now,
205
+ "updated_at": now,
206
+ "session_id": None,
207
+ "turns": [],
208
+ }
209
+ d().store.save_chat(pr_key(req.url), chat)
210
+ return chat
211
+
212
+ @app.get("/api/chats/{chat_id}")
213
+ async def get_chat(chat_id: str, url: str) -> dict:
214
+ chat = d().store.load_chat(pr_key(url), chat_id)
215
+ if not chat:
216
+ raise HTTPException(404, "chat not found")
217
+ return chat
218
+
219
+ @app.post("/api/chats/{chat_id}/turn")
220
+ async def turn(chat_id: str, req: TurnReq) -> StreamingResponse:
221
+ deps = d()
222
+ key = pr_key(req.url)
223
+ chat = deps.store.load_chat(key, chat_id)
224
+ if not chat:
225
+ raise HTTPException(404, "chat not found")
226
+ if not req.questions:
227
+ raise HTTPException(400, "no questions")
228
+ if len({q.n for q in req.questions}) != len(req.questions):
229
+ raise HTTPException(400, "duplicate question numbers")
230
+ prep = await get_prepared(req.url)
231
+ guide = deps.store.load_guide(key, prep.meta.head_sha)
232
+ lock = chat_locks.setdefault(chat_id, asyncio.Lock())
233
+
234
+ async def events():
235
+ # One turn per chat at a time: a turn resumes the session the previous one produced.
236
+ async with lock:
237
+ chat = deps.store.load_chat(key, chat_id) # fresh: an earlier turn may have just saved
238
+ if chat is None: # deleted while this turn waited for the lock
239
+ yield "error", {"message": "chat not found"}
240
+ return
241
+ record = {
242
+ "at": time.time(),
243
+ "head_sha": prep.meta.head_sha,
244
+ "questions": [q.model_dump() for q in req.questions],
245
+ "answers": {str(q.n): "" for q in req.questions},
246
+ "tools": [],
247
+ "shows": [],
248
+ "status": "streaming",
249
+ }
250
+ chat["turns"].append(record)
251
+ chat["head_sha"] = prep.meta.head_sha
252
+ chat["updated_at"] = record["at"]
253
+ deps.store.save_chat(key, chat)
254
+ try:
255
+ async for ev in run_turn(
256
+ prep, guide, req.questions, deps.runner, deps.cfg.chat_model, chat.get("session_id")
257
+ ):
258
+ if isinstance(ev, ChatDelta):
259
+ record["answers"][str(ev.n)] += ev.text
260
+ yield "delta", {"n": ev.n, "text": ev.text}
261
+ elif isinstance(ev, ChatFocus):
262
+ record["shows"].append(ev.to_dict())
263
+ yield "focus", ev.to_dict()
264
+ elif isinstance(ev, ToolEvent):
265
+ record["tools"].append(ev.summary)
266
+ yield "tool", {"name": ev.name, "summary": ev.summary}
267
+ elif isinstance(ev, DoneEvent):
268
+ if ev.is_error:
269
+ raise RuntimeError(ev.result or "agent error")
270
+ chat["session_id"] = ev.session_id or chat.get("session_id")
271
+ record["status"] = "done"
272
+ yield "done", {"ns": [q.n for q in req.questions]}
273
+ except Exception as e: # surface to the client; keep partial answers
274
+ record["status"] = "error"
275
+ record["error"] = str(e) or type(e).__name__
276
+ yield "error", {"message": record["error"]}
277
+ finally:
278
+ chat["updated_at"] = time.time()
279
+ deps.store.save_chat(key, chat)
280
+
281
+ return sse_response(events())
282
+
283
+ dist = _web_dist()
284
+ if dist:
285
+ app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
286
+
287
+ @app.get("/{path:path}", include_in_schema=False)
288
+ async def spa(path: str):
289
+ f = dist / path
290
+ if path and f.is_file():
291
+ return FileResponse(f)
292
+ return FileResponse(dist / "index.html")
293
+
294
+ return app
295
+
296
+
297
+ def _web_dist() -> Path | None:
298
+ """Built UI: $DIFFUSION_WEB_DIST, the copy bundled in the package, or web/dist in a source checkout."""
299
+ env = os.environ.get("DIFFUSION_WEB_DIST")
300
+ here = Path(__file__).resolve()
301
+ candidates = [Path(env)] if env else []
302
+ candidates += [here.parent / "web", here.parents[3] / "web" / "dist"]
303
+ return next((d for d in candidates if (d / "index.html").is_file()), None)
304
+
305
+
306
+ async def run_guide(deps: Deps, prep: Prepared, emit: Emit) -> None:
307
+ t0 = time.monotonic()
308
+ done: GuideDone | None = None
309
+ async for ev in generate_guide(
310
+ prep.meta, prep.checkout, prep.index, deps.runner, deps.cfg.guide_model, deps.cfg.max_turns
311
+ ):
312
+ if isinstance(ev, ToolEvent):
313
+ emit("tool", {"name": ev.name, "summary": ev.summary})
314
+ elif isinstance(ev, DoneEvent) and ev.is_error:
315
+ raise GuideError(ev.result or "agent error")
316
+ elif isinstance(ev, GuideDone):
317
+ done = ev
318
+ assert done is not None
319
+ record = {
320
+ **done.guide.model_dump(by_alias=True),
321
+ "meta": {
322
+ "head_sha": prep.meta.head_sha,
323
+ "backend": deps.cfg.backend,
324
+ "model": deps.cfg.guide_model,
325
+ "turns": done.turns,
326
+ "cost_usd": done.cost_usd,
327
+ "attempts": done.attempts,
328
+ "seconds": round(time.monotonic() - t0, 1),
329
+ "created_at": time.time(),
330
+ },
331
+ }
332
+ deps.store.save_guide(prep.meta.ref.key, prep.meta.head_sha, record)
333
+ emit("guide", record)
@@ -0,0 +1 @@
1
+ """Agent backends. Each exposes a ``*_runner(cfg) -> Runner`` factory; see ``agent.make_runner``."""
@@ -0,0 +1,206 @@
1
+ """Backend: a Pydantic AI agent over a model API (Anthropic, OpenAI, OpenRouter or any OpenAI-compatible server).
2
+
3
+ Pydantic AI runs the loop and speaks each provider's wire format. We add the read-only repository tools, the
4
+ engine's custom tools, Anthropic prompt caching, and the stop rule: the run ends when the model answers without
5
+ calling tools, a terminal tool (e.g. ``submit_guide``) is accepted, or ``max_turns`` requests have been made.
6
+ Conversations are stored as JSON so a later request can ``resume`` them (chat follow-ups, guide retries).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from collections.abc import AsyncIterator
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlparse
16
+
17
+ import pydantic_ai
18
+ from pydantic_ai import (
19
+ Agent,
20
+ FunctionToolCallEvent,
21
+ FunctionToolResultEvent,
22
+ ModelMessage,
23
+ ModelMessagesTypeAdapter,
24
+ ModelResponse,
25
+ PartDeltaEvent,
26
+ PartStartEvent,
27
+ TextPart,
28
+ TextPartDelta,
29
+ Tool,
30
+ ToolFailed,
31
+ ToolReturnPart,
32
+ UsageLimitExceeded,
33
+ UsageLimits,
34
+ )
35
+ from pydantic_ai.models import Model
36
+ from pydantic_ai.settings import ModelSettings
37
+
38
+ from ..agent import AgentRequest, DoneEvent, Event, Runner, TextEvent, ToolEvent, ToolSpec, summarize_tool
39
+ from ..config import Config
40
+ from .tools import repo_tools
41
+
42
+ pydantic_ai.BANNER_ENABLED = False # our CLI owns its output
43
+
44
+ ACCEPTED = ("received",) # terminal tool replies that end the task
45
+ MAX_TOKENS = 16000
46
+
47
+
48
+ class SessionStore:
49
+ def __init__(self, root: Path):
50
+ self.root = root
51
+
52
+ def load(self, sid: str) -> list[ModelMessage]:
53
+ try:
54
+ return ModelMessagesTypeAdapter.validate_json((self.root / f"{sid}.json").read_bytes())
55
+ except (OSError, ValueError):
56
+ return []
57
+
58
+ def save(self, sid: str, messages: list[ModelMessage]) -> None:
59
+ self.root.mkdir(parents=True, exist_ok=True)
60
+ tmp = self.root / f"{sid}.json.tmp"
61
+ tmp.write_bytes(ModelMessagesTypeAdapter.dump_json(messages))
62
+ tmp.replace(self.root / f"{sid}.json")
63
+
64
+
65
+ def make_model(cfg: Config, name: str, http_client: Any = None) -> tuple[Model, ModelSettings]:
66
+ """The Pydantic AI model for a profile, and the settings every request uses."""
67
+ if cfg.protocol == "anthropic":
68
+ from anthropic import AsyncAnthropic
69
+ from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
70
+ from pydantic_ai.providers.anthropic import AnthropicProvider
71
+
72
+ kw: dict[str, Any] = {"base_url": cfg.base_url, "default_headers": cfg.headers or None}
73
+ kw["auth_token" if cfg.auth == "bearer" else "api_key"] = cfg.api_key
74
+ client = AsyncAnthropic(**kw, max_retries=3, http_client=http_client)
75
+ # Cache the system prompt, the tools, and the conversation so far. Explicit breakpoints rather than
76
+ # automatic caching, which some Anthropic-compatible gateways don't support.
77
+ settings = AnthropicModelSettings(
78
+ max_tokens=MAX_TOKENS,
79
+ anthropic_cache_instructions=True,
80
+ anthropic_cache_tool_definitions=True,
81
+ anthropic_cache_messages=True,
82
+ )
83
+ return AnthropicModel(name, provider=AnthropicProvider(anthropic_client=client)), settings
84
+
85
+ from openai import AsyncOpenAI
86
+
87
+ client = AsyncOpenAI(
88
+ base_url=cfg.base_url,
89
+ api_key=cfg.api_key or "none",
90
+ default_headers=cfg.headers or None,
91
+ max_retries=3,
92
+ http_client=http_client,
93
+ )
94
+ settings = ModelSettings(max_tokens=MAX_TOKENS)
95
+ if urlparse(cfg.base_url or "").hostname == "openrouter.ai":
96
+ from pydantic_ai.models.openrouter import OpenRouterModel
97
+ from pydantic_ai.providers.openrouter import OpenRouterProvider
98
+
99
+ return OpenRouterModel(name, provider=OpenRouterProvider(openai_client=client)), settings
100
+
101
+ from pydantic_ai.models.openai import OpenAIChatModel
102
+ from pydantic_ai.providers.openai import OpenAIProvider
103
+
104
+ return OpenAIChatModel(name, provider=OpenAIProvider(openai_client=client)), settings
105
+
106
+
107
+ def _tool(spec: ToolSpec) -> Tool:
108
+ async def call(**args: Any) -> str:
109
+ try:
110
+ return await spec.handler(args)
111
+ except Exception as e: # report to the model and let it recover
112
+ raise ToolFailed(f"error: {e}") from e
113
+
114
+ return Tool.from_schema(call, spec.name, spec.description, spec.schema)
115
+
116
+
117
+ def _cost(responses: list[ModelResponse]) -> float | None:
118
+ """OpenRouter reports the exact cost; otherwise price the usage with genai-prices when the model is known."""
119
+ total = None
120
+ for r in responses:
121
+ cost = (r.provider_details or {}).get("cost")
122
+ if not isinstance(cost, int | float):
123
+ try:
124
+ cost = float(r.cost().total_price)
125
+ except Exception: # unknown model or gateway
126
+ continue
127
+ total = (total or 0.0) + cost
128
+ return total
129
+
130
+
131
+ def api_runner(
132
+ cfg: Config, model: Model | None = None, sessions: SessionStore | None = None, http_client: Any = None
133
+ ) -> Runner:
134
+ store = sessions or SessionStore(cfg.cache_dir / "sessions")
135
+
136
+ async def run(req: AgentRequest) -> AsyncIterator[Event]:
137
+ specs: list[ToolSpec] = repo_tools(req.cwd) + list(req.tools)
138
+ terminal = {t.name for t in specs if t.terminal}
139
+ llm, settings = (model, None) if model else make_model(cfg, req.model, http_client)
140
+ agent = Agent(llm, instructions=req.system_prompt, tools=[_tool(t) for t in specs], retries=3)
141
+ sid = req.resume or uuid.uuid4().hex[:16]
142
+ history = store.load(req.resume) if req.resume else []
143
+ messages = list(history)
144
+ turns = 0
145
+ error: str | None = None
146
+ final_text = ""
147
+
148
+ try:
149
+ async with agent.iter(
150
+ req.prompt,
151
+ message_history=history,
152
+ model_settings=settings,
153
+ usage_limits=UsageLimits(request_limit=req.max_turns),
154
+ ) as agent_run:
155
+ finished = False
156
+ async for node in agent_run:
157
+ if finished: # a terminal tool was accepted: keep its results so the session can resume
158
+ if Agent.is_model_request_node(node):
159
+ messages = [*messages, node.request]
160
+ break
161
+ if Agent.is_model_request_node(node):
162
+ async with node.stream(agent_run.ctx) as stream:
163
+ async for ev in stream:
164
+ if not req.include_partial:
165
+ continue
166
+ if isinstance(ev, PartStartEvent) and isinstance(ev.part, TextPart):
167
+ text = ev.part.content
168
+ elif isinstance(ev, PartDeltaEvent) and isinstance(ev.delta, TextPartDelta):
169
+ text = ev.delta.content_delta
170
+ else:
171
+ continue
172
+ if text:
173
+ yield TextEvent(text)
174
+ turns += 1
175
+ response = stream.response
176
+ final_text = "".join(p.content for p in response.parts if isinstance(p, TextPart))
177
+ if final_text and not req.include_partial:
178
+ yield TextEvent(final_text)
179
+ elif Agent.is_call_tools_node(node):
180
+ async with node.stream(agent_run.ctx) as stream:
181
+ async for ev in stream:
182
+ if isinstance(ev, FunctionToolCallEvent):
183
+ yield ToolEvent(
184
+ ev.part.tool_name,
185
+ summarize_tool(ev.part.tool_name, ev.part.args_as_dict()),
186
+ )
187
+ elif isinstance(ev, FunctionToolResultEvent):
188
+ r = ev.part
189
+ finished |= (
190
+ isinstance(r, ToolReturnPart)
191
+ and r.outcome == "success"
192
+ and r.tool_name in terminal
193
+ and str(r.content).startswith(ACCEPTED)
194
+ )
195
+ messages = agent_run.all_messages()
196
+ except UsageLimitExceeded:
197
+ error = f"stopped after {req.max_turns} turns"
198
+ except Exception as e:
199
+ error = f"{type(e).__name__}: {e}"
200
+ finally:
201
+ store.save(sid, messages)
202
+
203
+ new = [m for m in messages[len(history) :] if isinstance(m, ModelResponse)]
204
+ yield DoneEvent(turns, _cost(new), error is not None, sid, error or final_text)
205
+
206
+ return run