@maudecode/talaria-web 1.18.0 → 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 (567) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.js +16 -16
  3. package/dist/api/auth-raw.js.map +1 -1
  4. package/dist/api/automation-raw.d.ts +2 -2
  5. package/dist/api/automation-raw.js +32 -32
  6. package/dist/api/automation-raw.js.map +1 -1
  7. package/dist/api/automation-router.d.ts +102 -22
  8. package/dist/api/automation-router.js +15 -13
  9. package/dist/api/automation-router.js.map +1 -1
  10. package/dist/api/chat-router.d.ts +1205 -34
  11. package/dist/api/chat-router.js +158 -78
  12. package/dist/api/chat-router.js.map +1 -1
  13. package/dist/api/git-router.d.ts +2 -2
  14. package/dist/api/git-router.js +29 -29
  15. package/dist/api/git-router.js.map +1 -1
  16. package/dist/api/raw-routes.d.ts +3 -3
  17. package/dist/api/raw-routes.js +63 -61
  18. package/dist/api/raw-routes.js.map +1 -1
  19. package/dist/api/router.d.ts +6 -6
  20. package/dist/api/router.js +22 -22
  21. package/dist/api/router.js.map +1 -1
  22. package/dist/api/session-visibility.d.ts +4 -4
  23. package/dist/api/session-visibility.js +26 -16
  24. package/dist/api/session-visibility.js.map +1 -1
  25. package/dist/api/sessions-router.d.ts +1928 -124
  26. package/dist/api/sessions-router.js +177 -173
  27. package/dist/api/sessions-router.js.map +1 -1
  28. package/dist/api/settings-router.d.ts +427 -9
  29. package/dist/api/settings-router.js +48 -32
  30. package/dist/api/settings-router.js.map +1 -1
  31. package/dist/api/sse-routes.d.ts +2 -2
  32. package/dist/api/sse-routes.js +63 -63
  33. package/dist/api/sse-routes.js.map +1 -1
  34. package/dist/api/tools-raw.d.ts +5 -0
  35. package/dist/api/tools-raw.js +58 -33
  36. package/dist/api/tools-raw.js.map +1 -1
  37. package/dist/api/tools-router.d.ts +663 -91
  38. package/dist/api/tools-router.js +309 -52
  39. package/dist/api/tools-router.js.map +1 -1
  40. package/dist/api/transcribe-stream.d.ts +16 -0
  41. package/dist/api/transcribe-stream.js +172 -0
  42. package/dist/api/transcribe-stream.js.map +1 -0
  43. package/dist/app.d.ts +4 -1
  44. package/dist/app.js +66 -47
  45. package/dist/app.js.map +1 -1
  46. package/dist/auth/gate.js +10 -9
  47. package/dist/auth/gate.js.map +1 -1
  48. package/dist/auth/passkeys.d.ts +15 -14
  49. package/dist/auth/passkeys.js +89 -91
  50. package/dist/auth/passkeys.js.map +1 -1
  51. package/dist/auth/store.d.ts +17 -7
  52. package/dist/auth/store.js +93 -52
  53. package/dist/auth/store.js.map +1 -1
  54. package/dist/bin/talaria-web.js +12 -81
  55. package/dist/bin/talaria-web.js.map +1 -1
  56. package/dist/cli/ctl.js +2 -1
  57. package/dist/cli/ctl.js.map +1 -1
  58. package/dist/cli/dotenv.d.ts +5 -0
  59. package/dist/cli/dotenv.js +14 -2
  60. package/dist/cli/dotenv.js.map +1 -1
  61. package/dist/cli/launcher.js +5 -5
  62. package/dist/cli/launcher.js.map +1 -1
  63. package/dist/cli/main.d.ts +1 -0
  64. package/dist/cli/main.js +85 -0
  65. package/dist/cli/main.js.map +1 -0
  66. package/dist/cli/supervise.d.ts +2 -0
  67. package/dist/cli/supervise.js +67 -4
  68. package/dist/cli/supervise.js.map +1 -1
  69. package/dist/config/agent-config.d.ts +34 -11
  70. package/dist/config/agent-config.js +113 -58
  71. package/dist/config/agent-config.js.map +1 -1
  72. package/dist/config.d.ts +2 -0
  73. package/dist/config.js +14 -5
  74. package/dist/config.js.map +1 -1
  75. package/dist/fs/atomic.d.ts +1 -0
  76. package/dist/fs/atomic.js +4 -1
  77. package/dist/fs/atomic.js.map +1 -1
  78. package/dist/fs/stat.d.ts +1 -0
  79. package/dist/fs/stat.js +11 -0
  80. package/dist/fs/stat.js.map +1 -0
  81. package/dist/http/context.d.ts +14 -9
  82. package/dist/http/context.js +29 -20
  83. package/dist/http/context.js.map +1 -1
  84. package/dist/onboarding.d.ts +2 -2
  85. package/dist/onboarding.js +10 -8
  86. package/dist/onboarding.js.map +1 -1
  87. package/dist/profiles/profiles.d.ts +10 -4
  88. package/dist/profiles/profiles.js +77 -30
  89. package/dist/profiles/profiles.js.map +1 -1
  90. package/dist/projects.d.ts +15 -6
  91. package/dist/projects.js +55 -47
  92. package/dist/projects.js.map +1 -1
  93. package/dist/providers/catalog.d.ts +78 -12
  94. package/dist/providers/catalog.js +445 -141
  95. package/dist/providers/catalog.js.map +1 -1
  96. package/dist/providers/env-file.d.ts +10 -3
  97. package/dist/providers/env-file.js +24 -75
  98. package/dist/providers/env-file.js.map +1 -1
  99. package/dist/providers/runtime-env.d.ts +4 -2
  100. package/dist/providers/runtime-env.js +9 -6
  101. package/dist/providers/runtime-env.js.map +1 -1
  102. package/dist/providers/tables.d.ts +3 -1
  103. package/dist/providers/tables.js +4 -2
  104. package/dist/providers/tables.js.map +1 -1
  105. package/dist/redact.js +18 -9
  106. package/dist/redact.js.map +1 -1
  107. package/dist/release.d.ts +7 -0
  108. package/dist/release.js +14 -0
  109. package/dist/release.js.map +1 -1
  110. package/dist/runtime.d.ts +5 -1
  111. package/dist/runtime.js +233 -90
  112. package/dist/runtime.js.map +1 -1
  113. package/dist/server.js +10 -0
  114. package/dist/server.js.map +1 -1
  115. package/dist/sessions/anchor.d.ts +9 -3
  116. package/dist/sessions/anchor.js +28 -36
  117. package/dist/sessions/anchor.js.map +1 -1
  118. package/dist/sessions/background-events.d.ts +2 -0
  119. package/dist/sessions/background-events.js +10 -0
  120. package/dist/sessions/background-events.js.map +1 -0
  121. package/dist/sessions/background-tasks.d.ts +26 -20
  122. package/dist/sessions/background-tasks.js +88 -86
  123. package/dist/sessions/background-tasks.js.map +1 -1
  124. package/dist/sessions/background-updates.d.ts +15 -8
  125. package/dist/sessions/background-updates.js +101 -25
  126. package/dist/sessions/background-updates.js.map +1 -1
  127. package/dist/sessions/cli-sessions.d.ts +15 -5
  128. package/dist/sessions/cli-sessions.js +62 -43
  129. package/dist/sessions/cli-sessions.js.map +1 -1
  130. package/dist/sessions/completions.d.ts +30 -2
  131. package/dist/sessions/completions.js +294 -53
  132. package/dist/sessions/completions.js.map +1 -1
  133. package/dist/sessions/compress.d.ts +8 -0
  134. package/dist/sessions/compress.js +2 -2
  135. package/dist/sessions/compress.js.map +1 -1
  136. package/dist/sessions/drafts.d.ts +14 -10
  137. package/dist/sessions/drafts.js +38 -43
  138. package/dist/sessions/drafts.js.map +1 -1
  139. package/dist/sessions/gateway-sessions.d.ts +46 -0
  140. package/dist/sessions/gateway-sessions.js +378 -0
  141. package/dist/sessions/gateway-sessions.js.map +1 -0
  142. package/dist/sessions/goals.d.ts +31 -0
  143. package/dist/sessions/goals.js +75 -0
  144. package/dist/sessions/goals.js.map +1 -0
  145. package/dist/sessions/journal.d.ts +5 -79
  146. package/dist/sessions/journal.js +15 -606
  147. package/dist/sessions/journal.js.map +1 -1
  148. package/dist/sessions/list.d.ts +27 -18
  149. package/dist/sessions/list.js +133 -131
  150. package/dist/sessions/list.js.map +1 -1
  151. package/dist/sessions/merge.d.ts +68 -10
  152. package/dist/sessions/merge.js +259 -51
  153. package/dist/sessions/merge.js.map +1 -1
  154. package/dist/sessions/notices.d.ts +22 -0
  155. package/dist/sessions/notices.js +72 -0
  156. package/dist/sessions/notices.js.map +1 -0
  157. package/dist/sessions/pending.d.ts +10 -4
  158. package/dist/sessions/pending.js +83 -7
  159. package/dist/sessions/pending.js.map +1 -1
  160. package/dist/sessions/prefix-index.d.ts +82 -0
  161. package/dist/sessions/prefix-index.js +232 -0
  162. package/dist/sessions/prefix-index.js.map +1 -0
  163. package/dist/sessions/recovery.d.ts +4 -7
  164. package/dist/sessions/recovery.js +97 -281
  165. package/dist/sessions/recovery.js.map +1 -1
  166. package/dist/sessions/relay.d.ts +20 -20
  167. package/dist/sessions/relay.js +205 -127
  168. package/dist/sessions/relay.js.map +1 -1
  169. package/dist/sessions/service.d.ts +162 -59
  170. package/dist/sessions/service.js +1133 -521
  171. package/dist/sessions/service.js.map +1 -1
  172. package/dist/sessions/session.d.ts +18 -2
  173. package/dist/sessions/session.js +29 -5
  174. package/dist/sessions/session.js.map +1 -1
  175. package/dist/sessions/shares.d.ts +7 -6
  176. package/dist/sessions/shares.js +22 -45
  177. package/dist/sessions/shares.js.map +1 -1
  178. package/dist/sessions/source-kind.js +1 -0
  179. package/dist/sessions/source-kind.js.map +1 -1
  180. package/dist/sessions/state-db-identity.d.ts +18 -0
  181. package/dist/sessions/state-db-identity.js +67 -0
  182. package/dist/sessions/state-db-identity.js.map +1 -0
  183. package/dist/sessions/state-db-worker.js +3 -1
  184. package/dist/sessions/state-db-worker.js.map +1 -1
  185. package/dist/sessions/state-db.d.ts +53 -8
  186. package/dist/sessions/state-db.js +576 -50
  187. package/dist/sessions/state-db.js.map +1 -1
  188. package/dist/sessions/storage/backend.d.ts +162 -0
  189. package/dist/sessions/storage/backend.js +19 -0
  190. package/dist/sessions/storage/backend.js.map +1 -0
  191. package/dist/sessions/storage/conformance.d.ts +20 -0
  192. package/dist/sessions/storage/conformance.js +237 -0
  193. package/dist/sessions/storage/conformance.js.map +1 -0
  194. package/dist/sessions/storage/document.d.ts +16 -0
  195. package/dist/sessions/storage/document.js +44 -0
  196. package/dist/sessions/storage/document.js.map +1 -0
  197. package/dist/sessions/storage/json-documents.d.ts +23 -0
  198. package/dist/sessions/storage/json-documents.js +233 -0
  199. package/dist/sessions/storage/json-documents.js.map +1 -0
  200. package/dist/sessions/storage/json-files.d.ts +12 -0
  201. package/dist/sessions/storage/json-files.js +124 -0
  202. package/dist/sessions/storage/json-files.js.map +1 -0
  203. package/dist/sessions/storage/json-installation.d.ts +3 -0
  204. package/dist/sessions/storage/json-installation.js +31 -0
  205. package/dist/sessions/storage/json-installation.js.map +1 -0
  206. package/dist/sessions/storage/json-journal.d.ts +114 -0
  207. package/dist/sessions/storage/json-journal.js +686 -0
  208. package/dist/sessions/storage/json-journal.js.map +1 -0
  209. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  210. package/dist/sessions/storage/json-recovery.js +195 -0
  211. package/dist/sessions/storage/json-recovery.js.map +1 -0
  212. package/dist/sessions/storage/json.d.ts +68 -0
  213. package/dist/sessions/storage/json.js +258 -0
  214. package/dist/sessions/storage/json.js.map +1 -0
  215. package/dist/sessions/storage/row.d.ts +6 -0
  216. package/dist/sessions/storage/row.js +49 -0
  217. package/dist/sessions/storage/row.js.map +1 -0
  218. package/dist/sessions/storage/search-text.d.ts +4 -0
  219. package/dist/sessions/storage/search-text.js +16 -0
  220. package/dist/sessions/storage/search-text.js.map +1 -0
  221. package/dist/sessions/store.d.ts +63 -73
  222. package/dist/sessions/store.js +305 -611
  223. package/dist/sessions/store.js.map +1 -1
  224. package/dist/sessions/streams.d.ts +4 -0
  225. package/dist/sessions/streams.js +9 -1
  226. package/dist/sessions/streams.js.map +1 -1
  227. package/dist/sessions/titles.js +1 -1
  228. package/dist/sessions/titles.js.map +1 -1
  229. package/dist/sessions/todo.d.ts +5 -0
  230. package/dist/sessions/todo.js +28 -21
  231. package/dist/sessions/todo.js.map +1 -1
  232. package/dist/sessions/tool-display.d.ts +5 -4
  233. package/dist/sessions/tool-display.js +22 -16
  234. package/dist/sessions/tool-display.js.map +1 -1
  235. package/dist/sessions/turn-context.d.ts +10 -1
  236. package/dist/sessions/turn-context.js +7 -4
  237. package/dist/sessions/turn-context.js.map +1 -1
  238. package/dist/sessions/turn.d.ts +87 -17
  239. package/dist/sessions/turn.js +795 -347
  240. package/dist/sessions/turn.js.map +1 -1
  241. package/dist/sessions/wakeup-pause.d.ts +8 -13
  242. package/dist/sessions/wakeup-pause.js +31 -49
  243. package/dist/sessions/wakeup-pause.js.map +1 -1
  244. package/dist/sessions/window.js +13 -12
  245. package/dist/sessions/window.js.map +1 -1
  246. package/dist/settings.d.ts +19 -7
  247. package/dist/settings.js +179 -183
  248. package/dist/settings.js.map +1 -1
  249. package/dist/sidecar/client.d.ts +17 -3
  250. package/dist/sidecar/client.js +78 -8
  251. package/dist/sidecar/client.js.map +1 -1
  252. package/dist/sidecar/discover.d.ts +2 -1
  253. package/dist/sidecar/discover.js +93 -10
  254. package/dist/sidecar/discover.js.map +1 -1
  255. package/dist/sidecar/env.d.ts +4 -0
  256. package/dist/sidecar/env.js +3 -0
  257. package/dist/sidecar/env.js.map +1 -0
  258. package/dist/sidecar/fake.js +19 -2
  259. package/dist/sidecar/fake.js.map +1 -1
  260. package/dist/test/env-file.d.ts +5 -0
  261. package/dist/test/env-file.js +88 -0
  262. package/dist/test/env-file.js.map +1 -0
  263. package/dist/test/harness.d.ts +1 -0
  264. package/dist/test/harness.js +1 -1
  265. package/dist/test/harness.js.map +1 -1
  266. package/dist/test/passkeys.d.ts +22 -0
  267. package/dist/test/passkeys.js +62 -0
  268. package/dist/test/passkeys.js.map +1 -0
  269. package/dist/test/storage.d.ts +15 -0
  270. package/dist/test/storage.js +26 -0
  271. package/dist/test/storage.js.map +1 -0
  272. package/dist/tools/commands.d.ts +6 -1
  273. package/dist/tools/commands.js +8 -2
  274. package/dist/tools/commands.js.map +1 -1
  275. package/dist/tools/connectors.d.ts +33 -0
  276. package/dist/tools/connectors.js +121 -0
  277. package/dist/tools/connectors.js.map +1 -0
  278. package/dist/tools/crons.d.ts +38 -1
  279. package/dist/tools/crons.js +82 -15
  280. package/dist/tools/crons.js.map +1 -1
  281. package/dist/tools/dashboard-plugins.d.ts +5 -5
  282. package/dist/tools/dashboard-plugins.js +14 -14
  283. package/dist/tools/dashboard-plugins.js.map +1 -1
  284. package/dist/tools/extensions.d.ts +5 -4
  285. package/dist/tools/extensions.js +213 -207
  286. package/dist/tools/extensions.js.map +1 -1
  287. package/dist/tools/gateway-restart.d.ts +62 -0
  288. package/dist/tools/gateway-restart.js +142 -0
  289. package/dist/tools/gateway-restart.js.map +1 -0
  290. package/dist/tools/health.d.ts +15 -3
  291. package/dist/tools/health.js +97 -34
  292. package/dist/tools/health.js.map +1 -1
  293. package/dist/tools/hygiene.d.ts +4 -3
  294. package/dist/tools/hygiene.js +23 -9
  295. package/dist/tools/hygiene.js.map +1 -1
  296. package/dist/tools/kanban.d.ts +4 -2
  297. package/dist/tools/kanban.js +28 -19
  298. package/dist/tools/kanban.js.map +1 -1
  299. package/dist/tools/memory-masks.d.ts +5 -0
  300. package/dist/tools/memory-masks.js +256 -0
  301. package/dist/tools/memory-masks.js.map +1 -0
  302. package/dist/tools/memory.d.ts +29 -3
  303. package/dist/tools/memory.js +69 -27
  304. package/dist/tools/memory.js.map +1 -1
  305. package/dist/tools/plugin-settings.d.ts +15 -0
  306. package/dist/tools/plugin-settings.js +90 -0
  307. package/dist/tools/plugin-settings.js.map +1 -0
  308. package/dist/tools/skills.d.ts +9 -4
  309. package/dist/tools/skills.js +44 -13
  310. package/dist/tools/skills.js.map +1 -1
  311. package/dist/tools/update-notifications.d.ts +34 -15
  312. package/dist/tools/update-notifications.js +141 -57
  313. package/dist/tools/update-notifications.js.map +1 -1
  314. package/dist/tools/update-rollback.d.ts +11 -0
  315. package/dist/tools/update-rollback.js +28 -0
  316. package/dist/tools/update-rollback.js.map +1 -0
  317. package/dist/tools/updates.d.ts +93 -33
  318. package/dist/tools/updates.js +1051 -345
  319. package/dist/tools/updates.js.map +1 -1
  320. package/dist/util.d.ts +2 -0
  321. package/dist/util.js +5 -0
  322. package/dist/util.js.map +1 -1
  323. package/dist/workspace/git.d.ts +16 -1
  324. package/dist/workspace/git.js +291 -11
  325. package/dist/workspace/git.js.map +1 -1
  326. package/dist/workspace/media.d.ts +12 -9
  327. package/dist/workspace/media.js +31 -20
  328. package/dist/workspace/media.js.map +1 -1
  329. package/dist/workspace/rollback.d.ts +2 -2
  330. package/dist/workspace/rollback.js +5 -5
  331. package/dist/workspace/rollback.js.map +1 -1
  332. package/dist/workspace/workspaces.d.ts +15 -19
  333. package/dist/workspace/workspaces.js +55 -127
  334. package/dist/workspace/workspaces.js.map +1 -1
  335. package/dist/workspace/worktrees.js +170 -32
  336. package/dist/workspace/worktrees.js.map +1 -1
  337. package/package.json +5 -3
  338. package/sidecar/agent_dependency.json +4 -4
  339. package/sidecar/agent_support.json +26 -0
  340. package/sidecar/scripts/record-fixtures.py +12 -3
  341. package/sidecar/scripts/replay_sidecar.py +8 -0
  342. package/sidecar/scripts/suite_runner.py +302 -0
  343. package/sidecar/scripts/test.sh +12 -4
  344. package/sidecar/talaria_sidecar/__init__.py +6 -1
  345. package/sidecar/talaria_sidecar/__main__.py +4 -2
  346. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  347. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  348. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  349. package/sidecar/talaria_sidecar/home.py +43 -1
  350. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  351. package/sidecar/talaria_sidecar/methods/aux.py +139 -31
  352. package/sidecar/talaria_sidecar/methods/chat.py +847 -134
  353. package/sidecar/talaria_sidecar/methods/commands.py +213 -4
  354. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  355. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  356. package/sidecar/talaria_sidecar/methods/cron.py +366 -34
  357. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  358. package/sidecar/talaria_sidecar/methods/gateway.py +194 -54
  359. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  360. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  361. package/sidecar/talaria_sidecar/methods/kanban.py +80 -27
  362. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  363. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  364. package/sidecar/talaria_sidecar/methods/process.py +160 -11
  365. package/sidecar/talaria_sidecar/methods/profiles.py +44 -6
  366. package/sidecar/talaria_sidecar/methods/providers.py +196 -26
  367. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  368. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  369. package/sidecar/talaria_sidecar/methods/state_db.py +277 -70
  370. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  371. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  372. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  373. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  374. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  375. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  376. package/sidecar/talaria_sidecar/rpc.py +45 -4
  377. package/sidecar/talaria_sidecar/runtime.py +81 -12
  378. package/sidecar/talaria_sidecar/speed.py +35 -0
  379. package/static/dist/FILES.txt +126 -127
  380. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  381. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  382. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  383. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  384. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  385. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  386. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  387. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  388. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  389. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  390. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  391. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  392. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  393. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  394. package/static/dist/assets/{_app.ext._extensionId-gfbzpoou.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  395. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  396. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  397. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  398. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  399. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  400. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  401. package/static/dist/assets/{_app.session._sessionId-fn1bhde6.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  402. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  403. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  404. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  405. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  406. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  407. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  408. package/static/dist/assets/{abnfDiagram-VCTEODGH-c8kdixke.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  409. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  410. package/static/dist/assets/{architectureDiagram-5GKGNRK7-cku06x1p.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  411. package/static/dist/assets/archive-b1j004vn.js +1 -0
  412. package/static/dist/assets/back-g74gkozu.js +1 -0
  413. package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  414. package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  415. package/static/dist/assets/channel-es95o4y8.js +1 -0
  416. package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  417. package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  418. package/static/dist/assets/{chunk-DU6HZSFF-ofajdixh.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  419. package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  420. package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  421. package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  422. package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  423. package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  424. package/static/dist/assets/{chunk-OSK3NFVY-e7gkikcw.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  425. package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  426. package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  427. package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  428. package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  429. package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  430. package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  431. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  432. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  433. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  434. package/static/dist/assets/{copy-magtofyp.js → copy-jamtd1pf.js} +1 -1
  435. package/static/dist/assets/{cynefin-OW5HDTMX-siqlulkl.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  436. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-i648tah9.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  437. package/static/dist/assets/{dagre-GXQ25YYZ-mwn6uc17.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  438. package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  439. package/static/dist/assets/{diagram-UQ7AKVKN-mekq7zgq.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  440. package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  441. package/static/dist/assets/{diagram-VX7I27RA-xe6r5qfw.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  442. package/static/dist/assets/{diagram-Z3DM3KII-nq07dg7k.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  443. package/static/dist/assets/{ebnfDiagram-PWID7BFC-c62ipm3g.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  444. package/static/dist/assets/{erDiagram-RLTQ6QDP-h39kmnzy.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  445. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  446. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  447. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  448. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  449. package/static/dist/assets/{gitGraph-4MIJSDKK-fr2i7caa.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  450. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  451. package/static/dist/assets/{highlighted-body-KPVGNVTW-jwpop9p8.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  452. package/static/dist/assets/host-hzm8n85n.js +1 -0
  453. package/static/dist/assets/index-drdcaqeq.css +2 -0
  454. package/static/dist/assets/index-jzm844he.js +11 -0
  455. package/static/dist/assets/{info-A6RAGUB7-h11x0dxo.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  456. package/static/dist/assets/{infoDiagram-27XIBGKW-dfh47dw9.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  457. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  458. package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  459. package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  460. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  461. package/static/dist/assets/{line-meizqsvn.js → line-h0ojzsvs.js} +1 -1
  462. package/static/dist/assets/link-k01ryaf9.js +1 -0
  463. package/static/dist/assets/login-bsae49ew.js +1 -0
  464. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  465. package/static/dist/assets/{mermaid-parser.core-ntne45i9.js → mermaid-parser.core-bnre27u8.js} +3 -3
  466. package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  467. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  468. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  469. package/static/dist/assets/{packet-AYTQ26CC-l6jdka8p.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  470. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  471. package/static/dist/assets/{pegDiagram-XKGWAZYB-g1er3bxj.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  472. package/static/dist/assets/{pie-WAS4IAKB-i0ylcrt9.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  473. package/static/dist/assets/{pieDiagram-E7YTZNPT-fbz1e55z.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  474. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  475. package/static/dist/assets/{radar-RG4KPBEZ-f9b70aa4.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  476. package/static/dist/assets/{railroad-74A4TZTK-lgsuoeda.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  477. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  478. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  479. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  480. package/static/dist/assets/{railroadDiagram-O6MQD6OU-eqpy9fnz.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  481. package/static/dist/assets/{refresh-cw-hlwb02qv.js → refresh-cw-mqbkv2a3.js} +1 -1
  482. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  483. package/static/dist/assets/{requirementDiagram-BXWQKSXE-ogf6puhi.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  484. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  485. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  486. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  487. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  488. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  489. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  490. package/static/dist/assets/{stateDiagram-D77RDMKH-fyoznpcs.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  491. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  492. package/static/dist/assets/{swimlanes-42K2YHIH-oefldhfy.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  493. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  494. package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  495. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  496. package/static/dist/assets/{treeView-Q6P3EWNA-f7hcci5o.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  497. package/static/dist/assets/{treemap-WGGIJYW6-nl85071a.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  498. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  499. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  500. package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-gjc70qru.js} +1 -1
  501. package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-e6ae539u.js} +1 -1
  502. package/static/dist/assets/{vennDiagram-4TSXK5OY-h5hzbk0n.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  503. package/static/dist/assets/{wardley-WFR3VGLG-jftvpm3e.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  504. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-hedh85ge.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  505. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  506. package/static/dist/index.html +1 -1
  507. package/static/dist/sw.js +1 -1
  508. package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
  509. package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
  510. package/static/dist/assets/Dialog-g355qxhp.js +0 -1
  511. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
  512. package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
  513. package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
  514. package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
  515. package/static/dist/assets/Select-kddq75qc.js +0 -1
  516. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
  517. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
  518. package/static/dist/assets/States-eq6fe29m.js +0 -1
  519. package/static/dist/assets/Toaster-pc247u9f.js +0 -1
  520. package/static/dist/assets/_app-f8n1i0m1.js +0 -1
  521. package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
  522. package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
  523. package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
  524. package/static/dist/assets/_app.logs-i69qds98.js +0 -1
  525. package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
  526. package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
  527. package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
  528. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
  529. package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
  530. package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
  531. package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
  532. package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
  533. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
  534. package/static/dist/assets/back-h7aun7ew.js +0 -1
  535. package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
  536. package/static/dist/assets/channel-b2zo73z1.js +0 -1
  537. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
  538. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
  539. package/static/dist/assets/copied-hcplv46p.js +0 -1
  540. package/static/dist/assets/endpoints-klbxod4t.js +0 -64
  541. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
  542. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
  543. package/static/dist/assets/host-i828xuo7.js +0 -1
  544. package/static/dist/assets/index-eh8zilnv.css +0 -2
  545. package/static/dist/assets/index-r648f4o1.js +0 -11
  546. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
  547. package/static/dist/assets/link-oot2yn73.js +0 -1
  548. package/static/dist/assets/login-gu28l9ou.js +0 -1
  549. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
  550. package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
  551. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
  552. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
  553. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
  554. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
  555. package/static/dist/assets/refresh-abtu93ke.js +0 -1
  556. package/static/dist/assets/saved-girnabkf.js +0 -1
  557. package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
  558. package/static/dist/assets/share._token-kl0r7ido.js +0 -2
  559. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
  560. package/static/dist/assets/sse-iwku0sjo.js +0 -1
  561. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
  562. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
  563. package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
  564. package/static/dist/assets/units-kgxzxfhi.js +0 -1
  565. package/static/dist/assets/untitled-cikfjntn.js +0 -1
  566. package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
  567. package/static/dist/assets/useLocale-o59tbiea.js +0 -1
@@ -14,21 +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
31
34
  from .checkpoints import checkpoint_kwargs
35
+ from .providers import runtime_failure
36
+ from .toolsets import disabled_toolsets
32
37
 
33
38
  log = logging.getLogger("talaria_sidecar.chat")
34
39
 
@@ -37,10 +42,10 @@ _AGENT_CACHE_MAX = 32
37
42
  _AGENT_RELEASE_TIMEOUT = 4.0
38
43
  # Profile deletion waits this long for the profile's memory flushes, under the server's 120 s call timeout.
39
44
  _PROFILE_RELEASE_TIMEOUT = 60.0
45
+ _SESSION_FINALIZE_TIMEOUT = 10.0
40
46
  _TOOL_RESULT_SNIPPET_MAX = 4000
41
47
  # Predecessor ``_TOOL_ARG_CONTENT_KEYS`` (#4928): card content / diff-reconstruction inputs keep the long cap.
42
48
  _TOOL_ARG_CONTENT_KEYS = frozenset({"command", "cmd", "script", "code", "patch", "diff", "old_string", "new_string", "content", "path", "file_path"})
43
- _CLARIFY_FALLBACK = "The user did not provide a response within the time limit. Use your best judgement to make the choice and proceed."
44
49
 
45
50
 
46
51
  class _Run:
@@ -56,6 +61,10 @@ class _Run:
56
61
  self.lock = threading.Lock()
57
62
  # The Agent's transcript list from before this turn: a Stop while the Agent still holds it has no checkpoint.
58
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``
59
68
 
60
69
 
61
70
  class _ClarifyEntry:
@@ -64,6 +73,7 @@ class _ClarifyEntry:
64
73
  self.data = data
65
74
  self.event = threading.Event()
66
75
  self.result: str | None = None
76
+ self.deadline: float | None = None
67
77
 
68
78
 
69
79
  _RUNS: dict[str, _Run] = {}
@@ -79,7 +89,7 @@ _AGENT_CACHE_LOCK = threading.Lock()
79
89
  # Running release thread -> the profile home it runs under.
80
90
  _RELEASES: dict[threading.Thread, Any] = {}
81
91
  _RELEASES_LOCK = threading.Lock()
82
- # 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.
83
93
  _COMMITTING: list = []
84
94
  # Set (under ``_RUNS_LOCK``) when shutdown drains: no turn is admitted after the drain's snapshot of ``_RUNS``.
85
95
  _DRAINING = threading.Event()
@@ -114,6 +124,31 @@ def _result_diff(raw: Any) -> str | None:
114
124
  return diff if isinstance(diff, str) else None
115
125
 
116
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
+
117
152
  def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
118
153
  """The tool result as the server's outcome rule reads it, bounded: a dict (or JSON-object text) keeps its first
119
154
  ``_RAW_RESULT_MAX_KEYS`` top-level fields plus its outcome fields, scalars as they are, text and non-empty nested
@@ -138,6 +173,18 @@ def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
138
173
  return out
139
174
 
140
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
+
141
188
  def _delegation_cost_usd(name: Any, raw: Any):
142
189
  """Predecessor ``_delegation_cost_usd``: total spend a ``delegate_task`` result reports, or None."""
143
190
  if str(name or "") != "delegate_task":
@@ -255,9 +302,29 @@ def _context_length(agent: Any) -> dict:
255
302
  value = getattr(cc, key, None)
256
303
  if isinstance(value, (int, float)):
257
304
  out[key] = int(value)
305
+ threshold = _next_turn_threshold(agent, cc)
306
+ if threshold:
307
+ out["next_turn_threshold_tokens"] = threshold
258
308
  return out
259
309
 
260
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
+
261
328
  # TAL-539: the hard cap on raw tool output kept in a compressed context (predecessor ``_hard_prune_post_compression_*``).
262
329
  _TAIL_TOOL_BUDGET_TOKENS = 4096
263
330
  _TAIL_TOOL_MIN_SNIPPET_TOKENS = 256
@@ -280,25 +347,111 @@ def _rough_tokens(text: str) -> int:
280
347
  return -(-len(text) // _ROUGH_TOKEN_CHARS)
281
348
 
282
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
+
283
371
  def _capped_tool_result(text: str, tokens: int, keep_tokens: int) -> str:
284
- """The kept head of an over-budget tool result plus a note on what the model no longer sees."""
285
- prefix = "[Talaria context budget: omitted "
286
- 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
287
392
  if keep_tokens < _TAIL_TOOL_MIN_SNIPPET_TOKENS:
288
- return f"{prefix}{len(text)} chars{suffix}"
289
- 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
290
395
  snippet = text[: len(text) * room // tokens]
291
396
  # Density varies along the text: shrink until the kept head fits the remaining tokens.
292
397
  while snippet and _rough_tokens(snippet) > room:
293
398
  snippet = snippet[: len(snippet) * 3 // 4]
294
399
  snippet = snippet.rstrip()
295
- if not snippet:
296
- return f"{prefix}{len(text)} chars{suffix}"
297
- return f"{snippet}\n\n{prefix}{len(text) - len(snippet)} of {len(text)} chars{suffix}"
400
+ return cut(snippet) if snippet else note_only
298
401
 
299
402
 
300
- def _cap_tool_results(messages: list, compressor: Any) -> list:
301
- """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)
302
455
  budget = getattr(compressor, "tail_token_budget", None)
303
456
  budget = budget if isinstance(budget, int) and budget > 0 else _TAIL_TOOL_BUDGET_TOKENS
304
457
  threshold = getattr(compressor, "threshold_tokens", None)
@@ -312,13 +465,17 @@ def _cap_tool_results(messages: list, compressor: Any) -> list:
312
465
  if msg.get("role") != "tool":
313
466
  continue
314
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}
315
472
  if not text.strip():
316
473
  continue
317
474
  tokens = _rough_tokens(text)
318
475
  if tokens <= budget - spent:
319
476
  spent += tokens
320
477
  continue
321
- 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)}
322
479
  spent = budget
323
480
  return out
324
481
 
@@ -337,7 +494,7 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
337
494
  context = pruned
338
495
  except Exception: # noqa: BLE001 - the hard cap below still bounds the context
339
496
  log.debug("post-compression tool-result pruning failed", exc_info=True)
340
- context = _cap_tool_results(context, compressor)
497
+ context = _cap_tool_results(context, compressor, messages)
341
498
  estimate = None
342
499
  try:
343
500
  from agent.model_metadata import estimate_request_tokens_rough
@@ -350,6 +507,40 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
350
507
  return context, estimate if isinstance(estimate, int) and estimate > 0 else None
351
508
 
352
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
+
353
544
  def _clarify_timeout(params: dict) -> int:
354
545
  """Predecessor ``_clarify_timeout_seconds``: an explicit request value wins, else the Agent's resolver over config."""
355
546
  explicit = params.get("clarify_timeout_seconds")
@@ -481,7 +672,7 @@ def _resolve_runtime(provider: str | None, model: str) -> dict:
481
672
  try:
482
673
  runtime = resolve_runtime_provider(requested=provider or None, target_model=model or None)
483
674
  except Exception as exc: # noqa: BLE001
484
- raise RpcError(str(exc), condition="credential_missing", data={"error_type": type(exc).__name__}) from exc
675
+ raise runtime_failure(exc, provider) from exc
485
676
  if not isinstance(runtime, dict):
486
677
  runtime = dict(vars(runtime)) if hasattr(runtime, "__dict__") else {}
487
678
  return runtime
@@ -520,14 +711,8 @@ def _turn_identity(session_id: str, workspace: str):
520
711
  except Exception: # noqa: BLE001
521
712
  log.debug("per-turn session context bind failed", exc_info=True)
522
713
  try:
523
- from agent.runtime_cwd import _SESSION_CWD
524
-
525
- tok = _SESSION_CWD.set(str(workspace))
526
- resets.append(lambda: _SESSION_CWD.reset(tok))
527
- except Exception: # noqa: BLE001
528
- log.debug("per-turn session cwd bind failed", exc_info=True)
529
- try:
530
- yield
714
+ with session_workspace(workspace):
715
+ yield
531
716
  finally:
532
717
  for reset in reversed(resets):
533
718
  try:
@@ -562,16 +747,13 @@ def _discover_mcp_tools(run: _Run) -> None:
562
747
  The Agent keys connections by profile scope and skips live ones, so later turns are cheap. Runs under the call's
563
748
  ``scoped_home``; a failure leaves the turn without MCP tools rather than failing it. A Stop does not wait for a slow
564
749
  server: the turn moves on to its cancelled end while the connect finishes in the background."""
565
- from .commands import retire_unscoped_launch_mcp_servers
750
+ from .commands import discover_profile_mcp_tools
566
751
 
567
752
  done = threading.Event()
568
753
 
569
754
  def discover() -> None:
570
755
  try:
571
- from tools.mcp_tool_discovery import discover_mcp_tools
572
-
573
- retire_unscoped_launch_mcp_servers()
574
- discover_mcp_tools()
756
+ discover_profile_mcp_tools()
575
757
  except Exception: # noqa: BLE001
576
758
  log.warning("MCP discovery failed", exc_info=True)
577
759
  finally:
@@ -593,7 +775,18 @@ def _profile_fallback_chain() -> list[dict] | None:
593
775
  return get_fallback_chain(load_config() or {}) or None
594
776
 
595
777
 
596
- 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:
597
790
  """Predecessor ``_main_model_request_overrides``: the profile's ``model.service_tier`` and ``model.extra_body``
598
791
  (written by ``/api/model/set``) apply only to turns on that configured main model. Runs under ``scoped_home``."""
599
792
  from hermes_cli.config import load_config
@@ -606,17 +799,31 @@ def _main_model_request_overrides(model: str, provider) -> dict | None:
606
799
  if not main_model or main_model != model.strip():
607
800
  return None
608
801
  # An unset or ``auto`` main provider resolves at runtime, so the model id alone identifies the main model.
609
- 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()):
610
805
  return None
611
- overrides: dict = {}
612
- if str(model_cfg.get("service_tier") or "").strip().lower() == "priority":
613
- 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 {}
614
809
  extra_body = model_cfg.get("extra_body")
615
810
  if isinstance(extra_body, dict) and extra_body:
616
811
  overrides["extra_body"] = extra_body
617
812
  return overrides or None
618
813
 
619
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
+
620
827
  def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, kwargs: dict) -> str:
621
828
  """Cache identity of an ``AIAgent``: everything its constructor bound from the resolved runtime, so a rotated key,
622
829
  a different API mode, ACP command, or credential pool never reuses an agent built for the old bundle. The key
@@ -628,7 +835,8 @@ def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, k
628
835
  bundle = {
629
836
  "model": model, "provider": provider, "base_url": runtime.get("base_url"), "api_key": key_digest,
630
837
  "api_mode": runtime.get("api_mode"), "acp_command": runtime.get("acp_command"), "acp_args": runtime.get("acp_args"),
631
- "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"),
632
840
  "max_iterations": kwargs.get("max_iterations"), "max_tokens": kwargs.get("max_tokens"),
633
841
  # Bound at construction too: a reasoning-effort change from the composer must build a fresh agent.
634
842
  "reasoning_config": kwargs.get("reasoning_config"),
@@ -636,10 +844,79 @@ def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, k
636
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,51 +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
- # TAL-913: the profile's ``checkpoints`` section, as the Agent's own CLI and gateway turns read it.
1106
- for name, value in checkpoint_kwargs().items():
1107
- if _supported(AIAgent, name):
1108
- kwargs[name] = value
1109
1652
  signature = _agent_signature(resolved_model, resolved_provider, runtime, toolsets, str(params.get("profile_home")), kwargs)
1110
1653
  agent = None
1111
1654
 
@@ -1113,6 +1656,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1113
1656
  """Claim the agent for this run under ``_AGENT_CACHE_LOCK``, so an eviction never releases it mid-turn."""
1114
1657
  run.prior_messages = getattr(bound, "_session_messages", None)
1115
1658
  run.agent = bound
1659
+ if run.session_ended:
1660
+ bound.skip_background_review = True
1116
1661
 
1117
1662
  if not session_busy:
1118
1663
  with _AGENT_CACHE_LOCK:
@@ -1122,27 +1667,31 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1122
1667
  _AGENT_CACHE.move_to_end(session_id)
1123
1668
  bind(agent)
1124
1669
  if agent is not None:
1125
- 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"):
1126
1671
  if name in kwargs and hasattr(agent, name):
1127
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()
1128
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", {})
1129
1686
  # A ``/btw`` side question's throwaway session stays out of state.db.
1130
- session_db = _acquire_session_db(home) if not params.get("ephemeral") and _supported(AIAgent, "session_db") else None
1131
- if session_db is not None:
1132
- kwargs["session_db"] = session_db
1133
- kwargs["session_id"] = _execution_session_id(session_db, session_id)
1134
- try:
1135
- agent = AIAgent(**kwargs)
1136
- except BaseException:
1137
- _release_session_db(session_db)
1138
- raise
1139
- 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"))
1140
1688
  with _AGENT_CACHE_LOCK:
1141
1689
  bind(agent)
1142
- replaced = _AGENT_CACHE.pop(session_id, None)
1143
- _AGENT_CACHE[session_id] = (agent, signature, home)
1144
- _release_agents_locked([replaced] if replaced else [])
1145
- _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()
1146
1695
  # Predecessor ``agent.ephemeral_system_prompt``: personality, surface context, progress guidance, and delivery
1147
1696
  # hints travel as runtime instructions that are never persisted to history.
1148
1697
  ephemeral = params.get("ephemeral_system_prompt")
@@ -1189,27 +1738,40 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1189
1738
  watcher = threading.Thread(target=_watch_cancel, daemon=True, name=f"chat-cancel-{stream_id[:8]}")
1190
1739
  watcher.start()
1191
1740
  run_kwargs: dict = {"user_message": user_message, "conversation_history": history, "task_id": session_id}
1192
- try:
1193
- run_params = set(inspect.signature(agent.run_conversation).parameters)
1194
- except (TypeError, ValueError):
1195
- run_params = set()
1741
+ run_params = _run_conversation_parameters(type(agent))
1196
1742
  if system_message and ("system_message" in run_params or not run_params):
1197
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"] = ""
1198
1747
  # The server's clean user text (no workspace prefix) is what state.db stores; the Agent keeps the sent bytes beside it.
1199
1748
  persist = params.get("persist_user_message")
1200
1749
  if not (isinstance(persist, str) and persist.strip()):
1201
1750
  persist = user_message if isinstance(user_message, str) else None
1202
1751
  if "persist_user_message" in run_params and persist is not None:
1203
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
1204
1757
  error: str | None = None
1205
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)
1206
1761
  try:
1207
- 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)
1208
1769
  result = raw if isinstance(raw, dict) else {}
1209
1770
  except Exception as exc: # noqa: BLE001
1210
1771
  log.warning("agent turn failed for %s: %s", session_id, exc)
1211
1772
  error = f"{type(exc).__name__}: {exc}"
1212
1773
  finally:
1774
+ _set_vault_prompts(None, None, None, None)
1213
1775
  run.finished.set()
1214
1776
  watcher.join(timeout=2)
1215
1777
  # A successor turn for the same session (admitted past the cancel-unwind ceiling) may have registered
@@ -1255,7 +1817,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1255
1817
  "max_iterations_summary_request": _max_iterations_summary_request(),
1256
1818
  "usage": _usage(agent),
1257
1819
  "context": _context_length(agent),
1258
- "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),
1259
1822
  "provider": str(resolved_provider or ""),
1260
1823
  "compressed": compressed,
1261
1824
  "agent_session_id": str(getattr(agent, "session_id", None) or session_id),
@@ -1269,6 +1832,10 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1269
1832
  _RUNS.pop(stream_id, None)
1270
1833
  if _RUNS_BY_SESSION.get(session_id) == stream_id:
1271
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)
1272
1839
  # An agent evicted or replaced while this turn held it is released now that the turn is over.
1273
1840
  _release_if_dropped_locked(run.agent, home)
1274
1841
 
@@ -1351,6 +1918,7 @@ def compress(ctx: CallContext, params: dict) -> dict:
1351
1918
  platform="webui",
1352
1919
  quiet_mode=True,
1353
1920
  enabled_toolsets=toolsets,
1921
+ disabled_toolsets=disabled_toolsets(),
1354
1922
  session_id=session_id,
1355
1923
  )
1356
1924
  for name, value in (
@@ -1360,6 +1928,8 @@ def compress(ctx: CallContext, params: dict) -> dict:
1360
1928
  ("credential_pool", runtime.get("credential_pool")),
1361
1929
  ("gateway_session_key", session_id),
1362
1930
  ("skip_memory", not _checkpoint_required()),
1931
+ ("request_overrides", _request_overrides(kwargs["model"], kwargs["provider"], runtime)),
1932
+ ("prefill_messages", _profile_prefill_messages()),
1363
1933
  ):
1364
1934
  if _supported(AIAgent, name) and value is not None:
1365
1935
  kwargs[name] = value
@@ -1398,6 +1968,93 @@ def compress(ctx: CallContext, params: dict) -> dict:
1398
1968
  _release_compression(agent, committed=False)
1399
1969
 
1400
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
+
1401
2058
  def _run_for(params: dict) -> _Run | None:
1402
2059
  stream_id = str(params.get("stream_id") or "").strip()
1403
2060
  session_id = str(params.get("session_id") or "").strip()
@@ -1426,6 +2083,11 @@ def register(registry) -> None:
1426
2083
  with scoped_home(profile_home_param(params)):
1427
2084
  return compress(ctx, params)
1428
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
+
1429
2091
  @registry.method("chat.compress_finalize", requires_agent=False)
1430
2092
  def compress_finalize_(ctx: CallContext, params: dict) -> dict:
1431
2093
  token = str(params.get("commit_token") or "").strip()
@@ -1487,28 +2149,74 @@ def register(registry) -> None:
1487
2149
  pending, index = _steer_queue_params(params)
1488
2150
  run = _run_for(params)
1489
2151
  if run is None or run.agent is None:
1490
- return {"redirected": False, "withdrawn": False}
2152
+ return {"redirected": False, "withdrawn": False, "reason": "not_running"}
1491
2153
  return steer_now(run.agent, pending, index)
1492
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
+
1493
2194
  @registry.method("chat.evict_agent", requires_agent=False)
1494
2195
  def evict_(ctx: CallContext, params: dict) -> dict:
1495
2196
  """Drop a session's cached agent. Only delete/clear (``clear_session``) end its approval grants, parked
1496
- 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."""
1497
2199
  session_id = str(params.get("session_id") or "").strip()
1498
2200
  end_session = params.get("clear_session") is True
1499
2201
  with _AGENT_CACHE_LOCK:
1500
2202
  with _RUNS_LOCK:
1501
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
1502
2206
  if not end_session and run is not None and not run.finished.is_set():
1503
2207
  return {"evicted": False}
1504
2208
  entry = _AGENT_CACHE.pop(session_id, None)
1505
2209
  _release_agents_locked([entry] if entry else [])
1506
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")
1507
2213
  try:
1508
2214
  from tools.approval import clear_session
1509
2215
  clear_session(session_id)
1510
2216
  except Exception: # noqa: BLE001
1511
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)
1512
2220
  return {"evicted": entry is not None}
1513
2221
 
1514
2222
  @registry.method("chat.commit_memory", requires_agent=False)
@@ -1600,22 +2308,27 @@ def register(registry) -> None:
1600
2308
 
1601
2309
  @registry.method("clarify.respond", requires_agent=False)
1602
2310
  def clarify_respond_(ctx: CallContext, params: dict) -> dict:
1603
- response = str(params.get("response") or "").strip()
2311
+ response = str(params.get("response") or "")
1604
2312
  clarify_id = str(params.get("clarify_id") or "").strip()
1605
- if not response:
1606
- raise InvalidParams("response is required")
1607
2313
  run = _run_for(params)
1608
2314
  if run is None:
1609
2315
  return {"ok": False}
2316
+ if params.get("session_id") and str(params["session_id"]) != run.session_id:
2317
+ return {"ok": False}
1610
2318
  with run.lock:
1611
2319
  entries = list(run.clarify_entries.values())
1612
- target = None
1613
- if clarify_id:
1614
- target = next((e for e in entries if e.clarify_id == clarify_id), None)
1615
- elif entries:
1616
- target = entries[0]
1617
- if target is None:
1618
- return {"ok": False}
1619
- target.result = response
1620
- target.event.set()
1621
- 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}