polyrob 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. webview/webgate.py +196 -0
@@ -0,0 +1,2398 @@
1
+ """TaskAgent - Minimal wrapper for task automation package.
2
+
3
+ This is a thin wrapper that delegates all real work to the task package components:
4
+ - SessionManager: Handles all session tracking and lifecycle
5
+ - SessionOrchestrator: Handles agent coordination and execution
6
+ - Controller: Handles tool management and action execution
7
+
8
+ Key principle: NO DUPLICATION - all logic exists in the task package.
9
+ """
10
+
11
+ import logging
12
+ import asyncio
13
+ import time
14
+ import re
15
+ import json
16
+ from typing import Optional, Dict, Any, Union, List
17
+ from datetime import datetime
18
+ import uuid
19
+ from dataclasses import dataclass
20
+
21
+ from agents.base_agent import BaseAgent
22
+ from agents.task.session_registry import SessionRegistry
23
+ from core.exceptions import AgentError, SessionOwnershipError
24
+ from core.exceptions import InsufficientCreditsError
25
+ from core.exceptions import MessageQueueFullError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Session limits per user
30
+ # Defensive fallback ONLY for the getattr below; the runtime SSOT is the config
31
+ # field BotConfig.max_sessions_per_user (core/config.py, default 10). Keep this in
32
+ # sync with that default. (B3: was previously shadowed by a stray, unused
33
+ # constants.MAX_SESSIONS_PER_USER=100 — removed.)
34
+ MAX_SESSIONS_PER_USER = 10
35
+
36
+ #: Strong references to in-flight fire-and-forget self-wake ``run_session`` tasks
37
+ #: (AU-F3.1). asyncio only holds a WEAK reference to a task created via
38
+ #: ``asyncio.create_task`` -- without this, the task object can be garbage-collected
39
+ #: mid-run (a well-known asyncio footgun), silently dropping the self-wake dispatch.
40
+ #: Mirrors ``core/autonomy_runtime.py::_BACKGROUND_TASKS``. Self-cleans via
41
+ #: ``add_done_callback``.
42
+ _SELF_WAKE_TASKS: set = set()
43
+
44
+
45
+ @dataclass
46
+ class SessionRequest:
47
+ """Model for session configuration."""
48
+ task: str
49
+ model: str = "gpt-5"
50
+ provider: str = "openai"
51
+ tools: List[str] = None
52
+ max_steps: int = 50
53
+ temperature: float = 0.0
54
+ use_vision: bool = True
55
+ session_config: Optional[Dict[str, Any]] = None
56
+
57
+ def __post_init__(self):
58
+ if self.tools is None:
59
+ self.tools = ["browser", "filesystem", "task"]
60
+
61
+
62
+ def _resolve_chat_runtime(env=None):
63
+ """Resolve (provider, model) for chat_once via the shared core resolver (Seam 2).
64
+
65
+ Precedence: CHAT_PROVIDER/CHAT_MODEL or DEFAULT_PROVIDER/DEFAULT_MODEL pin >
66
+ the historical openai/gpt-5 default (used only if an OpenAI key is present) >
67
+ first keyed provider (canonical order) > openai/gpt-5 last resort. The model is
68
+ always filled to match the resolved provider, so a first-keyed/anthropic-pinned
69
+ provider never inherits SessionRequest's gpt-5 default.
70
+ """
71
+ import os as _os
72
+ from core.runtime_config import resolve_runtime_config
73
+ from modules.llm.llm_client_registry import get_default_model
74
+
75
+ env = _os.environ if env is None else env
76
+ pinned_provider = env.get("CHAT_PROVIDER") or env.get("DEFAULT_PROVIDER")
77
+ pinned_model = env.get("CHAT_MODEL") or env.get("DEFAULT_MODEL")
78
+ provider, model = resolve_runtime_config(
79
+ None,
80
+ None,
81
+ env=env,
82
+ pinned_provider=pinned_provider,
83
+ pinned_model=pinned_model,
84
+ cli_store_default=("openai", "gpt-5"),
85
+ last_resort=("openai", "gpt-5"),
86
+ )
87
+ if not model:
88
+ model = get_default_model(provider)
89
+ return provider, model
90
+
91
+
92
+ class TaskAgent(BaseAgent):
93
+ """Minimal task automation wrapper.
94
+
95
+ Core responsibilities:
96
+ 1. Verify task package availability
97
+ 2. Create/run sessions via task package
98
+ 3. Report status from SessionManager
99
+ 4. Route messages to active sessions
100
+ """
101
+
102
+ def __init__(self, name: str = "task_agent", config=None, container=None):
103
+ """Initialize task agent wrapper."""
104
+ # Get container if not provided - CRITICAL for tools
105
+ if not container:
106
+ from core.container import DependencyContainer
107
+ container = DependencyContainer.get_instance()
108
+
109
+ # Get config if not provided
110
+ if not config:
111
+ from core.config import BotConfig
112
+ config = BotConfig()
113
+
114
+ super().__init__(
115
+ name=name,
116
+ config=config,
117
+ container=container
118
+ )
119
+
120
+ # Store capabilities
121
+ self.capabilities = ["automation", "browser", "planning", "multi-agent"]
122
+ self.description = "Task automation agent"
123
+
124
+ # Core components from task package
125
+ self.session_manager = None
126
+ self.task_available = False
127
+ self._initialized = False
128
+
129
+ # Handles for the fire-and-forget periodic background loops so they can be
130
+ # cancelled deterministically via aclose() — otherwise they are GC'd on
131
+ # event-loop teardown ("Task was destroyed but it is pending" / "I/O
132
+ # operation on closed file"), which is noise in prod and pollution in tests.
133
+ self._bg_tasks: list = []
134
+
135
+ # Active orchestrators, keyed by session_id, behind a registry interface.
136
+ # Default: in-process dict (the reason UVICORN_WORKERS=1). Opt in to the
137
+ # SQLite-backed registry (cross-process visibility for workers>1) with
138
+ # SESSION_REGISTRY_BACKEND=sqlite — P6. Drop-in compatible interface.
139
+ self._registry = self._build_registry(config)
140
+
141
+ # Memory management with TTL and LRU eviction (from config)
142
+ self.session_ttl_seconds = config.session_ttl_seconds
143
+ self.max_sessions_in_memory = config.max_sessions_in_memory
144
+ self.cleanup_interval = config.session_cleanup_interval
145
+ # Shorter TTL for never-run 'created' sessions (keyed off created_at, not the
146
+ # run-path activity clock) so they stop leaking the per-user session limit.
147
+ self.created_session_ttl_seconds = getattr(
148
+ config, 'created_session_ttl_seconds', 3600
149
+ )
150
+ self._session_last_activity = {} # session_id → timestamp
151
+
152
+ # Session execution locks for concurrency control
153
+ self._session_execution_locks = {} # session_id → asyncio.Lock
154
+ self._recreate_locks = {} # session_id → asyncio.Lock (serializes orchestrator recreation)
155
+
156
+ # Compatibility attributes for API
157
+ self.active_sessions = {} # Will be populated from SessionManager
158
+ self.user_sessions = {} # Maps user_id to active session_id
159
+ # S2 (chat consolidation): durable map (user_id, chat_id) -> session_id so
160
+ # chat_once continues the same conversation across stateless HTTP calls
161
+ # (keyed finer-grained than user_sessions, which keys by user only).
162
+ self._chat_sessions = {} # "chat:{user_id}:{chat_id}" -> session_id
163
+ # Per-chat-key locks so concurrent chat_once() calls for the SAME
164
+ # (user_id, chat_id) serialize: without this, two in-flight HTTP turns
165
+ # could both create a session (one leaks) or read each other's reply off
166
+ # the shared history. Lazily populated, keyed by _chat_key().
167
+ self._chat_locks: Dict[str, "asyncio.Lock"] = {}
168
+
169
+ # Initialize telemetry for continuous chat tracking
170
+ try:
171
+ from agents.task.telemetry.manager import TelemetryManager
172
+ self.telemetry = TelemetryManager(
173
+ session_id="task_agent",
174
+ agent_id="continuous_chat"
175
+ )
176
+ except Exception as e:
177
+ logger.debug(f"Failed to initialize telemetry: {e}")
178
+ self.telemetry = None
179
+
180
+ # --- Orchestrator registry accessors (public API) ---
181
+ #
182
+ # External callers (HTTP layer, tests) must use these instead of reaching
183
+ # into the registry dict directly, so the storage can be swapped without
184
+ # touching them. See agents/task/session_registry.py.
185
+
186
+ @staticmethod
187
+ def _build_registry(config):
188
+ """Pick the registry backend (P6). Defaults to the in-process dict so
189
+ production (UVICORN_WORKERS=1) is unchanged; SESSION_REGISTRY_BACKEND=sqlite
190
+ opts into the cross-process SQLite registry."""
191
+ import os
192
+ backend = os.getenv("SESSION_REGISTRY_BACKEND", "memory").strip().lower()
193
+ if backend == "sqlite":
194
+ from agents.task.sqlite_session_registry import SqliteSessionRegistry
195
+ data_dir = getattr(config, "data_dir", "data") if config else "data"
196
+ return SqliteSessionRegistry(os.path.join(data_dir, "session_registry.db"))
197
+ return SessionRegistry()
198
+
199
+ def get_orchestrator(self, session_id: str):
200
+ """Return the active orchestrator for ``session_id`` or ``None``."""
201
+ return self._registry.get(session_id)
202
+
203
+ def route_session(self, session_id: str):
204
+ """Cross-worker routing decision (P6) — a SessionRoute (LOCAL/REMOTE/MISSING).
205
+ Lets the API distinguish 'owned by another worker' from a true 404 when
206
+ SESSION_REGISTRY_BACKEND=sqlite and UVICORN_WORKERS>1. With the in-process
207
+ registry it is always LOCAL or MISSING."""
208
+ if hasattr(self._registry, "route"):
209
+ return self._registry.route(session_id)
210
+ # ultra-defensive fallback for a registry without route()
211
+ from agents.task.session_route import SessionRoute, LOCAL, MISSING
212
+ orch = self._registry.get(session_id)
213
+ return SessionRoute(status=LOCAL, orchestrator=orch) if orch else SessionRoute(status=MISSING)
214
+
215
+ def register_orchestrator(self, session_id: str, orchestrator) -> None:
216
+ """Register (or replace) the orchestrator for ``session_id``."""
217
+ self._registry.register(session_id, orchestrator)
218
+ # SA-01: give the orchestrator a bound "re-run my loop" kick so a background
219
+ # delegation that completes into an already-idle session can drain its result
220
+ # instead of parking it forever. run_session refuses concurrent execution, so
221
+ # the kick is a safe no-op when a loop is still active. Set here (the single
222
+ # registration seam) so every registration path — create + recreate-from-disk —
223
+ # gets it. Fail-open: a kick-wiring error must never block registration.
224
+ try:
225
+ uid = getattr(orchestrator, "user_id", None)
226
+
227
+ async def _wake_kick():
228
+ try:
229
+ await self.run_session(uid, session_id)
230
+ except Exception:
231
+ pass
232
+
233
+ orchestrator._wake_kick = _wake_kick
234
+ except Exception:
235
+ pass
236
+
237
+ def remove_orchestrator(self, session_id: str):
238
+ """Remove and return the orchestrator for ``session_id`` (``None`` if absent)."""
239
+ return self._registry.remove(session_id)
240
+
241
+ def heartbeat_session(self, session_id: str) -> None:
242
+ """Refresh the registry's liveness clock for ``session_id`` (P6).
243
+
244
+ No-op for the in-process registry (which has no heartbeat). For the SQLite
245
+ backend this bumps ``last_seen_at`` so the periodic reaper spares live
246
+ sessions. Fail-open — a heartbeat must never disrupt the run loop."""
247
+ try:
248
+ registry = self._registry
249
+ if hasattr(registry, "heartbeat"):
250
+ registry.heartbeat(session_id)
251
+ except Exception:
252
+ # Fail-open: heartbeat failure is non-critical, continue without it
253
+ pass
254
+
255
+ def active_orchestrators(self) -> List[Any]:
256
+ """Snapshot list of the currently-registered orchestrators."""
257
+ return self._registry.values()
258
+
259
+ def active_session_count(self) -> int:
260
+ """Number of orchestrators currently held in memory."""
261
+ return self._registry.count()
262
+
263
+ @property
264
+ def _active_orchestrators(self) -> Dict[str, Any]:
265
+ """Backward-compat view of the registry's underlying dict.
266
+
267
+ Retained for existing tests/callers that mutate the dict directly.
268
+ New code should use get_orchestrator/register_orchestrator/remove_orchestrator.
269
+ """
270
+ return self._registry._orchestrators
271
+
272
+ @_active_orchestrators.setter
273
+ def _active_orchestrators(self, value: Dict[str, Any]) -> None:
274
+ self._registry._orchestrators = value
275
+
276
+ async def _initialize(self) -> None:
277
+ """Initialize and verify task package availability."""
278
+ if self._initialized:
279
+ return
280
+
281
+ await super()._initialize()
282
+
283
+ # Check task package and get SessionManager
284
+ try:
285
+ from agents.task.agent.session import get_session_manager
286
+
287
+ self.session_manager = get_session_manager()
288
+ self.task_available = True
289
+
290
+ # Register in container for other services
291
+ if self.container and not self.container.has_service('session_manager'):
292
+ self.container.register_service('session_manager', self.session_manager)
293
+
294
+ # P1b-0: install the Singular Chat outbound bus on the shared container
295
+ # so create_session's binding + the P1a mirrors + cron/delivery's sink
296
+ # have a router to reach. Flag-gated (default OFF -> no-op, byte-identical)
297
+ # and fail-open (a bus error never breaks TaskAgent startup).
298
+ if self.container:
299
+ try:
300
+ from core.surfaces.bootstrap import install_surface_bus
301
+ # SB-04: do NOT pass a hardcoded "data/surfaces.db". This install
302
+ # runs first (during build_cli_container) and is idempotent, so a
303
+ # hardcoded path pinned the live outbound-allowlist/router DB to
304
+ # ./data/surfaces.db while `polyrob owner allow` writes
305
+ # <data_home>/surfaces.db (cwd/.polyrob or POLYROB_DATA_DIR) — a
306
+ # split-brain that left the P1 `message` allowlist un-configurable
307
+ # from the CLI admin surface. Let the config-aware default
308
+ # (<container.config.data_dir>/surfaces.db) resolve so both sides
309
+ # share one DB under the same data-home isolation.
310
+ install_surface_bus(self.container)
311
+ except Exception as e:
312
+ logger.debug(f"surface bus install skipped: {e}")
313
+
314
+ logger.info(f"✓ TaskAgent initialized with SessionManager")
315
+
316
+ # Start background cleanup task for memory eviction
317
+ self._bg_tasks.append(asyncio.create_task(self._periodic_cleanup()))
318
+ logger.info(f"✓ Started periodic cleanup task (interval: {self.cleanup_interval}s, TTL: {self.session_ttl_seconds}s)")
319
+
320
+ # Start workspace cleanup task
321
+ self._bg_tasks.append(asyncio.create_task(self._periodic_workspace_cleanup()))
322
+ logger.info("✓ Started periodic workspace cleanup task")
323
+
324
+ except ImportError as e:
325
+ logger.error(f"Task package not available: {e}")
326
+ self.task_available = False
327
+
328
+ self._initialized = True
329
+
330
+ def _assert_session_owner(self, session_id: Optional[str], user_id: str) -> None:
331
+ """C4: refuse to reuse a session_id that already belongs to another user.
332
+
333
+ Allows: no session_id (generated), a brand-new id, an id with no recorded
334
+ owner (legacy), or the caller's own id. Raises SessionOwnershipError only on
335
+ a real cross-user collision.
336
+ """
337
+ if not session_id:
338
+ return
339
+ existing = self.session_manager.get_session_info(session_id)
340
+ if not existing:
341
+ return
342
+ owner = existing.get('user_id')
343
+ if owner and user_id and owner != user_id:
344
+ raise SessionOwnershipError(
345
+ f"Session {session_id} belongs to another user"
346
+ )
347
+
348
+ async def create_session(
349
+ self,
350
+ user_id: str,
351
+ request: Union[str, Dict[str, Any], SessionRequest],
352
+ session_id: Optional[str] = None,
353
+ skip_credit_check: bool = False,
354
+ on_stream_chunk=None,
355
+ **kwargs
356
+ ) -> Dict[str, Any]:
357
+ """Create a new task session with credit pre-validation.
358
+
359
+ Args:
360
+ user_id: User creating the session
361
+ request: Task string, request dict, or SessionRequest object
362
+ session_id: Optional session ID
363
+ skip_credit_check: If True, skip credit validation (for admin/already-verified)
364
+ on_stream_chunk: Optional streaming callback override. When provided,
365
+ replaces the default webview stream callback. Used by CLI to print
366
+ chunks to stdout. Signature: (session_id, agent_id, chunk, step) -> Awaitable[None]
367
+ **kwargs: Additional parameters
368
+
369
+ Returns:
370
+ Session info dictionary
371
+ """
372
+ if not self._initialized:
373
+ await self._initialize()
374
+
375
+ if not self.task_available:
376
+ raise AgentError("Task package not available")
377
+
378
+ # Pre-validate credits before creating session (unless bypassed)
379
+ if not skip_credit_check:
380
+ await self._validate_user_credits(user_id)
381
+
382
+ # B2: reap never-run 'created' sessions on-demand BEFORE the cap check.
383
+ # The periodic cleanup task only ticks in a long-lived server process; a
384
+ # one-shot `polyrob run` exits before it fires, so stale 'created' slots from
385
+ # prior runs (loaded from disk on startup) would false-positive the cap.
386
+ try:
387
+ await self._cleanup_stale_created_sessions()
388
+ except Exception:
389
+ logger.debug("on-demand stale-session reap failed (non-fatal)", exc_info=True)
390
+
391
+ # Check per-user session limit
392
+ if not self._check_user_session_limit(user_id):
393
+ raise AgentError(
394
+ f"Session limit reached for user {user_id}. "
395
+ f"Please complete existing sessions before creating new ones."
396
+ )
397
+
398
+ # Parse request into standard format
399
+ if isinstance(request, str):
400
+ session_request = SessionRequest(task=request)
401
+ elif isinstance(request, SessionRequest):
402
+ session_request = request
403
+ else:
404
+ # Dict format from API
405
+ session_request = SessionRequest(
406
+ task=request.get('task', ''),
407
+ model=request.get('model', 'gpt-5'),
408
+ provider=request.get('provider', 'openai'),
409
+ tools=request.get('tools', ['browser', 'filesystem', 'task']),
410
+ max_steps=request.get('max_steps', 50),
411
+ temperature=request.get('temperature', 0.0),
412
+ use_vision=request.get('use_vision', True),
413
+ session_config=request.get('session_config')
414
+ )
415
+
416
+ # SECURITY (C4): a client may supply a custom session_id (CLI/API/A2A). If
417
+ # that id already belongs to a DIFFERENT user, refuse — otherwise we would
418
+ # build a fresh orchestrator over theirs (register_orchestrator overwrite =
419
+ # DoS) and stomp their task/model/tools metadata.
420
+ self._assert_session_owner(session_id, user_id)
421
+
422
+ # Generate session ID if needed
423
+ if not session_id:
424
+ session_id = str(uuid.uuid4())
425
+
426
+ # Create session via SessionManager
427
+ actual_id = self.session_manager.create_session(session_id, user_id)
428
+
429
+ # Create orchestrator for this session
430
+ from agents.task.agent.orchestrator import SessionOrchestrator
431
+
432
+ # Streaming callback resolution: caller-provided > webview > None (headless).
433
+ stream_callback = on_stream_chunk
434
+ if stream_callback is None:
435
+ try:
436
+ from agents.task.utils_webview import make_webview_stream_callback
437
+ stream_callback = make_webview_stream_callback()
438
+ except Exception as e:
439
+ logger.debug(f"Webview streaming unavailable, running headless: {e}")
440
+
441
+ orchestrator = SessionOrchestrator(
442
+ session_id=actual_id,
443
+ user_id=user_id,
444
+ container=self.container, # Pass container for tools
445
+ on_stream_chunk=stream_callback,
446
+ )
447
+
448
+ # P1b-2: bind this session's orchestrator to the Singular Chat outbound bus
449
+ # BEFORE initialize() — _register_stream_callback captures the router+key by
450
+ # value, so binding after init would be a permanent no-op. Flag-gated +
451
+ # fail-open: with the flag OFF or no chat_session_key (legacy callers pass
452
+ # neither), this touches nothing and the legacy callback path is unchanged.
453
+ try:
454
+ from core.surfaces.binding import bind_chat_surface
455
+ bind_chat_surface(
456
+ orchestrator, self.container,
457
+ session_source=kwargs.get("session_source"),
458
+ chat_session_key=kwargs.get("chat_session_key"),
459
+ session_id=actual_id, user_id=user_id,
460
+ )
461
+ except Exception as e:
462
+ logger.debug(f"chat-surface bind skipped: {e}")
463
+
464
+ # Initialize orchestrator with tools. A surface may pass an explicit `tool_ids`
465
+ # override (e.g. the telegram owner-interactive toolset) WITHOUT going through
466
+ # the request-dict path, so provider/model resolution stays untouched.
467
+ tool_ids_override = kwargs.get("tool_ids")
468
+ await orchestrator.initialize(
469
+ tool_ids=tool_ids_override or session_request.tools,
470
+ tools_config=session_request.session_config.get('tools_config', {}) if session_request.session_config else {}
471
+ )
472
+
473
+ # Store orchestrator reference
474
+ self.register_orchestrator(actual_id, orchestrator)
475
+
476
+ # Store full session metadata with config for API compatibility
477
+ self.session_manager.update_session_metadata(actual_id, {
478
+ 'task': session_request.task,
479
+ 'model': session_request.model,
480
+ 'tools': session_request.tools,
481
+ 'config': {
482
+ 'model': session_request.model,
483
+ 'provider': session_request.provider,
484
+ 'tools': session_request.tools,
485
+ 'max_steps': session_request.max_steps,
486
+ 'temperature': session_request.temperature,
487
+ 'use_vision': session_request.use_vision,
488
+ 'tools_config': session_request.session_config.get('tools_config', {}) if session_request.session_config else {}
489
+ },
490
+ 'request': session_request.__dict__,
491
+ 'created_at': datetime.now().isoformat(),
492
+ 'status': 'created',
493
+ 'orchestrator_ready': True
494
+ })
495
+
496
+ # Save task to dedicated file for webview (uses SessionManager helper)
497
+ try:
498
+ self.session_manager._save_summary_file(actual_id, 'task.json', {
499
+ "task": session_request.task,
500
+ "model": session_request.model,
501
+ "provider": session_request.provider,
502
+ "created_at": datetime.now().isoformat()
503
+ })
504
+ except Exception as e:
505
+ logger.warning(f"Failed to create task.json: {e}")
506
+
507
+ # Update user_sessions mapping for API compatibility
508
+ self.user_sessions[user_id] = actual_id
509
+
510
+ logger.info(f"Created session {actual_id} with orchestrator for user {user_id}")
511
+
512
+ return {
513
+ 'id': actual_id,
514
+ 'user_id': user_id,
515
+ 'task': session_request.task,
516
+ 'status': 'created',
517
+ 'model': session_request.model,
518
+ 'tools': session_request.tools,
519
+ 'config': {
520
+ 'model': session_request.model,
521
+ 'provider': session_request.provider,
522
+ 'tools': session_request.tools,
523
+ 'max_steps': session_request.max_steps,
524
+ 'temperature': session_request.temperature,
525
+ 'use_vision': session_request.use_vision
526
+ }
527
+ }
528
+
529
+ def _rebind_recreated_chat(self, orchestrator, session_id: str, user_id: str) -> None:
530
+ """Re-attach the outbound chat surface to a recreated orchestrator (#0).
531
+
532
+ Recreation rebuilds the orchestrator without `_message_router`/`_chat_session_key`,
533
+ so a resumed chat's replies would be silently dropped (the streaming + send_message
534
+ seams read those by getattr→None). Reverse-look-up the chat binding and re-bind.
535
+ MUST run BEFORE orchestrator.initialize()/create_agent — the stream callback
536
+ captures the router by value. Fail-open; no-op for non-chat sessions / bus off."""
537
+ try:
538
+ if not self.container:
539
+ return
540
+ registry = self.container.get_service("session_chat_registry")
541
+ if registry is None or not hasattr(registry, "resolve_by_session_id"):
542
+ return
543
+ row = registry.resolve_by_session_id(session_id)
544
+ if not row:
545
+ return # not a chat-bound session
546
+ from core.surfaces.binding import bind_chat_surface
547
+ from core.surfaces.envelopes import SessionSource
548
+ src = SessionSource(
549
+ surface_id=row.get("surface_id"),
550
+ chat_id=row.get("chat_id"),
551
+ chat_type="dm",
552
+ )
553
+ bind_chat_surface(
554
+ orchestrator, self.container,
555
+ session_source=src,
556
+ chat_session_key=row.get("session_key"),
557
+ session_id=session_id,
558
+ user_id=user_id,
559
+ )
560
+ except Exception as e:
561
+ logger.debug(f"_rebind_recreated_chat failed for {session_id}: {e}")
562
+
563
+ def touch_chat_binding(self, session_key: str) -> None:
564
+ """Bump a chat binding's last-activity clock (idle boundary, a1). Fail-open;
565
+ resolves the session_chat_registry from the container. No-op if the bus is off."""
566
+ try:
567
+ if not self.container:
568
+ return
569
+ registry = self.container.get_service("session_chat_registry")
570
+ if registry is not None:
571
+ registry.touch(session_key)
572
+ except Exception as e:
573
+ logger.debug(f"touch_chat_binding failed for {session_key}: {e}")
574
+
575
+ async def _resolve_or_recreate(
576
+ self, session_id: str, session_info: Dict[str, Any]
577
+ ) -> Optional[Any]:
578
+ """Return the resident orchestrator for ``session_id``, recreating it from disk
579
+ under a per-session lock if it was evicted (a2-complete).
580
+
581
+ ALL recreation paths route through here so two callers (e.g. a STEER message and
582
+ a self-wake) racing on the same evicted session can't double-build the
583
+ orchestrator and orphan one. A resident session returns immediately WITHOUT
584
+ taking the lock, so this never blocks queuing into an already-running session.
585
+ The lock is a dedicated ``_recreate_locks`` entry — not the execution lock."""
586
+ orchestrator = self._registry.get(session_id)
587
+ if orchestrator:
588
+ return orchestrator
589
+ if not hasattr(self, "_recreate_locks"):
590
+ self._recreate_locks = {}
591
+ lock = self._recreate_locks.setdefault(session_id, asyncio.Lock())
592
+ async with lock:
593
+ orchestrator = self._registry.get(session_id) # re-check under lock
594
+ if not orchestrator:
595
+ orchestrator = await self._recreate_orchestrator(session_id, session_info)
596
+ # If recreation failed the session never becomes resident, so _evict_session's
597
+ # lock-pop (gated on a live orchestrator) will never fire — drop the lock here so
598
+ # a stream of failed recreations can't leak one Lock per distinct session_id.
599
+ if orchestrator is None and not lock.locked():
600
+ self._recreate_locks.pop(session_id, None)
601
+ return orchestrator
602
+
603
+ def unbind_chat(self, session_key: str) -> None:
604
+ """Drop a chat<->session binding (explicit /new). The next message from that
605
+ chat then routes cold (fresh session) instead of STEERing into the old thread.
606
+ Fail-open; no-op if the singular-chat bus is off (a4)."""
607
+ try:
608
+ if not self.container:
609
+ return
610
+ registry = self.container.get_service("session_chat_registry")
611
+ if registry is not None:
612
+ registry.delete(session_key)
613
+ except Exception as e:
614
+ logger.debug(f"unbind_chat failed for {session_key}: {e}")
615
+
616
+ async def ensure_session_and_deliver(
617
+ self,
618
+ user_id: str,
619
+ session_id: str,
620
+ text: str,
621
+ *,
622
+ kind: str = "comment",
623
+ metadata: Optional[Dict[str, Any]] = None,
624
+ ) -> str:
625
+ """Resident-or-recreate the BOUND session, then queue a user message into it.
626
+
627
+ Reuses the self-wake "resident-or-recreatable" rail: if the orchestrator was
628
+ evicted, ``_recreate_orchestrator`` restores it from disk (``load_from_disk``
629
+ rehydrates the full message history).
630
+
631
+ Returns one of (a-MED2):
632
+ - ``"delivered"`` — queued; the caller then runs the session to process it.
633
+ - ``"busy"`` — the session is alive but its queue is full; the caller
634
+ should tell the user "still working", NOT mint a fresh one.
635
+ - ``"gone"`` — truly gone (no on-disk metadata / wrong tenant / not
636
+ recreatable); the caller falls back to a fresh session.
637
+ """
638
+ try:
639
+ session_info = self.session_manager.get_session_info(session_id)
640
+ if not session_info:
641
+ return "gone"
642
+ # Tenant guard: never deliver into another user's session.
643
+ owner = session_info.get("user_id")
644
+ if user_id is not None and owner is not None and owner != user_id:
645
+ logger.warning(
646
+ f"ensure_session_and_deliver: user mismatch for {session_id} "
647
+ f"(owner={owner}, caller={user_id}) — refusing"
648
+ )
649
+ return "gone"
650
+ orchestrator = await self._resolve_or_recreate(session_id, session_info)
651
+ if not orchestrator:
652
+ return "gone"
653
+ try:
654
+ await orchestrator.submit_user_message(
655
+ agent_id=None, text=text, kind=kind, metadata=metadata,
656
+ )
657
+ except MessageQueueFullError:
658
+ # The session is resident and processing — its queue is just saturated.
659
+ # Surfacing "busy" keeps the user on the SAME thread instead of dropping
660
+ # them into a fresh, amnesiac session.
661
+ logger.info(
662
+ f"ensure_session_and_deliver: queue full for {session_id} — busy"
663
+ )
664
+ return "busy"
665
+ return "delivered"
666
+ except Exception as e:
667
+ logger.error(
668
+ f"ensure_session_and_deliver failed for {session_id}: {e}", exc_info=True
669
+ )
670
+ return "gone"
671
+
672
+ def _session_has_pending_input(self, session_id: str) -> bool:
673
+ """True if the session has genuine queued input waiting to be processed.
674
+
675
+ Checks the orchestrator's pre-agent pending queue and every resident
676
+ agent's HITL queue. An evicted orchestrator (not in the registry) has
677
+ nothing queued in memory by definition — recreation paths queue their
678
+ message first via ensure_session_and_deliver. Fail-open: on any error,
679
+ report input present so legacy behaviour (run) is preserved.
680
+ """
681
+ orchestrator = self._registry.get(session_id)
682
+ if not orchestrator:
683
+ return False
684
+ try:
685
+ if getattr(orchestrator, '_pending_messages', None):
686
+ return True
687
+ for agent in (getattr(orchestrator, 'agents', None) or {}).values():
688
+ hitl = getattr(agent, 'hitl_manager', None)
689
+ if hitl is not None and hitl.get_queue_size() > 0:
690
+ return True
691
+ except Exception as e:
692
+ logger.debug(f"_session_has_pending_input({session_id}) failed: {e}")
693
+ return True # when in doubt, run (legacy behaviour)
694
+ return False
695
+
696
+ async def run_session(
697
+ self,
698
+ user_id: str,
699
+ session_id: Optional[str] = None
700
+ ) -> str:
701
+ """Run a task session with concurrency protection.
702
+
703
+ This method encapsulates the entire execution flow:
704
+ 1. Acquire execution lock for the session (prevents race conditions)
705
+ 2. Get orchestrator (already initialized during create_session)
706
+ 3. Get LLM client
707
+ 4. Create agent
708
+ 5. Execute session
709
+
710
+ All complexity is handled internally by the orchestrator.
711
+
712
+ Args:
713
+ user_id: User ID
714
+ session_id: Session to run (or latest if None)
715
+
716
+ Returns:
717
+ Result message
718
+ """
719
+ if not self.task_available:
720
+ return "Task package not available"
721
+
722
+ # Get session
723
+ if not session_id:
724
+ sessions = self.session_manager.get_active_sessions(user_id)
725
+ if not sessions:
726
+ return "No active session found"
727
+ session_id = sessions[-1]
728
+
729
+ # CONCURRENCY PROTECTION (DUAL-LOCK PATTERN):
730
+ # ==========================================
731
+ # PRIMARY DEFENSE: Execution lock (prevents concurrent execution)
732
+ # SECONDARY DEFENSE: Status transition check (validates state machine integrity)
733
+ #
734
+ # Get or create lock for this session
735
+ if session_id not in self._session_execution_locks:
736
+ self._session_execution_locks[session_id] = asyncio.Lock()
737
+
738
+ # Acquire lock - only one execution at a time per session
739
+ # This is the PRIMARY protection against race conditions
740
+ async with self._session_execution_locks[session_id]:
741
+ return await self._run_session_impl(user_id, session_id)
742
+
743
+ async def _run_session_impl(
744
+ self,
745
+ user_id: str,
746
+ session_id: str
747
+ ) -> str:
748
+ """Internal session execution implementation.
749
+
750
+ This is the actual implementation separated for lock management.
751
+ Should only be called from run_session() which holds the execution lock.
752
+
753
+ Args:
754
+ user_id: User ID
755
+ session_id: Session ID to run
756
+
757
+ Returns:
758
+ Result message
759
+ """
760
+ session_info = self.session_manager.get_session_info(session_id)
761
+ if not session_info or session_info.get('user_id') != user_id:
762
+ return "Session not found or unauthorized"
763
+
764
+ # Get current status
765
+ current_status = session_info.get('status', 'unknown')
766
+
767
+ # P2/P3 (2026-07-02): kill the "resume-to-check" model. A COMPLETED session
768
+ # only re-runs when genuine queued input exists — every legitimate resume
769
+ # path (STEER, continuation, self-wake, delegation-result) queues its
770
+ # message BEFORE calling run_session. Without this gate, a no-input resume
771
+ # burned an LLM call, concluded "No new user input", and appended another
772
+ # wall of no-op done-turns to the persisted history (prod fa1212de).
773
+ # No status churn, and an evicted orchestrator is NOT recreated just to
774
+ # discover there is nothing to do.
775
+ if current_status == 'completed' and not self._session_has_pending_input(session_id):
776
+ logger.info(
777
+ f"Session {session_id} is completed with no pending input — "
778
+ f"skipping no-op resume"
779
+ )
780
+ return "No new input; session remains completed"
781
+
782
+ # SECONDARY DEFENSE: Status transition validation
783
+ # ================================================
784
+ # This provides:
785
+ # 1. Additional safety (defense-in-depth)
786
+ # 2. Status state machine integrity
787
+ # 3. Detection of logic errors that bypass the lock
788
+ #
789
+ # NOTE: The execution lock (line 312) is PRIMARY - this is SECONDARY verification
790
+ # Include 'error' for backward compatibility with sessions that used old status
791
+ if current_status in ['completed', 'suspended', 'failed', 'error']:
792
+ # CONTINUOUS CHAT: completed/suspended/failed/error → resumed → running
793
+ # Valid transitions require two steps for these statuses
794
+ if not self.session_manager.try_transition_status(session_id, current_status, 'resumed'):
795
+ logger.warning(f"Session {session_id} is already being resumed (concurrent call)")
796
+ return "Session is already executing"
797
+ # Update current status for next transition
798
+ current_status = 'resumed'
799
+
800
+ if current_status == 'resumed':
801
+ # Try: resumed → running
802
+ if not self.session_manager.try_transition_status(session_id, 'resumed', 'running'):
803
+ logger.warning(f"Session {session_id} is already running (concurrent call)")
804
+ return "Session is already executing"
805
+
806
+ # Track session resume telemetry
807
+ if self.telemetry:
808
+ try:
809
+ # Get message queue size if available
810
+ queue_size = 0
811
+ orchestrator = self._registry.get(session_id)
812
+ if orchestrator and orchestrator.agents:
813
+ agent = next(iter(orchestrator.agents.values()))
814
+ if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
815
+ queue_size = agent.hitl_manager.get_queue_size()
816
+
817
+ self.telemetry.capture_event(
818
+ event_type="session_resume",
819
+ data={
820
+ "session_id": session_id,
821
+ "previous_status": current_status,
822
+ "message_queue_size": queue_size,
823
+ "concurrent_sessions": self._registry.count()
824
+ }
825
+ )
826
+ except Exception as e:
827
+ logger.debug(f"Failed to emit session resume telemetry: {e}")
828
+ elif current_status in ('created', 'initializing'):
829
+ # 'created' = fresh session; 'initializing' = the orchestrator just (re)created it
830
+ # (orchestrator.__init__ sets status='initializing'). A warm STEER resume — e.g. the
831
+ # owner sending a voice/text message to continue a suspended session — lands in
832
+ # 'initializing', and WITHOUT this case it dead-ends at the else below ("Cannot run
833
+ # session in status: initializing") so Rob never replies. Both are pre-run states →
834
+ # transition to running. try_transition_status is a compare-and-swap, so it's safe.
835
+ if not self.session_manager.try_transition_status(session_id, current_status, 'running'):
836
+ logger.warning(f"Session {session_id} is already being processed")
837
+ return "Session is already executing"
838
+ elif current_status == 'running':
839
+ # Already running!
840
+ logger.warning(f"Session {session_id} is already running")
841
+ return "Session is already executing"
842
+ elif current_status == 'active':
843
+ # Legacy status, try to transition
844
+ if not self.session_manager.try_transition_status(session_id, 'active', 'running'):
845
+ logger.warning(f"Session {session_id} is already being processed")
846
+ return "Session is already executing"
847
+ else:
848
+ logger.warning(f"Cannot run session {session_id} in status: {current_status}")
849
+ return f"Cannot run session in status: {current_status}"
850
+
851
+ # EXCLUSIVE EXECUTION ACQUIRED
852
+ # =============================
853
+ # Protected by:
854
+ # ✅ Execution lock (PRIMARY - held throughout this method)
855
+ # ✅ Status transition (SECONDARY - validated state machine)
856
+ #
857
+ # Update activity time
858
+ self._session_last_activity[session_id] = time.time()
859
+
860
+ orchestrator = None
861
+ final_status = None # Track final status to pass to cleanup
862
+
863
+ try:
864
+ # Get orchestrator (should already exist from create_session); recreate
865
+ # from disk under the shared per-session lock if it was evicted (a2).
866
+ orchestrator = self._registry.get(session_id)
867
+ if not orchestrator:
868
+ logger.info(f"No orchestrator in memory for {session_id}, attempting recreation")
869
+ orchestrator = await self._resolve_or_recreate(session_id, session_info)
870
+ if not orchestrator:
871
+ raise RuntimeError(f"Failed to recreate orchestrator for session {session_id}")
872
+
873
+ # Get request details
874
+ request = session_info.get('request', {})
875
+ task = request.get('task', '')
876
+
877
+ # Check if agent already exists (for continuous task execution)
878
+ # IMPORTANT: Use just the agent name, orchestrator will format the full ID
879
+ agent_name = "executor"
880
+
881
+ # Try to get existing agent using orchestrator's registry
882
+ # The orchestrator stores agents as "executor_{session_id}"
883
+ agent_id = f"{agent_name}_{session_id}"
884
+ existing_agent = orchestrator.agents.get(agent_id)
885
+
886
+ if existing_agent:
887
+ # ✅ REUSE: Continuous task - reuse existing agent with all history
888
+ logger.info(f"♻️ Reusing existing agent for continuous task execution: {agent_id}")
889
+ agent = existing_agent
890
+
891
+ # CRITICAL FIX: Reset agent state for continuation
892
+ # Without this, _last_result still has is_done=True from previous run,
893
+ # causing the agent to exit immediately without processing new messages
894
+ if hasattr(agent, 'reset_for_continuation'):
895
+ agent.reset_for_continuation()
896
+ else:
897
+ # Fallback for older agent versions
898
+ logger.warning(f"Agent {agent_id} missing reset_for_continuation, manual reset")
899
+ agent._last_result = None
900
+ agent._cancelled = False
901
+ if hasattr(agent, 'state'):
902
+ agent.state.stopped = False
903
+ agent.state.done = False
904
+
905
+ # Log context for debugging
906
+ if hasattr(agent, 'message_manager') and hasattr(agent.message_manager, 'history'):
907
+ messages_count = len(agent.message_manager.history.messages)
908
+ logger.info(f"Agent has {messages_count} messages in history")
909
+ else:
910
+ # ✅ CREATE: First task in session
911
+ logger.info(f"🆕 Creating new agent for session {session_id}")
912
+
913
+ # Get LLM - simplified approach
914
+ llm = await self._get_llm_for_request(request)
915
+
916
+ agent = await orchestrator.create_agent(
917
+ task=task,
918
+ llm=llm,
919
+ agent_name=agent_name, # ← Use simple name, orchestrator adds session_id
920
+ use_vision=request.get('use_vision', True),
921
+ max_actions_per_step=10,
922
+ session_config=request.get('session_config')
923
+ )
924
+
925
+ # Execute - orchestrator handles everything
926
+ results = await orchestrator.execute_session(
927
+ agent_sequence=[agent.agent_id],
928
+ max_steps_per_agent={
929
+ agent.agent_id: request.get('max_steps', 100)
930
+ }
931
+ )
932
+
933
+ # Check results and set status ONCE
934
+ agent_result = results.get(agent.agent_id, {})
935
+ result_status = agent_result.get('status', 'error')
936
+
937
+ if result_status == 'completed':
938
+ final_status = 'completed'
939
+ self.session_manager.update_session_status(session_id, final_status)
940
+ return "Session completed successfully"
941
+ elif result_status == 'stopped':
942
+ # Agent was explicitly stopped/cancelled by user
943
+ final_status = 'cancelled'
944
+ self.session_manager.update_session_status(session_id, final_status)
945
+ return "Session cancelled by user"
946
+ else:
947
+ # Error or unknown status
948
+ final_status = 'failed'
949
+ error_msg = agent_result.get('error', 'Unknown error')
950
+ self.session_manager.update_session_status(session_id, final_status)
951
+ return f"Session failed: {error_msg}"
952
+
953
+ except InsufficientCreditsError as e:
954
+ # Special handling for billing errors - suspend rather than fail
955
+ logger.warning(f"Session {session_id} suspended due to insufficient credits: {e}")
956
+
957
+ final_status = 'suspended'
958
+ self._session_last_activity[session_id] = time.time()
959
+
960
+ # Update status and metadata with user-friendly message
961
+ self.session_manager.update_session_status(session_id, final_status)
962
+ self.session_manager.update_session_metadata(session_id, {
963
+ 'error': str(e),
964
+ 'error_time': datetime.now().isoformat(),
965
+ 'error_type': 'InsufficientCreditsError',
966
+ 'suspension_reason': 'insufficient_credits',
967
+ 'credits_required': e.required,
968
+ 'credits_available': e.available,
969
+ 'resume_instructions': 'Add credits at /api/payments/deposit to resume'
970
+ })
971
+
972
+ # Don't re-raise - return gracefully with suspension message
973
+ return f"Session suspended: {str(e)}. Add credits to resume."
974
+
975
+ except Exception as e:
976
+ logger.error(f"Session {session_id} failed: {e}", exc_info=True)
977
+
978
+ # Set final status to 'failed' (matches SessionStatus enum)
979
+ final_status = 'failed'
980
+
981
+ # Update activity time even on error
982
+ self._session_last_activity[session_id] = time.time()
983
+
984
+ # Update status and metadata
985
+ self.session_manager.update_session_status(session_id, final_status)
986
+ self.session_manager.update_session_metadata(session_id, {
987
+ 'error': str(e),
988
+ 'error_time': datetime.now().isoformat(),
989
+ 'error_type': type(e).__name__
990
+ })
991
+
992
+ # Re-raise for proper error handling
993
+ raise
994
+ finally:
995
+ # NOTE: For continuous chat, we keep the orchestrator and agent alive
996
+ # but release browser contexts to free resources (can be reacquired on next run)
997
+ if orchestrator:
998
+ try:
999
+ # STEP 1: Save message history BEFORE cleanup (BUG FIX #2)
1000
+ # This ensures conversation context is preserved for continuous chat
1001
+ try:
1002
+ for agent in orchestrator.agents.values():
1003
+ if hasattr(agent, 'message_manager') and agent.message_manager:
1004
+ try:
1005
+ agent.message_manager.save_to_disk(
1006
+ session_id=session_id,
1007
+ user_id=orchestrator.user_id
1008
+ )
1009
+ logger.info(f"💾 Saved message history for agent {agent.agent_id}")
1010
+ except Exception as e:
1011
+ logger.error(f"Failed to save message history for agent {agent.agent_id}: {e}")
1012
+ except Exception as e:
1013
+ logger.error(f"Failed to save message histories during completion: {e}")
1014
+
1015
+ # STEP 2: Release browser contexts but PRESERVE agents for continuous chat
1016
+ # Pass the final_status to cleanup to prevent overwriting
1017
+ await orchestrator.cleanup(
1018
+ preserve_workspace=True,
1019
+ preserve_agents=True, # Keep agents in registry for continuous chat
1020
+ status=final_status or 'completed', # Use determined status or default
1021
+ full_cleanup=False # Keep for continuous chat
1022
+ )
1023
+
1024
+ # Verify agent persistence
1025
+ agent_ids = list(orchestrator.agents.keys())
1026
+ if agent_ids:
1027
+ logger.info(
1028
+ f"Session {session_id} finished with status '{final_status}'. "
1029
+ f"Orchestrator kept alive with {len(agent_ids)} agent(s) for continuous chat"
1030
+ )
1031
+ else:
1032
+ logger.warning(
1033
+ f"Session {session_id} finished but no agents in registry. "
1034
+ f"Next message may trigger agent recreation."
1035
+ )
1036
+
1037
+ # Update activity time after completion
1038
+ self._session_last_activity[session_id] = time.time()
1039
+
1040
+ except Exception as e:
1041
+ logger.error(f"Error in cleanup for {session_id}: {e}")
1042
+
1043
+ async def _get_llm_for_request(self, request: Dict[str, Any]):
1044
+ """Get LLM using canonical LLMManager.get_chat_model() method.
1045
+
1046
+ Uses the existing LLMManager utility which handles:
1047
+ - Client lookup and initialization
1048
+ - Token limits from model_registry
1049
+ - Native chat model creation
1050
+
1051
+ If the requested provider is unavailable, automatically falls back
1052
+ to the next available provider in the fallback hierarchy.
1053
+
1054
+ Args:
1055
+ request: Request dictionary with provider, model, temperature
1056
+
1057
+ Returns:
1058
+ Native BaseChatModel instance
1059
+
1060
+ Raises:
1061
+ RuntimeError: If LLMManager not available or ALL providers failed
1062
+ """
1063
+ # Get LLM manager from container (single source of truth)
1064
+ llm_manager = self.container.get_service('llm')
1065
+ if not llm_manager:
1066
+ raise RuntimeError("LLMManager not available in container")
1067
+
1068
+ provider = request.get('provider', 'openai')
1069
+ model = request.get('model', 'gpt-5')
1070
+ temperature = request.get('temperature', 0.0)
1071
+
1072
+ # Try the requested provider first
1073
+ try:
1074
+ return await llm_manager.get_chat_model(
1075
+ provider=provider,
1076
+ model=model,
1077
+ temperature=temperature
1078
+ )
1079
+ except ValueError as e:
1080
+ # Provider not available - try fallback
1081
+ logger.warning(
1082
+ f"⚠️ Requested provider '{provider}' unavailable: {e}. "
1083
+ f"Attempting automatic fallback..."
1084
+ )
1085
+
1086
+ # Use the fallback method to get next available provider
1087
+ fallback_llm = await llm_manager.get_fallback_chat_model(
1088
+ exclude_providers=[provider],
1089
+ original_model=model,
1090
+ temperature=temperature
1091
+ )
1092
+
1093
+ if fallback_llm:
1094
+ fallback_model = getattr(fallback_llm, 'model_name', 'unknown')
1095
+ logger.info(f"✅ Using fallback LLM: {fallback_model}")
1096
+ return fallback_llm
1097
+ else:
1098
+ logger.error(f"❌ No fallback LLM available after {provider} failed")
1099
+ raise RuntimeError(
1100
+ f"LLM provider '{provider}' is not available and no fallback providers "
1101
+ f"could be initialized. Please check your API keys and configuration."
1102
+ )
1103
+ except Exception as e:
1104
+ logger.error(f"Failed to get LLM for request: {e}", exc_info=True)
1105
+ raise
1106
+
1107
+ async def get_session_status(
1108
+ self,
1109
+ user_id: str,
1110
+ session_id: Optional[str] = None
1111
+ ) -> Dict[str, Any]:
1112
+ """Get session status.
1113
+
1114
+ Args:
1115
+ user_id: User ID
1116
+ session_id: Optional specific session
1117
+
1118
+ Returns:
1119
+ Status dictionary
1120
+ """
1121
+ if not self.task_available:
1122
+ return {
1123
+ 'found': False,
1124
+ 'message': 'Task package not available'
1125
+ }
1126
+
1127
+ if session_id:
1128
+ session_info = self.session_manager.get_session_info(session_id)
1129
+ if session_info and session_info.get('user_id') == user_id:
1130
+ return {
1131
+ 'found': True,
1132
+ 'id': session_id,
1133
+ 'status': session_info.get('status'),
1134
+ 'task': session_info.get('task'),
1135
+ 'created_at': session_info.get('created_at')
1136
+ }
1137
+ else:
1138
+ # Get latest session
1139
+ sessions = self.session_manager.get_active_sessions(user_id)
1140
+ if sessions:
1141
+ return await self.get_session_status(user_id, sessions[-1])
1142
+
1143
+ return {
1144
+ 'found': False,
1145
+ 'message': 'No session found'
1146
+ }
1147
+
1148
+ async def cancel_session(
1149
+ self,
1150
+ user_id: str,
1151
+ force: bool = False,
1152
+ session_id: Optional[str] = None
1153
+ ) -> bool:
1154
+ """Cancel a session.
1155
+
1156
+ Args:
1157
+ user_id: User ID
1158
+ force: Force cancellation
1159
+ session_id: Session to cancel (or latest)
1160
+
1161
+ Returns:
1162
+ True if cancelled
1163
+ """
1164
+ if not self.task_available:
1165
+ return False
1166
+
1167
+ if not session_id:
1168
+ sessions = self.session_manager.get_active_sessions(user_id)
1169
+ if not sessions:
1170
+ return False
1171
+ session_id = sessions[-1]
1172
+
1173
+ # Verify ownership
1174
+ session_info = self.session_manager.get_session_info(session_id)
1175
+ if not session_info or session_info.get('user_id') != user_id:
1176
+ return False
1177
+
1178
+ # FIX: Actually stop the running session by calling orchestrator.cancel()
1179
+ # This sets the cancellation flag on all agents, causing them to stop at the next step
1180
+ orchestrator = self._registry.get(session_id)
1181
+ if orchestrator:
1182
+ try:
1183
+ orchestrator.cancel()
1184
+ logger.info(f"Called cancel() on orchestrator for session {session_id}")
1185
+ except Exception as e:
1186
+ logger.error(f"Error calling orchestrator.cancel(): {e}")
1187
+
1188
+ # F3: persist the terminal status unconditionally (idempotent;
1189
+ # CREATED→CANCELLED is a valid transition per session.py). orchestrator
1190
+ # .cancel() only flips an in-memory flag — it does NOT touch the
1191
+ # session's persisted status — so without this an interactively-cancelled
1192
+ # session would leak as 'created' on disk. Flipping it here removes the
1193
+ # fragile two-hop persistence (the previous else-branch only updated
1194
+ # status when the session was NOT running).
1195
+ self.session_manager.update_session_status(session_id, 'cancelled')
1196
+ logger.debug(f"Session {session_id} status set to cancelled")
1197
+
1198
+ if force:
1199
+ self.session_manager.cleanup_session(session_id, delete_files=False)
1200
+
1201
+ logger.info(f"Cancelled session {session_id}")
1202
+ return True
1203
+
1204
+ async def pause_session(self, user_id: str, session_id: str) -> bool:
1205
+ """Pause (suspend) a session — an honest status op, not a hard stop.
1206
+
1207
+ Only a RUNNING session can suspend (per session.py's transition table:
1208
+ RUNNING -> SUSPENDED). Returns True on a successful transition, False if the
1209
+ session is unknown, not owned by ``user_id``, or not in a suspendable state.
1210
+ Does NOT interrupt an in-flight step — use ``cancel_session`` for that.
1211
+ """
1212
+ if not self.task_available:
1213
+ return False
1214
+ info = self.session_manager.get_session_info(session_id)
1215
+ if not info or info.get('user_id') != user_id:
1216
+ return False
1217
+ return self.session_manager.try_transition_status(
1218
+ session_id, info.get('status'), 'suspended'
1219
+ )
1220
+
1221
+ async def resume_session(self, user_id: str, session_id: str) -> bool:
1222
+ """Mark a suspended/failed/completed session resumable (-> RESUMED).
1223
+
1224
+ Flips the persisted status so the normal resume path
1225
+ (try_transition_status RESUMED -> RUNNING inside the run loop) will pick it
1226
+ up; actually continuing execution still requires ``rob run``/attach. Returns
1227
+ False for an unknown/foreign session or one not in a resumable state.
1228
+ """
1229
+ if not self.task_available:
1230
+ return False
1231
+ info = self.session_manager.get_session_info(session_id)
1232
+ if not info or info.get('user_id') != user_id:
1233
+ return False
1234
+ cur = info.get('status')
1235
+ if cur not in ('suspended', 'failed', 'completed'):
1236
+ return False
1237
+ return self.session_manager.try_transition_status(session_id, cur, 'resumed')
1238
+
1239
+ async def process_user_message(
1240
+ self,
1241
+ user_id: str,
1242
+ input_text: str,
1243
+ metadata: Optional[Dict[str, Any]] = None
1244
+ ) -> str:
1245
+ """Process user input - supports continuous chat.
1246
+
1247
+ Flow:
1248
+ 1. Check for existing session (even if completed)
1249
+ 2. If exists: Queue message and resume that session
1250
+ 3. If not: Create new session
1251
+
1252
+ Args:
1253
+ user_id: User ID
1254
+ input_text: User's message
1255
+ metadata: Optional metadata
1256
+
1257
+ Returns:
1258
+ Response message
1259
+ """
1260
+ if not self._initialized:
1261
+ await self._initialize()
1262
+
1263
+ if not self.task_available:
1264
+ return "Task automation not available"
1265
+
1266
+ text = input_text.strip()
1267
+
1268
+ # Handle commands
1269
+ if text.startswith('/v2_status'):
1270
+ status = await self.get_session_status(user_id)
1271
+ if status['found']:
1272
+ return f"Session: {status['id']}\nStatus: {status['status']}\nTask: {status['task']}"
1273
+ return "No active session"
1274
+
1275
+ if text.startswith('/v2_cancel'):
1276
+ if await self.cancel_session(user_id, force=True):
1277
+ return "Session cancelled"
1278
+ return "No session to cancel"
1279
+
1280
+ if not text:
1281
+ return "Please provide a task description"
1282
+
1283
+ try:
1284
+ # CONTINUOUS CHAT: Check for existing session first
1285
+ # User might be continuing a conversation from seconds/hours/months ago
1286
+ existing_session_id = None
1287
+ # Set when an existing session could not be resumed, so the new-session
1288
+ # path can tell the user instead of silently pretending to continue (B2).
1289
+ resume_failed_id = None
1290
+
1291
+ # Try to get user's latest session (even if completed/suspended)
1292
+ if user_id in self.user_sessions:
1293
+ existing_session_id = self.user_sessions[user_id]
1294
+ else:
1295
+ # Check SessionManager for any sessions for this user
1296
+ all_sessions = self.session_manager._sessions
1297
+ user_session_ids = [sid for sid, info in all_sessions.items()
1298
+ if info.get('user_id') == user_id]
1299
+ if user_session_ids:
1300
+ # Get most recent
1301
+ existing_session_id = user_session_ids[-1]
1302
+
1303
+ # If existing session found, queue message and resume
1304
+ if existing_session_id:
1305
+ session_info = self.session_manager.get_session_info(existing_session_id)
1306
+ if session_info:
1307
+ logger.info(f"💬 Continuing session {existing_session_id} (status: {session_info.get('status')})")
1308
+
1309
+ # W1: a genuine user turn clears the self-wake re-entry budget so
1310
+ # the agent can again earn forged continuations on its own work.
1311
+ try:
1312
+ from agents.task.constants import AutonomyConfig
1313
+ if AutonomyConfig.self_wake_enabled():
1314
+ from agents.task.agent.core.self_wake import get_reentry_budget
1315
+ get_reentry_budget().reset(existing_session_id)
1316
+ except Exception:
1317
+ # Fail-open: reentry budget reset failure is non-critical
1318
+ pass
1319
+
1320
+ # Get or recreate orchestrator (shared per-session lock, a2)
1321
+ orchestrator = await self._resolve_or_recreate(existing_session_id, session_info)
1322
+
1323
+ if orchestrator:
1324
+ # Queue message to agent via orchestrator
1325
+ try:
1326
+ await orchestrator.submit_user_message(
1327
+ agent_id=None, # Routes to first agent
1328
+ text=text,
1329
+ kind="continuation",
1330
+ metadata=metadata or {}
1331
+ )
1332
+ logger.info(f"✅ Queued message to session {existing_session_id}")
1333
+ except Exception as e:
1334
+ logger.error(f"Failed to queue message: {e}")
1335
+
1336
+ # Resume session execution
1337
+ asyncio.create_task(self.run_session(user_id, existing_session_id))
1338
+
1339
+ return f"💬 Message sent to existing session\nSession: {existing_session_id}"
1340
+ else:
1341
+ # Could not resume (orchestrator missing AND recreation failed).
1342
+ # Fresh-start-with-notice: rather than silently spawn a duplicate,
1343
+ # retire the un-recreatable session from in-memory tracking so it
1344
+ # stops leaking the per-user limit and isn't re-selected next
1345
+ # message — history stays on disk (delete_files=False, recoverable
1346
+ # by id) — then fall through to create a new session and tell the
1347
+ # user what happened.
1348
+ logger.warning(
1349
+ f"Could not resume session {existing_session_id} "
1350
+ f"(orchestrator recreation failed); starting a new session"
1351
+ )
1352
+ resume_failed_id = existing_session_id
1353
+ self.session_manager.cleanup_session(existing_session_id, delete_files=False)
1354
+ self._session_last_activity.pop(existing_session_id, None)
1355
+ self._session_execution_locks.pop(existing_session_id, None)
1356
+
1357
+ # No existing session or failed to resume - create new session
1358
+ logger.info(f"🆕 Creating new session for user {user_id}")
1359
+ session = await self.create_session(user_id, text)
1360
+
1361
+ # Run in background
1362
+ asyncio.create_task(self.run_session(user_id, session['id']))
1363
+
1364
+ if resume_failed_id:
1365
+ return (
1366
+ f"⚠️ Couldn't resume your previous session ({resume_failed_id[:8]}…), "
1367
+ f"so I started a new one.\nSession: {session['id']}"
1368
+ )
1369
+ return f"🤖 Task started: {text[:100]}...\nSession: {session['id']}"
1370
+
1371
+ except Exception as e:
1372
+ logger.error(f"Failed to process message: {e}")
1373
+ return f"Error: {str(e)}"
1374
+
1375
+ # ------------------------------------------------------------------
1376
+ # S2 (chat consolidation): synchronous chat adapter
1377
+ # ------------------------------------------------------------------
1378
+ @staticmethod
1379
+ def _chat_key(user_id: str, chat_id: Optional[str]) -> str:
1380
+ """Durable conversation key. Falls back to user_id when no chat_id is
1381
+ given, mirroring the legacy ChatAgent's one-thread-per-user behavior."""
1382
+ return f"chat:{user_id}:{chat_id or user_id}"
1383
+
1384
+ def _chat_tool_ids(self) -> List[str]:
1385
+ from agents.task.constants import CHAT_TOOL_IDS
1386
+ return list(CHAT_TOOL_IDS)
1387
+
1388
+ async def _resolve_chat_persona(self) -> str:
1389
+ """Resolve the persona via the surface-shared resolver (T1-07): explicit
1390
+ POLYROB_PERSONA (template key or literal) > default character > "".
1391
+ Fail-open to "" (off-path byte-identical)."""
1392
+ try:
1393
+ from agents.personality.persona_resolver import resolve_persona
1394
+ return await resolve_persona(container=self.container)
1395
+ except Exception as e:
1396
+ logger.debug(f"chat persona resolve skipped: {e}")
1397
+ return ""
1398
+
1399
+ @staticmethod
1400
+ def _looks_like_brain_state(text) -> bool:
1401
+ """True when *text* is agent brain-state telemetry, not a chat reply.
1402
+
1403
+ Mirrors cli.ui.dialog.is_brain_state WITHOUT importing the CLI layer
1404
+ (agents must not depend on cli). Recognises the JSON shapes
1405
+ ({"current_state": {...}} and >=2 bare brain keys, tolerating a leading
1406
+ ```json fence) and the Memory:/Next: text echo. Live-caught on GLM, whose
1407
+ raw brain-JSON content can be the last AIMessage in history.
1408
+ """
1409
+ if not text:
1410
+ return False
1411
+ t = str(text).strip()
1412
+ # strip a single enclosing markdown code fence (DeepSeek JSON-fallback path)
1413
+ if t.startswith("```") or t.startswith("~~~"):
1414
+ m = re.match(r"^\s*(?:```|~~~)[^\n]*\n(.*?)\n?(?:```|~~~)\s*$", t, re.DOTALL)
1415
+ if m:
1416
+ t = m.group(1).strip()
1417
+ _brain_keys = {"current_state", "next_goal", "evaluation_previous_goal",
1418
+ "page_summary", "memory", "reasoning", "macro_goal", "subgoal"}
1419
+ if t.startswith("{"):
1420
+ try:
1421
+ obj, _end = json.JSONDecoder().raw_decode(t)
1422
+ except (ValueError, TypeError):
1423
+ obj = None
1424
+ if isinstance(obj, dict):
1425
+ if isinstance(obj.get("current_state"), dict):
1426
+ return True
1427
+ if sum(1 for k in obj if k in _brain_keys) >= 2:
1428
+ return True
1429
+ # Memory:/Next: echo form (require both so prose with a stray header isn't caught)
1430
+ has_mem = re.search(r"(?im)^\s*memory\s*:", t)
1431
+ has_next = re.search(r"(?im)^\s*next(?:[_ ]?goal)?\s*:", t)
1432
+ return bool(has_mem and has_next)
1433
+
1434
+ def _extract_chat_reply(self, session_id: str) -> str:
1435
+ """Return the agent's ACTUAL last assistant reply for a finished turn.
1436
+
1437
+ Priority (corrected after a live GLM brain-JSON leak):
1438
+ 1. The clean done() output — history.final_result() when is_done() — since
1439
+ done() adds its clean message to history BEFORE the atomic add of the
1440
+ model's raw content (which may be brain-state JSON), so "last AIMessage"
1441
+ alone would capture telemetry.
1442
+ 2. The conversational/send path: the last NON-brain AIMessage (the real
1443
+ send_message text; ActionResult.extracted_content is only a placeholder).
1444
+ 3. final_result() as a last resort.
1445
+ Brain-state telemetry is never returned; the generic run_session string never is.
1446
+ """
1447
+ try:
1448
+ orch = self._registry.get(session_id)
1449
+ if not orch or not getattr(orch, 'agents', None):
1450
+ return ""
1451
+ agent = next(iter(orch.agents.values()), None)
1452
+ if agent is None:
1453
+ return ""
1454
+
1455
+ # 1) clean done() output
1456
+ try:
1457
+ hist = agent.state.history
1458
+ if hist.is_done():
1459
+ fr = hist.final_result()
1460
+ if fr and not self._looks_like_brain_state(fr):
1461
+ return str(fr).strip()
1462
+ except Exception:
1463
+ # Fail-open: done() output extraction failure is non-critical
1464
+ pass
1465
+
1466
+ # 2) last non-brain AIMessage (conversational/send path)
1467
+ mm = getattr(agent, 'message_manager', None)
1468
+ if mm is not None:
1469
+ try:
1470
+ from modules.llm.messages import AIMessage
1471
+ for managed in reversed(list(mm.history.messages)):
1472
+ msg = getattr(managed, 'message', None)
1473
+ content = getattr(msg, 'content', None)
1474
+ if isinstance(msg, AIMessage) and isinstance(content, str) and content.strip():
1475
+ text = content.strip()
1476
+ prefix = "✅ Task Complete\n\n"
1477
+ if text.startswith(prefix):
1478
+ text = text[len(prefix):].strip()
1479
+ if text and not self._looks_like_brain_state(text):
1480
+ return text
1481
+ except Exception as e:
1482
+ logger.debug(f"chat reply scan failed: {e}")
1483
+
1484
+ # 3) last-resort fallback
1485
+ try:
1486
+ fr = agent.state.history.final_result()
1487
+ if fr and not self._looks_like_brain_state(fr):
1488
+ return str(fr).strip()
1489
+ except Exception:
1490
+ # Fail-open: history scan failure is non-critical, try next fallback
1491
+ pass
1492
+ except Exception as e:
1493
+ logger.debug(f"_extract_chat_reply failed: {e}")
1494
+ return ""
1495
+
1496
+ async def chat_once(
1497
+ self,
1498
+ user_id: str,
1499
+ text: str,
1500
+ chat_id: Optional[str] = None,
1501
+ provider: Optional[str] = None,
1502
+ model: Optional[str] = None,
1503
+ ) -> str:
1504
+ """Run ONE synchronous chat turn on the unified task agent and return the
1505
+ assistant's reply text.
1506
+
1507
+ This is the synchronous counterpart to the fire-and-forget
1508
+ process_user_message: it awaits run_session and returns the real reply
1509
+ (not the "Task started…" ack, not run_session's generic completion
1510
+ string). It reuses a durable session keyed by (user_id, chat_id) so a
1511
+ follow-up continues the same conversation, and runs tool-light.
1512
+
1513
+ `provider`/`model` (B3) are an optional PER-REQUEST model override —
1514
+ e.g. from the OpenAI-compat `/v1/chat/completions` `body.model` field
1515
+ via `map_model()`. When set, they steer this turn's model (and, since
1516
+ chat sessions are durable, every subsequent turn of this (user_id,
1517
+ chat_id) session until overridden again). See `_chat_once_locked` for
1518
+ how the override is applied (live swap_model for a reused session;
1519
+ baked into the SessionRequest for a brand-new one).
1520
+ """
1521
+ if not self._initialized:
1522
+ await self._initialize()
1523
+ if not self.task_available:
1524
+ return "Task automation not available"
1525
+
1526
+ text = (text or "").strip()
1527
+ if not text:
1528
+ return "Please provide a message"
1529
+
1530
+ key = self._chat_key(user_id, chat_id)
1531
+ locks = getattr(self, "_chat_locks", None)
1532
+ if locks is None:
1533
+ locks = {}
1534
+ self._chat_locks = locks
1535
+ lock = locks.get(key) or locks.setdefault(key, asyncio.Lock())
1536
+ try:
1537
+ async with lock:
1538
+ result = await self._chat_once_locked(
1539
+ user_id, text, key, provider=provider, model=model,
1540
+ )
1541
+ finally:
1542
+ # Evict the lock only if this turn left no active session mapping
1543
+ # for `key` (e.g. session creation raised or failed) -- a
1544
+ # successful turn always re-populates self._chat_sessions[key], so
1545
+ # this is a no-op on the common path. Safe without extra locking:
1546
+ # no `await` sits between the membership check and the pop, so
1547
+ # asyncio cannot interleave another task's get-or-create for the
1548
+ # same key in between (task switches only happen at await points).
1549
+ if key not in self._chat_sessions:
1550
+ locks.pop(key, None)
1551
+ return result
1552
+
1553
+ async def _maybe_swap_chat_model(
1554
+ self, orch, provider: Optional[str], model: str,
1555
+ ) -> None:
1556
+ """Apply a B3 per-request model override to a REUSED session's live
1557
+ agent, idempotently.
1558
+
1559
+ Uses the same accessor `_extract_chat_reply` uses (`orch.agents` is a
1560
+ dict; chat sessions run a single agent, so the first value is it). A
1561
+ no-op when no live agent exists yet (e.g. mid-creation) or when the
1562
+ requested (provider, model) already matches the agent's current
1563
+ SSOT (`model_name`/`llm_provider`) — a falsy `provider` never counts
1564
+ as "different", it means swap_model should auto-detect. This guard
1565
+ is what stops an unchanged `body.model` from rebuilding the LLM on
1566
+ every single request. A failed swap is logged and the turn proceeds
1567
+ on the agent's current (unchanged) model.
1568
+ """
1569
+ agent = next(iter(orch.agents.values()), None) if getattr(orch, "agents", None) else None
1570
+ if agent is None or not hasattr(agent, "swap_model"):
1571
+ return
1572
+ same_model = getattr(agent, "model_name", None) == model
1573
+ # A never-swapped agent never has `.llm_provider` set to the request's
1574
+ # provider label the way swap_model would — it may be None (older builds)
1575
+ # or only the Agent-level mirror. Fall back to the MessageManager-backed
1576
+ # `provider_name` SSOT so an unchanged model doesn't rebuild the LLM on
1577
+ # every request (B3 idempotence). A falsy request provider never counts
1578
+ # as "different" (auto-detect).
1579
+ current_provider = (
1580
+ getattr(agent, "llm_provider", None) or getattr(agent, "provider_name", None)
1581
+ )
1582
+ same_provider = (not provider) or current_provider == provider
1583
+ if same_model and same_provider:
1584
+ return
1585
+ # A failed swap must warn + proceed on the current model — an exception
1586
+ # from swap_model (e.g. a provider build blowing up) must never 500 the
1587
+ # /v1 request (the plan's contract: failed swap = warn + proceed).
1588
+ try:
1589
+ res = await agent.swap_model(provider, model)
1590
+ except Exception as e:
1591
+ logger.warning(
1592
+ f"per-request model swap raised: {e} — "
1593
+ f"turn continues on {getattr(agent, 'model_name', '?')}"
1594
+ )
1595
+ return
1596
+ if not res.get("ok"):
1597
+ logger.warning(
1598
+ f"per-request model swap failed: {res.get('error')} — "
1599
+ f"turn continues on {getattr(agent, 'model_name', '?')}"
1600
+ )
1601
+
1602
+ async def _chat_once_locked(
1603
+ self,
1604
+ user_id: str,
1605
+ text: str,
1606
+ key: str,
1607
+ provider: Optional[str] = None,
1608
+ model: Optional[str] = None,
1609
+ ) -> str:
1610
+ """The body of chat_once, run under the per-chat-key lock (see chat_once)."""
1611
+ session_id = self._chat_sessions.get(key)
1612
+
1613
+ # Validate a remembered session still exists; else drop and recreate.
1614
+ if session_id and not self.session_manager.get_session_info(session_id):
1615
+ self._chat_sessions.pop(key, None)
1616
+ session_id = None
1617
+
1618
+ # C1: expand @file/@folder/@diff/@url references (opt-in, fail-soft).
1619
+ # Use the session workspace when available; fall back to CWD for new sessions.
1620
+ try:
1621
+ from agents.task.constants import AutonomyConfig
1622
+ if AutonomyConfig.context_references_enabled():
1623
+ from agents.task.agent.messages.context_references import (
1624
+ preprocess_context_references,
1625
+ )
1626
+ from agents.task.path import pm
1627
+ import os as _os
1628
+ _root = str(pm().get_workspace_dir(session_id, user_id)) if session_id else _os.getcwd()
1629
+ text = preprocess_context_references(
1630
+ text, root=_root, confine_to_root=True, allow_filesystem=True
1631
+ )
1632
+ except Exception:
1633
+ pass # fail-soft: leave text unchanged
1634
+
1635
+ persona = await self._resolve_chat_persona()
1636
+
1637
+ if session_id:
1638
+ # Reuse: queue a continuation and resume (await, unlike the
1639
+ # background process_user_message path).
1640
+ orch = self._registry.get(session_id)
1641
+ if not orch:
1642
+ info = self.session_manager.get_session_info(session_id)
1643
+ orch = await self._resolve_or_recreate(session_id, info) if info else None
1644
+ if orch:
1645
+ if persona:
1646
+ orch._persona_block = persona
1647
+ if model:
1648
+ await self._maybe_swap_chat_model(orch, provider, model)
1649
+ try:
1650
+ await orch.submit_user_message(
1651
+ agent_id=None, text=text, kind="continuation",
1652
+ )
1653
+ except Exception as e:
1654
+ logger.warning(f"chat_once: failed to queue continuation: {e}")
1655
+ else:
1656
+ # Un-recreatable — drop the mapping and fall through to create.
1657
+ self._chat_sessions.pop(key, None)
1658
+ session_id = None
1659
+
1660
+ if not session_id:
1661
+ from agents.task.constants import CHAT_MAX_STEPS
1662
+ # Provider/model via the one shared resolver (Seam 2): CHAT_/DEFAULT_ env
1663
+ # pins win; else the historical openai/gpt-5 default if OpenAI is keyed;
1664
+ # else the first keyed provider (fixes only-one-key chat). Model always
1665
+ # matches the resolved provider.
1666
+ _provider, _model = _resolve_chat_runtime()
1667
+ # B3: a per-request model override for a BRAND-NEW session has no
1668
+ # live agent yet to swap_model() (that's created later, inside
1669
+ # run_session) — bake it into the SessionRequest instead, the
1670
+ # same knob CHAT_PROVIDER/CHAT_MODEL already use above.
1671
+ if model:
1672
+ _model = model
1673
+ _provider = provider or _provider
1674
+ req = SessionRequest(
1675
+ task=text,
1676
+ tools=self._chat_tool_ids(),
1677
+ max_steps=CHAT_MAX_STEPS,
1678
+ use_vision=False,
1679
+ provider=_provider,
1680
+ model=_model,
1681
+ )
1682
+ from agents.task.constants import CHAT_SKIP_CREDIT_CHECK
1683
+ info = await self.create_session(
1684
+ user_id, req, chat_session_key=key,
1685
+ skip_credit_check=CHAT_SKIP_CREDIT_CHECK,
1686
+ )
1687
+ session_id = info['id']
1688
+ self._chat_sessions[key] = session_id
1689
+ orch = self._registry.get(session_id)
1690
+ if orch and persona:
1691
+ orch._persona_block = persona
1692
+
1693
+ await self.run_session(user_id, session_id)
1694
+ return self._extract_chat_reply(session_id) or ""
1695
+
1696
+ async def deliver_self_wake(
1697
+ self,
1698
+ session_id: str,
1699
+ user_id: str,
1700
+ text: str,
1701
+ metadata: Optional[Dict[str, Any]] = None,
1702
+ ) -> bool:
1703
+ """Forge a fresh internal turn for ``session_id`` (W1 self-wake rail).
1704
+
1705
+ The public producer seam for non-user re-entry: a finished goal run, cron job,
1706
+ or future background producer calls this to re-enter a session that has gone
1707
+ idle (its loop ended on done()/conversational-exit). Reuses UP-12's proven
1708
+ ingress — ``submit_user_message`` → HITL queue → run-loop drain — rather than a
1709
+ parallel queue, then kicks the loop via ``run_session`` (the forge-fresh-run
1710
+ UP-12 lacks: it only re-enters an already-running loop).
1711
+
1712
+ Safety (all enforced here):
1713
+ * gated ``SELF_WAKE_ENABLED`` (default OFF → returns False, no-op);
1714
+ * ``ReentryBudget`` depth + idle-backoff cap (prevents ping-pong storms);
1715
+ * resident-or-recreatable only (in-process; never claims a remote session —
1716
+ an unrecoverable session is dropped + audit-logged, the honest behaviour);
1717
+ * forged text framed as UP-06 untrusted DATA + tagged ``kind="self_wake"``.
1718
+
1719
+ Returns True iff a forged turn was dispatched.
1720
+ """
1721
+ from agents.task.constants import AutonomyConfig
1722
+ if not AutonomyConfig.self_wake_enabled():
1723
+ return False
1724
+
1725
+ from agents.task.agent.core.self_wake import (
1726
+ get_reentry_budget, format_self_wake, SELF_WAKE_KIND,
1727
+ )
1728
+ def _wake_ev(outcome: str, reason: Optional[str] = None) -> None:
1729
+ # Self-wake was entirely invisible (no log/telemetry): fired vs
1730
+ # skipped vs dropped collapsed to a silent return. Emit each outcome
1731
+ # to the durable event log (fail-open).
1732
+ try:
1733
+ from agents.task.telemetry.event_log import get_event_log, event_log_enabled
1734
+ if event_log_enabled():
1735
+ get_event_log().record(
1736
+ "self_wake", user_id=user_id, session_id=session_id,
1737
+ source="self_wake", outcome=outcome, reason=reason)
1738
+ except Exception:
1739
+ pass
1740
+
1741
+ budget = get_reentry_budget()
1742
+ # Atomically consume the slot up-front (see ReentryBudget.try_consume) so two
1743
+ # concurrent producers can't both pass the check and exceed the cap. A slot
1744
+ # consumed on a subsequently-failed dispatch errs toward FEWER wakes — the
1745
+ # safe direction for a runaway guard.
1746
+ if not budget.try_consume(session_id):
1747
+ logger.info(f"🛌 self-wake budget exhausted for session {session_id} — dropping")
1748
+ _wake_ev("skipped", "budget_exhausted")
1749
+ return False
1750
+
1751
+ try:
1752
+ session_info = self.session_manager.get_session_info(session_id)
1753
+ if not session_info:
1754
+ logger.warning(f"self-wake: session {session_id} not found — dropping (audit)")
1755
+ _wake_ev("dropped", "session_not_found")
1756
+ return False
1757
+
1758
+ orchestrator = await self._resolve_or_recreate(session_id, session_info)
1759
+ if not orchestrator:
1760
+ logger.warning(
1761
+ f"self-wake: session {session_id} not resident and not recreatable "
1762
+ f"— dropping (cross-worker/expired reality, audit)"
1763
+ )
1764
+ _wake_ev("dropped", "not_resident")
1765
+ return False
1766
+
1767
+ meta = dict(metadata or {})
1768
+ meta.setdefault("source", "self_wake")
1769
+ await orchestrator.submit_user_message(
1770
+ agent_id=None,
1771
+ text=format_self_wake(text),
1772
+ kind=SELF_WAKE_KIND,
1773
+ metadata=meta,
1774
+ )
1775
+ # NOTE: the budget slot was already consumed atomically by try_consume()
1776
+ # above — do not record() again here (that would double-count).
1777
+ # AU-F3.1: hold a strong ref (asyncio.create_task alone only weakly
1778
+ # references the task — see _SELF_WAKE_TASKS docstring above).
1779
+ t = asyncio.create_task(self.run_session(user_id, session_id))
1780
+ _SELF_WAKE_TASKS.add(t)
1781
+ t.add_done_callback(_SELF_WAKE_TASKS.discard)
1782
+ logger.info(f"🛎️ self-wake dispatched to session {session_id} "
1783
+ f"(remaining budget {budget.remaining(session_id)})")
1784
+ _wake_ev("fired", None)
1785
+ return True
1786
+ except Exception as e:
1787
+ logger.error(f"self-wake delivery failed for {session_id}: {e}", exc_info=True)
1788
+ _wake_ev("error", str(e)[:200])
1789
+ return False
1790
+
1791
+ async def deliver_correspondent_data(
1792
+ self,
1793
+ session_id: str,
1794
+ source: str,
1795
+ text: str,
1796
+ metadata: Optional[Dict[str, Any]] = None,
1797
+ ) -> bool:
1798
+ """WS-A: deliver a third-party correspondent reply as DATA into ``session_id``.
1799
+
1800
+ The reply is injected as a CORRESPONDENT-origin control message (untrusted-
1801
+ wrapped) into the session that INITIATED contact — never a steering user turn,
1802
+ never a new session. Resident-or-recreatable only: an unrecoverable session is
1803
+ dropped + audit-logged (a third party can't resurrect a dead session). The
1804
+ owner ``user_id`` for the re-run comes from the session's OWN metadata, never
1805
+ the correspondent's identity (tenant safety). Gated ``CORRESPONDENT_ACCESS_ENABLED``
1806
+ (default OFF → no-op). Returns True iff the data was delivered.
1807
+ """
1808
+ from agents.task.surface_config import SurfaceConfig
1809
+ if not SurfaceConfig.correspondent_access_enabled():
1810
+ return False
1811
+ try:
1812
+ session_info = self.session_manager.get_session_info(session_id)
1813
+ if not session_info:
1814
+ logger.warning(
1815
+ f"correspondent delivery: session {session_id} not found — dropping (audit)")
1816
+ return False
1817
+ orchestrator = await self._resolve_or_recreate(session_id, session_info)
1818
+ if not orchestrator:
1819
+ logger.warning(
1820
+ f"correspondent delivery: session {session_id} not resident/recreatable "
1821
+ f"— dropping (audit)")
1822
+ return False
1823
+ delivered = orchestrator.inject_correspondent_message(text, source, metadata)
1824
+ if not delivered:
1825
+ logger.warning(
1826
+ f"correspondent delivery: no resident agent for {session_id} — dropping (audit)")
1827
+ return False
1828
+ owner_user_id = (session_info.get("user_id")
1829
+ if isinstance(session_info, dict)
1830
+ else getattr(session_info, "user_id", None))
1831
+ asyncio.create_task(self.run_session(owner_user_id, session_id))
1832
+ logger.info(f"📨 correspondent DATA from {source} delivered to session {session_id}")
1833
+ return True
1834
+ except Exception as e:
1835
+ logger.error(f"correspondent delivery failed for {session_id}: {e}", exc_info=True)
1836
+ return False
1837
+
1838
+ async def _recreate_orchestrator(
1839
+ self,
1840
+ session_id: str,
1841
+ session_info: Dict[str, Any]
1842
+ ) -> Optional[Any]:
1843
+ """Recreate orchestrator from saved session state (for suspended sessions).
1844
+
1845
+ Args:
1846
+ session_id: Session identifier
1847
+ session_info: Session metadata from SessionManager
1848
+
1849
+ Returns:
1850
+ Recreated orchestrator or None if recreation failed
1851
+ """
1852
+ try:
1853
+ from agents.task.agent.orchestrator import SessionOrchestrator
1854
+
1855
+ # Get session request metadata - check multiple sources
1856
+ request = session_info.get('request', {})
1857
+ config = session_info.get('config', {})
1858
+
1859
+ # Log what we found for debugging
1860
+ if not request and not config:
1861
+ logger.error(f"No request or config metadata found for session {session_id}")
1862
+ logger.debug(f"Session info keys: {list(session_info.keys())}")
1863
+ return None
1864
+
1865
+ user_id = session_info.get('user_id')
1866
+ if not user_id:
1867
+ logger.error(f"No user_id found for session {session_id}")
1868
+ return None
1869
+
1870
+ # Webview streaming is optional in core mode (see create_session).
1871
+ stream_callback = None
1872
+ try:
1873
+ from agents.task.utils_webview import make_webview_stream_callback
1874
+ stream_callback = make_webview_stream_callback()
1875
+ except Exception as e:
1876
+ logger.debug(f"Webview streaming unavailable on recreate: {e}")
1877
+
1878
+ # Recreate orchestrator
1879
+ orchestrator = SessionOrchestrator(
1880
+ session_id=session_id,
1881
+ user_id=user_id,
1882
+ container=self.container,
1883
+ on_stream_chunk=stream_callback,
1884
+ )
1885
+
1886
+ # #0 mute-on-resume: re-attach the outbound chat surface BEFORE initialize()
1887
+ # so a resumed chat's replies route back out (recreation otherwise leaves
1888
+ # _message_router/_chat_session_key unset → the agent answers into the void).
1889
+ self._rebind_recreated_chat(orchestrator, session_id, user_id)
1890
+
1891
+ # Initialize with same tools - check multiple sources for tools
1892
+ # Priority: request.tools > config.tools > session_info.tools > defaults
1893
+ tool_ids = (
1894
+ request.get('tools') or
1895
+ config.get('tools') or
1896
+ session_info.get('tools') or
1897
+ ['browser', 'filesystem', 'task']
1898
+ )
1899
+
1900
+ # Get tools_config from multiple sources
1901
+ tools_config = (
1902
+ (request.get('session_config') or {}).get('tools_config') or
1903
+ config.get('tools_config') or
1904
+ {}
1905
+ )
1906
+
1907
+ logger.info(f"Recreating orchestrator with tools: {tool_ids}")
1908
+
1909
+ await orchestrator.initialize(
1910
+ tool_ids=tool_ids,
1911
+ tools_config=tools_config
1912
+ )
1913
+
1914
+ # Recreate agent - use both request and config for fallbacks
1915
+ # Merge request and config for _get_llm_for_request
1916
+ llm_request = {**config, **request} # request takes priority
1917
+ llm = await self._get_llm_for_request(llm_request)
1918
+ agent = await orchestrator.create_agent(
1919
+ task=request.get('task') or session_info.get('task', ''),
1920
+ llm=llm,
1921
+ agent_name="executor",
1922
+ use_vision=request.get('use_vision', config.get('use_vision', True)),
1923
+ max_actions_per_step=10,
1924
+ session_config=request.get('session_config') or config
1925
+ )
1926
+
1927
+ # RESTORE MESSAGE HISTORY (FIX #4)
1928
+ if hasattr(agent, 'message_manager') and agent.message_manager:
1929
+ try:
1930
+ loaded = agent.message_manager.load_from_disk(
1931
+ session_id=session_id,
1932
+ user_id=user_id
1933
+ )
1934
+ if loaded:
1935
+ logger.info(f"📂 Restored message history for session {session_id}")
1936
+ else:
1937
+ logger.info(f"No message history to restore for session {session_id}")
1938
+ except Exception as e:
1939
+ logger.error(f"Failed to restore message history: {e}")
1940
+
1941
+ # RESTORE HITL STATE (queued messages from before eviction)
1942
+ if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
1943
+ try:
1944
+ from agents.task.path import pm
1945
+ import json
1946
+ hitl_path = pm().create_file_path(
1947
+ session_id=session_id,
1948
+ subdir_name="memory",
1949
+ filename="hitl_state.json",
1950
+ user_id=user_id
1951
+ )
1952
+ if hitl_path.exists():
1953
+ with open(hitl_path, 'r') as f:
1954
+ hitl_state = json.load(f)
1955
+ agent.hitl_manager.restore_state(hitl_state)
1956
+ queued_count = len(hitl_state.get('queued_messages', []))
1957
+ logger.info(f"📂 Restored HITL state for session {session_id} ({queued_count} queued messages)")
1958
+
1959
+ # Remove the file after restoration to avoid re-restoring stale state
1960
+ hitl_path.unlink()
1961
+ except Exception as e:
1962
+ logger.warning(f"Could not restore HITL state: {e}")
1963
+
1964
+ # PRE-LOAD hierarchical memory to validate session can be fully restored
1965
+ # Note: The actual loading happens in Agent.run(), but we validate here
1966
+ if hasattr(agent, 'task_context_manager') and agent.task_context_manager:
1967
+ try:
1968
+ # Try to load the H-MEM session to verify it exists
1969
+ memory = agent.task_context_manager.load_session(session_id, user_id)
1970
+ if memory:
1971
+ logger.info(f"📂 Validated hierarchical memory exists for session {session_id}")
1972
+ else:
1973
+ logger.info(f"No existing H-MEM for session {session_id} (will be created on run)")
1974
+ except Exception as e:
1975
+ logger.warning(f"Could not pre-load H-MEM for session {session_id}: {e}")
1976
+
1977
+ # Store orchestrator
1978
+ self.register_orchestrator(session_id, orchestrator)
1979
+ self._session_last_activity[session_id] = time.time()
1980
+
1981
+ logger.info(f"Successfully recreated orchestrator for suspended session {session_id}")
1982
+ return orchestrator
1983
+
1984
+ except Exception as e:
1985
+ logger.error(f"Failed to recreate orchestrator for {session_id}: {e}", exc_info=True)
1986
+ return None
1987
+
1988
+ async def aclose(self) -> None:
1989
+ """Cancel the periodic background loops. Idempotent and fail-open.
1990
+
1991
+ Safe to call when the loops were never started (the ImportError path sets
1992
+ task_available=False and skips creation, leaving _bg_tasks empty).
1993
+ """
1994
+ tasks = getattr(self, "_bg_tasks", None) or []
1995
+ for t in tasks:
1996
+ try:
1997
+ t.cancel()
1998
+ except Exception:
1999
+ pass
2000
+ for t in tasks:
2001
+ try:
2002
+ await t
2003
+ except (asyncio.CancelledError, Exception):
2004
+ pass
2005
+ self._bg_tasks = []
2006
+
2007
+ async def _periodic_cleanup(self):
2008
+ """Periodically clean up old sessions (TTL and LRU eviction)."""
2009
+ while True:
2010
+ try:
2011
+ await asyncio.sleep(self.cleanup_interval)
2012
+ await self._cleanup_old_sessions()
2013
+ except Exception as e:
2014
+ logger.error(f"Error in periodic cleanup: {e}")
2015
+
2016
+ async def _periodic_workspace_cleanup(self):
2017
+ """Periodically clean up old session workspaces."""
2018
+ while True:
2019
+ try:
2020
+ # Run cleanup daily
2021
+ await asyncio.sleep(86400) # 24 hours
2022
+
2023
+ if self.session_manager:
2024
+ cleaned = self.session_manager.cleanup_old_workspaces(max_age_days=7)
2025
+ logger.info(f"Workspace cleanup: removed {cleaned} old workspaces")
2026
+
2027
+ except Exception as e:
2028
+ logger.error(f"Error in workspace cleanup: {e}")
2029
+
2030
+ @staticmethod
2031
+ def _session_age_seconds(created_at, now: Optional[datetime] = None) -> Optional[float]:
2032
+ """Age in seconds from an ISO 'created_at' string, or None if unparseable."""
2033
+ if not created_at:
2034
+ return None
2035
+ try:
2036
+ ts = datetime.fromisoformat(created_at)
2037
+ except (ValueError, TypeError):
2038
+ return None
2039
+ return ((now or datetime.now()) - ts).total_seconds()
2040
+
2041
+ async def _cleanup_stale_created_sessions(self) -> int:
2042
+ """Retire sessions stuck in 'created' that never ran.
2043
+
2044
+ A 'created' session counts toward the per-user limit (get_active_sessions), but
2045
+ the TTL/LRU GC keys off _session_last_activity, which is only written on the run
2046
+ path. So a created-but-never-run session is invisible to that GC and would consume
2047
+ the per-user slot forever, eventually blocking all new runs. This sweep is keyed
2048
+ off 'created_at' (always set at create time) and deletes both the in-memory entry
2049
+ and the on-disk metadata.
2050
+
2051
+ Returns the number of sessions retired.
2052
+ """
2053
+ if not self.session_manager:
2054
+ return 0
2055
+
2056
+ now = datetime.now()
2057
+ ttl = self.created_session_ttl_seconds
2058
+ stale = []
2059
+ # get_active_sessions() (no user filter) reads in-memory _sessions only — exactly
2060
+ # the entries that count toward the per-user limit. Disk-only sessions are excluded.
2061
+ for session_id in self.session_manager.get_active_sessions():
2062
+ info = self.session_manager.get_session_info(session_id) or {}
2063
+ if info.get('status') != 'created':
2064
+ continue
2065
+ # A run-path activity timestamp means this is a normal in-flight session.
2066
+ if session_id in self._session_last_activity:
2067
+ continue
2068
+ age = self._session_age_seconds(info.get('created_at'), now)
2069
+ if age is None or age <= ttl:
2070
+ continue
2071
+ stale.append(session_id)
2072
+
2073
+ for session_id in stale:
2074
+ logger.info(
2075
+ f"GC: retiring stale 'created' session {session_id} "
2076
+ f"(age > {ttl}s, never ran)"
2077
+ )
2078
+ self._registry.remove(session_id) # no-op if never registered
2079
+ self._session_execution_locks.pop(session_id, None)
2080
+ self.session_manager.cleanup_session(session_id, delete_files=True)
2081
+
2082
+ return len(stale)
2083
+
2084
+ async def _cleanup_old_sessions(self):
2085
+ """Remove sessions that haven't been active recently."""
2086
+ # First retire never-run 'created' sessions that the activity-keyed GC below
2087
+ # can never see (they have no _session_last_activity entry).
2088
+ await self._cleanup_stale_created_sessions()
2089
+
2090
+ now = time.time()
2091
+ ttl_threshold = now - self.session_ttl_seconds
2092
+
2093
+ sessions_to_evict = []
2094
+
2095
+ # Find sessions past TTL
2096
+ for session_id, last_activity in list(self._session_last_activity.items()):
2097
+ if last_activity < ttl_threshold:
2098
+ sessions_to_evict.append((session_id, 'ttl'))
2099
+
2100
+ # Evict by TTL
2101
+ for session_id, reason in sessions_to_evict:
2102
+ logger.info(f"Evicting session {session_id} (TTL {self.session_ttl_seconds}s exceeded)")
2103
+ await self._evict_session(session_id, reason='ttl')
2104
+
2105
+ # Also enforce max sessions limit (LRU eviction)
2106
+ if self._registry.count() > self.max_sessions_in_memory:
2107
+ # Sort by last activity (oldest first)
2108
+ sorted_sessions = sorted(
2109
+ self._session_last_activity.items(),
2110
+ key=lambda x: x[1]
2111
+ )
2112
+
2113
+ # Calculate how many to remove
2114
+ excess_count = self._registry.count() - self.max_sessions_in_memory
2115
+
2116
+ # Remove oldest
2117
+ for session_id, _ in sorted_sessions[:excess_count]:
2118
+ logger.info(f"Evicting session {session_id} (max sessions limit: {self.max_sessions_in_memory})")
2119
+ await self._evict_session(session_id, reason='lru')
2120
+
2121
+ async def _evict_session(self, session_id: str, reason: str = 'unknown'):
2122
+ """Fully evict a session from memory with state persistence.
2123
+
2124
+ Args:
2125
+ session_id: Session to evict
2126
+ reason: Eviction reason (ttl, lru, manual)
2127
+ """
2128
+ try:
2129
+ # a3: never reap a session with a live run loop. A held execution lock means
2130
+ # a turn is in flight — tearing it down mid-run would corrupt state and drop
2131
+ # the user's reply. Leave it; the next cleanup pass collects it once idle.
2132
+ exec_lock = self._session_execution_locks.get(session_id)
2133
+ if exec_lock is not None and exec_lock.locked():
2134
+ logger.debug(
2135
+ f"Skipping eviction of {session_id} (reason: {reason}) — run in flight"
2136
+ )
2137
+ return
2138
+
2139
+ orchestrator = self._registry.get(session_id)
2140
+ if orchestrator:
2141
+ # Save HITL state BEFORE cleanup (queued messages not saved by cleanup)
2142
+ # NOTE: Message history is saved by cleanup(full_cleanup=True) - no need to duplicate
2143
+ try:
2144
+ for agent in orchestrator.agents.values():
2145
+ # Save HITL state (queued messages, callbacks info)
2146
+ # This is NOT handled by cleanup() so we must do it here
2147
+ if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
2148
+ try:
2149
+ hitl_state = agent.hitl_manager.get_state()
2150
+ if hitl_state.get('queued_messages'):
2151
+ from agents.task.path import pm
2152
+ import json
2153
+ hitl_path = pm().create_file_path(
2154
+ session_id=session_id,
2155
+ subdir_name="memory",
2156
+ filename="hitl_state.json",
2157
+ user_id=orchestrator.user_id
2158
+ )
2159
+ hitl_path.parent.mkdir(parents=True, exist_ok=True)
2160
+ with open(hitl_path, 'w') as f:
2161
+ json.dump(hitl_state, f, indent=2, default=str)
2162
+ logger.info(f"💾 Saved HITL state for agent {agent.agent_id} ({len(hitl_state.get('queued_messages', []))} queued messages)")
2163
+ except Exception as e:
2164
+ logger.error(f"Failed to save HITL state for agent {agent.agent_id}: {e}")
2165
+ except Exception as e:
2166
+ logger.error(f"Failed to save HITL state during eviction: {e}")
2167
+
2168
+ # Capture telemetry before eviction
2169
+ if self.telemetry:
2170
+ try:
2171
+ # Calculate session age
2172
+ last_activity = self._session_last_activity.get(session_id, time.time())
2173
+ session_age = time.time() - last_activity
2174
+
2175
+ self.telemetry.capture_event(
2176
+ event_type="session_eviction",
2177
+ data={
2178
+ "session_id": session_id,
2179
+ "reason": reason,
2180
+ "session_age_seconds": session_age,
2181
+ "total_sessions_before": self._registry.count(),
2182
+ "max_sessions_limit": self.max_sessions_in_memory
2183
+ }
2184
+ )
2185
+ except Exception as e:
2186
+ logger.debug(f"Failed to emit eviction telemetry: {e}")
2187
+
2188
+ # STEP 2: Full cleanup - release all resources
2189
+ await orchestrator.cleanup(
2190
+ preserve_workspace=True,
2191
+ status="suspended",
2192
+ full_cleanup=True # Release agents, LLMs, tools
2193
+ )
2194
+
2195
+ # Remove from all tracking
2196
+ self._registry.remove(session_id)
2197
+ self._session_last_activity.pop(session_id, None)
2198
+ self._session_execution_locks.pop(session_id, None) # Remove execution lock
2199
+ self._recreate_locks.pop(session_id, None) # a3: don't leak recreate locks
2200
+
2201
+ logger.info(f"Evicted session {session_id} from memory (reason: {reason}) with persistence")
2202
+ except Exception as e:
2203
+ logger.error(f"Error evicting session {session_id}: {e}")
2204
+
2205
+ async def _cleanup(self) -> None:
2206
+ """Cleanup orchestrators and resources during TaskAgent shutdown."""
2207
+ # Cleanup active orchestrators - full cleanup since we're shutting down
2208
+ for session_id, orchestrator in self._registry.items():
2209
+ try:
2210
+ await orchestrator.cleanup(
2211
+ preserve_workspace=True,
2212
+ preserve_agents=False, # Clear agents during shutdown
2213
+ full_cleanup=True # Release all resources
2214
+ )
2215
+ except Exception as e:
2216
+ logger.error(f"Error cleaning up orchestrator for {session_id}: {e}")
2217
+
2218
+ self._registry.clear()
2219
+ self._session_last_activity.clear()
2220
+ self._session_execution_locks.clear()
2221
+ self._recreate_locks.clear() # a3: don't leak recreate locks across shutdown
2222
+ logger.info("TaskAgent cleanup complete")
2223
+
2224
+ # Required abstract methods from BaseAgent
2225
+
2226
+ async def cleanup(self) -> None:
2227
+ """Clean up resources."""
2228
+ await self._cleanup()
2229
+
2230
+ async def process_input(
2231
+ self,
2232
+ user_input: str,
2233
+ user_id: str,
2234
+ chat_id: Optional[str] = None,
2235
+ **kwargs
2236
+ ) -> str:
2237
+ """Process user input - delegates to process_user_message."""
2238
+ return await self.process_user_message(user_id, user_input, kwargs)
2239
+
2240
+ async def start_conversation(
2241
+ self,
2242
+ user_id: str,
2243
+ chat_id: Optional[str] = None,
2244
+ metadata: Optional[Dict[str, Any]] = None
2245
+ ) -> bool:
2246
+ """Start a conversation - task agent doesn't need this."""
2247
+ return True
2248
+
2249
+ # Additional convenience methods that delegate to SessionManager
2250
+
2251
+ def list_sessions(self, user_id: str) -> list:
2252
+ """List user's sessions."""
2253
+ if not self.task_available:
2254
+ return []
2255
+ return self.session_manager.get_active_sessions(user_id)
2256
+
2257
+ def _count_user_sessions(self, user_id: str) -> int:
2258
+ """Count active sessions for a user.
2259
+
2260
+ Args:
2261
+ user_id: User identifier
2262
+
2263
+ Returns:
2264
+ Number of active sessions for this user
2265
+ """
2266
+ if not self.session_manager:
2267
+ return 0
2268
+
2269
+ sessions = self.session_manager.get_active_sessions(user_id)
2270
+ return len(sessions)
2271
+
2272
+ def _check_user_session_limit(self, user_id: str) -> bool:
2273
+ """Check if user has reached session limit.
2274
+
2275
+ Args:
2276
+ user_id: User identifier
2277
+
2278
+ Returns:
2279
+ True if under limit, False if at/over limit
2280
+ """
2281
+ current_count = self._count_user_sessions(user_id)
2282
+ max_limit = getattr(self.config, 'max_sessions_per_user', MAX_SESSIONS_PER_USER)
2283
+
2284
+ if current_count >= max_limit:
2285
+ logger.warning(
2286
+ f"User {user_id} at session limit: {current_count}/{max_limit}"
2287
+ )
2288
+ return False
2289
+
2290
+ return True
2291
+
2292
+ async def _validate_user_credits(self, user_id: str, min_credits: int = 1):
2293
+ """Validate user has sufficient credits before session creation.
2294
+
2295
+ This is a pre-validation check to fail fast before creating resources.
2296
+ The actual billing happens during LLM calls via usage_tracker.
2297
+
2298
+ Args:
2299
+ user_id: User to validate
2300
+ min_credits: Minimum credits required (default: 1 for session creation)
2301
+
2302
+ Raises:
2303
+ AgentError: If insufficient credits
2304
+ """
2305
+ # Skip for admin users and x402 users (they already paid via x402 middleware)
2306
+ try:
2307
+ tier_mgr = self.container.get_service('tier_manager')
2308
+ if tier_mgr:
2309
+ user_tier = await tier_mgr.get_user_tier(user_id)
2310
+ if user_tier == 'admin':
2311
+ logger.debug(f"Admin user {user_id} bypassed credit check")
2312
+ return
2313
+ if user_tier == 'x402':
2314
+ # x402 users pay per-request via middleware, not via credits
2315
+ logger.debug(f"x402 user {user_id} bypassed credit check (paid via x402)")
2316
+ return
2317
+ except Exception as e:
2318
+ logger.debug(f"Could not check tier (proceeding with credit check): {e}")
2319
+
2320
+ # Check balance
2321
+ try:
2322
+ balance_mgr = self.container.get_service('balance_manager')
2323
+ if not balance_mgr:
2324
+ logger.warning("Balance manager not available - skipping credit check")
2325
+ return
2326
+
2327
+ has_credits = await balance_mgr.has_sufficient_balance(user_id, min_credits)
2328
+ if not has_credits:
2329
+ balance = await balance_mgr.get_balance(user_id)
2330
+ available = balance.get('balance', 0) if balance else 0
2331
+ raise AgentError(
2332
+ f"Insufficient credits. Required: {min_credits}, Available: {available}. "
2333
+ f"Please add credits at /api/payments/deposit to continue."
2334
+ )
2335
+ except AgentError:
2336
+ raise # Re-raise our own error
2337
+ except Exception as e:
2338
+ logger.warning(f"Credit check failed (allowing session): {e}")
2339
+
2340
+ def get_all_sessions(self) -> Dict[str, Any]:
2341
+ """Get all sessions (admin function)."""
2342
+ if not self.task_available or not self.session_manager:
2343
+ return {}
2344
+ # Access internal tracking for admin purposes
2345
+ return self.session_manager._sessions.copy()
2346
+
2347
+ # Methods required by API
2348
+
2349
+ def _get_user_sessions(self, user_id: str) -> Dict[str, Any]:
2350
+ """Get all sessions for a user."""
2351
+ if not self.session_manager:
2352
+ return {}
2353
+
2354
+ sessions = {}
2355
+ for session_id in self.session_manager.get_active_sessions(user_id):
2356
+ info = self.session_manager.get_session_info(session_id)
2357
+ if info:
2358
+ sessions[session_id] = info
2359
+ return sessions
2360
+
2361
+ def _remove_session(self, user_id: str, session_id: str) -> None:
2362
+ """Remove a session and cleanup."""
2363
+ orchestrator = self._registry.remove(session_id)
2364
+ if orchestrator:
2365
+ asyncio.create_task(orchestrator.cleanup())
2366
+
2367
+ # NOTE: Don't call cleanup_session - keep completed sessions in memory
2368
+ # for continuous chat feature. They remain accessible via get_session_by_id()
2369
+ # but the orchestrator is cleaned up to free resources.
2370
+
2371
+ # API compatibility methods
2372
+
2373
+ async def get_session_by_id(self, session_id: str) -> Optional[Dict[str, Any]]:
2374
+ """Get session info by ID (API compatibility)."""
2375
+ if not self.session_manager:
2376
+ return None
2377
+
2378
+ info = self.session_manager.get_session_info(session_id)
2379
+ if info:
2380
+ # Update active_sessions for compatibility
2381
+ user_id = info.get('user_id')
2382
+ if user_id:
2383
+ if user_id not in self.active_sessions:
2384
+ self.active_sessions[user_id] = {}
2385
+ self.active_sessions[user_id][session_id] = info
2386
+ return info
2387
+
2388
+ async def cancel_session_by_id(self, session_id: str, force: bool = False) -> bool:
2389
+ """Cancel a session by ID (API compatibility)."""
2390
+ try:
2391
+ info = self.session_manager.get_session_info(session_id)
2392
+ if info:
2393
+ user_id = info.get('user_id')
2394
+ return await self.cancel_session(user_id, force=force, session_id=session_id)
2395
+ return False
2396
+ except Exception as e:
2397
+ logger.error(f"Failed to cancel session {session_id}: {e}")
2398
+ return False