@maudecode/talaria-web 1.17.2 → 1.19.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 (623) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +20 -20
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +7 -1
  6. package/dist/api/automation-raw.js +74 -27
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +128 -23
  9. package/dist/api/automation-router.js +29 -13
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +1845 -40
  12. package/dist/api/chat-router.js +196 -114
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +37 -3
  15. package/dist/api/git-router.js +47 -48
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +9 -1
  18. package/dist/api/raw-routes.js +286 -73
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +16 -6
  21. package/dist/api/router.js +28 -26
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/session-visibility.d.ts +4 -4
  24. package/dist/api/session-visibility.js +26 -16
  25. package/dist/api/session-visibility.js.map +1 -1
  26. package/dist/api/sessions-router.d.ts +3312 -131
  27. package/dist/api/sessions-router.js +287 -157
  28. package/dist/api/sessions-router.js.map +1 -1
  29. package/dist/api/settings-router.d.ts +457 -8
  30. package/dist/api/settings-router.js +54 -33
  31. package/dist/api/settings-router.js.map +1 -1
  32. package/dist/api/sse-routes.d.ts +7 -2
  33. package/dist/api/sse-routes.js +109 -62
  34. package/dist/api/sse-routes.js.map +1 -1
  35. package/dist/api/tools-raw.d.ts +5 -0
  36. package/dist/api/tools-raw.js +63 -55
  37. package/dist/api/tools-raw.js.map +1 -1
  38. package/dist/api/tools-router.d.ts +998 -85
  39. package/dist/api/tools-router.js +447 -61
  40. package/dist/api/tools-router.js.map +1 -1
  41. package/dist/api/transcribe-stream.d.ts +16 -0
  42. package/dist/api/transcribe-stream.js +172 -0
  43. package/dist/api/transcribe-stream.js.map +1 -0
  44. package/dist/app.d.ts +4 -1
  45. package/dist/app.js +98 -59
  46. package/dist/app.js.map +1 -1
  47. package/dist/auth/gate.js +15 -11
  48. package/dist/auth/gate.js.map +1 -1
  49. package/dist/auth/oidc.d.ts +5 -2
  50. package/dist/auth/oidc.js +8 -7
  51. package/dist/auth/oidc.js.map +1 -1
  52. package/dist/auth/passkeys.d.ts +15 -14
  53. package/dist/auth/passkeys.js +89 -91
  54. package/dist/auth/passkeys.js.map +1 -1
  55. package/dist/auth/store.d.ts +17 -7
  56. package/dist/auth/store.js +93 -52
  57. package/dist/auth/store.js.map +1 -1
  58. package/dist/bin/talaria-web.js +12 -81
  59. package/dist/bin/talaria-web.js.map +1 -1
  60. package/dist/cli/ctl.js +2 -1
  61. package/dist/cli/ctl.js.map +1 -1
  62. package/dist/cli/dotenv.d.ts +5 -0
  63. package/dist/cli/dotenv.js +15 -2
  64. package/dist/cli/dotenv.js.map +1 -1
  65. package/dist/cli/launcher.js +5 -5
  66. package/dist/cli/launcher.js.map +1 -1
  67. package/dist/cli/main.d.ts +1 -0
  68. package/dist/cli/main.js +85 -0
  69. package/dist/cli/main.js.map +1 -0
  70. package/dist/cli/supervise.d.ts +2 -0
  71. package/dist/cli/supervise.js +67 -4
  72. package/dist/cli/supervise.js.map +1 -1
  73. package/dist/config/agent-config.d.ts +34 -11
  74. package/dist/config/agent-config.js +116 -60
  75. package/dist/config/agent-config.js.map +1 -1
  76. package/dist/config.d.ts +4 -0
  77. package/dist/config.js +19 -6
  78. package/dist/config.js.map +1 -1
  79. package/dist/fs/atomic.d.ts +1 -0
  80. package/dist/fs/atomic.js +4 -1
  81. package/dist/fs/atomic.js.map +1 -1
  82. package/dist/fs/stat.d.ts +1 -0
  83. package/dist/fs/stat.js +11 -0
  84. package/dist/fs/stat.js.map +1 -0
  85. package/dist/http/context.d.ts +31 -12
  86. package/dist/http/context.js +60 -24
  87. package/dist/http/context.js.map +1 -1
  88. package/dist/http/csp.d.ts +2 -1
  89. package/dist/http/csp.js +3 -2
  90. package/dist/http/csp.js.map +1 -1
  91. package/dist/onboarding.d.ts +2 -2
  92. package/dist/onboarding.js +10 -8
  93. package/dist/onboarding.js.map +1 -1
  94. package/dist/profiles/profiles.d.ts +20 -4
  95. package/dist/profiles/profiles.js +115 -30
  96. package/dist/profiles/profiles.js.map +1 -1
  97. package/dist/projects.d.ts +15 -6
  98. package/dist/projects.js +55 -47
  99. package/dist/projects.js.map +1 -1
  100. package/dist/providers/catalog.d.ts +84 -14
  101. package/dist/providers/catalog.js +474 -151
  102. package/dist/providers/catalog.js.map +1 -1
  103. package/dist/providers/env-file.d.ts +10 -3
  104. package/dist/providers/env-file.js +24 -75
  105. package/dist/providers/env-file.js.map +1 -1
  106. package/dist/providers/runtime-env.d.ts +4 -2
  107. package/dist/providers/runtime-env.js +9 -6
  108. package/dist/providers/runtime-env.js.map +1 -1
  109. package/dist/providers/tables.d.ts +3 -1
  110. package/dist/providers/tables.js +4 -2
  111. package/dist/providers/tables.js.map +1 -1
  112. package/dist/raster-image.d.ts +7 -0
  113. package/dist/raster-image.js +267 -0
  114. package/dist/raster-image.js.map +1 -0
  115. package/dist/redact.js +342 -46
  116. package/dist/redact.js.map +1 -1
  117. package/dist/release.d.ts +7 -0
  118. package/dist/release.js +14 -0
  119. package/dist/release.js.map +1 -1
  120. package/dist/runtime.d.ts +5 -1
  121. package/dist/runtime.js +305 -96
  122. package/dist/runtime.js.map +1 -1
  123. package/dist/server.js +15 -0
  124. package/dist/server.js.map +1 -1
  125. package/dist/sessions/anchor.d.ts +9 -3
  126. package/dist/sessions/anchor.js +28 -36
  127. package/dist/sessions/anchor.js.map +1 -1
  128. package/dist/sessions/background-events.d.ts +2 -0
  129. package/dist/sessions/background-events.js +10 -0
  130. package/dist/sessions/background-events.js.map +1 -0
  131. package/dist/sessions/background-tasks.d.ts +26 -27
  132. package/dist/sessions/background-tasks.js +89 -97
  133. package/dist/sessions/background-tasks.js.map +1 -1
  134. package/dist/sessions/background-updates.d.ts +15 -8
  135. package/dist/sessions/background-updates.js +101 -25
  136. package/dist/sessions/background-updates.js.map +1 -1
  137. package/dist/sessions/cli-sessions.d.ts +27 -6
  138. package/dist/sessions/cli-sessions.js +152 -42
  139. package/dist/sessions/cli-sessions.js.map +1 -1
  140. package/dist/sessions/completions.d.ts +77 -9
  141. package/dist/sessions/completions.js +482 -45
  142. package/dist/sessions/completions.js.map +1 -1
  143. package/dist/sessions/compress.d.ts +8 -0
  144. package/dist/sessions/compress.js +2 -2
  145. package/dist/sessions/compress.js.map +1 -1
  146. package/dist/sessions/drafts.d.ts +14 -10
  147. package/dist/sessions/drafts.js +38 -43
  148. package/dist/sessions/drafts.js.map +1 -1
  149. package/dist/sessions/export.d.ts +5 -0
  150. package/dist/sessions/export.js +30 -0
  151. package/dist/sessions/export.js.map +1 -1
  152. package/dist/sessions/gateway-sessions.d.ts +46 -0
  153. package/dist/sessions/gateway-sessions.js +378 -0
  154. package/dist/sessions/gateway-sessions.js.map +1 -0
  155. package/dist/sessions/gateway-watcher.d.ts +7 -3
  156. package/dist/sessions/gateway-watcher.js +31 -19
  157. package/dist/sessions/gateway-watcher.js.map +1 -1
  158. package/dist/sessions/goals.d.ts +31 -0
  159. package/dist/sessions/goals.js +75 -0
  160. package/dist/sessions/goals.js.map +1 -0
  161. package/dist/sessions/handoff.d.ts +32 -0
  162. package/dist/sessions/handoff.js +114 -0
  163. package/dist/sessions/handoff.js.map +1 -0
  164. package/dist/sessions/journal.d.ts +5 -79
  165. package/dist/sessions/journal.js +15 -606
  166. package/dist/sessions/journal.js.map +1 -1
  167. package/dist/sessions/list.d.ts +31 -16
  168. package/dist/sessions/list.js +139 -129
  169. package/dist/sessions/list.js.map +1 -1
  170. package/dist/sessions/merge.d.ts +69 -13
  171. package/dist/sessions/merge.js +265 -64
  172. package/dist/sessions/merge.js.map +1 -1
  173. package/dist/sessions/notices.d.ts +22 -0
  174. package/dist/sessions/notices.js +72 -0
  175. package/dist/sessions/notices.js.map +1 -0
  176. package/dist/sessions/pending.d.ts +10 -4
  177. package/dist/sessions/pending.js +83 -7
  178. package/dist/sessions/pending.js.map +1 -1
  179. package/dist/sessions/prefix-index.d.ts +82 -0
  180. package/dist/sessions/prefix-index.js +232 -0
  181. package/dist/sessions/prefix-index.js.map +1 -0
  182. package/dist/sessions/recovery.d.ts +19 -0
  183. package/dist/sessions/recovery.js +322 -0
  184. package/dist/sessions/recovery.js.map +1 -0
  185. package/dist/sessions/relay.d.ts +38 -19
  186. package/dist/sessions/relay.js +302 -79
  187. package/dist/sessions/relay.js.map +1 -1
  188. package/dist/sessions/service.d.ts +260 -55
  189. package/dist/sessions/service.js +1448 -462
  190. package/dist/sessions/service.js.map +1 -1
  191. package/dist/sessions/session.d.ts +24 -2
  192. package/dist/sessions/session.js +69 -7
  193. package/dist/sessions/session.js.map +1 -1
  194. package/dist/sessions/shares.d.ts +7 -6
  195. package/dist/sessions/shares.js +22 -45
  196. package/dist/sessions/shares.js.map +1 -1
  197. package/dist/sessions/source-kind.js +1 -0
  198. package/dist/sessions/source-kind.js.map +1 -1
  199. package/dist/sessions/state-db-identity.d.ts +18 -0
  200. package/dist/sessions/state-db-identity.js +67 -0
  201. package/dist/sessions/state-db-identity.js.map +1 -0
  202. package/dist/sessions/state-db-worker.d.ts +1 -0
  203. package/dist/sessions/state-db-worker.js +35 -0
  204. package/dist/sessions/state-db-worker.js.map +1 -0
  205. package/dist/sessions/state-db.d.ts +113 -7
  206. package/dist/sessions/state-db.js +861 -44
  207. package/dist/sessions/state-db.js.map +1 -1
  208. package/dist/sessions/storage/backend.d.ts +162 -0
  209. package/dist/sessions/storage/backend.js +19 -0
  210. package/dist/sessions/storage/backend.js.map +1 -0
  211. package/dist/sessions/storage/conformance.d.ts +20 -0
  212. package/dist/sessions/storage/conformance.js +237 -0
  213. package/dist/sessions/storage/conformance.js.map +1 -0
  214. package/dist/sessions/storage/document.d.ts +16 -0
  215. package/dist/sessions/storage/document.js +44 -0
  216. package/dist/sessions/storage/document.js.map +1 -0
  217. package/dist/sessions/storage/json-documents.d.ts +23 -0
  218. package/dist/sessions/storage/json-documents.js +233 -0
  219. package/dist/sessions/storage/json-documents.js.map +1 -0
  220. package/dist/sessions/storage/json-files.d.ts +12 -0
  221. package/dist/sessions/storage/json-files.js +124 -0
  222. package/dist/sessions/storage/json-files.js.map +1 -0
  223. package/dist/sessions/storage/json-installation.d.ts +3 -0
  224. package/dist/sessions/storage/json-installation.js +31 -0
  225. package/dist/sessions/storage/json-installation.js.map +1 -0
  226. package/dist/sessions/storage/json-journal.d.ts +114 -0
  227. package/dist/sessions/storage/json-journal.js +686 -0
  228. package/dist/sessions/storage/json-journal.js.map +1 -0
  229. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  230. package/dist/sessions/storage/json-recovery.js +195 -0
  231. package/dist/sessions/storage/json-recovery.js.map +1 -0
  232. package/dist/sessions/storage/json.d.ts +68 -0
  233. package/dist/sessions/storage/json.js +258 -0
  234. package/dist/sessions/storage/json.js.map +1 -0
  235. package/dist/sessions/storage/row.d.ts +6 -0
  236. package/dist/sessions/storage/row.js +49 -0
  237. package/dist/sessions/storage/row.js.map +1 -0
  238. package/dist/sessions/storage/search-text.d.ts +4 -0
  239. package/dist/sessions/storage/search-text.js +16 -0
  240. package/dist/sessions/storage/search-text.js.map +1 -0
  241. package/dist/sessions/store.d.ts +65 -68
  242. package/dist/sessions/store.js +305 -602
  243. package/dist/sessions/store.js.map +1 -1
  244. package/dist/sessions/streams.d.ts +4 -0
  245. package/dist/sessions/streams.js +9 -1
  246. package/dist/sessions/streams.js.map +1 -1
  247. package/dist/sessions/titles.d.ts +2 -0
  248. package/dist/sessions/titles.js +5 -1
  249. package/dist/sessions/titles.js.map +1 -1
  250. package/dist/sessions/todo.d.ts +5 -0
  251. package/dist/sessions/todo.js +28 -21
  252. package/dist/sessions/todo.js.map +1 -1
  253. package/dist/sessions/tool-display.d.ts +5 -4
  254. package/dist/sessions/tool-display.js +30 -50
  255. package/dist/sessions/tool-display.js.map +1 -1
  256. package/dist/sessions/turn-context.d.ts +11 -2
  257. package/dist/sessions/turn-context.js +18 -11
  258. package/dist/sessions/turn-context.js.map +1 -1
  259. package/dist/sessions/turn.d.ts +122 -17
  260. package/dist/sessions/turn.js +939 -358
  261. package/dist/sessions/turn.js.map +1 -1
  262. package/dist/sessions/wakeup-pause.d.ts +54 -0
  263. package/dist/sessions/wakeup-pause.js +163 -0
  264. package/dist/sessions/wakeup-pause.js.map +1 -0
  265. package/dist/sessions/window.js +13 -12
  266. package/dist/sessions/window.js.map +1 -1
  267. package/dist/settings.d.ts +19 -7
  268. package/dist/settings.js +178 -184
  269. package/dist/settings.js.map +1 -1
  270. package/dist/sidecar/client.d.ts +17 -3
  271. package/dist/sidecar/client.js +78 -8
  272. package/dist/sidecar/client.js.map +1 -1
  273. package/dist/sidecar/discover.d.ts +2 -1
  274. package/dist/sidecar/discover.js +93 -10
  275. package/dist/sidecar/discover.js.map +1 -1
  276. package/dist/sidecar/env.d.ts +4 -0
  277. package/dist/sidecar/env.js +3 -0
  278. package/dist/sidecar/env.js.map +1 -0
  279. package/dist/sidecar/fake.d.ts +2 -2
  280. package/dist/sidecar/fake.js +19 -2
  281. package/dist/sidecar/fake.js.map +1 -1
  282. package/dist/startup.d.ts +13 -5
  283. package/dist/startup.js +48 -0
  284. package/dist/startup.js.map +1 -1
  285. package/dist/test/env-file.d.ts +5 -0
  286. package/dist/test/env-file.js +88 -0
  287. package/dist/test/env-file.js.map +1 -0
  288. package/dist/test/harness.d.ts +1 -0
  289. package/dist/test/harness.js +1 -1
  290. package/dist/test/harness.js.map +1 -1
  291. package/dist/test/passkeys.d.ts +22 -0
  292. package/dist/test/passkeys.js +62 -0
  293. package/dist/test/passkeys.js.map +1 -0
  294. package/dist/test/storage.d.ts +15 -0
  295. package/dist/test/storage.js +26 -0
  296. package/dist/test/storage.js.map +1 -0
  297. package/dist/test/ts-worker-hooks.d.ts +1 -0
  298. package/dist/test/ts-worker-hooks.js +24 -0
  299. package/dist/test/ts-worker-hooks.js.map +1 -0
  300. package/dist/text/diff.d.ts +9 -14
  301. package/dist/text/diff.js +74 -108
  302. package/dist/text/diff.js.map +1 -1
  303. package/dist/tools/commands.d.ts +6 -1
  304. package/dist/tools/commands.js +8 -2
  305. package/dist/tools/commands.js.map +1 -1
  306. package/dist/tools/connectors.d.ts +33 -0
  307. package/dist/tools/connectors.js +121 -0
  308. package/dist/tools/connectors.js.map +1 -0
  309. package/dist/tools/crons.d.ts +38 -1
  310. package/dist/tools/crons.js +122 -18
  311. package/dist/tools/crons.js.map +1 -1
  312. package/dist/tools/dashboard-plugins.d.ts +53 -0
  313. package/dist/tools/dashboard-plugins.js +188 -0
  314. package/dist/tools/dashboard-plugins.js.map +1 -0
  315. package/dist/tools/extensions.d.ts +18 -5
  316. package/dist/tools/extensions.js +344 -266
  317. package/dist/tools/extensions.js.map +1 -1
  318. package/dist/tools/gateway-restart.d.ts +62 -0
  319. package/dist/tools/gateway-restart.js +142 -0
  320. package/dist/tools/gateway-restart.js.map +1 -0
  321. package/dist/tools/health.d.ts +44 -0
  322. package/dist/tools/health.js +201 -46
  323. package/dist/tools/health.js.map +1 -1
  324. package/dist/tools/hygiene.d.ts +4 -3
  325. package/dist/tools/hygiene.js +23 -9
  326. package/dist/tools/hygiene.js.map +1 -1
  327. package/dist/tools/kanban.d.ts +4 -2
  328. package/dist/tools/kanban.js +28 -19
  329. package/dist/tools/kanban.js.map +1 -1
  330. package/dist/tools/mcp.d.ts +13 -0
  331. package/dist/tools/mcp.js +88 -0
  332. package/dist/tools/mcp.js.map +1 -1
  333. package/dist/tools/memory-masks.d.ts +5 -0
  334. package/dist/tools/memory-masks.js +256 -0
  335. package/dist/tools/memory-masks.js.map +1 -0
  336. package/dist/tools/memory.d.ts +29 -3
  337. package/dist/tools/memory.js +69 -27
  338. package/dist/tools/memory.js.map +1 -1
  339. package/dist/tools/plugin-settings.d.ts +15 -0
  340. package/dist/tools/plugin-settings.js +90 -0
  341. package/dist/tools/plugin-settings.js.map +1 -0
  342. package/dist/tools/project-os.d.ts +11 -0
  343. package/dist/tools/project-os.js +267 -0
  344. package/dist/tools/project-os.js.map +1 -0
  345. package/dist/tools/skills.d.ts +14 -5
  346. package/dist/tools/skills.js +49 -16
  347. package/dist/tools/skills.js.map +1 -1
  348. package/dist/tools/update-notifications.d.ts +34 -15
  349. package/dist/tools/update-notifications.js +141 -57
  350. package/dist/tools/update-notifications.js.map +1 -1
  351. package/dist/tools/update-rollback.d.ts +11 -0
  352. package/dist/tools/update-rollback.js +28 -0
  353. package/dist/tools/update-rollback.js.map +1 -0
  354. package/dist/tools/updates.d.ts +110 -32
  355. package/dist/tools/updates.js +1112 -294
  356. package/dist/tools/updates.js.map +1 -1
  357. package/dist/tools/wiki.d.ts +68 -0
  358. package/dist/tools/wiki.js +413 -0
  359. package/dist/tools/wiki.js.map +1 -0
  360. package/dist/util.d.ts +2 -0
  361. package/dist/util.js +5 -0
  362. package/dist/util.js.map +1 -1
  363. package/dist/workspace/escape.d.ts +61 -0
  364. package/dist/workspace/escape.js +149 -0
  365. package/dist/workspace/escape.js.map +1 -0
  366. package/dist/workspace/extract.d.ts +22 -0
  367. package/dist/workspace/extract.js +308 -0
  368. package/dist/workspace/extract.js.map +1 -0
  369. package/dist/workspace/fs.d.ts +12 -0
  370. package/dist/workspace/fs.js +92 -23
  371. package/dist/workspace/fs.js.map +1 -1
  372. package/dist/workspace/git.d.ts +34 -1
  373. package/dist/workspace/git.js +328 -35
  374. package/dist/workspace/git.js.map +1 -1
  375. package/dist/workspace/media.d.ts +16 -13
  376. package/dist/workspace/media.js +43 -61
  377. package/dist/workspace/media.js.map +1 -1
  378. package/dist/workspace/rollback.d.ts +36 -22
  379. package/dist/workspace/rollback.js +178 -272
  380. package/dist/workspace/rollback.js.map +1 -1
  381. package/dist/workspace/unzip.js +4 -1
  382. package/dist/workspace/unzip.js.map +1 -1
  383. package/dist/workspace/upload.d.ts +7 -4
  384. package/dist/workspace/upload.js +5 -2
  385. package/dist/workspace/upload.js.map +1 -1
  386. package/dist/workspace/workspaces.d.ts +15 -19
  387. package/dist/workspace/workspaces.js +55 -127
  388. package/dist/workspace/workspaces.js.map +1 -1
  389. package/dist/workspace/worktrees.d.ts +2 -1
  390. package/dist/workspace/worktrees.js +179 -42
  391. package/dist/workspace/worktrees.js.map +1 -1
  392. package/package.json +7 -3
  393. package/sidecar/agent_dependency.json +4 -4
  394. package/sidecar/agent_support.json +26 -0
  395. package/sidecar/scripts/record-fixtures.py +12 -3
  396. package/sidecar/scripts/replay_sidecar.py +32 -0
  397. package/sidecar/scripts/suite_runner.py +302 -0
  398. package/sidecar/scripts/test.sh +12 -4
  399. package/sidecar/talaria_sidecar/__init__.py +6 -1
  400. package/sidecar/talaria_sidecar/__main__.py +4 -2
  401. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  402. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  403. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  404. package/sidecar/talaria_sidecar/home.py +56 -3
  405. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  406. package/sidecar/talaria_sidecar/methods/aux.py +166 -24
  407. package/sidecar/talaria_sidecar/methods/chat.py +852 -135
  408. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  409. package/sidecar/talaria_sidecar/methods/commands.py +294 -4
  410. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  411. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  412. package/sidecar/talaria_sidecar/methods/cron.py +395 -36
  413. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  414. package/sidecar/talaria_sidecar/methods/gateway.py +212 -55
  415. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  416. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  417. package/sidecar/talaria_sidecar/methods/kanban.py +108 -34
  418. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  419. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  420. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  421. package/sidecar/talaria_sidecar/methods/process.py +218 -15
  422. package/sidecar/talaria_sidecar/methods/profiles.py +121 -13
  423. package/sidecar/talaria_sidecar/methods/providers.py +217 -28
  424. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  425. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  426. package/sidecar/talaria_sidecar/methods/state_db.py +316 -75
  427. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  428. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  429. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  430. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  431. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  432. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  433. package/sidecar/talaria_sidecar/rpc.py +45 -4
  434. package/sidecar/talaria_sidecar/runtime.py +98 -13
  435. package/sidecar/talaria_sidecar/speed.py +35 -0
  436. package/static/dist/FILES.txt +126 -126
  437. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  438. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  439. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  440. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  441. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  442. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  443. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  444. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  445. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  446. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  447. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  448. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  449. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  450. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  451. package/static/dist/assets/{_app.ext._extensionId-opyh9y0v.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  452. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  453. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  454. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  455. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  456. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  457. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  458. package/static/dist/assets/{_app.session._sessionId-bfd4h3id.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  459. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  460. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  461. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  462. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  463. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  464. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  465. package/static/dist/assets/{abnfDiagram-VCTEODGH-b2x1lqot.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  466. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  467. package/static/dist/assets/{architectureDiagram-5GKGNRK7-h38o5l7b.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  468. package/static/dist/assets/archive-b1j004vn.js +1 -0
  469. package/static/dist/assets/back-g74gkozu.js +1 -0
  470. package/static/dist/assets/{blockDiagram-I7D4REHJ-bqquxv2u.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  471. package/static/dist/assets/{c4Diagram-7LVT6UL2-f3fb8w6z.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  472. package/static/dist/assets/channel-es95o4y8.js +1 -0
  473. package/static/dist/assets/{chunk-4HAMMTFA-bzfyyzp3.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  474. package/static/dist/assets/{chunk-75Z2AOVW-ly99rfx5.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  475. package/static/dist/assets/{chunk-DU6HZSFF-bg6ecfqf.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  476. package/static/dist/assets/{chunk-F27PBJKO-e8k7mmqw.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  477. package/static/dist/assets/{chunk-GMAD6QVW-cmalnoaj.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  478. package/static/dist/assets/{chunk-GVQU2GXP-gmif5o01.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  479. package/static/dist/assets/{chunk-IMKFNOWR-f1nwwh6u.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  480. package/static/dist/assets/{chunk-L3NEJ4N5-oktfh5v7.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  481. package/static/dist/assets/{chunk-OSK3NFVY-b1sdx96z.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  482. package/static/dist/assets/{chunk-P2QGCYS3-fhu4nzxt.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  483. package/static/dist/assets/{chunk-POPQ4Y6H-em31rnef.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  484. package/static/dist/assets/{chunk-PWAF6VOD-bxhhyftw.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  485. package/static/dist/assets/{chunk-SHT3W25Y-fmrvwcav.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  486. package/static/dist/assets/{chunk-SVP7TREG-lymukl2j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  487. package/static/dist/assets/{chunk-TICWLB2K-lmza6k23.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  488. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  489. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  490. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  491. package/static/dist/assets/{copy-fw5b1pc6.js → copy-jamtd1pf.js} +1 -1
  492. package/static/dist/assets/{cynefin-OW5HDTMX-czlhbyhs.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  493. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-gq7mvswb.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  494. package/static/dist/assets/{dagre-GXQ25YYZ-cqdbf7dr.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  495. package/static/dist/assets/{diagram-S7CK7UJ4-o6arxa58.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  496. package/static/dist/assets/{diagram-UQ7AKVKN-nhnd68tz.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  497. package/static/dist/assets/{diagram-VSXAHHWV-dk2sclzn.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  498. package/static/dist/assets/{diagram-VX7I27RA-n3k5nyx3.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  499. package/static/dist/assets/{diagram-Z3DM3KII-m6q9cwdh.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  500. package/static/dist/assets/{ebnfDiagram-PWID7BFC-lsa5641p.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  501. package/static/dist/assets/{erDiagram-RLTQ6QDP-fo2ckbe6.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  502. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  503. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  504. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  505. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-nzqz6x9g.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  506. package/static/dist/assets/{gitGraph-4MIJSDKK-gukvcxe6.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  507. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-lqhdp8f0.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  508. package/static/dist/assets/{highlighted-body-KPVGNVTW-igw6cp39.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  509. package/static/dist/assets/host-hzm8n85n.js +1 -0
  510. package/static/dist/assets/index-drdcaqeq.css +2 -0
  511. package/static/dist/assets/index-jzm844he.js +11 -0
  512. package/static/dist/assets/{info-A6RAGUB7-gvu7a93l.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  513. package/static/dist/assets/{infoDiagram-27XIBGKW-m8t8g6w1.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  514. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-l07vkbgj.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  515. package/static/dist/assets/{journeyDiagram-3NMN7TZE-hybkgj8l.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  516. package/static/dist/assets/{kanban-definition-UXKFOSKX-by13uvtn.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  517. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  518. package/static/dist/assets/{line-osxq6fq7.js → line-h0ojzsvs.js} +1 -1
  519. package/static/dist/assets/link-k01ryaf9.js +1 -0
  520. package/static/dist/assets/login-bsae49ew.js +1 -0
  521. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  522. package/static/dist/assets/{mermaid-parser.core-i91oxmou.js → mermaid-parser.core-bnre27u8.js} +3 -3
  523. package/static/dist/assets/{mindmap-definition-YA3MSWOX-m82mx8ww.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  524. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  525. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  526. package/static/dist/assets/{packet-AYTQ26CC-nbtgge2o.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  527. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  528. package/static/dist/assets/{pegDiagram-XKGWAZYB-f1zxzga1.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  529. package/static/dist/assets/{pie-WAS4IAKB-of8vuhxs.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  530. package/static/dist/assets/{pieDiagram-E7YTZNPT-o7y8ftio.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  531. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-iqiwia1t.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  532. package/static/dist/assets/{radar-RG4KPBEZ-delnsy2v.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  533. package/static/dist/assets/{railroad-74A4TZTK-nspnyq9u.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  534. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  535. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  536. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  537. package/static/dist/assets/{railroadDiagram-O6MQD6OU-z41tqx5z.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  538. package/static/dist/assets/{refresh-cw-nt1dtsx4.js → refresh-cw-mqbkv2a3.js} +1 -1
  539. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  540. package/static/dist/assets/{requirementDiagram-BXWQKSXE-bdvif194.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  541. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-h73e0rol.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  542. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  543. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-iv95mb1z.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  544. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  545. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  546. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  547. package/static/dist/assets/{stateDiagram-D77RDMKH-k417ixmf.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  548. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  549. package/static/dist/assets/{swimlanes-42K2YHIH-esola70w.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  550. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  551. package/static/dist/assets/{timeline-definition-24CTP7MA-getrz2y8.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  552. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  553. package/static/dist/assets/{treeView-Q6P3EWNA-hc7ap1q7.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  554. package/static/dist/assets/{treemap-WGGIJYW6-b7k1syy5.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  555. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  556. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  557. package/static/dist/assets/{useParams-lhg566ew.js → useParams-gjc70qru.js} +1 -1
  558. package/static/dist/assets/{useSearch-lbrh6ejn.js → useSearch-e6ae539u.js} +1 -1
  559. package/static/dist/assets/{vennDiagram-4TSXK5OY-n6pcy5qb.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  560. package/static/dist/assets/{wardley-WFR3VGLG-lpa18vz1.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  561. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-kpclwwg7.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  562. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-paa2n8y0.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  563. package/static/dist/index.html +1 -1
  564. package/static/dist/sw.js +1 -1
  565. package/static/dist/assets/AppShell-fb2td9zi.js +0 -1
  566. package/static/dist/assets/ChatPage-cj96kiy9.js +0 -25
  567. package/static/dist/assets/Dialog-kxcg7ard.js +0 -1
  568. package/static/dist/assets/ExtensionRoute-jf7h0754.js +0 -1
  569. package/static/dist/assets/HubRoute-gmanpd5y.js +0 -2
  570. package/static/dist/assets/Markdown-mdwlvj3w.js +0 -598
  571. package/static/dist/assets/RightPanel-ftvrjlcp.js +0 -1
  572. package/static/dist/assets/Select-bilaypej.js +0 -1
  573. package/static/dist/assets/SessionListPanel-og8n36qs.js +0 -1
  574. package/static/dist/assets/SettingsLayout-jonjefmj.js +0 -1
  575. package/static/dist/assets/States-nmejrwrm.js +0 -1
  576. package/static/dist/assets/Toaster-nnl8zkt9.js +0 -1
  577. package/static/dist/assets/_app-gdidzd94.js +0 -1
  578. package/static/dist/assets/_app.index-hbarzbjx.js +0 -1
  579. package/static/dist/assets/_app.insights-luywlarz.js +0 -1
  580. package/static/dist/assets/_app.kanban-kincnbr8.js +0 -1
  581. package/static/dist/assets/_app.logs-oi2gr3gs.js +0 -1
  582. package/static/dist/assets/_app.memory-b6jfivt6.js +0 -1
  583. package/static/dist/assets/_app.profiles-lpvixmqp.js +0 -1
  584. package/static/dist/assets/_app.settings-vowx3iq0.js +0 -1
  585. package/static/dist/assets/_app.settings._section-cnntg1tu.js +0 -3
  586. package/static/dist/assets/_app.skills-fb57lpau.js +0 -1
  587. package/static/dist/assets/_app.tasks-eszpx1q7.js +0 -2
  588. package/static/dist/assets/_app.todos-nr05ac6w.js +0 -1
  589. package/static/dist/assets/_app.workspaces-d2luomu6.js +0 -1
  590. package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +0 -1
  591. package/static/dist/assets/back-bemaer09.js +0 -1
  592. package/static/dist/assets/cancel-drd0g6d9.js +0 -1
  593. package/static/dist/assets/channel-cpdgf867.js +0 -1
  594. package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +0 -1
  595. package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +0 -1
  596. package/static/dist/assets/copied-ima3pysb.js +0 -1
  597. package/static/dist/assets/endpoints-d33siokx.js +0 -64
  598. package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +0 -1
  599. package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +0 -1
  600. package/static/dist/assets/index-cxq58xb1.js +0 -11
  601. package/static/dist/assets/index-hgtx08n5.css +0 -2
  602. package/static/dist/assets/lazyRouteComponent-af0lvgjx.js +0 -1
  603. package/static/dist/assets/link-jyxicdhg.js +0 -1
  604. package/static/dist/assets/login-ec21t39x.js +0 -1
  605. package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +0 -1
  606. package/static/dist/assets/onboarding-btnltvyz.js +0 -1
  607. package/static/dist/assets/onboarding_model_label-dp7xffoh.js +0 -1
  608. package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +0 -1
  609. package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +0 -1
  610. package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +0 -1
  611. package/static/dist/assets/refresh-b4uams1i.js +0 -1
  612. package/static/dist/assets/saved-kg52csy7.js +0 -1
  613. package/static/dist/assets/settings_section_extensions_title-ej0iapxe.js +0 -1
  614. package/static/dist/assets/share._token-f97e94r0.js +0 -2
  615. package/static/dist/assets/sliders-horizontal-fj18it47.js +0 -1
  616. package/static/dist/assets/sse-fwpqtzbg.js +0 -1
  617. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +0 -1
  618. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +0 -8
  619. package/static/dist/assets/todoStore-eginf9h4.js +0 -1
  620. package/static/dist/assets/tts-lxdxnmjd.js +0 -1
  621. package/static/dist/assets/untitled-ovpfthg4.js +0 -1
  622. package/static/dist/assets/useForm-bcblv1rx.js +0 -1
  623. package/static/dist/assets/useLocale-fi5jb8ma.js +0 -1
@@ -14,20 +14,26 @@ import contextlib
14
14
  import contextvars
15
15
  import copy
16
16
  import hashlib
17
+ import importlib.util
17
18
  import inspect
18
19
  import json
19
20
  import logging
20
21
  import os
22
+ import re
21
23
  import threading
22
24
  import time
23
25
  import uuid
24
26
  from collections import OrderedDict
27
+ from functools import lru_cache
25
28
  from pathlib import Path
26
29
  from typing import Any
27
30
 
28
31
  from ..errors import InvalidParams, RpcError
29
- from ..home import profile_home_param, scoped_home
32
+ from ..home import profile_home_param, scoped_home, session_workspace
30
33
  from ..rpc import CallContext
34
+ from .checkpoints import checkpoint_kwargs
35
+ from .providers import runtime_failure
36
+ from .toolsets import disabled_toolsets
31
37
 
32
38
  log = logging.getLogger("talaria_sidecar.chat")
33
39
 
@@ -36,10 +42,10 @@ _AGENT_CACHE_MAX = 32
36
42
  _AGENT_RELEASE_TIMEOUT = 4.0
37
43
  # Profile deletion waits this long for the profile's memory flushes, under the server's 120 s call timeout.
38
44
  _PROFILE_RELEASE_TIMEOUT = 60.0
45
+ _SESSION_FINALIZE_TIMEOUT = 10.0
39
46
  _TOOL_RESULT_SNIPPET_MAX = 4000
40
47
  # Predecessor ``_TOOL_ARG_CONTENT_KEYS`` (#4928): card content / diff-reconstruction inputs keep the long cap.
41
48
  _TOOL_ARG_CONTENT_KEYS = frozenset({"command", "cmd", "script", "code", "patch", "diff", "old_string", "new_string", "content", "path", "file_path"})
42
- _CLARIFY_FALLBACK = "The user did not provide a response within the time limit. Use your best judgement to make the choice and proceed."
43
49
 
44
50
 
45
51
  class _Run:
@@ -55,6 +61,10 @@ class _Run:
55
61
  self.lock = threading.Lock()
56
62
  # The Agent's transcript list from before this turn: a Stop while the Agent still holds it has no checkpoint.
57
63
  self.prior_messages = None
64
+ # TAL-897: the session ended (clear/delete) or the sidecar is draining, set under both cache and run locks; an
65
+ # agent this run binds afterwards forks no review and stays out of the cache.
66
+ self.session_ended = False
67
+ self.home = None # the profile home its agent runs under, set by ``start``
58
68
 
59
69
 
60
70
  class _ClarifyEntry:
@@ -63,6 +73,7 @@ class _ClarifyEntry:
63
73
  self.data = data
64
74
  self.event = threading.Event()
65
75
  self.result: str | None = None
76
+ self.deadline: float | None = None
66
77
 
67
78
 
68
79
  _RUNS: dict[str, _Run] = {}
@@ -78,7 +89,7 @@ _AGENT_CACHE_LOCK = threading.Lock()
78
89
  # Running release thread -> the profile home it runs under.
79
90
  _RELEASES: dict[threading.Thread, Any] = {}
80
91
  _RELEASES_LOCK = threading.Lock()
81
- # Agents inside ``chat.commit_memory`` (guarded by ``_AGENT_CACHE_LOCK``): like a turn, a commit defers their release.
92
+ # Agents inside ``chat.commit_memory`` or ``chat.refine`` (guarded by ``_AGENT_CACHE_LOCK``): like a turn, each defers their release.
82
93
  _COMMITTING: list = []
83
94
  # Set (under ``_RUNS_LOCK``) when shutdown drains: no turn is admitted after the drain's snapshot of ``_RUNS``.
84
95
  _DRAINING = threading.Event()
@@ -113,6 +124,31 @@ def _result_diff(raw: Any) -> str | None:
113
124
  return diff if isinstance(diff, str) else None
114
125
 
115
126
 
127
+ def _edit_snapshot(name: str, args: Any, task_id: str) -> Any:
128
+ """TAL-909: the Agent's before-state of a file edit, through the task's terminal; None for a remote backend (the
129
+ Agent never diffs an unrelated local file), a non-edit tool, or any failure."""
130
+ try:
131
+ from agent.display import capture_local_edit_snapshot
132
+
133
+ return capture_local_edit_snapshot(name, args, task_id=task_id)
134
+ except Exception: # noqa: BLE001
135
+ return None
136
+
137
+
138
+ def _edit_metadata(name: str, args: Any, result: Any, snapshot: Any) -> dict:
139
+ """TAL-909: a tool-result row's ``display_metadata``: an edit's unified ``diff`` from its snapshot, which the server
140
+ turns into ``edit_diff``. Empty when the result carries its own ``diff`` (``patch``), which the server already reads."""
141
+ if snapshot is None or _result_diff(result) is not None:
142
+ return {}
143
+ try:
144
+ from agent.display import extract_edit_diff
145
+
146
+ diff = extract_edit_diff(name, result, function_args=args, snapshot=snapshot)
147
+ except Exception: # noqa: BLE001
148
+ return {}
149
+ return {"diff": diff} if diff else {}
150
+
151
+
116
152
  def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
117
153
  """The tool result as the server's outcome rule reads it, bounded: a dict (or JSON-object text) keeps its first
118
154
  ``_RAW_RESULT_MAX_KEYS`` top-level fields plus its outcome fields, scalars as they are, text and non-empty nested
@@ -137,6 +173,18 @@ def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
137
173
  return out
138
174
 
139
175
 
176
+ _TODO_TOOL_NAMES = frozenset({"todo", "todo_list"})
177
+
178
+
179
+ def _is_todo_tool(name: Any) -> bool:
180
+ """TAL-889: the Agent's own todo-tool predicate (canonical ``todo_list`` and legacy aliases); older Agents lack it."""
181
+ try:
182
+ from tools.todo_tool import is_todo_tool_name
183
+ except Exception: # noqa: BLE001 - older Agent without the predicate
184
+ return isinstance(name, str) and name in _TODO_TOOL_NAMES
185
+ return bool(is_todo_tool_name(name))
186
+
187
+
140
188
  def _delegation_cost_usd(name: Any, raw: Any):
141
189
  """Predecessor ``_delegation_cost_usd``: total spend a ``delegate_task`` result reports, or None."""
142
190
  if str(name or "") != "delegate_task":
@@ -254,9 +302,29 @@ def _context_length(agent: Any) -> dict:
254
302
  value = getattr(cc, key, None)
255
303
  if isinstance(value, (int, float)):
256
304
  out[key] = int(value)
305
+ threshold = _next_turn_threshold(agent, cc)
306
+ if threshold:
307
+ out["next_turn_threshold_tokens"] = threshold
257
308
  return out
258
309
 
259
310
 
311
+ def _next_turn_threshold(agent: Any, cc: Any) -> int | None:
312
+ """TAL-1005: the compression threshold the next turn runs under. A turn that ended on a fallback leaves the
313
+ fallback's threshold on the compressor, but the Agent restores the primary runtime at the next turn's start
314
+ (``restore_primary_runtime``), so this is the trigger the compressor installs for that runtime's snapshot."""
315
+ value = getattr(cc, "threshold_tokens", None)
316
+ primary = getattr(agent, "_primary_runtime", None)
317
+ if getattr(agent, "_fallback_activated", False) is True and isinstance(primary, dict):
318
+ value = primary.get("compressor_threshold_tokens")
319
+ preview = getattr(cc, "preview_threshold_tokens", None)
320
+ if callable(preview) and isinstance(primary.get("compressor_context_length"), int):
321
+ try:
322
+ value = preview(str(primary.get("compressor_model") or ""), primary["compressor_context_length"], str(primary.get("compressor_provider") or ""))
323
+ except Exception: # noqa: BLE001
324
+ log.debug("primary threshold preview failed", exc_info=True)
325
+ return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 else None
326
+
327
+
260
328
  # TAL-539: the hard cap on raw tool output kept in a compressed context (predecessor ``_hard_prune_post_compression_*``).
261
329
  _TAIL_TOOL_BUDGET_TOKENS = 4096
262
330
  _TAIL_TOOL_MIN_SNIPPET_TOKENS = 256
@@ -279,25 +347,111 @@ def _rough_tokens(text: str) -> int:
279
347
  return -(-len(text) // _ROUGH_TOKEN_CHARS)
280
348
 
281
349
 
350
+ def _tool_failure_outcome(text: str) -> str:
351
+ """`` FAILED: <error>`` for a JSON tool payload with a top-level ``error`` / ``success: false``, `` FAILED: exit N`` for
352
+ a non-zero ``exit_code`` / ``exitCode`` the agent did not kill itself, else ``""``: the Agent's compression-stub rule
353
+ (``_payload_failure_suffix``, ``_sum_process_manage``). Payloads put these fields after ``output``, so a cut drops them."""
354
+ try:
355
+ payload = json.loads(text)
356
+ except (ValueError, RecursionError):
357
+ return ""
358
+ if not isinstance(payload, dict):
359
+ return ""
360
+ error = payload.get("error")
361
+ if not error and payload.get("success") is not False:
362
+ code = payload.get("exit_code")
363
+ code = payload.get("exitCode") if code is None else code
364
+ if not isinstance(code, int) or isinstance(code, bool) or code == 0 or payload.get("completion_reason") == "killed":
365
+ return ""
366
+ error = f"exit {code}"
367
+ preview = " ".join(str(error).split())[:80] if error else ""
368
+ return f" FAILED: {preview}" if preview else " FAILED"
369
+
370
+
282
371
  def _capped_tool_result(text: str, tokens: int, keep_tokens: int) -> str:
283
- """The kept head of an over-budget tool result plus a note on what the model no longer sees."""
284
- prefix = "[Talaria context budget: omitted "
285
- suffix = f" (~{tokens} rough tokens) from this tool result; the full output remains in the visible transcript.]"
372
+ """The kept head of an over-budget tool result plus a note on what the model no longer sees, ending on the outcome.
373
+ TAL-827: the Agent's elision marker marks the cut, so its dispatch guard rejects a model that copies the marker
374
+ into a durable write; Talaria's note follows on its own line."""
375
+ outcome = _tool_failure_outcome(text)
376
+ try:
377
+ from agent.compression_marker import elide_middle
378
+ except ImportError: # An Agent without the shared marker keeps Talaria's own.
379
+ prefix = "[Talaria context budget: omitted "
380
+ suffix = f" (~{tokens} rough tokens) from this tool result; the full output remains in the visible transcript.{outcome}]"
381
+ note_only, widest = f"{prefix}{len(text)} chars{suffix}", f"{prefix}{len(text)} of {len(text)} chars{suffix}"
382
+
383
+ def cut(snippet: str) -> str:
384
+ return f"{snippet}\n\n{prefix}{len(text) - len(snippet)} of {len(text)} chars{suffix}"
385
+ else:
386
+ note = f"\n[Talaria context budget: this tool result is ~{tokens} rough tokens; the full output remains in the visible transcript.{outcome}]"
387
+ # With nothing kept the marker counts every char, its widest form for this text.
388
+ note_only = widest = elide_middle(text, 0, 0) + note
389
+
390
+ def cut(snippet: str) -> str:
391
+ return elide_middle(text, len(snippet), 0) + note
286
392
  if keep_tokens < _TAIL_TOOL_MIN_SNIPPET_TOKENS:
287
- return f"{prefix}{len(text)} chars{suffix}"
288
- room = keep_tokens - _rough_tokens(f"{prefix}{len(text)} of {len(text)} chars{suffix}") - 1
393
+ return note_only
394
+ room = keep_tokens - _rough_tokens(widest) - 1
289
395
  snippet = text[: len(text) * room // tokens]
290
396
  # Density varies along the text: shrink until the kept head fits the remaining tokens.
291
397
  while snippet and _rough_tokens(snippet) > room:
292
398
  snippet = snippet[: len(snippet) * 3 // 4]
293
399
  snippet = snippet.rstrip()
294
- if not snippet:
295
- return f"{prefix}{len(text)} chars{suffix}"
296
- return f"{snippet}\n\n{prefix}{len(text) - len(snippet)} of {len(text)} chars{suffix}"
400
+ return cut(snippet) if snippet else note_only
297
401
 
298
402
 
299
- def _cap_tool_results(messages: list, compressor: Any) -> list:
300
- """Newest tool results spend one shared budget; the first one over it is cut to the remainder, older ones to a note."""
403
+ def _skill_result_stubs(messages: list) -> dict[int, str]:
404
+ """Recovery metadata for paired skill loads, captured before the Agent can prune their success/failure payloads.
405
+ Extends the Agent's call-id and marker helpers; its older summaries omit small-skill markers and failure verdicts."""
406
+ try:
407
+ from agent.context_compressor import _tool_calls_by_id
408
+ except ImportError:
409
+ return {}
410
+ try:
411
+ from agent.context_compressor import _skill_pruned_marker
412
+ except ImportError:
413
+ def _skill_pruned_marker(name: str) -> str:
414
+ return f"[SKILL_PRUNED: content lost in compression; reload with skill_view(name='{name}')]"
415
+
416
+ calls = _tool_calls_by_id(messages)
417
+ stubs = {}
418
+ for i, msg in enumerate(messages):
419
+ if msg.get("role") != "tool" or not msg.get("tool_call_id"):
420
+ continue
421
+ tool, raw_args = calls.get(msg["tool_call_id"], (None, None))
422
+ if tool != "skill_view":
423
+ continue
424
+ try:
425
+ args = json.loads(raw_args)
426
+ except (TypeError, ValueError, RecursionError):
427
+ continue
428
+ name = args.get("name") if isinstance(args, dict) else None
429
+ if not isinstance(name, str) or not name or args.get("file_path"):
430
+ continue
431
+ text = _content_text(msg.get("content"))
432
+ outcome = _tool_failure_outcome(text)
433
+ if text.startswith("[skill_view] ") and " FAILED" in text.split(" (", 1)[0]:
434
+ outcome = " FAILED"
435
+ if outcome:
436
+ stubs[i] = f"[skill_view] name={name}{outcome}"
437
+ continue
438
+ try:
439
+ payload = json.loads(text)
440
+ except (ValueError, RecursionError):
441
+ payload = None
442
+ successful = isinstance(payload, dict) and payload.get("success") is True and isinstance(payload.get("content"), str)
443
+ marker = _skill_pruned_marker(name)
444
+ # Lean-tail stubs predate the current cap and may have been persisted by a different supported Agent.
445
+ demoted = text.startswith(("[skill_view output demoted at compaction ", "[tool output demoted at compaction ")) and text.endswith("] " + marker)
446
+ if successful or text.startswith(f"[skill_view] name={name} (") or text == marker or demoted:
447
+ stubs[i] = marker
448
+ return stubs
449
+
450
+
451
+ def _cap_tool_results(messages: list, compressor: Any, original_messages: list | None = None) -> list:
452
+ """Newest bodies spend one shared budget. Skill reload/failure stubs, like omission notes, survive exhaustion."""
453
+ originals = messages if original_messages is None else original_messages
454
+ skill_stubs = _skill_result_stubs(originals)
301
455
  budget = getattr(compressor, "tail_token_budget", None)
302
456
  budget = budget if isinstance(budget, int) and budget > 0 else _TAIL_TOOL_BUDGET_TOKENS
303
457
  threshold = getattr(compressor, "threshold_tokens", None)
@@ -311,13 +465,17 @@ def _cap_tool_results(messages: list, compressor: Any) -> list:
311
465
  if msg.get("role") != "tool":
312
466
  continue
313
467
  text = _content_text(msg.get("content"))
468
+ stub = skill_stubs.get(i)
469
+ if stub and text != _content_text(originals[i].get("content")):
470
+ text = stub
471
+ out[i] = {**msg, "content": text}
314
472
  if not text.strip():
315
473
  continue
316
474
  tokens = _rough_tokens(text)
317
475
  if tokens <= budget - spent:
318
476
  spent += tokens
319
477
  continue
320
- out[i] = {**msg, "content": _capped_tool_result(text, tokens, budget - spent)}
478
+ out[i] = {**msg, "content": stub or _capped_tool_result(text, tokens, budget - spent)}
321
479
  spent = budget
322
480
  return out
323
481
 
@@ -336,7 +494,7 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
336
494
  context = pruned
337
495
  except Exception: # noqa: BLE001 - the hard cap below still bounds the context
338
496
  log.debug("post-compression tool-result pruning failed", exc_info=True)
339
- context = _cap_tool_results(context, compressor)
497
+ context = _cap_tool_results(context, compressor, messages)
340
498
  estimate = None
341
499
  try:
342
500
  from agent.model_metadata import estimate_request_tokens_rough
@@ -349,6 +507,40 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
349
507
  return context, estimate if isinstance(estimate, int) and estimate > 0 else None
350
508
 
351
509
 
510
+ # TAL-887: the Agent's password-manager prompts, answered like a clarify; the waits are the Agent's own surfaces' (its TUI).
511
+ _VAULT_PROMPT_TIMEOUTS = {"vault_unlock": 120, "vault_save_login": 180, "vault_code": 180, "skill_secret": 120}
512
+
513
+
514
+ def _set_vault_session(session_id) -> None:
515
+ """Bind the session an unlock made on this thread belongs to (released with it)."""
516
+ try:
517
+ from agent.vault_backends import unlock
518
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
519
+ return
520
+ unlock.set_current_session_id(session_id)
521
+
522
+
523
+ def _release_vault_session(session_id) -> None:
524
+ """Lock the password managers ``session_id`` unlocked; other sessions keep theirs."""
525
+ try:
526
+ from agent.vault_backends.unlock import release_session
527
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
528
+ return
529
+ release_session(session_id)
530
+
531
+
532
+ def _set_vault_prompts(session_id, unlock_cb, save_login_cb, code_cb) -> None:
533
+ """Bind (or with ``None``s clear) the Agent's per-thread vault prompts and the session an unlock belongs to."""
534
+ try:
535
+ from agent.vault_backends import unlock
536
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
537
+ return
538
+ _set_vault_session(session_id)
539
+ unlock.set_unlock_prompt_callback(unlock_cb)
540
+ unlock.set_save_login_prompt_callback(save_login_cb)
541
+ unlock.set_code_prompt_callback(code_cb)
542
+
543
+
352
544
  def _clarify_timeout(params: dict) -> int:
353
545
  """Predecessor ``_clarify_timeout_seconds``: an explicit request value wins, else the Agent's resolver over config."""
354
546
  explicit = params.get("clarify_timeout_seconds")
@@ -480,7 +672,7 @@ def _resolve_runtime(provider: str | None, model: str) -> dict:
480
672
  try:
481
673
  runtime = resolve_runtime_provider(requested=provider or None, target_model=model or None)
482
674
  except Exception as exc: # noqa: BLE001
483
- raise RpcError(str(exc), condition="credential_missing", data={"error_type": type(exc).__name__}) from exc
675
+ raise runtime_failure(exc, provider) from exc
484
676
  if not isinstance(runtime, dict):
485
677
  runtime = dict(vars(runtime)) if hasattr(runtime, "__dict__") else {}
486
678
  return runtime
@@ -496,10 +688,8 @@ def _turn_identity(session_id: str, workspace: str):
496
688
  not fatal."""
497
689
  resets: list = []
498
690
  try:
499
- try:
500
- from tools.approval_context import reset_current_session_key, set_current_session_key
501
- except ImportError:
502
- from tools.approval import reset_current_session_key, set_current_session_key # type: ignore[no-redef]
691
+ from tools.approval_context import reset_current_session_key, set_current_session_key
692
+
503
693
  token = set_current_session_key(session_id)
504
694
  resets.append(lambda: reset_current_session_key(token))
505
695
  except Exception: # noqa: BLE001
@@ -521,14 +711,8 @@ def _turn_identity(session_id: str, workspace: str):
521
711
  except Exception: # noqa: BLE001
522
712
  log.debug("per-turn session context bind failed", exc_info=True)
523
713
  try:
524
- from agent.runtime_cwd import _SESSION_CWD
525
-
526
- tok = _SESSION_CWD.set(str(workspace))
527
- resets.append(lambda: _SESSION_CWD.reset(tok))
528
- except Exception: # noqa: BLE001
529
- log.debug("per-turn session cwd bind failed", exc_info=True)
530
- try:
531
- yield
714
+ with session_workspace(workspace):
715
+ yield
532
716
  finally:
533
717
  for reset in reversed(resets):
534
718
  try:
@@ -538,7 +722,7 @@ def _turn_identity(session_id: str, workspace: str):
538
722
 
539
723
 
540
724
  #: Older configs named the CLI composite ``hermes``; the Agent registers it split in two (predecessor
541
- #: ``_LEGACY_CLI_TOOLSET_ALIASES``).
725
+ #: ``_LEGACY_CLI_TOOLSET_ALIASES``). Agent 0.21.6 expands it itself; delete this once 0.21.6 is the oldest supported.
542
726
  _LEGACY_TOOLSET_ALIASES = {"hermes": ("hermes-cli", "hermes-api-server")}
543
727
 
544
728
 
@@ -563,16 +747,13 @@ def _discover_mcp_tools(run: _Run) -> None:
563
747
  The Agent keys connections by profile scope and skips live ones, so later turns are cheap. Runs under the call's
564
748
  ``scoped_home``; a failure leaves the turn without MCP tools rather than failing it. A Stop does not wait for a slow
565
749
  server: the turn moves on to its cancelled end while the connect finishes in the background."""
566
- from .commands import retire_unscoped_launch_mcp_servers
750
+ from .commands import discover_profile_mcp_tools
567
751
 
568
752
  done = threading.Event()
569
753
 
570
754
  def discover() -> None:
571
755
  try:
572
- from tools.mcp_tool_discovery import discover_mcp_tools
573
-
574
- retire_unscoped_launch_mcp_servers()
575
- discover_mcp_tools()
756
+ discover_profile_mcp_tools()
576
757
  except Exception: # noqa: BLE001
577
758
  log.warning("MCP discovery failed", exc_info=True)
578
759
  finally:
@@ -594,7 +775,18 @@ def _profile_fallback_chain() -> list[dict] | None:
594
775
  return get_fallback_chain(load_config() or {}) or None
595
776
 
596
777
 
597
- def _main_model_request_overrides(model: str, provider) -> dict | None:
778
+ def _profile_prefill_messages() -> list[dict] | None:
779
+ """The profile's few-shot prefill (``HERMES_PREFILL_MESSAGES_FILE``, then ``prefill_messages_file``, then legacy
780
+ ``agent.prefill_messages_file``; relative to the profile) through the Agent's own loader, like the CLI, gateway and
781
+ Desktop. A missing or empty file is None. Runs under the call's ``scoped_home``."""
782
+ from hermes_cli.cli_config_load import _load_prefill_messages, _resolve_prefill_messages_file
783
+ from hermes_cli.config import load_config
784
+ from hermes_constants import get_hermes_home
785
+
786
+ return _load_prefill_messages(_resolve_prefill_messages_file(load_config() or {}), get_hermes_home()) or None
787
+
788
+
789
+ def _main_model_request_overrides(model: str, provider, base_url: str = "") -> dict | None:
598
790
  """Predecessor ``_main_model_request_overrides``: the profile's ``model.service_tier`` and ``model.extra_body``
599
791
  (written by ``/api/model/set``) apply only to turns on that configured main model. Runs under ``scoped_home``."""
600
792
  from hermes_cli.config import load_config
@@ -607,17 +799,31 @@ def _main_model_request_overrides(model: str, provider) -> dict | None:
607
799
  if not main_model or main_model != model.strip():
608
800
  return None
609
801
  # An unset or ``auto`` main provider resolves at runtime, so the model id alone identifies the main model.
610
- if main_provider not in ("", "auto") and main_provider != str(provider or "").strip().lower():
802
+ from hermes_cli.models import normalize_provider
803
+
804
+ if main_provider not in ("", "auto") and normalize_provider(main_provider) != normalize_provider(str(provider or "").strip().lower()):
611
805
  return None
612
- overrides: dict = {}
613
- if str(model_cfg.get("service_tier") or "").strip().lower() == "priority":
614
- overrides["service_tier"] = "priority"
806
+ from ..speed import fast_overrides
807
+
808
+ overrides = fast_overrides(model, str(provider or ""), base_url, str(model_cfg.get("service_tier") or "").strip().lower()) or {}
615
809
  extra_body = model_cfg.get("extra_body")
616
810
  if isinstance(extra_body, dict) and extra_body:
617
811
  overrides["extra_body"] = extra_body
618
812
  return overrides or None
619
813
 
620
814
 
815
+ def _request_overrides(model: str, provider, runtime: dict) -> dict | None:
816
+ """The resolver's ``request_overrides`` (a custom provider entry's ``extra_body``) under the main-model ones, whose
817
+ ``extra_body`` wins key by key, the Agent's own merge order. Runs under ``scoped_home``."""
818
+ merged = dict(runtime.get("request_overrides") or {})
819
+ main = _main_model_request_overrides(model, provider, str(runtime.get("base_url") or "")) or {}
820
+ extra_body = {**(merged.get("extra_body") or {}), **(main.get("extra_body") or {})}
821
+ merged.update(main)
822
+ if extra_body:
823
+ merged["extra_body"] = extra_body
824
+ return merged or None
825
+
826
+
621
827
  def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, kwargs: dict) -> str:
622
828
  """Cache identity of an ``AIAgent``: everything its constructor bound from the resolved runtime, so a rotated key,
623
829
  a different API mode, ACP command, or credential pool never reuses an agent built for the old bundle. The key
@@ -629,17 +835,88 @@ def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, k
629
835
  bundle = {
630
836
  "model": model, "provider": provider, "base_url": runtime.get("base_url"), "api_key": key_digest,
631
837
  "api_mode": runtime.get("api_mode"), "acp_command": runtime.get("acp_command"), "acp_args": runtime.get("acp_args"),
632
- "credential_pool": pool_identity, "toolsets": toolsets, "home": home,
838
+ "credential_pool": pool_identity, "toolsets": toolsets, "home": home, "cwd": kwargs.get("cwd"),
839
+ "disabled_toolsets": kwargs.get("disabled_toolsets"),
633
840
  "max_iterations": kwargs.get("max_iterations"), "max_tokens": kwargs.get("max_tokens"),
634
841
  # Bound at construction too: a reasoning-effort change from the composer must build a fresh agent.
635
842
  "reasoning_config": kwargs.get("reasoning_config"),
636
843
  "request_overrides": kwargs.get("request_overrides"),
844
+ "checkpoints": [kwargs.get(name) for name in ("checkpoints_enabled", "checkpoint_max_snapshots", "checkpoint_max_total_size_mb", "checkpoint_max_file_size_mb")],
637
845
  # Entries may carry their own ``api_key``.
638
846
  "fallback_model": hashlib.sha256(json.dumps(kwargs.get("fallback_model"), sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16],
847
+ # Read each turn, so an edited prefill file builds a fresh agent.
848
+ "prefill_messages": hashlib.sha256(json.dumps(kwargs.get("prefill_messages"), sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16],
639
849
  }
640
850
  return json.dumps(bundle, sort_keys=True, default=str)
641
851
 
642
852
 
853
+ def _agent_kwargs(AIAgent, params: dict, session_id: str, model: str, provider, runtime: dict, toolsets) -> dict:
854
+ """The ``AIAgent`` arguments of a session's agent apart from a turn's callbacks: the resolved runtime, the session's
855
+ toolsets and budgets, and the profile's fallback chain, request overrides, prefill and checkpoints. Everything
856
+ ``_agent_signature`` reads is here, so an agent ``/refine`` builds (TAL-902) is the one the next turn reuses. Runs under
857
+ the call's ``scoped_home``."""
858
+ kwargs: dict = dict(
859
+ model=model,
860
+ provider=provider,
861
+ base_url=runtime.get("base_url"),
862
+ api_key=runtime.get("api_key"),
863
+ platform="webui",
864
+ quiet_mode=True,
865
+ enabled_toolsets=toolsets,
866
+ disabled_toolsets=disabled_toolsets(),
867
+ session_id=session_id,
868
+ )
869
+ for name, value in (
870
+ ("api_mode", runtime.get("api_mode")),
871
+ ("acp_command", runtime.get("acp_command")),
872
+ ("acp_args", runtime.get("acp_args")),
873
+ ("credential_pool", runtime.get("credential_pool")),
874
+ ("gateway_session_key", session_id),
875
+ # TAL-903: anchors subdirectory AGENTS.md discovery (and the memory provider) at the session workspace, when the
876
+ # server says it is a host path; a remote terminal backend's target-side path must never be read on the host.
877
+ ("cwd", params.get("workspace") if params.get("workspace_local") is True else None),
878
+ ):
879
+ if _supported(AIAgent, name) and value is not None:
880
+ kwargs[name] = value
881
+ if isinstance(params.get("max_iterations"), int) and params["max_iterations"] > 0 and _supported(AIAgent, "max_iterations"):
882
+ kwargs["max_iterations"] = params["max_iterations"]
883
+ if isinstance(params.get("max_tokens"), int) and params["max_tokens"] > 0 and _supported(AIAgent, "max_tokens"):
884
+ kwargs["max_tokens"] = params["max_tokens"]
885
+ # Predecessor: `agent.reasoning_effort` (already coerced for the model) → AIAgent ``reasoning_config``.
886
+ reasoning_config = params.get("reasoning_config")
887
+ if isinstance(reasoning_config, dict) and _supported(AIAgent, "reasoning_config"):
888
+ kwargs["reasoning_config"] = reasoning_config
889
+ fallback_chain = _profile_fallback_chain()
890
+ if fallback_chain and _supported(AIAgent, "fallback_model"):
891
+ kwargs["fallback_model"] = fallback_chain
892
+ request_overrides = _request_overrides(model, provider, runtime)
893
+ if request_overrides and _supported(AIAgent, "request_overrides"):
894
+ kwargs["request_overrides"] = request_overrides
895
+ prefill_messages = _profile_prefill_messages()
896
+ if prefill_messages and _supported(AIAgent, "prefill_messages"):
897
+ kwargs["prefill_messages"] = prefill_messages
898
+ # TAL-913: the profile's ``checkpoints`` section, as the Agent's own CLI and gateway turns read it.
899
+ for name, value in checkpoint_kwargs().items():
900
+ if _supported(AIAgent, name):
901
+ kwargs[name] = value
902
+ return kwargs
903
+
904
+
905
+ def _build_agent(AIAgent, kwargs: dict, home, session_id: str, state_db: bool = True):
906
+ """A new session agent on the profile's ``state.db`` (unless ``state_db`` is false), which it releases with it."""
907
+ session_db = _acquire_session_db(home) if state_db and _supported(AIAgent, "session_db") else None
908
+ if session_db is not None:
909
+ kwargs["session_db"] = session_db
910
+ kwargs["session_id"] = _execution_session_id(session_db, session_id)
911
+ try:
912
+ agent = AIAgent(**kwargs)
913
+ except BaseException:
914
+ _release_session_db(session_db)
915
+ raise
916
+ agent._talaria_session_db = session_db # released with the agent (``_release_agent``)
917
+ return agent
918
+
919
+
643
920
  def _evict_idle_agents_locked() -> None:
644
921
  """Trim the agent LRU (caller holds ``_AGENT_CACHE_LOCK``), never dropping a session whose turn is still running."""
645
922
  if len(_AGENT_CACHE) <= _AGENT_CACHE_MAX:
@@ -718,6 +995,73 @@ def _join(threads, deadline: float) -> None:
718
995
  thread.join(max(0.0, deadline - time.monotonic()))
719
996
 
720
997
 
998
+ def _cancel_background_reviews(entries, message: str) -> list[threading.Thread]:
999
+ """TAL-897: stop the post-turn memory/skill review fork of each ``(agent, home)``, which no turn interrupt or release
1000
+ reaches, so a deleted session or a stopping sidecar no longer pays for it. Each cancel then holds the agent's review
1001
+ slot (a run token from the Agent's ``prepare_background_review_run``, never finished), so the Agent admits no later
1002
+ review: not from a turn still running (a clear does not stop it), a spawn already under way, or a review deferred to
1003
+ or requeued on the idle queue (managed local runtime). ``skip_background_review`` spares the turn finalizer queueing
1004
+ one at all. Only a turn's start waits on a held slot, and an ended agent runs no further turn.
1005
+
1006
+ A cancel lasts until its fork acknowledges, which the Agent's own wait does not bound, and runs as a release under
1007
+ ``home`` (``_RELEASES``): shutdown waits for it, and profile deletion, retries included, refuses while it runs or
1008
+ once it failed."""
1009
+ unique = {id(agent): (agent, home) for agent, home in entries if agent is not None}
1010
+ for agent, _home in unique.values():
1011
+ try:
1012
+ agent.skip_background_review = True
1013
+ except AttributeError: # an object without instance attributes forks no reviews
1014
+ pass
1015
+ try:
1016
+ from agent.background_review import cancel_background_review_for_live_turn, prepare_background_review_run
1017
+ except ImportError:
1018
+ return []
1019
+
1020
+ def cancel(agent) -> None:
1021
+ thread = threading.current_thread()
1022
+ try:
1023
+ try:
1024
+ # One cancel per agent at a time, so none mistakes another's freshly held slot for a review to cancel.
1025
+ lock = vars(agent).setdefault("_talaria_review_cancel_lock", threading.Lock())
1026
+ except TypeError:
1027
+ return # no instance attributes, so no review slot either
1028
+ with lock:
1029
+ # A spawn that slipped in between the cancel and the slot claim is cancelled on the next pass.
1030
+ for _ in range(3):
1031
+ if getattr(agent, "_talaria_review_slot", None) is not None:
1032
+ return
1033
+ cancel_background_review_for_live_turn(agent, message=message, tool_reason="session ended")
1034
+ slot = prepare_background_review_run(agent)
1035
+ if slot is not None:
1036
+ agent._talaria_review_slot = slot
1037
+ return
1038
+ current = getattr(agent, "_background_review_run", None)
1039
+ if current is None:
1040
+ return # not an Agent with a review slot
1041
+ if current.cancel_requested.is_set():
1042
+ # A cancelled fork past the Agent's wait may still write: wait for it to acknowledge, then claim
1043
+ # the slot before the Agent can requeue it.
1044
+ current.request_done.wait()
1045
+ log.warning("a background review kept restarting after its session ended")
1046
+ thread.failed = True
1047
+ except Exception: # noqa: BLE001 - teardown continues without it
1048
+ log.warning("cancelling a background review failed", exc_info=True)
1049
+ thread.failed = True
1050
+ finally:
1051
+ with _RELEASES_LOCK:
1052
+ _RELEASES.pop(thread, None)
1053
+
1054
+ threads = []
1055
+ for agent, home in unique.values():
1056
+ thread = threading.Thread(target=cancel, args=(agent,), daemon=True, name="chat-review-cancel")
1057
+ thread.failed = False # type: ignore[attr-defined] - read by profile deletion
1058
+ with _RELEASES_LOCK:
1059
+ _RELEASES[thread] = home
1060
+ thread.start()
1061
+ threads.append(thread)
1062
+ return threads
1063
+
1064
+
721
1065
  def evict_all_agents() -> int:
722
1066
  """Drop every cached agent (credentials or environment changed underneath them)."""
723
1067
  with _AGENT_CACHE_LOCK:
@@ -731,10 +1075,15 @@ def drain_agents(timeout: float = _AGENT_RELEASE_TIMEOUT) -> None:
731
1075
  """Shutdown: release every cached agent, stop running turns (like a Stop) so each releases the agent it holds, and
732
1076
  wait, bounded, for those turns and every release still running."""
733
1077
  deadline = time.monotonic() + timeout
734
- evict_all_agents()
735
- with _RUNS_LOCK:
1078
+ with _AGENT_CACHE_LOCK, _RUNS_LOCK:
1079
+ # Admission closes with the snapshot, so no later turn forks a review outside it.
736
1080
  _DRAINING.set()
737
1081
  runs = list(_RUNS.values())
1082
+ owners = [(entry[0], entry[2]) for entry in _AGENT_CACHE.values()] + [(run.agent, run.home) for run in runs]
1083
+ for run in runs:
1084
+ run.session_ended = True
1085
+ _cancel_background_reviews(owners, "sidecar shutting down")
1086
+ evict_all_agents()
738
1087
  for run in runs:
739
1088
  run.cancel.set()
740
1089
  while time.monotonic() < deadline:
@@ -749,15 +1098,39 @@ def drain_agents(timeout: float = _AGENT_RELEASE_TIMEOUT) -> None:
749
1098
  _join(threads, deadline)
750
1099
 
751
1100
 
1101
+ def refresh_profile_agents_mcp_tools(home) -> None:
1102
+ """MCP reload: republish the live registry's tools to the profile's cached agents, like the gateway's
1103
+ ``_mcp_reload_refresh_cached_agents``. ``content_aware`` publishes a same-name schema change on every supported
1104
+ Agent (0.22.0 implies it for an explicit reload, 0.21.x does not). Each agent keeps its build-time toolset
1105
+ selection. An agent whose refresh fails leaves the cache, so its session's next turn builds one from the live
1106
+ registry instead of keeping stale schemas; the rest still refresh."""
1107
+ from tools.mcp_tool_agent import refresh_agent_mcp_tools
1108
+
1109
+ target = os.path.realpath(home)
1110
+ with _AGENT_CACHE_LOCK:
1111
+ failed = []
1112
+ for session_id, entry in list(_AGENT_CACHE.items()):
1113
+ if os.path.realpath(entry[2]) != target:
1114
+ continue
1115
+ try:
1116
+ refresh_agent_mcp_tools(entry[0], quiet_mode=True, content_aware=True)
1117
+ except Exception: # noqa: BLE001
1118
+ log.warning("Failed to refresh MCP tools for session %s; evicting its agent", session_id, exc_info=True)
1119
+ failed.append(_AGENT_CACHE.pop(session_id))
1120
+ _release_agents_locked(failed)
1121
+
1122
+
752
1123
  def release_profile_agents(home) -> None:
753
- """Profile deletion: release the profile's cached agents and wait for every release running under its home. Fails
754
- rather than let the home go while a memory flush still runs under it."""
1124
+ """Profile deletion: release the profile's cached agents, cancel their background reviews, and wait for both and
1125
+ every release running under its home. Fails rather than let the home go while a memory flush still runs under it."""
755
1126
  target = os.path.realpath(home)
756
1127
  with _AGENT_CACHE_LOCK:
757
1128
  entries = [_AGENT_CACHE.pop(sid) for sid, entry in list(_AGENT_CACHE.items()) if os.path.realpath(entry[2]) == target]
758
- _release_agents_locked(entries)
1129
+ started = _release_agents_locked(entries)
1130
+ started += _cancel_background_reviews([(entry[0], entry[2]) for entry in entries], "profile deleted")
1131
+ # The threads started here count even once they left ``_RELEASES``: a fast failure must still block the delete.
759
1132
  with _RELEASES_LOCK:
760
- threads = [thread for thread, owner in _RELEASES.items() if os.path.realpath(owner) == target]
1133
+ threads = list(dict.fromkeys(started + [thread for thread, owner in _RELEASES.items() if owner is not None and os.path.realpath(owner) == target]))
761
1134
  _join(threads, time.monotonic() + _PROFILE_RELEASE_TIMEOUT)
762
1135
  if any(thread.is_alive() for thread in threads):
763
1136
  raise RpcError("The profile is still saving memory from its chats. Retry in a moment.")
@@ -823,6 +1196,14 @@ def _max_iterations_summary_request() -> str:
823
1196
  return str(MAX_ITERATIONS_SUMMARY_REQUEST)
824
1197
 
825
1198
 
1199
+ def _has_voice_turn_route() -> bool:
1200
+ """Whether the Agent routes a marked turn to ``auxiliary.voice_chat`` (``agent.voice_turn_route``, v0.21.6)."""
1201
+ try:
1202
+ return importlib.util.find_spec("agent.voice_turn_route") is not None
1203
+ except (ImportError, ValueError):
1204
+ return False
1205
+
1206
+
826
1207
  def _supported(cls, name: str) -> bool:
827
1208
  """Whether ``cls.__init__`` accepts ``name`` (named, or through ``**kwargs``)."""
828
1209
  try:
@@ -832,6 +1213,15 @@ def _supported(cls, name: str) -> bool:
832
1213
  return name in parameters or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values())
833
1214
 
834
1215
 
1216
+ @lru_cache(maxsize=32)
1217
+ def _run_conversation_parameters(cls) -> frozenset[str]:
1218
+ """Probe explicit turn capabilities once per Agent class; unknown signatures keep legacy arguments."""
1219
+ try:
1220
+ return frozenset(inspect.signature(cls.run_conversation).parameters)
1221
+ except (TypeError, ValueError):
1222
+ return frozenset()
1223
+
1224
+
835
1225
  def _approval_notifier(session_id: str, emit):
836
1226
  """The Agent's gateway notify callback for one turn: emits each parked approval, then ``approval_resolved``
837
1227
  (id plus the Agent's reason) once its wait ends by any path, an ``approvals.timeout`` expiry included (TAL-514)."""
@@ -876,20 +1266,51 @@ def _is_compression_start(lower: str) -> bool:
876
1266
 
877
1267
 
878
1268
  def _status_events(kind, text: str) -> list[str]:
879
- """Events for one Agent status line: the compressing card, a fallback warning, and the turn's terminal error."""
1269
+ """Events for one Agent status line: compression, provider diagnostics, and the turn's terminal error."""
880
1270
  lower = text.lower()
881
1271
  events = ["terminal_error"] if "non-retryable error" in lower else []
882
- if str(kind or "").strip().lower() == "lifecycle":
1272
+ kind = str(kind or "").strip().lower()
1273
+ # TAL-1006: the Agent's one terminal edge of a started compaction (``_emit_compaction_done``); an aborted attempt
1274
+ # sends none and ends with the turn.
1275
+ if kind == "compacted":
1276
+ return ["compression_finished"]
1277
+ if kind == "lifecycle":
883
1278
  if _is_compression_start(lower):
884
1279
  return events + ["compressing"]
885
1280
  # The Agent's own compression retries ("Compressed N → M messages, retrying...") are not provider fallbacks.
886
1281
  if "compress" in lower or "context reduced to" in lower:
887
1282
  return events
1283
+ # Buffered diagnostics change wording across Agent releases; their severity glyph stays stable.
1284
+ if not events and text.startswith(("⚠", "❌")):
1285
+ return ["warning"]
888
1286
  if "fallback" in lower or "rate limit" in lower or ("retry" in lower and "non-retryable" not in lower):
889
1287
  events.append("warning")
890
1288
  return events
891
1289
 
892
1290
 
1291
+ #: TAL-821: the Agent's live status line during a rate-limit backoff (``turn_recovery.compute_error_backoff``), e.g.
1292
+ #: "⏳ the free model is busy — resets in ~17m, retrying in 600s (attempt 1/3)". The Agent's other waits are not known
1293
+ #: rate limits: "waiting on provider" covers a 5xx retry as well as a 429 without a reset, and "provider overloaded" is a
1294
+ #: Z.AI overload. ``webui`` is not an attended source, so the Agent sits out a free-tier cooldown instead of ending.
1295
+ _RATE_LIMIT_WAIT_RE = re.compile(r"^⏳ (?:rate limited|the free model is busy) —.* retrying in (\d+)s\b", re.IGNORECASE)
1296
+ #: The line's reset window, in the Agent's ``format_duration_compact`` units ("45s", "17m", "1h 5m", "1.5d").
1297
+ _RESET_IN_RE = re.compile(r"resets in ~((?:\d+(?:\.\d+)?[dhms] ?)+)")
1298
+ _DURATION_UNITS = {"d": 86400, "h": 3600, "m": 60, "s": 1}
1299
+
1300
+
1301
+ def _rate_limit_wait(text: str) -> tuple[float, str] | None:
1302
+ """``(seconds, "reset")`` until the provider's limit resets, else ``(seconds, "retry")`` until the Agent retries, for a
1303
+ rate-limit wait line; None for any other line. The reset is the provider's own ``reset_at`` as the Agent read it from
1304
+ the error; the credential pool's exhausted mark is no better, since without that field it is a fixed one-hour guess."""
1305
+ wait = _RATE_LIMIT_WAIT_RE.match(text)
1306
+ if not wait:
1307
+ return None
1308
+ reset = _RESET_IN_RE.search(text)
1309
+ if not reset:
1310
+ return float(wait.group(1)), "retry"
1311
+ return sum(float(amount) * _DURATION_UNITS[unit] for amount, unit in re.findall(r"(\d+(?:\.\d+)?)([dhms])", reset.group(1))), "reset"
1312
+
1313
+
893
1314
  def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn, one function
894
1315
  session_id = str(params.get("session_id") or "").strip()
895
1316
  stream_id = str(params.get("stream_id") or "").strip()
@@ -909,6 +1330,7 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
909
1330
  system_message = params.get("system_message")
910
1331
  home = profile_home_param(params)
911
1332
  run = _Run(stream_id, session_id, ctx)
1333
+ run.home = home
912
1334
  with _RUNS_LOCK:
913
1335
  if _DRAINING.is_set():
914
1336
  raise RpcError("The sidecar is shutting down.", condition="sidecar_unavailable")
@@ -916,6 +1338,11 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
916
1338
  # A previous turn for this session that never returned (a tool ignoring its interrupt) still owns the cached
917
1339
  # agent; this turn must not share, rebind, or un-interrupt that live instance.
918
1340
  session_busy = prior is not None and not prior.finished.is_set()
1341
+ # TAL-829: a second agent beside a turn nobody stopped would wait on that turn's durable turn lease (up to 30
1342
+ # minutes) and then write the same session. A stopped turn only lingers until the Agent abandons the wedged
1343
+ # tool, which lets its lease go within seconds.
1344
+ if session_busy and not (prior.cancel.is_set() or prior.ctx.cancelled):
1345
+ raise RpcError("This chat is still answering the previous message. Stop it or wait for it to finish.", condition="session_busy")
919
1346
  _RUNS[stream_id] = run
920
1347
  _RUNS_BY_SESSION[session_id] = stream_id
921
1348
  raw_emit = ctx.emit
@@ -945,6 +1372,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
945
1372
  live_tool_calls: list[dict] = []
946
1373
  started_ids: set[str] = set()
947
1374
  completed_ids: set[str] = set()
1375
+ edit_snapshots: dict[str, Any] = {}
1376
+ prepared_metadata: dict[str, dict] = {}
948
1377
  token_sent = [False]
949
1378
 
950
1379
  def on_token(text):
@@ -970,6 +1399,9 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
970
1399
  return
971
1400
  if tid:
972
1401
  started_ids.add(tid)
1402
+ snapshot = _edit_snapshot(name, args, session_id)
1403
+ if snapshot is not None:
1404
+ edit_snapshots[tid] = snapshot
973
1405
  live_tool_calls.append({"name": name, "args": args if isinstance(args, dict) else {}, "tid": tid, "done": False})
974
1406
  emit("tool", {"event_type": "tool.started", "name": name, "preview": None, "args": _args_snapshot(args), "tid": tid})
975
1407
 
@@ -988,8 +1420,12 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
988
1420
  call["snippet"] = snippet
989
1421
  break
990
1422
  payload = {"event_type": "tool.completed", "name": name, "preview": snippet, "args": _args_snapshot(args), "tid": tid, "raw_result": _raw_result(function_result)}
1423
+ # TAL-909: the metadata the Agent persisted on the result row, or, from an Agent without the callback, live only.
1424
+ metadata = prepared_metadata.pop(tid) if tid in prepared_metadata else _edit_metadata(name, args, function_result, edit_snapshots.pop(tid, None))
1425
+ if metadata:
1426
+ payload["display_metadata"] = metadata
991
1427
  # TAL-397: ``raw_result`` caps nested values, so the todo tool's list travels whole for the server's ``todo_state``.
992
- if name == "todo" and function_result is not None:
1428
+ if function_result is not None and _is_todo_tool(name):
993
1429
  payload["todo_result"] = function_result if isinstance(function_result, str) else json.dumps(function_result, default=str)
994
1430
  # TAL-448: likewise a result's unified ``diff``, so the server's ``edit_diff`` counts cover the whole change.
995
1431
  diff = _result_diff(function_result)
@@ -1004,6 +1440,14 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1004
1440
  break
1005
1441
  emit("tool_complete", payload)
1006
1442
 
1443
+ def on_tool_result_metadata(tool_call_id, name, args, function_result):
1444
+ """TAL-909: called before the Agent appends and flushes the tool-result row, which keeps the returned dict as
1445
+ its ``display_metadata``; the completion frame that follows carries the same dict."""
1446
+ tid = str(tool_call_id or "")
1447
+ metadata = _edit_metadata(name, args, function_result, edit_snapshots.pop(tid, None))
1448
+ prepared_metadata[tid] = metadata
1449
+ return metadata
1450
+
1007
1451
  def on_tool(*cb_args, **cb_kwargs):
1008
1452
  # Structured callbacks carry tool cards; the progress callback only feeds reasoning text on older builds.
1009
1453
  if len(cb_args) >= 3 and cb_args[0] in ("reasoning.available", "_thinking"):
@@ -1016,43 +1460,172 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1016
1460
  for event in _status_events(kind, text):
1017
1461
  if event == "compressing":
1018
1462
  emit("compressing", {"session_id": session_id, "message": "Compressing context"})
1463
+ elif event == "compression_finished":
1464
+ emit("compression_finished", {"session_id": session_id})
1019
1465
  elif event == "warning":
1020
1466
  emit("warning", {"type": "fallback", "message": text})
1021
1467
  else:
1022
1468
  emit("status", {"kind": "terminal_error", "message": text})
1023
1469
 
1024
- def clarify_callback(question, choices, multi_select=False, questions=None):
1025
- choices_list = [str(c) for c in (choices or [])]
1026
- # Predecessor `_clarify_timeout_seconds`: the Agent's own resolver over this profile's config
1027
- # (``clarify.timeout`` else ``agent.clarify_timeout`` else 3600); ``<= 0`` waits until answered or cancelled.
1028
- # The advertised ``timeout_seconds`` is what the clients count down, so it is the same number.
1029
- timeout = _clarify_timeout(params)
1030
- data = {"question": str(question or ""), "choices_offered": choices_list, "session_id": session_id, "kind": "clarify", "requested_at": time.time(), "timeout_seconds": timeout}
1031
- if multi_select:
1032
- data["multi_select"] = True
1033
- # Batch prompts (the Agent's ``_run_batch``) carry no top-level question and expect the ``{"answers": {...}}`` reply.
1034
- if isinstance(questions, list) and questions:
1035
- data["questions"] = questions
1470
+ wait_state = {"active": False, "prompt": False}
1471
+
1472
+ def on_thinking(text):
1473
+ """Normalize provider waits and prompt-reading progress from the Agent's live status callback."""
1474
+ line = str(text or "").strip()
1475
+ progress = re.fullmatch(r"⚙ processing prompt(?: — (\d+(?:\.\d+)?)%)?", line)
1476
+ if progress:
1477
+ wait_state["prompt"] = True
1478
+ emit("provider_phase", {"phase": {"phase": "prompt_reading", "progress_percent": min(100, float(progress[1])) if progress[1] else None}})
1479
+ elif wait_state["prompt"]:
1480
+ wait_state["prompt"] = False
1481
+ emit("provider_phase", {"phase": None})
1482
+ wait = _rate_limit_wait(line)
1483
+ if wait is not None:
1484
+ wait_state["active"] = True
1485
+ emit("rate_limit_wait", {"provider": str(getattr(run.agent, "provider", None) or resolved_provider or "") or None,
1486
+ "until": time.time() + wait[0], "until_kind": wait[1]})
1487
+ elif wait_state["active"]:
1488
+ wait_state["active"] = False
1489
+ emit("rate_limit_wait", {"provider": None, "until": None})
1490
+
1491
+ # TAL-803: the Agent's sticky ``AgentNotice``s (a startup warning such as a memory provider that failed to load,
1492
+ # credit and usage bands) and their clears; the server keeps the session's active set.
1493
+ def on_notice(notice):
1494
+ text = str(getattr(notice, "text", "") or "").strip()
1495
+ if not text:
1496
+ return
1497
+ ttl_ms = getattr(notice, "ttl_ms", None)
1498
+ key = getattr(notice, "key", None)
1499
+ # The Agent raises a startup warning (memory provider unavailable) once per process, so a rebuilt agent never
1500
+ # repeats it: it outlives the agent that raised it. Credit notices are per agent and re-raised by the next one.
1501
+ emit("notice", {"id": getattr(notice, "id", None), "key": key, "level": str(getattr(notice, "level", "") or "info"),
1502
+ "kind": str(getattr(notice, "kind", "") or "sticky"), "ttl_ms": ttl_ms if isinstance(ttl_ms, int) else None, "text": text,
1503
+ "lapses_with_agent": not str(key or "").startswith("startup-warning.")})
1504
+
1505
+ def on_notice_clear(key):
1506
+ if key:
1507
+ emit("notice_clear", {"key": str(key)})
1508
+
1509
+ def ask(data: dict, timeout: int) -> str | None:
1510
+ """Park on a prompt the server relays until it is answered, the turn is cancelled or ends, or ``timeout``
1511
+ passes (``<= 0``: no limit). The answer, or None, goes back to the Agent and is never emitted."""
1036
1512
  entry = _ClarifyEntry(data)
1037
1513
  entry.data["clarify_id"] = entry.clarify_id
1514
+ entry.deadline = None if timeout <= 0 else time.monotonic() + float(timeout)
1038
1515
  with run.lock:
1039
1516
  run.clarify_entries[entry.clarify_id] = entry
1040
1517
  emit("clarify", dict(entry.data))
1041
- deadline = None if timeout <= 0 else time.monotonic() + float(timeout)
1042
1518
  while not entry.event.is_set():
1043
- if run.cancel.is_set() or run.finished.is_set():
1519
+ if run.cancel.is_set() or run.finished.is_set() or (data.get("kind") == "skill_secret" and (ctx.cancelled or run.session_ended)):
1044
1520
  break
1045
- if deadline is None:
1521
+ if entry.deadline is None:
1046
1522
  entry.event.wait(1.0)
1047
1523
  continue
1048
- remaining = deadline - time.monotonic()
1524
+ remaining = entry.deadline - time.monotonic()
1049
1525
  if remaining <= 0:
1050
1526
  break
1051
1527
  entry.event.wait(min(1.0, remaining))
1052
1528
  with run.lock:
1053
1529
  run.clarify_entries.pop(entry.clarify_id, None)
1054
1530
  emit("clarify_resolved", {"clarify_id": entry.clarify_id, "session_id": session_id})
1055
- return entry.result or _CLARIFY_FALLBACK
1531
+ return entry.result
1532
+
1533
+ def ask_vault(kind: str, fields: dict) -> str:
1534
+ """TAL-887: a password-manager prompt; an empty answer is the Agent's decline, as are expiry and cancel."""
1535
+ timeout = _VAULT_PROMPT_TIMEOUTS[kind]
1536
+ data = {"kind": kind, "session_id": session_id, "requested_at": time.time(), "timeout_seconds": timeout, **fields}
1537
+ return ask(data, timeout) or ""
1538
+
1539
+ def vault_unlock_prompt(backend, display_name):
1540
+ # The Agent stores the unlock right after this returns, on the thread that called it: a tool worker
1541
+ # inherits this callback but not the turn's session, so bind it here too, or closing the session could
1542
+ # not lock the manager again.
1543
+ _set_vault_session(session_id)
1544
+ return ask_vault("vault_unlock", {"backend": str(backend), "display_name": str(display_name)})
1545
+
1546
+ def vault_save_login_prompt(origin, site):
1547
+ # The server sends ``{"identifier", "password"}``; the Agent stores it in its vault and fills the page.
1548
+ raw = ask_vault("vault_save_login", {"origin": str(origin), "site": str(site)})
1549
+ try:
1550
+ answer = json.loads(raw) if raw else None
1551
+ except ValueError:
1552
+ return None
1553
+ return answer if isinstance(answer, dict) and answer.get("password") else None
1554
+
1555
+ def vault_code_prompt(site, hint):
1556
+ return ask_vault("vault_code", {"site": str(site), "hint": str(hint or "")})
1557
+
1558
+ def skill_secret_prompt(env_var, prompt, metadata=None):
1559
+ skipped = {"success": True, "stored_as": env_var, "validated": False, "skipped": True}
1560
+ if not isinstance(env_var, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_var):
1561
+ return skipped
1562
+ metadata = metadata if isinstance(metadata, dict) else {}
1563
+ fields = {"env_var": env_var, "prompt": str(prompt), **{key: str(metadata.get(key) or "") for key in ("skill_name", "help", "required_for")}}
1564
+ value = ask_vault("skill_secret", fields)
1565
+ if not value.strip():
1566
+ return skipped
1567
+ # The native writer strips these bytes, and reports non-ASCII characters to stderr. Refuse rather
1568
+ # than change the credential or expose any of its characters in logs.
1569
+ if not value.isascii() or any(char in value for char in ("\r", "\n", "\0")):
1570
+ return {**skipped, "success": False}
1571
+ try:
1572
+ from hermes_cli.config import save_env_value_secure
1573
+ from hermes_constants import get_hermes_home
1574
+
1575
+ # Keep the inherited secret scope so setup can use the value immediately. Fence the write against
1576
+ # eviction/replacement under the same lock that changes the run's identity and session lifetime.
1577
+ with _RUNS_LOCK:
1578
+ if (ctx.cancelled or run.cancel.is_set() or run.finished.is_set() or run.session_ended
1579
+ or _RUNS_BY_SESSION.get(session_id) != stream_id or _RUNS.get(stream_id) is not run
1580
+ or get_hermes_home() != home):
1581
+ return skipped
1582
+ saved = save_env_value_secure(env_var, value)
1583
+ return {"success": saved.get("success") is True, "stored_as": env_var, "validated": saved.get("validated") is True, "skipped": False}
1584
+ except Exception: # noqa: BLE001 - never expose an exception that could include the submitted value
1585
+ return {**skipped, "success": False}
1586
+
1587
+ def clarify_callback(question, choices=None, multi_select=False, questions=None):
1588
+ # TAL-351: remove the legacy signature path when v0.21.6 is the oldest supported Agent.
1589
+ structured = isinstance(question, list)
1590
+ if structured:
1591
+ questions, question = question, ""
1592
+ choices_list = [str(c) for c in (choices or [])]
1593
+ # Predecessor `_clarify_timeout_seconds`: the Agent's own resolver over this profile's config
1594
+ # (``clarify.timeout`` else ``agent.clarify_timeout`` else 3600); ``<= 0`` waits until answered or cancelled.
1595
+ # The advertised ``timeout_seconds`` is what the clients count down, so it is the same number.
1596
+ timeout = _clarify_timeout(params)
1597
+ data = {"question": str(question or ""), "choices_offered": choices_list, "session_id": session_id, "kind": "clarify", "requested_at": time.time(), "timeout_seconds": timeout}
1598
+ if multi_select:
1599
+ data["multi_select"] = True
1600
+ # Batch prompts (the Agent's ``_run_batch``) carry no top-level question and expect the ``{"answers": {...}}`` reply.
1601
+ if isinstance(questions, list) and questions:
1602
+ data["questions"] = questions
1603
+ answer = ask(data, timeout)
1604
+ if answer is not None:
1605
+ if structured:
1606
+ return {"answers": json.loads(answer)["answers"], "outcome": "submitted"}
1607
+ return answer
1608
+ cancelled = run.cancel.is_set() or run.finished.is_set()
1609
+ if structured:
1610
+ return {"answers": {}, "outcome": "cancelled" if cancelled else "timed_out",
1611
+ "notice": "The user cancelled the clarification." if cancelled else "The clarification timed out."}
1612
+ # Older tools have no outcome field. A batch understands timed_out; a single needs an empty answer.
1613
+ if questions:
1614
+ return json.dumps({"answers": {}, "timed_out": not cancelled})
1615
+ return ""
1616
+
1617
+ # TAL-818: a ``manage_connections`` card (a hosted connect, an MCP install) opened; the server turns the frame into
1618
+ # the chat's connect card, which reads and answers the operation through ``connectors.operation`` / ``respond``.
1619
+ # Only a client that draws the card asks for it: without the callback the Agent answers with plain links at once.
1620
+ connect_card = params.get("connect_card") is True
1621
+
1622
+ def on_connection(payload):
1623
+ from .connectors import remember_chat_operation
1624
+
1625
+ view = remember_chat_operation(home, payload)
1626
+ if view is not None:
1627
+ emit("connection", view)
1628
+ return None
1056
1629
 
1057
1630
  # Approval bridge: the Agent parks dangerous commands in its gateway queue and calls back here.
1058
1631
  try:
@@ -1061,47 +1634,21 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1061
1634
  register_gateway_notify = unregister_gateway_notify = None
1062
1635
  approval_cb = _approval_notifier(session_id, emit) if register_gateway_notify is not None else None
1063
1636
 
1064
- kwargs: dict = dict(
1065
- model=resolved_model,
1066
- provider=resolved_provider,
1067
- base_url=runtime.get("base_url"),
1068
- api_key=runtime.get("api_key"),
1069
- platform="webui",
1070
- quiet_mode=True,
1071
- enabled_toolsets=toolsets,
1072
- session_id=session_id,
1073
- stream_delta_callback=on_token,
1074
- reasoning_callback=on_reasoning,
1075
- tool_progress_callback=on_tool,
1076
- clarify_callback=clarify_callback,
1077
- )
1637
+ kwargs = _agent_kwargs(AIAgent, params, session_id, resolved_model, resolved_provider, runtime, toolsets)
1638
+ kwargs.update(stream_delta_callback=on_token, reasoning_callback=on_reasoning, tool_progress_callback=on_tool, clarify_callback=clarify_callback)
1078
1639
  for name, value in (
1079
1640
  ("interim_assistant_callback", on_interim),
1080
1641
  ("tool_start_callback", on_tool_start),
1081
1642
  ("tool_complete_callback", on_tool_complete),
1643
+ ("tool_result_metadata_callback", on_tool_result_metadata),
1082
1644
  ("status_callback", on_status),
1083
- ("api_mode", runtime.get("api_mode")),
1084
- ("acp_command", runtime.get("acp_command")),
1085
- ("acp_args", runtime.get("acp_args")),
1086
- ("credential_pool", runtime.get("credential_pool")),
1087
- ("gateway_session_key", session_id),
1645
+ ("thinking_callback", on_thinking),
1646
+ ("notice_callback", on_notice),
1647
+ ("notice_clear_callback", on_notice_clear),
1648
+ ("connection_callback", on_connection if connect_card else None),
1088
1649
  ):
1089
1650
  if _supported(AIAgent, name) and value is not None:
1090
1651
  kwargs[name] = value
1091
- if isinstance(params.get("max_iterations"), int) and params["max_iterations"] > 0 and _supported(AIAgent, "max_iterations"):
1092
- kwargs["max_iterations"] = params["max_iterations"]
1093
- if isinstance(params.get("max_tokens"), int) and params["max_tokens"] > 0 and _supported(AIAgent, "max_tokens"):
1094
- kwargs["max_tokens"] = params["max_tokens"]
1095
- # Predecessor: `agent.reasoning_effort` (already coerced for the model) → AIAgent ``reasoning_config``.
1096
- reasoning_config = params.get("reasoning_config")
1097
- if isinstance(reasoning_config, dict) and _supported(AIAgent, "reasoning_config"):
1098
- kwargs["reasoning_config"] = reasoning_config
1099
- fallback_chain = _profile_fallback_chain()
1100
- if fallback_chain and _supported(AIAgent, "fallback_model"):
1101
- kwargs["fallback_model"] = fallback_chain
1102
- request_overrides = _main_model_request_overrides(resolved_model, resolved_provider)
1103
- if request_overrides and _supported(AIAgent, "request_overrides"):
1104
- kwargs["request_overrides"] = request_overrides
1105
1652
  signature = _agent_signature(resolved_model, resolved_provider, runtime, toolsets, str(params.get("profile_home")), kwargs)
1106
1653
  agent = None
1107
1654
 
@@ -1109,6 +1656,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1109
1656
  """Claim the agent for this run under ``_AGENT_CACHE_LOCK``, so an eviction never releases it mid-turn."""
1110
1657
  run.prior_messages = getattr(bound, "_session_messages", None)
1111
1658
  run.agent = bound
1659
+ if run.session_ended:
1660
+ bound.skip_background_review = True
1112
1661
 
1113
1662
  if not session_busy:
1114
1663
  with _AGENT_CACHE_LOCK:
@@ -1118,27 +1667,31 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1118
1667
  _AGENT_CACHE.move_to_end(session_id)
1119
1668
  bind(agent)
1120
1669
  if agent is not None:
1121
- for name in ("stream_delta_callback", "reasoning_callback", "tool_progress_callback", "clarify_callback", "interim_assistant_callback", "tool_start_callback", "tool_complete_callback", "status_callback"):
1670
+ for name in ("stream_delta_callback", "reasoning_callback", "tool_progress_callback", "clarify_callback", "interim_assistant_callback", "tool_start_callback", "tool_complete_callback", "tool_result_metadata_callback", "status_callback", "thinking_callback", "notice_callback", "notice_clear_callback"):
1122
1671
  if name in kwargs and hasattr(agent, name):
1123
1672
  setattr(agent, name, kwargs[name])
1673
+ # A cached agent keeps the last turn's card callback; this turn's client decides afresh (TAL-818).
1674
+ if hasattr(agent, "connection_callback"):
1675
+ agent.connection_callback = on_connection if connect_card else None
1676
+ # TAL-902: an agent ``/refine`` built runs its first turn here. Like a new one, it lapses the notices of the
1677
+ # agent it replaced and raises the startup notices it parked while it had no callback.
1678
+ if vars(agent).pop("_talaria_unannounced", False):
1679
+ emit("notices_reset", {})
1680
+ replay = getattr(agent, "_replay_startup_warnings", None)
1681
+ if callable(replay):
1682
+ replay()
1124
1683
  else:
1684
+ # TAL-803: the notices a replaced agent raised lapse with it (``lapses_with_agent``); this one raises its own.
1685
+ emit("notices_reset", {})
1125
1686
  # A ``/btw`` side question's throwaway session stays out of state.db.
1126
- session_db = _acquire_session_db(home) if not params.get("ephemeral") and _supported(AIAgent, "session_db") else None
1127
- if session_db is not None:
1128
- kwargs["session_db"] = session_db
1129
- kwargs["session_id"] = _execution_session_id(session_db, session_id)
1130
- try:
1131
- agent = AIAgent(**kwargs)
1132
- except BaseException:
1133
- _release_session_db(session_db)
1134
- raise
1135
- agent._talaria_session_db = session_db # released with the agent (``_release_agent``)
1687
+ agent = _build_agent(AIAgent, kwargs, home, session_id, state_db=not params.get("ephemeral"))
1136
1688
  with _AGENT_CACHE_LOCK:
1137
1689
  bind(agent)
1138
- replaced = _AGENT_CACHE.pop(session_id, None)
1139
- _AGENT_CACHE[session_id] = (agent, signature, home)
1140
- _release_agents_locked([replaced] if replaced else [])
1141
- _evict_idle_agents_locked()
1690
+ if not run.session_ended:
1691
+ replaced = _AGENT_CACHE.pop(session_id, None)
1692
+ _AGENT_CACHE[session_id] = (agent, signature, home)
1693
+ _release_agents_locked([replaced] if replaced else [])
1694
+ _evict_idle_agents_locked()
1142
1695
  # Predecessor ``agent.ephemeral_system_prompt``: personality, surface context, progress guidance, and delivery
1143
1696
  # hints travel as runtime instructions that are never persisted to history.
1144
1697
  ephemeral = params.get("ephemeral_system_prompt")
@@ -1185,27 +1738,40 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1185
1738
  watcher = threading.Thread(target=_watch_cancel, daemon=True, name=f"chat-cancel-{stream_id[:8]}")
1186
1739
  watcher.start()
1187
1740
  run_kwargs: dict = {"user_message": user_message, "conversation_history": history, "task_id": session_id}
1188
- try:
1189
- run_params = set(inspect.signature(agent.run_conversation).parameters)
1190
- except (TypeError, ValueError):
1191
- run_params = set()
1741
+ run_params = _run_conversation_parameters(type(agent))
1192
1742
  if system_message and ("system_message" in run_params or not run_params):
1193
1743
  run_kwargs["system_message"] = system_message
1744
+ # Talaria generates and syncs the title; suppress the Agent's duplicate auxiliary request.
1745
+ if "title_user_message" in run_params:
1746
+ run_kwargs["title_user_message"] = ""
1194
1747
  # The server's clean user text (no workspace prefix) is what state.db stores; the Agent keeps the sent bytes beside it.
1195
1748
  persist = params.get("persist_user_message")
1196
1749
  if not (isinstance(persist, str) and persist.strip()):
1197
1750
  persist = user_message if isinstance(user_message, str) else None
1198
1751
  if "persist_user_message" in run_params and persist is not None:
1199
1752
  run_kwargs["persist_user_message"] = persist
1753
+ # TAL-801: a spoken turn runs on the profile's ``auxiliary.voice_chat`` slot; the Agent consumes the mark at turn
1754
+ # setup. Every turn sets it, as the Agent's own surfaces do, so a mark a failed turn never consumed cannot leak.
1755
+ if _has_voice_turn_route():
1756
+ agent._voice_turn_pending = params.get("voice_turn") is True
1200
1757
  error: str | None = None
1201
1758
  result: dict = {}
1759
+ # The Agent reads its vault prompts from the thread running the turn (and copies them onto tool workers).
1760
+ _set_vault_prompts(session_id, vault_unlock_prompt, vault_save_login_prompt, vault_code_prompt)
1202
1761
  try:
1203
- raw = agent.run_conversation(**run_kwargs)
1762
+ try:
1763
+ from tools.skills_tool import bind_turn_secret_capture
1764
+ except ImportError: # Older Agents and v0.22.2 have only an unsafe process-global callback slot.
1765
+ bind_turn_secret_capture = None
1766
+ log.debug("Agent lacks turn-local skill secret capture; secure setup is unavailable")
1767
+ with bind_turn_secret_capture(skill_secret_prompt) if bind_turn_secret_capture and params.get("skill_secret") is True else contextlib.nullcontext():
1768
+ raw = agent.run_conversation(**run_kwargs)
1204
1769
  result = raw if isinstance(raw, dict) else {}
1205
1770
  except Exception as exc: # noqa: BLE001
1206
1771
  log.warning("agent turn failed for %s: %s", session_id, exc)
1207
1772
  error = f"{type(exc).__name__}: {exc}"
1208
1773
  finally:
1774
+ _set_vault_prompts(None, None, None, None)
1209
1775
  run.finished.set()
1210
1776
  watcher.join(timeout=2)
1211
1777
  # A successor turn for the same session (admitted past the cancel-unwind ceiling) may have registered
@@ -1251,7 +1817,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1251
1817
  "max_iterations_summary_request": _max_iterations_summary_request(),
1252
1818
  "usage": _usage(agent),
1253
1819
  "context": _context_length(agent),
1254
- "model": str(getattr(agent, "model", None) or resolved_model),
1820
+ # TAL-801: the Agent's result names the model the turn ran on; a voice turn's route is undone by now.
1821
+ "model": str(result.get("model") or getattr(agent, "model", None) or resolved_model),
1255
1822
  "provider": str(resolved_provider or ""),
1256
1823
  "compressed": compressed,
1257
1824
  "agent_session_id": str(getattr(agent, "session_id", None) or session_id),
@@ -1265,6 +1832,10 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1265
1832
  _RUNS.pop(stream_id, None)
1266
1833
  if _RUNS_BY_SESSION.get(session_id) == stream_id:
1267
1834
  _RUNS_BY_SESSION.pop(session_id, None)
1835
+ # TAL-887: a session ended mid-turn (``chat.evict_agent`` marks the run under these locks) released its
1836
+ # unlocks then; one the Agent stored after that is released now.
1837
+ if run.session_ended:
1838
+ _release_vault_session(session_id)
1268
1839
  # An agent evicted or replaced while this turn held it is released now that the turn is over.
1269
1840
  _release_if_dropped_locked(run.agent, home)
1270
1841
 
@@ -1347,6 +1918,7 @@ def compress(ctx: CallContext, params: dict) -> dict:
1347
1918
  platform="webui",
1348
1919
  quiet_mode=True,
1349
1920
  enabled_toolsets=toolsets,
1921
+ disabled_toolsets=disabled_toolsets(),
1350
1922
  session_id=session_id,
1351
1923
  )
1352
1924
  for name, value in (
@@ -1356,6 +1928,8 @@ def compress(ctx: CallContext, params: dict) -> dict:
1356
1928
  ("credential_pool", runtime.get("credential_pool")),
1357
1929
  ("gateway_session_key", session_id),
1358
1930
  ("skip_memory", not _checkpoint_required()),
1931
+ ("request_overrides", _request_overrides(kwargs["model"], kwargs["provider"], runtime)),
1932
+ ("prefill_messages", _profile_prefill_messages()),
1359
1933
  ):
1360
1934
  if _supported(AIAgent, name) and value is not None:
1361
1935
  kwargs[name] = value
@@ -1394,6 +1968,93 @@ def compress(ctx: CallContext, params: dict) -> dict:
1394
1968
  _release_compression(agent, committed=False)
1395
1969
 
1396
1970
 
1971
+ _REFINE_BUSY = "Agent is running — wait for the turn to finish, then /refine."
1972
+
1973
+
1974
+ def _session_running_locked(session_id: str) -> bool:
1975
+ """Whether a turn for ``session_id`` has not returned yet, or no turn can start (caller holds ``_RUNS_LOCK``)."""
1976
+ run = _RUNS.get(_RUNS_BY_SESSION.get(session_id) or "")
1977
+ return _DRAINING.is_set() or (run is not None and not run.finished.is_set())
1978
+
1979
+
1980
+ def _refine_agent(params: dict, session_id: str, home):
1981
+ """The session's cached agent, else one built the way its turn builds it and cached for that turn, held in
1982
+ ``_COMMITTING`` until the caller lets go; None when a turn started meanwhile. Runs under the call's ``scoped_home``."""
1983
+ with _AGENT_CACHE_LOCK:
1984
+ with _RUNS_LOCK:
1985
+ # Checked again with the claim: a turn admitted since ``refine``'s check holds the cached agent.
1986
+ if _session_running_locked(session_id):
1987
+ return None
1988
+ cached = _AGENT_CACHE.get(session_id)
1989
+ if cached is not None:
1990
+ _COMMITTING.append(cached[0])
1991
+ return cached[0]
1992
+ model = str(params.get("model") or "").strip()
1993
+ provider = str(params.get("model_provider") or "").strip() or None
1994
+ toolsets = _turn_toolsets(params.get("enabled_toolsets"))
1995
+ # Like a turn: the profile's MCP servers connect before the agent snapshots its tools, which its next turn keeps.
1996
+ try:
1997
+ from .commands import discover_profile_mcp_tools
1998
+
1999
+ discover_profile_mcp_tools()
2000
+ except Exception: # noqa: BLE001
2001
+ log.warning("MCP discovery failed", exc_info=True)
2002
+ runtime = _resolve_runtime(provider, model)
2003
+ resolved_model = model or str(runtime.get("model") or "")
2004
+ resolved_provider = provider or runtime.get("provider")
2005
+ AIAgent = _agent_class()
2006
+ kwargs = _agent_kwargs(AIAgent, params, session_id, resolved_model, resolved_provider, runtime, toolsets)
2007
+ signature = _agent_signature(resolved_model, resolved_provider, runtime, toolsets, str(params.get("profile_home")), kwargs)
2008
+ built = _build_agent(AIAgent, kwargs, home, session_id)
2009
+ with _AGENT_CACHE_LOCK:
2010
+ with _RUNS_LOCK:
2011
+ running = _session_running_locked(session_id)
2012
+ cached = _AGENT_CACHE.get(session_id)
2013
+ agent = None if running else cached[0] if cached else built
2014
+ if agent is not None:
2015
+ _COMMITTING.append(agent)
2016
+ if agent is built:
2017
+ built._talaria_unannounced = True # its first turn announces it (``start``)
2018
+ _AGENT_CACHE[session_id] = (built, signature, home)
2019
+ _evict_idle_agents_locked()
2020
+ else:
2021
+ _release_agents_locked([(built, None, home)])
2022
+ return agent
2023
+
2024
+
2025
+ def refine(params: dict) -> str:
2026
+ """TAL-902: ``/refine [focus]`` like the Agent's live-session handler (``_format_live_refine_output``): the memory/skill
2027
+ review fork of ``messages`` (the server's model context for the session) on the session's agent, never while its turn
2028
+ runs. The fork carries this call's ``scoped_home``, so its memory and skill writes land in the session's profile."""
2029
+ session_id = str(params.get("session_id") or "").strip()
2030
+ messages = params.get("messages")
2031
+ if not session_id or not isinstance(messages, list):
2032
+ raise InvalidParams("session_id and messages are required")
2033
+ focus = str(params.get("focus") or "").strip()
2034
+ with _RUNS_LOCK:
2035
+ if _session_running_locked(session_id):
2036
+ return _REFINE_BUSY
2037
+ if not messages:
2038
+ return "Nothing to refine yet — the conversation is empty."
2039
+ home = profile_home_param(params)
2040
+ agent = _refine_agent(params, session_id, home)
2041
+ if agent is None:
2042
+ return _REFINE_BUSY
2043
+ try:
2044
+ agent._spawn_background_review(messages_snapshot=list(messages), review_memory=True,
2045
+ review_skills="skill_manage" in (getattr(agent, "valid_tool_names", None) or set()),
2046
+ focus=focus or None, explicit=True)
2047
+ except Exception as exc: # noqa: BLE001 - the Agent's own wording for a fork that could not start
2048
+ log.warning("/refine failed to start for session %s", session_id, exc_info=True)
2049
+ return f"/refine failed to start: {exc}"
2050
+ finally:
2051
+ with _AGENT_CACHE_LOCK:
2052
+ _COMMITTING.remove(agent)
2053
+ _release_if_dropped_locked(agent, home)
2054
+ tail = f" (focus: {focus})" if focus else ""
2055
+ return f"⚗ Reviewing this conversation in the background{tail} — any memory/skill updates will be reported when done."
2056
+
2057
+
1397
2058
  def _run_for(params: dict) -> _Run | None:
1398
2059
  stream_id = str(params.get("stream_id") or "").strip()
1399
2060
  session_id = str(params.get("session_id") or "").strip()
@@ -1422,6 +2083,11 @@ def register(registry) -> None:
1422
2083
  with scoped_home(profile_home_param(params)):
1423
2084
  return compress(ctx, params)
1424
2085
 
2086
+ @registry.method("chat.refine")
2087
+ def refine_(ctx: CallContext, params: dict) -> dict:
2088
+ with scoped_home(profile_home_param(params)):
2089
+ return {"output": refine(params)}
2090
+
1425
2091
  @registry.method("chat.compress_finalize", requires_agent=False)
1426
2092
  def compress_finalize_(ctx: CallContext, params: dict) -> dict:
1427
2093
  token = str(params.get("commit_token") or "").strip()
@@ -1483,28 +2149,74 @@ def register(registry) -> None:
1483
2149
  pending, index = _steer_queue_params(params)
1484
2150
  run = _run_for(params)
1485
2151
  if run is None or run.agent is None:
1486
- return {"redirected": False, "withdrawn": False}
2152
+ return {"redirected": False, "withdrawn": False, "reason": "not_running"}
1487
2153
  return steer_now(run.agent, pending, index)
1488
2154
 
2155
+ @registry.method("chat.finalize_session", requires_agent=False)
2156
+ def finalize_(ctx: CallContext, params: dict) -> dict:
2157
+ session_id = str(params.get("session_id") or "").strip()
2158
+ reason = params.get("reason")
2159
+ if not session_id or reason not in ("clear", "delete"):
2160
+ raise InvalidParams("session_id and reason (clear/delete) are required")
2161
+ home = profile_home_param(params)
2162
+ messages: list[str] = []
2163
+
2164
+ def finalize() -> None:
2165
+ try:
2166
+ # Scope inside the worker: a timed-out plugin keeps its own profile until it finishes.
2167
+ with scoped_home(home):
2168
+ from hermes_cli import lifecycle
2169
+
2170
+ callback = getattr(lifecycle, "finalize_session", None)
2171
+ if callback is None:
2172
+ return
2173
+ results = callback(session_id=session_id, platform="webui", reason=reason)
2174
+ extract = getattr(lifecycle, "session_end_messages", None)
2175
+ if extract is not None:
2176
+ messages.extend(extract(results))
2177
+ except Exception: # noqa: BLE001 - hooks must never block the boundary
2178
+ log.warning("Session finalize failed for %s (%s)", session_id, reason, exc_info=True)
2179
+ finally:
2180
+ with _RELEASES_LOCK:
2181
+ _RELEASES.pop(worker, None)
2182
+
2183
+ worker = threading.Thread(target=finalize, name="session-finalize", daemon=True)
2184
+ worker.failed = False # type: ignore[attr-defined] - hook failures do not prevent profile deletion
2185
+ with _RELEASES_LOCK:
2186
+ _RELEASES[worker] = home
2187
+ worker.start()
2188
+ worker.join(_SESSION_FINALIZE_TIMEOUT)
2189
+ if worker.is_alive():
2190
+ log.warning("Session finalize exceeded %ss for %s (%s)", _SESSION_FINALIZE_TIMEOUT, session_id, reason)
2191
+ return {"messages": []}
2192
+ return {"messages": messages}
2193
+
1489
2194
  @registry.method("chat.evict_agent", requires_agent=False)
1490
2195
  def evict_(ctx: CallContext, params: dict) -> dict:
1491
2196
  """Drop a session's cached agent. Only delete/clear (``clear_session``) end its approval grants, parked
1492
- approvals and kernels; a model switch, truncate or compress leaves a running turn's agent in place."""
2197
+ approvals, kernels and background review; a model switch, truncate or compress leaves a running turn's agent
2198
+ in place."""
1493
2199
  session_id = str(params.get("session_id") or "").strip()
1494
2200
  end_session = params.get("clear_session") is True
1495
2201
  with _AGENT_CACHE_LOCK:
1496
2202
  with _RUNS_LOCK:
1497
2203
  run = _RUNS.get(_RUNS_BY_SESSION.get(session_id) or "")
2204
+ if end_session and run is not None:
2205
+ run.session_ended = True
1498
2206
  if not end_session and run is not None and not run.finished.is_set():
1499
2207
  return {"evicted": False}
1500
2208
  entry = _AGENT_CACHE.pop(session_id, None)
1501
2209
  _release_agents_locked([entry] if entry else [])
1502
2210
  if end_session:
2211
+ # The server does not wait for an eviction; the cancels finish on their own.
2212
+ _cancel_background_reviews(([(entry[0], entry[2])] if entry else []) + ([(run.agent, run.home)] if run else []), "session deleted or cleared")
1503
2213
  try:
1504
2214
  from tools.approval import clear_session
1505
2215
  clear_session(session_id)
1506
2216
  except Exception: # noqa: BLE001
1507
2217
  pass
2218
+ # TAL-887: a password manager this session unlocked locks again with it; other sessions keep theirs.
2219
+ _release_vault_session(session_id)
1508
2220
  return {"evicted": entry is not None}
1509
2221
 
1510
2222
  @registry.method("chat.commit_memory", requires_agent=False)
@@ -1596,22 +2308,27 @@ def register(registry) -> None:
1596
2308
 
1597
2309
  @registry.method("clarify.respond", requires_agent=False)
1598
2310
  def clarify_respond_(ctx: CallContext, params: dict) -> dict:
1599
- response = str(params.get("response") or "").strip()
2311
+ response = str(params.get("response") or "")
1600
2312
  clarify_id = str(params.get("clarify_id") or "").strip()
1601
- if not response:
1602
- raise InvalidParams("response is required")
1603
2313
  run = _run_for(params)
1604
2314
  if run is None:
1605
2315
  return {"ok": False}
2316
+ if params.get("session_id") and str(params["session_id"]) != run.session_id:
2317
+ return {"ok": False}
1606
2318
  with run.lock:
1607
2319
  entries = list(run.clarify_entries.values())
1608
- target = None
1609
- if clarify_id:
1610
- target = next((e for e in entries if e.clarify_id == clarify_id), None)
1611
- elif entries:
1612
- target = entries[0]
1613
- if target is None:
1614
- return {"ok": False}
1615
- target.result = response
1616
- target.event.set()
1617
- return {"ok": True, "clarify_id": target.clarify_id}
2320
+ target = next((e for e in entries if e.clarify_id == clarify_id), None) if clarify_id else (entries[0] if entries else None)
2321
+ if target is None or target.event.is_set() or (target.deadline is not None and time.monotonic() >= target.deadline):
2322
+ return {"ok": False}
2323
+ skill_secret = target.data.get("kind") == "skill_secret"
2324
+ if skill_secret and (not clarify_id or ctx.cancelled or run.ctx.cancelled or run.cancel.is_set() or run.finished.is_set() or run.session_ended):
2325
+ return {"ok": False}
2326
+ # Secure answers are as typed; an empty reply is their decline. Only the first answer releases a waiter.
2327
+ secure = str(target.data.get("kind") or "").startswith("vault_") or skill_secret
2328
+ if not secure:
2329
+ response = response.strip()
2330
+ if not response and not secure:
2331
+ raise InvalidParams("response is required")
2332
+ target.result = response
2333
+ target.event.set()
2334
+ return {"ok": True, "clarify_id": target.clarify_id}