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
api/task_http_api.py ADDED
@@ -0,0 +1,1856 @@
1
+ """
2
+ HTTP API endpoints for Task multi-session support.
3
+ Provides RESTful endpoints for session management and user messaging.
4
+ """
5
+
6
+ from typing import Dict, Any, Optional, List
7
+ from fastapi import APIRouter, HTTPException, Depends, Request, UploadFile, File
8
+ from pydantic import BaseModel, Field
9
+ import logging
10
+ import os
11
+ import time
12
+ import uuid
13
+ from datetime import datetime
14
+ import aiofiles
15
+
16
+ from api.session_routing import guard_remote
17
+ import asyncio
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # H2 FIX: hold strong references to fire-and-forget session tasks so the event loop
23
+ # can't garbage-collect a running agent session (it keeps only a weak ref to a bare
24
+ # Task). Tasks discard themselves on completion via the done callback.
25
+ _BACKGROUND_SESSION_TASKS: set = set()
26
+
27
+
28
+ def _spawn_session_task(coro) -> "asyncio.Task":
29
+ """Schedule a background coroutine while retaining a strong reference to it."""
30
+ task = asyncio.create_task(coro)
31
+ _BACKGROUND_SESSION_TASKS.add(task)
32
+ task.add_done_callback(_BACKGROUND_SESSION_TASKS.discard)
33
+ return task
34
+
35
+ # Create router for Task endpoints
36
+ router = APIRouter(prefix="/task", tags=["task"])
37
+
38
+ # Import models from api.models to avoid duplication
39
+ from api.models import (
40
+ SessionStatusResponse,
41
+ MessageResponse,
42
+ UserMessage as UserMessageRequest,
43
+ SessionResponse
44
+ )
45
+
46
+ # Helper to clean session IDs at API entry (single point of cleaning)
47
+ def clean_session_id_at_entry(session_id: str) -> str:
48
+ """Clean session ID once at API entry point.
49
+
50
+ This is the ONLY place where session IDs should be cleaned in the API layer.
51
+ All downstream code can trust that session IDs are already sanitized.
52
+ """
53
+ from agents.task.path import pm
54
+ return pm().clean_session_id(session_id)
55
+
56
+ # Helper to normalize status values
57
+ def normalize_status_value(value: Any) -> str:
58
+ """Normalize status/state value to lowercase string.
59
+
60
+ Handles various input formats:
61
+ - SessionStatus enum objects
62
+ - Enum string representations ("SessionStatus.RUNNING")
63
+ - Plain strings ("running", "RUNNING")
64
+ - None/null values
65
+
66
+ Returns:
67
+ Lowercase status string (e.g., "running", "completed")
68
+ """
69
+ # Import here to avoid circular dependency
70
+ from agents.task.agent.session import SessionStatus
71
+
72
+ # Handle enum objects
73
+ if isinstance(value, SessionStatus):
74
+ return value.value
75
+
76
+ # Handle strings
77
+ if isinstance(value, str):
78
+ # Handle enum string like "SessionStatus.RUNNING" or "SessionState.RUNNING"
79
+ if '.' in value:
80
+ return value.split('.')[-1].lower()
81
+ return value.lower()
82
+
83
+ # Handle None or other types
84
+ return str(value).lower() if value else "unknown"
85
+
86
+ def _require_session_owner(req: Request, resource_owner: Optional[str]) -> None:
87
+ """Raise 403 if the authenticated caller does not own `resource_owner`.
88
+
89
+ Mirrors the ownership-check pattern already correct at the
90
+ workspace/upload endpoint (api/task_http_api.py ~1554-1564) — applied
91
+ consistently across the session read/write endpoints that were missing it
92
+ (E8 / A6 gap 4). No-op when resource_owner is falsy (nothing recorded to
93
+ compare against — matches the pre-existing upload-endpoint convention).
94
+ """
95
+ from utils.auth_utils import get_authenticated_user_id
96
+ caller_id = get_authenticated_user_id(req)
97
+ if resource_owner and caller_id != resource_owner:
98
+ raise HTTPException(status_code=403, detail="Access denied - session owned by a different user")
99
+
100
+ # Dependency to get TaskAgent instance
101
+ async def get_task_agent():
102
+ """Get the TaskAgent instance from the container"""
103
+ from core.container import DependencyContainer
104
+ container = DependencyContainer.get_instance()
105
+ # Use get_agent for consistency
106
+ agent = container.get_agent("task_agent")
107
+ if not agent:
108
+ # Fallback to get_service for backward compatibility
109
+ agent = container.get_service("task_agent")
110
+ if not agent:
111
+ raise HTTPException(status_code=503, detail="Task agent not available")
112
+ return agent
113
+
114
+ @router.post("/sessions/{session_id}/messages", response_model=MessageResponse)
115
+ async def send_user_message(
116
+ session_id: str,
117
+ request: UserMessageRequest,
118
+ req: Request,
119
+ agent = Depends(get_task_agent)
120
+ ):
121
+ """
122
+ Send a user message to a running session.
123
+
124
+ This endpoint allows sending guidance, corrections, or other messages
125
+ to an active Task session for processing in the next step.
126
+ """
127
+ try:
128
+ # OPTIMIZATION: Clean session ID once at API entry
129
+ session_id = clean_session_id_at_entry(session_id)
130
+
131
+ # Item 6: if this session is owned by another worker, return an honest 409
132
+ # (with owner_pid) instead of a false-404. No-op for the in-process registry.
133
+ guard_remote(agent, session_id)
134
+
135
+ # Get session from agent
136
+ session_info = await agent.get_session_by_id(session_id)
137
+ if not session_info:
138
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
139
+
140
+ # Check session status (unified field name)
141
+ status = session_info.get('status', 'unknown').lower()
142
+
143
+ # Allow messages in resumable states for continuous chat
144
+ # - "created": first message to start the agent with attached files
145
+ # - "running": active session
146
+ # - "completed/resumed/suspended": continuous chat resume
147
+ # - "failed/error": retry after failure (error is legacy, failed is current)
148
+ RESUMABLE_STATUSES = ["created", "running", "completed", "resumed", "suspended", "failed", "error"]
149
+ if status not in RESUMABLE_STATUSES:
150
+ raise HTTPException(
151
+ status_code=400,
152
+ detail=f"Session is in {status} status, cannot send messages"
153
+ )
154
+
155
+ # Get user_id early for use in closure
156
+ user_id = session_info.get('user_id')
157
+
158
+ # E8 (A6 gap 4): the caller must own this session — any authenticated
159
+ # caller could otherwise inject a message into another tenant's session.
160
+ _require_session_owner(req, user_id)
161
+
162
+ # ============================================================================
163
+ # CRITICAL FIX: Process attached files BEFORE starting session
164
+ # This eliminates race condition where agent starts before message is queued
165
+ # ============================================================================
166
+
167
+ # STEP 1: Verify all attached files are ready (explicit synchronization)
168
+ if request.attached_files:
169
+ logger.info(f"📁 Verifying {len(request.attached_files)} attached file(s) are ready...")
170
+ files_ready, missing_files = await verify_files_ready(
171
+ request.attached_files,
172
+ session_id,
173
+ user_id,
174
+ max_wait_seconds=10.0
175
+ )
176
+
177
+ if not files_ready:
178
+ logger.error(f"📁 File verification failed. Missing/unreadable: {missing_files}")
179
+ raise HTTPException(
180
+ status_code=425, # Too Early
181
+ detail=f"Files not ready: {', '.join(missing_files)}. Please retry in a few seconds.",
182
+ headers={"Retry-After": "5"}
183
+ )
184
+
185
+ logger.info(f"📁 ✅ All {len(request.attached_files)} file(s) verified and ready")
186
+
187
+ # STEP 2: Process attached files to create metadata (BEFORE starting session)
188
+ message_text = request.text
189
+ image_attachments = [] # Track images for vision
190
+
191
+ if request.attached_files:
192
+ logger.info(f"📎 Processing {len(request.attached_files)} attached file(s)...")
193
+ for file_path in request.attached_files:
194
+ logger.info(f"📎 Processing file: {file_path}")
195
+ # Returns tuple (text, images)
196
+ message_text, images = await inject_file_content_to_message(
197
+ file_path,
198
+ message_text,
199
+ session_id,
200
+ user_id
201
+ )
202
+ logger.info(f"📎 File processed: {file_path}")
203
+
204
+ # Collect images for vision
205
+ if images:
206
+ image_attachments.extend(images)
207
+ logger.info(f"📷 Collected {len(images)} image(s) for vision from {file_path}")
208
+
209
+ # STEP 3: Create metadata with images (BEFORE starting session)
210
+ metadata = request.metadata or {}
211
+ if image_attachments:
212
+ metadata['image_attachments'] = image_attachments
213
+ logger.info(f"📷 Prepared message with {len(image_attachments)} image(s) for vision")
214
+
215
+ # STEP 4: Get orchestrator (or start session if needed)
216
+ orchestrator = session_info.get('orchestrator')
217
+ if not orchestrator:
218
+ # Look up the live orchestrator via the public accessor (for completed sessions)
219
+ if hasattr(agent, 'get_orchestrator'):
220
+ orchestrator = agent.get_orchestrator(session_id)
221
+
222
+ if not orchestrator:
223
+ # Try to get from active sessions (nested structure - for compatibility)
224
+ if user_id and hasattr(agent, 'active_sessions'):
225
+ user_sessions = agent.active_sessions.get(user_id, {})
226
+ session_data = user_sessions.get(session_id, {})
227
+ orchestrator = session_data.get('orchestrator')
228
+
229
+ # Check if orchestrator has agents
230
+ has_agents = orchestrator and hasattr(orchestrator, 'agents') and len(orchestrator.agents) > 0
231
+
232
+ # Track whether message has been queued to prevent double-queueing
233
+ message_queued = False
234
+
235
+ # STEP 5: Start session if needed (QUEUE MESSAGE FIRST to prevent race condition)
236
+ if not has_agents and status == "created":
237
+ logger.info(
238
+ f"Starting agent for session {session_id} with pre-queued message "
239
+ f"({len(image_attachments)} image(s) ready)"
240
+ )
241
+
242
+ # Start session in background (don't wait for it to be ready)
243
+ import asyncio
244
+
245
+ # CRITICAL FIX (Nov 25, 2025): Queue message BEFORE starting agent
246
+ # This prevents race condition where agent starts and checks for messages
247
+ # before the message is queued, causing images to be lost.
248
+
249
+ # Get the live orchestrator via the public accessor (not from session_info)
250
+ # Note: get_session_by_id() doesn't include orchestrator in its return value
251
+ pre_orchestrator = None
252
+ if hasattr(agent, 'get_orchestrator'):
253
+ pre_orchestrator = agent.get_orchestrator(session_id)
254
+
255
+ if pre_orchestrator:
256
+ # Queue message FIRST
257
+ logger.info(f"📷 Pre-queuing message with {len(image_attachments)} image(s) BEFORE agent starts")
258
+ await pre_orchestrator.submit_user_message(
259
+ None,
260
+ message_text,
261
+ request.kind,
262
+ metadata
263
+ )
264
+ message_queued = True # Mark as queued
265
+ logger.info(f"✅ Message pre-queued successfully")
266
+ else:
267
+ logger.warning(f"Orchestrator not ready for pre-queuing, will queue after start")
268
+
269
+ # Capture user_id and session_id in closure
270
+ _user_id = user_id
271
+ _session_id = session_id
272
+
273
+ async def run_session_with_logging():
274
+ try:
275
+ logger.info(f"Starting session execution for {_session_id}")
276
+ result = await agent.run_session(_user_id, _session_id)
277
+ logger.info(f"Session {_session_id} completed with result: {result}")
278
+ except Exception as e:
279
+ logger.error(f"Session {_session_id} failed with error: {e}", exc_info=True)
280
+
281
+ # Now start the agent (message is already queued; strong ref retained)
282
+ _spawn_session_task(run_session_with_logging())
283
+
284
+ # Give it a tiny moment to start up
285
+ await asyncio.sleep(0.1)
286
+
287
+ # Update orchestrator reference
288
+ orchestrator = pre_orchestrator
289
+
290
+ elif not orchestrator:
291
+ logger.warning(f"No orchestrator for session {session_id} in status {status}")
292
+ # Fall through to queue message below
293
+
294
+ # STEP 6: Queue message (only if not already queued above)
295
+ # Use explicit flag to prevent any double-queueing race conditions
296
+ if not message_queued and orchestrator:
297
+ await orchestrator.submit_user_message(
298
+ None, # Let orchestrator route to active agent
299
+ message_text,
300
+ request.kind,
301
+ metadata
302
+ )
303
+ message_queued = True
304
+
305
+ logger.info(f"Sent user message to session {session_id}: {request.text[:50]}...")
306
+
307
+ # NEW: Emit user message to feed for chat display
308
+ # CRITICAL FIX: Skip if running session with attached files - will be added later
309
+ # to prevent duplicate user_message events in the feed
310
+ should_add_to_feed = not (request.attached_files and status == "running" and not message_queued)
311
+
312
+ if should_add_to_feed and hasattr(agent, 'session_manager'):
313
+ try:
314
+ agent.session_manager.add_to_feed(
315
+ session_id,
316
+ 'user_message',
317
+ {
318
+ 'text': request.text,
319
+ 'kind': request.kind,
320
+ 'metadata': request.metadata or {},
321
+ 'timestamp': time.time()
322
+ }
323
+ )
324
+ logger.debug(f"Added user message to feed for session {session_id}")
325
+ except Exception as e:
326
+ logger.warning(f"Failed to add user message to feed: {e}")
327
+
328
+ # Handle terminal states that need resume
329
+ # NOTE: Don't update status here - let TaskAgent handle status transitions
330
+ # to avoid race condition where API and TaskAgent both try to update status
331
+ if status in ["suspended", "failed", "error"]:
332
+ logger.info(f"Session {session_id} is {status}, will be resumed by TaskAgent")
333
+
334
+ # Crash-mid-turn recovery: a session interrupted when the process died is
335
+ # rewritten running->"suspended" at startup (SessionManager._load_sessions_from_disk),
336
+ # so it enters the resumable block below. In a FRESH process its live orchestrator
337
+ # is absent, and STEP 6 (which needs an orchestrator) never queued the inbound
338
+ # message — see the ensure_session_and_deliver block below, which recreates the
339
+ # session from disk and delivers the message so the resume actually processes it.
340
+
341
+ # If session is in a resumable state, restart execution to process the message
342
+ if status in ["completed", "resumed", "suspended", "failed", "error"]:
343
+ import asyncio
344
+ user_id_from_session = session_info.get('user_id')
345
+
346
+ # Deliver the message into the (possibly evicted) session BEFORE running it.
347
+ # STEP 6 only queues when a live orchestrator exists; in a FRESH process the
348
+ # orchestrator is absent, so without this the resume runs with the message
349
+ # never queued — `_run_session_impl` recreates from disk but a completed
350
+ # session with no pending input short-circuits ("No new input"), silently
351
+ # dropping the message. `ensure_session_and_deliver` recreates-then-queues
352
+ # (the self-wake rail), so the recreated session actually processes it.
353
+ # Fail-open: on any delivery error, fall back to the bare run below.
354
+ if not message_queued and not orchestrator and hasattr(agent, 'ensure_session_and_deliver'):
355
+ try:
356
+ _delivery = await agent.ensure_session_and_deliver(
357
+ user_id_from_session, session_id, message_text,
358
+ kind=request.kind, metadata=metadata)
359
+ if _delivery == "delivered":
360
+ message_queued = True
361
+ logger.info(f"Session {session_id} message delivered into recreated orchestrator")
362
+ elif _delivery == "busy":
363
+ logger.info(f"Session {session_id} busy — message queued on resident session")
364
+ except Exception as e:
365
+ logger.warning(f"ensure_session_and_deliver failed for {session_id} "
366
+ f"(falling back to bare resume): {e}")
367
+
368
+ # NOTE: Status transition is handled by TaskAgent._run_session_impl
369
+ # API should NOT update status to avoid race condition:
370
+ # 1. API reads status='completed'
371
+ # 2. API updates to 'resumed'
372
+ # 3. TaskAgent tries try_transition_status(completed→resumed) - FAILS!
373
+ #
374
+ # Instead, let TaskAgent be the single owner of status transitions.
375
+ # API only increments task phase for multi-phase tracking.
376
+ if hasattr(agent, 'session_manager'):
377
+ # ✅ Increment task phase for multi-phase tracking
378
+ try:
379
+ new_phase = agent.session_manager.increment_task_phase(session_id)
380
+ logger.info(f"Session {session_id} entering Phase {new_phase} (status transition handled by TaskAgent)")
381
+ except Exception as e:
382
+ logger.debug(f"Could not increment task_phase: {e}")
383
+ logger.info(f"Session {session_id} resuming (status transition handled by TaskAgent)")
384
+
385
+ # Create background task with error tracking. H2 FIX: retain a strong
386
+ # reference in _BACKGROUND_SESSION_TASKS so the event loop can't GC this
387
+ # session task after the handler returns (the asyncio.shield below only
388
+ # keeps it alive during the 2s wait, not afterward).
389
+ restart_task = asyncio.create_task(
390
+ agent.run_session(user_id_from_session, session_id)
391
+ )
392
+ _BACKGROUND_SESSION_TASKS.add(restart_task)
393
+ restart_task.add_done_callback(_BACKGROUND_SESSION_TASKS.discard)
394
+
395
+ # Wait briefly to catch immediate errors (2 seconds)
396
+ resume_status = "running"
397
+ resume_error = None
398
+ is_retryable = False
399
+
400
+ try:
401
+ await asyncio.wait_for(
402
+ asyncio.shield(restart_task), # Shield so it continues in background
403
+ timeout=2.0
404
+ )
405
+ resume_status = "running"
406
+ resume_error = None
407
+ except asyncio.TimeoutError:
408
+ # Expected - task running in background
409
+ logger.debug(f"Session {session_id} auto-resume initiated (background)")
410
+ resume_status = "running"
411
+ resume_error = None
412
+ except ValueError as e:
413
+ # User/validation error - permanent failure
414
+ logger.error(f"Auto-resume validation error for {session_id}: {e}")
415
+ resume_status = "failed"
416
+ resume_error = str(e)
417
+ is_retryable = False
418
+
419
+ # Emit telemetry
420
+ if hasattr(agent, 'telemetry') and agent.telemetry:
421
+ try:
422
+ agent.telemetry.capture_event(
423
+ event_type="auto_resume_failure",
424
+ data={
425
+ "session_id": session_id,
426
+ "error": str(e),
427
+ "error_type": "validation",
428
+ "retryable": False
429
+ }
430
+ )
431
+ except Exception:
432
+ pass
433
+ except RuntimeError as e:
434
+ # System error - may be retryable
435
+ logger.error(f"Auto-resume system error for {session_id}: {e}")
436
+ resume_status = "error"
437
+ resume_error = str(e)
438
+ is_retryable = True
439
+
440
+ # Emit telemetry
441
+ if hasattr(agent, 'telemetry') and agent.telemetry:
442
+ try:
443
+ agent.telemetry.capture_event(
444
+ event_type="auto_resume_failure",
445
+ data={
446
+ "session_id": session_id,
447
+ "error": str(e),
448
+ "error_type": "runtime",
449
+ "retryable": True
450
+ }
451
+ )
452
+ except Exception:
453
+ pass
454
+ except Exception as e:
455
+ # Unknown error - log with traceback
456
+ logger.error(f"Auto-resume unexpected error for {session_id}: {e}", exc_info=True)
457
+ resume_status = "error"
458
+ resume_error = f"Unexpected error: {str(e)}"
459
+ is_retryable = True
460
+
461
+ # Emit telemetry
462
+ if hasattr(agent, 'telemetry') and agent.telemetry:
463
+ try:
464
+ agent.telemetry.capture_event(
465
+ event_type="auto_resume_failure",
466
+ data={
467
+ "session_id": session_id,
468
+ "error": str(e),
469
+ "error_type": type(e).__name__,
470
+ "retryable": True
471
+ }
472
+ )
473
+ except Exception:
474
+ pass
475
+
476
+ return MessageResponse(
477
+ success=(resume_status == "running"),
478
+ message=f"Message queued, resume: {resume_status}",
479
+ metadata={
480
+ "session_id": session_id,
481
+ "resumed": (resume_status == "running"),
482
+ "resume_status": resume_status,
483
+ "resume_error": resume_error,
484
+ "retryable": is_retryable # Hint for client retry logic
485
+ }
486
+ )
487
+
488
+ # CRITICAL FIX: Handle attached files for RUNNING sessions
489
+ # Our initial P0 fix only handled 'created' status, but if auto_start=true
490
+ # was used (incorrectly), the session is already 'running' when we try to
491
+ # send files. We need to handle this case too.
492
+ # Note: Only process if message wasn't already queued above
493
+ if request.attached_files and status == "running" and not message_queued:
494
+ logger.info(f"📁 Processing {len(request.attached_files)} attached file(s) for RUNNING session")
495
+
496
+ # STEP 1: Verify all attached files are ready
497
+ files_ready, missing_files = await verify_files_ready(
498
+ request.attached_files,
499
+ session_id,
500
+ user_id,
501
+ max_wait_seconds=10.0
502
+ )
503
+
504
+ if not files_ready:
505
+ logger.error(f"📁 File verification failed for running session. Missing/unreadable: {missing_files}")
506
+ raise HTTPException(
507
+ status_code=425, # Too Early
508
+ detail=f"Files not ready: {', '.join(missing_files)}. Please retry in a few seconds.",
509
+ headers={"Retry-After": "5"}
510
+ )
511
+
512
+ logger.info(f"📁 ✅ All {len(request.attached_files)} file(s) verified and ready for running session")
513
+
514
+ # STEP 2: Process files to create metadata
515
+ image_attachments_running = []
516
+ message_text_running = request.text
517
+
518
+ for file_path in request.attached_files:
519
+ message_text_running, images = await inject_file_content_to_message(
520
+ file_path,
521
+ message_text_running,
522
+ session_id,
523
+ user_id
524
+ )
525
+
526
+ if images:
527
+ image_attachments_running.extend(images)
528
+ logger.info(f"📷 Collected {len(images)} image(s) for vision from {file_path} (running session)")
529
+
530
+ # STEP 3: Add images to metadata and queue message
531
+ metadata_running = request.metadata or {}
532
+ if image_attachments_running:
533
+ metadata_running['image_attachments'] = image_attachments_running
534
+ logger.info(f"📷 Prepared message with {len(image_attachments_running)} image(s) for running session")
535
+
536
+ # STEP 4: Queue the message (agent will process on next queue check)
537
+ # Note: We already queued the message earlier at line 234-239, but that was
538
+ # BEFORE file processing. We need to queue again with the processed metadata.
539
+ # Actually, wait - looking at the code flow, we haven't queued yet for running
540
+ # sessions. The queue happens at line 234-239 but that's inside the "created"
541
+ # branch. So we need to queue here.
542
+ await orchestrator.submit_user_message(
543
+ None, # Let orchestrator route to active agent
544
+ message_text_running,
545
+ request.kind,
546
+ metadata_running
547
+ )
548
+ message_queued = True # Mark as queued to prevent any further queueing
549
+
550
+ logger.info(f"✅ Queued message with {len(image_attachments_running)} image(s) for running session {session_id}")
551
+
552
+ # Emit to feed
553
+ if hasattr(agent, 'session_manager'):
554
+ try:
555
+ agent.session_manager.add_to_feed(
556
+ session_id,
557
+ 'user_message',
558
+ {
559
+ 'text': request.text,
560
+ 'kind': request.kind,
561
+ 'metadata': request.metadata or {},
562
+ 'timestamp': time.time()
563
+ }
564
+ )
565
+ except Exception as e:
566
+ logger.warning(f"Failed to add user message to feed: {e}")
567
+
568
+ return MessageResponse(
569
+ success=True,
570
+ message=f"Message with {len(image_attachments_running)} image(s) queued for running session",
571
+ metadata={
572
+ "session_id": session_id,
573
+ "resumed": False,
574
+ "images_attached": len(image_attachments_running)
575
+ }
576
+ )
577
+
578
+ # For non-completed sessions WITHOUT attached files
579
+ return MessageResponse(
580
+ success=True,
581
+ message="Message sent successfully",
582
+ metadata={"session_id": session_id, "resumed": False}
583
+ )
584
+
585
+ except HTTPException:
586
+ raise
587
+ except Exception as e:
588
+ # Import here to avoid circular imports
589
+ from core.exceptions import MessageQueueFullError
590
+
591
+ # Handle specific exception types with proper HTTP status codes
592
+ if isinstance(e, MessageQueueFullError):
593
+ logger.warning(f"Message queue full for session {session_id}: {e}")
594
+ raise HTTPException(
595
+ status_code=429,
596
+ detail=str(e),
597
+ headers={"Retry-After": "30"}
598
+ )
599
+ elif isinstance(e, ValueError):
600
+ error_str = str(e)
601
+ # Handle rate limit errors from HITL manager
602
+ if "Rate limit exceeded" in error_str:
603
+ logger.warning(f"Rate limit exceeded for session {session_id}")
604
+ raise HTTPException(
605
+ status_code=429,
606
+ detail=error_str,
607
+ headers={"Retry-After": "60"}
608
+ )
609
+ raise HTTPException(status_code=400, detail=error_str)
610
+ else:
611
+ logger.error(f"Error sending user message: {e}")
612
+ raise HTTPException(status_code=500, detail=str(e))
613
+
614
+ @router.post("/sessions/{session_id}/cancel", response_model=MessageResponse)
615
+ async def cancel_session(
616
+ session_id: str,
617
+ req: Request,
618
+ agent = Depends(get_task_agent)
619
+ ):
620
+ """Cancel a running session"""
621
+ try:
622
+ # OPTIMIZATION: Clean session ID once at API entry
623
+ session_id = clean_session_id_at_entry(session_id)
624
+
625
+ session_info = await agent.get_session_by_id(session_id)
626
+ if not session_info:
627
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
628
+
629
+ # IDOR: the caller must own this session. cancel_session_by_id self-authorizes
630
+ # against the session's OWN user_id, so without this gate any authenticated
631
+ # tenant could force-cancel another tenant's in-flight (paid) run.
632
+ _require_session_owner(req, session_info.get('user_id'))
633
+
634
+ # Call agent's cancel method
635
+ success = await agent.cancel_session_by_id(session_id, force=True)
636
+
637
+ if success:
638
+ return MessageResponse(
639
+ success=True,
640
+ message="Session cancelled successfully"
641
+ )
642
+ else:
643
+ raise HTTPException(status_code=400, detail="Failed to cancel session")
644
+
645
+ except HTTPException:
646
+ raise
647
+ except Exception as e:
648
+ logger.error(f"Error cancelling session: {e}")
649
+ raise HTTPException(status_code=500, detail=str(e))
650
+
651
+ @router.get("/sessions/{session_id}", response_model=SessionStatusResponse)
652
+ async def get_session_status(
653
+ session_id: str,
654
+ req: Request,
655
+ agent = Depends(get_task_agent)
656
+ ):
657
+ """Get the status of a session with user-friendly status"""
658
+ try:
659
+ # OPTIMIZATION: Clean session ID once at API entry
660
+ session_id = clean_session_id_at_entry(session_id)
661
+
662
+ session_info = await agent.get_session_by_id(session_id)
663
+ if not session_info:
664
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
665
+
666
+ # E8 (A6 gap 4): the caller must own this session — any authenticated
667
+ # caller could otherwise read another tenant's session (task, config, metadata).
668
+ _require_session_owner(req, session_info.get('user_id'))
669
+
670
+ # Get internal status
671
+ internal_status = normalize_status_value(session_info.get("status", "unknown"))
672
+
673
+ # Convert to user-facing status
674
+ from agents.task.agent.session import get_user_status
675
+ user_status = get_user_status(internal_status)
676
+
677
+ # Determine user capabilities
678
+ can_cancel = (user_status == "active")
679
+ can_message = True # Always allow messages
680
+
681
+ # Build response with user-facing status
682
+ response_data = {
683
+ "id": session_info.get("id", session_id),
684
+ "session_id": session_info.get("id", session_id),
685
+ "user_id": session_info.get("user_id", "_anonymous_"),
686
+ "task": session_info.get("task", "Unknown task"),
687
+ "status": user_status, # User-facing: active/idle/stopped
688
+ "can_cancel": can_cancel,
689
+ "can_send_message": can_message,
690
+ "created_at": session_info.get("created_at", datetime.now().isoformat()),
691
+ "last_updated": session_info.get("last_updated"),
692
+ "internal_status": internal_status, # For debugging
693
+ "agents": {}, # Simplified for user
694
+ "config": session_info.get("config", {}),
695
+ "metadata": session_info.get("metadata", {}),
696
+ }
697
+
698
+ # Extract model and tools from config if present
699
+ config = session_info.get("config", {})
700
+ response_data["model"] = config.get("model")
701
+ response_data["tools"] = config.get("tools", [])
702
+
703
+ # Add webview URL if present in metadata
704
+ metadata = session_info.get("metadata", {})
705
+ response_data["webview_url"] = metadata.get("webview_url")
706
+
707
+ return SessionStatusResponse(**response_data)
708
+
709
+ except HTTPException:
710
+ raise
711
+ except Exception as e:
712
+ logger.error(f"Error getting session status: {e}")
713
+ raise HTTPException(status_code=500, detail=str(e))
714
+
715
+ @router.get("/sessions/{session_id}/queue-status", response_model=dict)
716
+ async def get_queue_status(
717
+ session_id: str,
718
+ req: Request,
719
+ agent = Depends(get_task_agent)
720
+ ):
721
+ """Get message queue status for a session.
722
+
723
+ Returns queue status for active sessions, or final status for completed/failed sessions.
724
+ This prevents 404 errors when WebView polls completed sessions.
725
+
726
+ Args:
727
+ session_id: Session identifier
728
+ agent: TaskAgent dependency injection
729
+
730
+ Returns:
731
+ {
732
+ "queued_messages": int,
733
+ "agent_status": str,
734
+ "session_completed": bool,
735
+ "streaming_callbacks": int,
736
+ "callback_failures": int
737
+ }
738
+
739
+ Raises:
740
+ HTTPException 404: Session not found
741
+ """
742
+ try:
743
+ # Clean session ID
744
+ session_id = clean_session_id_at_entry(session_id)
745
+
746
+ # Item 6: honest 409 (not false-404) if owned by another worker. No-op locally.
747
+ guard_remote(agent, session_id)
748
+
749
+ # IDOR: fetch metadata and confirm the caller owns this session BEFORE exposing
750
+ # runtime state (agent_status/queued/callbacks) or using 404-vs-200 as a
751
+ # session-existence enumeration oracle.
752
+ session_info = await agent.get_session_by_id(session_id)
753
+ if not session_info:
754
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
755
+ _require_session_owner(req, session_info.get('user_id'))
756
+
757
+ # Try to get orchestrator from active sessions
758
+ orchestrator = agent.get_orchestrator(session_id)
759
+
760
+ if not orchestrator:
761
+ # ✅ FIX #4: session exists but is not active - return final status
762
+ session_status = normalize_status_value(session_info.get('status', 'unknown'))
763
+
764
+ if session_status in ['completed', 'failed', 'suspended', 'error']:
765
+ logger.debug(f"Session {session_id} is {session_status}, returning final status")
766
+ return {
767
+ "queued_messages": 0,
768
+ "agent_status": session_status,
769
+ "session_completed": True,
770
+ "streaming_callbacks": 0,
771
+ "callback_failures": 0,
772
+ "message": f"Session {session_status}"
773
+ }
774
+
775
+ # Session exists but has no active orchestrator and a non-final status
776
+ raise HTTPException(
777
+ status_code=404,
778
+ detail=f"Session {session_id} not found"
779
+ )
780
+
781
+ # Session is active - return current status
782
+ agents = orchestrator.agents
783
+ if not agents:
784
+ logger.warning(f"Session {session_id} orchestrator has no agents")
785
+ return {
786
+ "queued_messages": 0,
787
+ "agent_status": "no_agent",
788
+ "session_completed": False,
789
+ "streaming_callbacks": 0,
790
+ "callback_failures": 0
791
+ }
792
+
793
+ # Get first agent (usually there's only one)
794
+ first_agent = next(iter(agents.values()))
795
+
796
+ # Check if agent has HITL manager
797
+ if not hasattr(first_agent, 'hitl_manager'):
798
+ logger.warning(f"Agent {first_agent.agent_id} has no hitl_manager")
799
+ return {
800
+ "queued_messages": 0,
801
+ "agent_status": "no_hitl",
802
+ "session_completed": False,
803
+ "streaming_callbacks": 0,
804
+ "callback_failures": 0
805
+ }
806
+
807
+ # Get stats from HITL manager
808
+ stats = first_agent.hitl_manager.get_stats()
809
+
810
+ # Determine agent status
811
+ agent_status = "running"
812
+ if hasattr(first_agent, 'is_done') and first_agent.is_done:
813
+ agent_status = "done"
814
+ elif hasattr(first_agent, 'is_running') and not first_agent.is_running:
815
+ agent_status = "idle"
816
+
817
+ return {
818
+ "queued_messages": stats.get("queued_messages", 0),
819
+ "agent_status": agent_status,
820
+ "session_completed": False,
821
+ "streaming_callbacks": stats.get("streaming_callbacks", 0),
822
+ "callback_failures": stats.get("callback_failures", 0)
823
+ }
824
+
825
+ except HTTPException:
826
+ raise
827
+ except Exception as e:
828
+ logger.error(f"Error getting queue status: {e}")
829
+ raise HTTPException(
830
+ status_code=500,
831
+ detail=f"Internal error: {str(e)}"
832
+ )
833
+
834
+ @router.get("/users/{user_id}/sessions", response_model=Dict[str, Any])
835
+ async def get_user_sessions(
836
+ user_id: str,
837
+ req: Request,
838
+ agent = Depends(get_task_agent)
839
+ ):
840
+ """Get all sessions for a user"""
841
+ try:
842
+ # E8 (A6 gap 4): the caller must be the user whose sessions are requested —
843
+ # any authenticated caller could otherwise list another tenant's sessions.
844
+ _require_session_owner(req, user_id)
845
+
846
+ # Get all sessions for user from agent's active sessions (nested structure)
847
+ user_sessions = []
848
+ if hasattr(agent, 'active_sessions') and user_id in agent.active_sessions:
849
+ for sid, session_data in agent.active_sessions[user_id].items():
850
+ session_info = await agent.get_session_by_id(sid)
851
+ if session_info:
852
+ user_sessions.append(session_info)
853
+
854
+ # Get active session from agent state
855
+ active_session_id = None
856
+ if hasattr(agent, 'user_sessions') and user_id in agent.user_sessions:
857
+ active_session_id = agent.user_sessions.get(user_id)
858
+
859
+ return {
860
+ "ok": True,
861
+ "sessions": user_sessions,
862
+ "active_session_id": active_session_id
863
+ }
864
+
865
+ except HTTPException:
866
+ raise
867
+ except Exception as e:
868
+ logger.error(f"Error getting user sessions: {e}")
869
+ raise HTTPException(status_code=500, detail=str(e))
870
+
871
+ @router.post("/users/{user_id}/active_session", response_model=MessageResponse)
872
+ async def switch_active_session(
873
+ user_id: str,
874
+ request: Dict[str, str],
875
+ req: Request,
876
+ agent = Depends(get_task_agent)
877
+ ):
878
+ """Switch the active session for a user"""
879
+ try:
880
+ # E8 (A6 gap 4): the caller must be the user whose active-session pointer
881
+ # is being changed — any authenticated caller could otherwise hijack it.
882
+ _require_session_owner(req, user_id)
883
+
884
+ session_id = request.get("session_id")
885
+ if not session_id:
886
+ raise HTTPException(status_code=400, detail="session_id is required")
887
+
888
+ # Set active session through agent
889
+ if hasattr(agent, 'user_sessions'):
890
+ agent.user_sessions[user_id] = session_id
891
+ success = True
892
+ else:
893
+ success = False
894
+
895
+ if success:
896
+ return MessageResponse(
897
+ success=True,
898
+ message=f"Switched to session {session_id}",
899
+ metadata={"session_id": session_id}
900
+ )
901
+ else:
902
+ raise HTTPException(status_code=400, detail="Failed to switch session")
903
+
904
+ except HTTPException:
905
+ raise
906
+ except Exception as e:
907
+ logger.error(f"Error switching session: {e}")
908
+ raise HTTPException(status_code=500, detail=str(e))
909
+
910
+ @router.get("/capabilities")
911
+ async def get_capabilities(request: Request):
912
+ """Get system capabilities including available models and tools."""
913
+ try:
914
+ from core.container import DependencyContainer
915
+ container = DependencyContainer.get_instance()
916
+
917
+ # FIXED: LLMManager is registered as 'llm', not 'llm_manager'
918
+ llm_manager = container.get_service("llm")
919
+
920
+ # Get available models (only from initialized providers)
921
+ # Include pricing and context info from registry
922
+ from modules.llm.model_registry import get_model_config
923
+
924
+ available_models = []
925
+ if llm_manager and hasattr(llm_manager, 'get_available_models'):
926
+ model_tuples = await llm_manager.get_available_models()
927
+ for provider, model in model_tuples:
928
+ model_info = {"provider": provider, "model": model}
929
+ # Enrich with registry data
930
+ config = get_model_config(model)
931
+ if config:
932
+ model_info["context_window"] = config.context_window
933
+ model_info["max_output"] = config.max_completion_tokens
934
+ if config.pricing:
935
+ model_info["price_input"] = config.pricing.input_price
936
+ model_info["price_output"] = config.pricing.output_price
937
+ available_models.append(model_info)
938
+ else:
939
+ # P0.6: fallback (no llm_manager) now rides the ONE catalog too. Import the
940
+ # builder under an alias to avoid shadowing the local `available_models` list.
941
+ from modules.llm.available_models import available_models as _build_models
942
+ for c in _build_models():
943
+ model_info = {"provider": c.provider, "model": c.model}
944
+ config = get_model_config(c.model)
945
+ if config:
946
+ model_info["context_window"] = config.context_window
947
+ model_info["max_output"] = config.max_completion_tokens
948
+ if config.pricing:
949
+ model_info["price_input"] = config.pricing.input_price
950
+ model_info["price_output"] = config.pricing.output_price
951
+ available_models.append(model_info)
952
+
953
+ # Get available tools dynamically from descriptors
954
+ # Uses centralized functions to ensure consistency across codebase
955
+ from tools.descriptors import get_agent_usable_tools, get_tool_display_name, get_default_tools
956
+
957
+ agent_tools = get_agent_usable_tools()
958
+ available_tools = {}
959
+
960
+ for tool_name, descriptor in agent_tools.items():
961
+ # Get display name (e.g., browser_manager -> browser)
962
+ display_name = get_tool_display_name(tool_name)
963
+
964
+ # Check if tool is initialized in container
965
+ tool = container.get_service(tool_name)
966
+ initialized = tool is not None
967
+
968
+ # Special case: browser_manager is registered as 'browser_manager' but displayed as 'browser'
969
+ if tool_name == 'browser_manager' and not initialized:
970
+ # Check if already checked
971
+ pass # Already using tool_name for lookup
972
+
973
+ available_tools[display_name] = {
974
+ "name": display_name,
975
+ "initialized": initialized,
976
+ "description": descriptor.description,
977
+ "category": descriptor.category.value if descriptor.category else "other"
978
+ }
979
+
980
+ # Get default tools from centralized source
981
+ default_tools_list = get_default_tools()
982
+
983
+ # Get default configuration
984
+ config = container.config
985
+ # Default to Grok 4.1 Fast for task agent (best agentic model)
986
+ default_model = getattr(config, 'model', 'x-ai/grok-4.1-fast')
987
+ default_provider = getattr(config, 'provider', 'openrouter')
988
+
989
+ # Get MCP server information if available, including connection status
990
+ mcp_servers = {"global": [], "user": [], "include_global": True}
991
+ mcp_tool = container.get_service('mcp')
992
+ if mcp_tool and hasattr(mcp_tool, 'server_manager') and mcp_tool.server_manager:
993
+ try:
994
+ # Get detailed server info including status
995
+ servers_info = await mcp_tool.server_manager.list_servers()
996
+ for server_info in servers_info:
997
+ name = server_info.get('name', '')
998
+ status = server_info.get('status', 'unknown')
999
+ last_error = server_info.get('last_error', None)
1000
+ tools_count = server_info.get('tools_count', 0)
1001
+
1002
+ server_entry = {
1003
+ "name": name,
1004
+ "tool_id": f"mcp:{name}",
1005
+ "type": "global",
1006
+ "status": status, # connected, error, disconnected
1007
+ "tools_count": tools_count
1008
+ }
1009
+ # Include error message if server failed to connect
1010
+ if status == "error" and last_error:
1011
+ server_entry["error"] = last_error
1012
+
1013
+ mcp_servers["global"].append(server_entry)
1014
+ except Exception as e:
1015
+ logger.warning(f"Failed to get MCP server details: {e}")
1016
+ # Fallback to basic listing
1017
+ if hasattr(mcp_tool, 'get_global_server_names'):
1018
+ global_server_names = mcp_tool.get_global_server_names()
1019
+ for full_name in global_server_names:
1020
+ name = full_name.replace("global::", "")
1021
+ mcp_servers["global"].append({
1022
+ "name": name,
1023
+ "tool_id": f"mcp:{name}",
1024
+ "type": "global",
1025
+ "status": "unknown"
1026
+ })
1027
+
1028
+ # Get user MCP servers if authenticated
1029
+ user_id = getattr(request.state, 'user_id', None)
1030
+ if user_id:
1031
+ user_mcp_service = container.get_service('user_mcp_service')
1032
+ if user_mcp_service:
1033
+ try:
1034
+ result = await user_mcp_service.get_available_servers_for_session(user_id)
1035
+ mcp_servers["user"] = result.get("user", [])
1036
+ mcp_servers["include_global"] = result.get("include_global", True)
1037
+ except Exception as e:
1038
+ logger.warning(f"Failed to get user MCP servers: {e}")
1039
+
1040
+ return {
1041
+ "models": available_models,
1042
+ "tools": available_tools,
1043
+ "mcp_servers": mcp_servers,
1044
+ "default_model": default_model,
1045
+ "default_provider": default_provider,
1046
+ "default_tools": default_tools_list # From descriptors.get_default_tools()
1047
+ }
1048
+
1049
+ except Exception as e:
1050
+ logger.error(f"Error getting capabilities: {e}")
1051
+ raise HTTPException(status_code=500, detail=str(e))
1052
+
1053
+ @router.post("/sessions")
1054
+ async def create_session(
1055
+ request_body: Dict[str, Any],
1056
+ req: Request,
1057
+ agent = Depends(get_task_agent)
1058
+ ):
1059
+ """Create and start a new Task session.
1060
+
1061
+ BETA: Requires DEN token ownership for session creation.
1062
+ Supports both credits (for authenticated users) and x402 payment.
1063
+ """
1064
+ try:
1065
+ from agents.task_agent_lite import SessionRequest
1066
+ from api.payment_verification import verify_payment_for_request, payment_required_response
1067
+ from fastapi.responses import JSONResponse
1068
+ from core.exceptions import SessionOwnershipError
1069
+
1070
+ # Extract parameters with defaults
1071
+ task = request_body.get("task")
1072
+ if not task:
1073
+ raise HTTPException(status_code=400, detail="Task is required")
1074
+
1075
+ # PAYMENT VERIFICATION (NEW)
1076
+ # This fixes the critical gap: endpoints now verify payment before execution
1077
+ try:
1078
+ payment_method, payment_details = await verify_payment_for_request(
1079
+ request=req,
1080
+ cost_credits=1 # Session creation = 1 credit ($0.01) - LLM costs metered separately
1081
+ )
1082
+ logger.info(f"Payment verified via {payment_method}: {payment_details}")
1083
+ except HTTPException as e:
1084
+ if e.status_code == 402:
1085
+ # Return payment options (credits + x402)
1086
+ response_data = payment_required_response(req, cost_credits=1)
1087
+ return JSONResponse(status_code=402, content=response_data)
1088
+ raise
1089
+
1090
+ # SECURITY: Get user_id from authenticated JWT token or x402 payment
1091
+ # Request body user_id is ignored to prevent session hijacking
1092
+ from utils.auth_utils import get_authenticated_user_id, get_user_tier
1093
+ import os
1094
+
1095
+ # If paid via x402, get user_id from request state (set by middleware)
1096
+ if payment_method == "x402":
1097
+ # Middleware sets proper user_id (usr_xxx format) from wallet
1098
+ user_id = getattr(req.state, 'user_id', None)
1099
+ if not user_id:
1100
+ # Fallback: generate from payer_address
1101
+ from core.identity import generate_user_id_from_wallet
1102
+ payer_address = payment_details.get("payer_address")
1103
+ if payer_address:
1104
+ user_id = generate_user_id_from_wallet(payer_address)
1105
+ else:
1106
+ user_id = "x402_user"
1107
+ tier = "x402" # x402 users have special tier
1108
+ else:
1109
+ user_id = get_authenticated_user_id(req)
1110
+ tier = get_user_tier(req)
1111
+
1112
+ logger.info(f"Creating session for user: {user_id} (tier: {tier}, payment: {payment_method})")
1113
+
1114
+ # BETA ACCESS: Only allow tiers with full access
1115
+ # - free: BLOCKED (no DEN token, no admin grant)
1116
+ # - free_access: ALLOWED (admin-granted access, uses credits)
1117
+ # - holder: ALLOWED (has DEN token)
1118
+ # - x402: ALLOWED (pay-per-request)
1119
+ # - admin: ALLOWED (via admin_bypass payment method)
1120
+ from api.auth_constants import has_full_access
1121
+ is_development = os.getenv('ENVIRONMENT', 'production') == 'development'
1122
+ is_admin_user = payment_method == "admin_bypass"
1123
+
1124
+ if not is_development and not is_admin_user and not has_full_access(tier):
1125
+ raise HTTPException(
1126
+ status_code=403,
1127
+ detail="Beta access: Task automation requires DEN token ownership or admin-granted access. Join https://t.me/tmachinrobot for access."
1128
+ )
1129
+
1130
+ # Build session config with tools_config if provided
1131
+ from agents.task.config import TaskSessionConfig
1132
+ from agents.task.utils import detect_llm_provider
1133
+
1134
+ session_config = TaskSessionConfig.defaults()
1135
+ session_config.llm.model = request_body.get("model", "gpt-5")
1136
+
1137
+ # Auto-detect provider from model name if not specified
1138
+ if "provider" in request_body:
1139
+ session_config.llm.provider = request_body["provider"]
1140
+ else:
1141
+ session_config.llm.provider = detect_llm_provider(None, session_config.llm.model)
1142
+ logger.info(f"Auto-detected provider '{session_config.llm.provider}' for model '{session_config.llm.model}'")
1143
+
1144
+ session_config.llm.temperature = request_body.get("temperature", 0.0)
1145
+
1146
+ # Auto-detect vision support from model registry if not explicitly set
1147
+ if "use_vision" in request_body:
1148
+ session_config.llm.use_vision = request_body["use_vision"]
1149
+ else:
1150
+ # Check model registry for vision support
1151
+ from modules.llm.model_registry import get_model_config
1152
+ model_config = get_model_config(session_config.llm.model)
1153
+ if model_config and model_config.capabilities:
1154
+ session_config.llm.use_vision = model_config.capabilities.supports_vision
1155
+ logger.info(f"Auto-detected vision support for '{session_config.llm.model}': {session_config.llm.use_vision}")
1156
+ else:
1157
+ session_config.llm.use_vision = False # Conservative default for unknown models
1158
+ logger.warning(f"Model '{session_config.llm.model}' not in registry - defaulting to no vision")
1159
+
1160
+ session_config.limits.max_steps = request_body.get("max_steps", 50)
1161
+ session_config.tools = request_body.get("tools", ["browser", "filesystem"])
1162
+
1163
+ # Handle MCP server selection
1164
+ # mcp_servers is a list of tool_ids like ["mcp:anysite", "mcp:user:myserver"]
1165
+ mcp_servers = request_body.get("mcp_servers", [])
1166
+ if mcp_servers:
1167
+ # Add 'mcp' to tools if not present and we have MCP servers selected
1168
+ if "mcp" not in session_config.tools:
1169
+ session_config.tools.append("mcp")
1170
+ # Store requested MCP servers in tools_config for the orchestrator
1171
+ if not hasattr(session_config, 'tools_config') or session_config.tools_config is None:
1172
+ session_config.tools_config = {}
1173
+ session_config.tools_config["mcp_servers"] = mcp_servers
1174
+ logger.info(f"Session configured with MCP servers: {mcp_servers}")
1175
+
1176
+ # Add tools_config if provided
1177
+ if "tools_config" in request_body:
1178
+ if not hasattr(session_config, 'tools_config') or session_config.tools_config is None:
1179
+ session_config.tools_config = {}
1180
+ session_config.tools_config.update(request_body.get("tools_config", {}))
1181
+
1182
+ # Create session request with session_config
1183
+ session_request = SessionRequest(
1184
+ task=task,
1185
+ model=session_config.llm.model,
1186
+ provider=session_config.llm.provider,
1187
+ tools=session_config.tools,
1188
+ max_steps=session_config.limits.max_steps,
1189
+ temperature=session_config.llm.temperature,
1190
+ use_vision=session_config.llm.use_vision,
1191
+ session_config=session_config.to_dict()
1192
+ )
1193
+
1194
+ # user_id already extracted above (line 732)
1195
+ session_id = request_body.get("session_id") # Allow CLI to specify
1196
+
1197
+ # Create session
1198
+ try:
1199
+ session_info = await agent.create_session(
1200
+ user_id,
1201
+ session_request,
1202
+ session_id=session_id
1203
+ )
1204
+ except SessionOwnershipError as owner_error:
1205
+ # C4: client-supplied session_id belongs to another user.
1206
+ raise HTTPException(status_code=403, detail=str(owner_error))
1207
+ except Exception as create_error:
1208
+ logger.error(f"Session creation failed: {create_error}", exc_info=True)
1209
+ raise HTTPException(status_code=500, detail=f"Failed to create session: {str(create_error)}")
1210
+
1211
+ if not session_info:
1212
+ raise HTTPException(status_code=500, detail="Failed to create session - no session info returned")
1213
+
1214
+ session_id = session_info.get("id")
1215
+
1216
+ # Create workspace directory immediately to prevent race condition with file uploads
1217
+ from agents.task.path import pm
1218
+ workspace_dir = pm().get_workspace_dir(session_id, user_id)
1219
+ workspace_dir.mkdir(parents=True, exist_ok=True)
1220
+ logger.info(f"Created workspace directory for session {session_id}: {workspace_dir}")
1221
+
1222
+ # Generate WebView URL using the utility function (single source of truth)
1223
+ from agents.task.utils_webview import get_webview_url
1224
+ webview_url = get_webview_url(session_id)
1225
+
1226
+ # Store in session metadata
1227
+ if hasattr(agent, 'session_manager'):
1228
+ try:
1229
+ agent.session_manager.update_session_metadata(
1230
+ session_id, {"webview_url": webview_url}
1231
+ )
1232
+ except Exception as e:
1233
+ logger.debug(f"Failed to store webview URL in metadata: {e}")
1234
+
1235
+ # AUTO-START: Check if client wants to delay start (for file uploads)
1236
+ # CRITICAL: Log the received value for debugging
1237
+ auto_start_requested = request_body.get("auto_start", True)
1238
+ logger.info(f"📋 Session creation auto_start parameter: {auto_start_requested} (type: {type(auto_start_requested).__name__})")
1239
+
1240
+ # CRITICAL FIX: Force auto_start=false if files will be attached
1241
+ # Don't trust frontend - enforce on backend to prevent race conditions
1242
+ wait_for_uploads = request_body.get("wait_for_uploads", False)
1243
+ has_attached_files = request_body.get("attached_files") and len(request_body.get("attached_files", [])) > 0
1244
+
1245
+ if wait_for_uploads or has_attached_files:
1246
+ auto_start = False
1247
+ logger.info(f"🔒 FORCING auto_start=false (wait_for_uploads={wait_for_uploads}, has_attached_files={has_attached_files})")
1248
+ else:
1249
+ auto_start = auto_start_requested
1250
+ logger.info(f"✓ Using requested auto_start={auto_start}")
1251
+
1252
+ if auto_start:
1253
+ # Start agent execution in background with the task
1254
+ logger.info(f"Auto-starting session {session_id} with task: {task[:100]}...")
1255
+
1256
+ import asyncio
1257
+
1258
+ async def start_session_with_task():
1259
+ try:
1260
+ logger.info(f"Starting session execution for {session_id}")
1261
+ result = await agent.run_session(user_id, session_id)
1262
+ logger.info(f"Session {session_id} completed with result: {result}")
1263
+ except Exception as e:
1264
+ logger.error(f"Session {session_id} failed with error: {e}", exc_info=True)
1265
+
1266
+ _spawn_session_task(start_session_with_task())
1267
+
1268
+ # Return immediately - agent runs in background
1269
+ return SessionResponse(
1270
+ ok=True,
1271
+ session_id=session_id,
1272
+ task=task,
1273
+ status="running",
1274
+ model=session_request.model,
1275
+ tools=session_request.tools,
1276
+ webview_url=webview_url,
1277
+ message="Session created and started successfully"
1278
+ )
1279
+ else:
1280
+ # Client will upload files and send first message manually
1281
+ logger.info(f"Created session {session_id} for user {user_id} (waiting for first message)")
1282
+ return SessionResponse(
1283
+ ok=True,
1284
+ session_id=session_id,
1285
+ task=task,
1286
+ status="created",
1287
+ model=session_request.model,
1288
+ tools=session_request.tools,
1289
+ webview_url=webview_url,
1290
+ message="Session created - send a message to start"
1291
+ )
1292
+
1293
+ except HTTPException:
1294
+ raise
1295
+ except Exception as e:
1296
+ logger.error(f"Error creating session: {e}")
1297
+ raise HTTPException(status_code=500, detail=str(e))
1298
+
1299
+ @router.get("/metrics", response_model=dict)
1300
+ async def get_resource_metrics(agent = Depends(get_task_agent)):
1301
+ """Get current resource usage metrics.
1302
+
1303
+ Returns:
1304
+ Resource usage statistics for monitoring
1305
+ """
1306
+ try:
1307
+ import psutil
1308
+ import os
1309
+
1310
+ # Get process memory
1311
+ process = psutil.Process(os.getpid())
1312
+ memory_info = process.memory_info()
1313
+
1314
+ # Session metrics
1315
+ sessions_in_memory = agent.active_session_count()
1316
+ max_sessions = agent.max_sessions_in_memory
1317
+
1318
+ # Browser metrics
1319
+ browser_manager = None
1320
+ if agent.container and agent.container.has_service('browser_manager'):
1321
+ browser_manager = agent.container.get_service('browser_manager')
1322
+
1323
+ browser_metrics = {}
1324
+ if browser_manager:
1325
+ browser_metrics = {
1326
+ "contexts_in_use": len(browser_manager.contexts_in_use),
1327
+ "max_contexts": browser_manager.browser_config.max_contexts,
1328
+ "contexts_in_pool": len(browser_manager.context_pool),
1329
+ "wait_queue_depth": len(browser_manager._wait_queue),
1330
+ "utilization_pct": (len(browser_manager.contexts_in_use) / browser_manager.browser_config.max_contexts * 100) if browser_manager.browser_config.max_contexts > 0 else 0
1331
+ }
1332
+
1333
+ # Session breakdown by status
1334
+ all_sessions = agent.get_all_sessions()
1335
+ status_counts = {}
1336
+ for session_id, session_info in all_sessions.items():
1337
+ status = session_info.get('status', 'unknown')
1338
+ status_counts[status] = status_counts.get(status, 0) + 1
1339
+
1340
+ # User distribution
1341
+ user_session_counts = {}
1342
+ for session_id, session_info in all_sessions.items():
1343
+ user_id = session_info.get('user_id', '_anonymous_')
1344
+ user_session_counts[user_id] = user_session_counts.get(user_id, 0) + 1
1345
+
1346
+ # Message queue metrics
1347
+ total_queued_messages = 0
1348
+ for orchestrator in agent.active_orchestrators():
1349
+ for agent_obj in orchestrator.agents.values():
1350
+ if hasattr(agent_obj, 'hitl_manager') and agent_obj.hitl_manager:
1351
+ total_queued_messages += agent_obj.hitl_manager.get_queue_size()
1352
+
1353
+ return {
1354
+ "timestamp": datetime.now().isoformat(),
1355
+ "sessions": {
1356
+ "in_memory": sessions_in_memory,
1357
+ "max_allowed": max_sessions,
1358
+ "utilization_pct": (sessions_in_memory / max_sessions * 100) if max_sessions > 0 else 0,
1359
+ "by_status": status_counts,
1360
+ "total_across_all_users": len(all_sessions)
1361
+ },
1362
+ "browser": browser_metrics,
1363
+ "memory": {
1364
+ "process_mb": memory_info.rss / 1024 / 1024,
1365
+ "process_mb_peak": memory_info.vms / 1024 / 1024,
1366
+ "system_available_mb": psutil.virtual_memory().available / 1024 / 1024,
1367
+ "system_total_mb": psutil.virtual_memory().total / 1024 / 1024,
1368
+ "system_used_pct": psutil.virtual_memory().percent
1369
+ },
1370
+ "messages": {
1371
+ "total_queued": total_queued_messages,
1372
+ "queues_active": sum(1 for o in agent.active_orchestrators() for a in o.agents.values() if hasattr(a, 'hitl_manager'))
1373
+ },
1374
+ "users": {
1375
+ "total_users": len(user_session_counts),
1376
+ "sessions_per_user": user_session_counts,
1377
+ "top_users": sorted(user_session_counts.items(), key=lambda x: x[1], reverse=True)[:5]
1378
+ }
1379
+ }
1380
+
1381
+ except Exception as e:
1382
+ logger.error(f"Error getting metrics: {e}")
1383
+ raise HTTPException(status_code=500, detail=str(e))
1384
+
1385
+ async def verify_files_ready(
1386
+ file_paths: List[str],
1387
+ session_id: str,
1388
+ user_id: str,
1389
+ max_wait_seconds: float = 10.0
1390
+ ) -> tuple[bool, List[str]]:
1391
+ """Verify that all uploaded files exist and are readable.
1392
+
1393
+ Implements explicit synchronization instead of relying on timing.
1394
+ Retries with exponential backoff to handle filesystem latency.
1395
+
1396
+ Args:
1397
+ file_paths: List of relative file paths (e.g., ['image.png'])
1398
+ session_id: Session ID for workspace resolution
1399
+ user_id: User ID for workspace resolution
1400
+ max_wait_seconds: Maximum time to wait for files (default 10s)
1401
+
1402
+ Returns:
1403
+ tuple: (all_ready: bool, missing_files: List[str])
1404
+ """
1405
+ from pathlib import Path
1406
+ from agents.task.path import pm
1407
+ import asyncio
1408
+
1409
+ workspace_dir = pm().get_workspace_dir(session_id, user_id)
1410
+
1411
+ # Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, 3.2s, ...
1412
+ wait_time = 0.1
1413
+ total_waited = 0.0
1414
+ attempt = 0
1415
+
1416
+ while total_waited < max_wait_seconds:
1417
+ attempt += 1
1418
+ missing_files = []
1419
+ unreadable_files = []
1420
+
1421
+ for file_path in file_paths:
1422
+ full_path = workspace_dir / file_path
1423
+
1424
+ # Check existence
1425
+ if not full_path.exists():
1426
+ missing_files.append(file_path)
1427
+ continue
1428
+
1429
+ # Check readability and size
1430
+ try:
1431
+ stat = full_path.stat()
1432
+ if stat.st_size == 0:
1433
+ unreadable_files.append(f"{file_path} (0 bytes)")
1434
+ continue
1435
+
1436
+ # Try to open for reading (verifies no write lock)
1437
+ with open(full_path, 'rb') as f:
1438
+ # Read first byte to ensure file is actually accessible
1439
+ f.read(1)
1440
+
1441
+ except (IOError, PermissionError) as e:
1442
+ unreadable_files.append(f"{file_path} ({e})")
1443
+
1444
+ # All files ready?
1445
+ if not missing_files and not unreadable_files:
1446
+ if attempt > 1:
1447
+ logger.info(
1448
+ f"📁 All {len(file_paths)} file(s) verified ready after {attempt} attempts "
1449
+ f"({total_waited:.2f}s total wait)"
1450
+ )
1451
+ return (True, [])
1452
+
1453
+ # Log status on first attempt and every 5th attempt
1454
+ if attempt == 1 or attempt % 5 == 0:
1455
+ logger.info(
1456
+ f"📁 Waiting for files (attempt {attempt}, {total_waited:.2f}s waited): "
1457
+ f"missing={len(missing_files)}, unreadable={len(unreadable_files)}"
1458
+ )
1459
+ if missing_files:
1460
+ logger.debug(f" Missing: {missing_files}")
1461
+ if unreadable_files:
1462
+ logger.debug(f" Unreadable: {unreadable_files}")
1463
+
1464
+ # Wait before retry
1465
+ await asyncio.sleep(wait_time)
1466
+ total_waited += wait_time
1467
+ wait_time = min(wait_time * 2, 2.0) # Cap at 2s
1468
+
1469
+ # Timeout - return what's missing
1470
+ all_problems = missing_files + unreadable_files
1471
+ logger.warning(
1472
+ f"📁 File verification timeout after {total_waited:.2f}s. "
1473
+ f"Problems with {len(all_problems)} file(s): {all_problems}"
1474
+ )
1475
+ return (False, all_problems)
1476
+
1477
+ async def inject_file_content_to_message(
1478
+ file_path,
1479
+ message_text: str,
1480
+ session_id: str,
1481
+ user_id: str
1482
+ ) -> tuple[str, Optional[List[Dict[str, Any]]]]:
1483
+ """Smart content injection with IMAGE support for vision.
1484
+
1485
+ Returns both updated text and optional image attachments for vision models.
1486
+
1487
+ Small text files (< 30KB): Inject full content inline
1488
+ Large text files (>= 30KB): Just mention with metadata
1489
+ Images: Convert to base64 for vision models
1490
+
1491
+ Args:
1492
+ file_path: Path to file in workspace
1493
+ message_text: User's message text
1494
+ session_id: Session ID for path resolution
1495
+ user_id: User ID for path resolution
1496
+
1497
+ Returns:
1498
+ tuple: (combined_message_text, image_attachments)
1499
+ - combined_message_text: Text with file references
1500
+ - image_attachments: List of image data dicts for vision (None if no images)
1501
+ """
1502
+ from pathlib import Path
1503
+ from agents.task.path import pm
1504
+ import aiofiles
1505
+ import base64
1506
+
1507
+ # Get full file path
1508
+ workspace_dir = pm().get_workspace_dir(session_id, user_id)
1509
+ full_path = workspace_dir / file_path
1510
+
1511
+ if not full_path.exists():
1512
+ return (f"{message_text}\n\n[Error: Attached file not found: {file_path}]", None)
1513
+
1514
+ file_size = full_path.stat().st_size
1515
+ file_ext = full_path.suffix.lower()
1516
+
1517
+ # IMAGE HANDLING (NEW): Convert to base64 for vision
1518
+ IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
1519
+ if file_ext in IMAGE_EXTENSIONS:
1520
+ try:
1521
+ # Validate image size (10MB max, same as upload limit)
1522
+ MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
1523
+ if file_size > MAX_IMAGE_SIZE:
1524
+ size_mb = file_size / 1024 / 1024
1525
+ return (
1526
+ f"{message_text}\n\n[Error: Image too large ({size_mb:.1f}MB). Max: 10MB for vision processing]",
1527
+ None
1528
+ )
1529
+
1530
+ # Read image as binary and convert to base64
1531
+ # Using synchronous I/O (FastAPI handles blocking ops via thread pool)
1532
+ with open(full_path, 'rb') as f:
1533
+ image_bytes = f.read()
1534
+
1535
+ image_base64 = base64.b64encode(image_bytes).decode('utf-8')
1536
+
1537
+ # Determine MIME type
1538
+ mime_map = {
1539
+ '.png': 'image/png',
1540
+ '.jpg': 'image/jpeg',
1541
+ '.jpeg': 'image/jpeg',
1542
+ '.gif': 'image/gif',
1543
+ '.webp': 'image/webp'
1544
+ }
1545
+ mime_type = mime_map.get(file_ext, 'image/png')
1546
+
1547
+ # Return text + image data for multimodal message
1548
+ size_kb = file_size / 1024
1549
+ size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
1550
+
1551
+ updated_text = f"{message_text}\n\n[Attached image: {full_path.name} ({size_str})]\nPath in workspace: {file_path}"
1552
+ image_data = {
1553
+ 'type': 'image_url',
1554
+ 'image_url': {
1555
+ 'url': f'data:{mime_type};base64,{image_base64}'
1556
+ }
1557
+ }
1558
+
1559
+ logger.info(f"📷 Image attached for vision: {full_path.name} ({size_str})")
1560
+ return (updated_text, [image_data])
1561
+
1562
+ except Exception as e:
1563
+ logger.error(f"Failed to process image {full_path.name}: {e}")
1564
+ return (
1565
+ f"{message_text}\n\n[Error: Failed to process image {full_path.name}: {str(e)}]",
1566
+ None
1567
+ )
1568
+
1569
+ # TEXT FILE HANDLING (existing logic)
1570
+ INJECT_SIZE_THRESHOLD = 30 * 1024 # 30KB
1571
+
1572
+ if file_size < INJECT_SIZE_THRESHOLD:
1573
+ # Small file - inject full content
1574
+ if file_ext in ['.txt', '.md', '.csv', '.json', '.xml', '.py', '.js', '.html', '.css']:
1575
+ try:
1576
+ # Using synchronous I/O (FastAPI handles blocking ops via thread pool)
1577
+ with open(full_path, 'r', encoding='utf-8') as f:
1578
+ content = f.read()
1579
+
1580
+ combined_text = f"""{message_text}
1581
+
1582
+ [Attached file: {full_path.name} ({file_size/1024:.1f}KB)]
1583
+ --- File Content Start ---
1584
+ {content}
1585
+ --- File Content End ---"""
1586
+ return (combined_text, None)
1587
+ except UnicodeDecodeError:
1588
+ # Fall through to large file handling
1589
+ pass
1590
+
1591
+ # Large file or binary - just reference
1592
+ size_kb = file_size / 1024
1593
+ size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
1594
+
1595
+ reference_text = f"""{message_text}
1596
+
1597
+ [Attached large file: {full_path.name} ({size_str})]
1598
+ File type: {file_ext}
1599
+ Path in workspace: {file_path}
1600
+
1601
+ To read this file, use: filesystem_read_file(path="{file_path}")"""
1602
+
1603
+ return (reference_text, None)
1604
+
1605
+ @router.post("/sessions/{session_id}/workspace/upload")
1606
+ async def upload_document(
1607
+ session_id: str,
1608
+ file: UploadFile = File(...),
1609
+ req: Request = None,
1610
+ agent = Depends(get_task_agent)
1611
+ ):
1612
+ """Upload a file to session workspace.
1613
+
1614
+ Supports:
1615
+ - Documents: PDF, DOCX, DOC, TXT, MD, CSV, JSON, XML
1616
+ - Images: PNG, JPG, JPEG, GIF, WEBP (for vision)
1617
+
1618
+ Max size: 10MB per file
1619
+ """
1620
+ from pathlib import Path
1621
+ from agents.task.path import pm
1622
+ import aiofiles
1623
+
1624
+ try:
1625
+ # 1. Clean session ID
1626
+ session_id = clean_session_id_at_entry(session_id)
1627
+
1628
+ # 2. Validate session exists
1629
+ session_info = agent.session_manager.get_session_info(session_id)
1630
+ if not session_info:
1631
+ raise HTTPException(status_code=404, detail="Session not found")
1632
+
1633
+ # 3. Security: Get authenticated user_id
1634
+ from utils.auth_utils import get_authenticated_user_id
1635
+ user_id = get_authenticated_user_id(req)
1636
+
1637
+ # Verify user owns this session
1638
+ session_owner = session_info.get('user_id')
1639
+ logger.info(f"[Upload Auth Check] Token user_id: {user_id}, Session owner: {session_owner}, Match: {user_id == session_owner}")
1640
+
1641
+ if session_owner != user_id:
1642
+ logger.warning(f"[Upload Auth FAILED] Access denied - user {user_id} tried to upload to session owned by {session_owner}")
1643
+ raise HTTPException(status_code=403, detail=f"Access denied - session owned by different user")
1644
+
1645
+ # 4. Validate file type (documents + images for vision)
1646
+ ALLOWED_EXTENSIONS = {'.pdf', '.docx', '.doc', '.txt', '.md', '.csv', '.json', '.xml', '.png', '.jpg', '.jpeg', '.gif', '.webp'}
1647
+ file_ext = Path(file.filename).suffix.lower()
1648
+
1649
+ if file_ext not in ALLOWED_EXTENSIONS:
1650
+ raise HTTPException(
1651
+ status_code=400,
1652
+ detail=f"File type {file_ext} not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
1653
+ )
1654
+
1655
+ # 5. Validate file size (10MB limit)
1656
+ MAX_SIZE = 10 * 1024 * 1024 # 10MB
1657
+ file_content = await file.read()
1658
+ if len(file_content) > MAX_SIZE:
1659
+ raise HTTPException(
1660
+ status_code=400,
1661
+ detail=f"File too large. Max size: {MAX_SIZE/1024/1024}MB"
1662
+ )
1663
+
1664
+ # 6. Validate MIME type (security: prevent file type spoofing)
1665
+ ALLOWED_MIME_TYPES = {
1666
+ 'application/pdf',
1667
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .docx
1668
+ 'application/msword', # .doc
1669
+ 'text/plain',
1670
+ 'text/markdown',
1671
+ 'text/csv',
1672
+ 'application/json',
1673
+ 'application/xml',
1674
+ 'text/xml',
1675
+ # Image types for vision
1676
+ 'image/png',
1677
+ 'image/jpeg',
1678
+ 'image/gif',
1679
+ 'image/webp'
1680
+ }
1681
+
1682
+ try:
1683
+ import magic
1684
+ # Detect actual MIME type from file content (not just extension)
1685
+ detected_mime = magic.from_buffer(file_content, mime=True)
1686
+
1687
+ if detected_mime not in ALLOWED_MIME_TYPES:
1688
+ logger.warning(f"Rejected file with MIME type {detected_mime}: {file.filename}")
1689
+ raise HTTPException(
1690
+ status_code=400,
1691
+ detail=f"Invalid file type detected: {detected_mime}. Allowed types: PDF, DOCX, DOC, TXT, MD, CSV, JSON, XML, PNG, JPG, JPEG, GIF, WEBP"
1692
+ )
1693
+
1694
+ logger.info(f"File MIME type validated: {detected_mime} for {file.filename}")
1695
+ except ImportError:
1696
+ logger.warning("python-magic not installed - skipping MIME type validation (security risk!)")
1697
+ except Exception as e:
1698
+ logger.warning(f"MIME type detection failed: {e}, using extension check only")
1699
+
1700
+ # 7. Sanitize filename (prevent path traversal)
1701
+ logger.info(f"[Upload] Step 7: Sanitizing filename '{file.filename}'")
1702
+ from utils.path_validator import sanitize_filename
1703
+ safe_filename = sanitize_filename(file.filename)
1704
+ logger.info(f"[Upload] Sanitized filename: '{safe_filename}'")
1705
+
1706
+ # 8. Get workspace directory
1707
+ logger.info(f"[Upload] Step 8: Getting workspace directory for session {session_id}, user {user_id}")
1708
+ workspace_dir = pm().get_workspace_dir(session_id, user_id)
1709
+ logger.info(f"[Upload] Workspace directory: {workspace_dir}")
1710
+ file_path = workspace_dir / safe_filename
1711
+ logger.info(f"[Upload] Full file path: {file_path}")
1712
+
1713
+ # Handle duplicate filenames
1714
+ if file_path.exists():
1715
+ # Add timestamp suffix
1716
+ from datetime import datetime
1717
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1718
+ stem = file_path.stem
1719
+ suffix = file_path.suffix
1720
+ safe_filename = f"{stem}_{timestamp}{suffix}"
1721
+ file_path = workspace_dir / safe_filename
1722
+
1723
+ # 9. Write file to workspace
1724
+ # Using synchronous I/O (FastAPI handles blocking ops via thread pool)
1725
+ # Previous async implementation was hanging indefinitely
1726
+ logger.info(f"[Upload] Step 9: Writing {len(file_content)} bytes to {file_path}")
1727
+ try:
1728
+ with open(file_path, 'wb') as f:
1729
+ f.write(file_content)
1730
+ logger.info(f"[Upload] File write completed successfully")
1731
+ except Exception as write_error:
1732
+ logger.error(f"[Upload] File write failed: {write_error}", exc_info=True)
1733
+ raise
1734
+
1735
+ logger.info(f"Uploaded document to session {session_id}: {safe_filename}")
1736
+
1737
+ # 10. Generate file ID for client tracking
1738
+ file_id = str(uuid.uuid4())
1739
+
1740
+ # 11. Determine if file can be auto-injected (30KB threshold)
1741
+ INJECT_SIZE_THRESHOLD = 30 * 1024 # 30KB
1742
+ can_inject = len(file_content) < INJECT_SIZE_THRESHOLD
1743
+
1744
+ # 12. Add to feed for UI notification
1745
+ logger.info(f"[Upload] Step 12: Adding to feed for session {session_id}")
1746
+ agent.session_manager.add_to_feed(
1747
+ session_id,
1748
+ 'document_uploaded',
1749
+ {
1750
+ 'file_id': file_id,
1751
+ 'filename': safe_filename,
1752
+ 'size': len(file_content),
1753
+ 'path': str(file_path.relative_to(workspace_dir)),
1754
+ 'can_inject': can_inject,
1755
+ 'timestamp': time.time()
1756
+ }
1757
+ )
1758
+ logger.info(f"[Upload] Feed notification added successfully")
1759
+
1760
+ # 13. Notify workspace context of upload (for agent awareness)
1761
+ logger.info(f"[Upload] Step 13: Notifying workspace context")
1762
+ try:
1763
+ from agents.task.workspace_context import get_workspace_context
1764
+ workspace_ctx = get_workspace_context()
1765
+ workspace_ctx.notify_upload(
1766
+ session_id=session_id,
1767
+ user_id=user_id,
1768
+ filename=safe_filename,
1769
+ size=len(file_content)
1770
+ )
1771
+ logger.debug(f"Notified workspace context of upload: {safe_filename}")
1772
+ except Exception as e:
1773
+ logger.warning(f"Failed to notify workspace context: {e}")
1774
+
1775
+ # 14. Return success response with file metadata
1776
+ # Determine file type for appropriate message
1777
+ is_image = file_ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
1778
+ file_type = "Image" if is_image else "Document"
1779
+
1780
+ response_data = {
1781
+ 'success': True,
1782
+ 'file_id': file_id,
1783
+ 'filename': safe_filename,
1784
+ 'path': str(file_path.relative_to(workspace_dir)),
1785
+ 'size': len(file_content),
1786
+ 'can_inject': can_inject,
1787
+ 'is_image': is_image, # NEW: Flag for frontend to handle images differently
1788
+ 'message': f'{file_type} uploaded successfully'
1789
+ }
1790
+ logger.info(f"[Upload] Step 14: Returning success response for {safe_filename}")
1791
+ return response_data
1792
+
1793
+ except HTTPException:
1794
+ raise
1795
+ except Exception as e:
1796
+ logger.error(f"Error uploading document: {e}", exc_info=True)
1797
+ raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
1798
+
1799
+ @router.get("/sessions/{session_id}/documents")
1800
+ async def list_session_documents(
1801
+ session_id: str,
1802
+ req: Request = None,
1803
+ agent = Depends(get_task_agent)
1804
+ ):
1805
+ """List all documents in session workspace with metadata."""
1806
+ from pathlib import Path
1807
+ from agents.task.path import pm
1808
+
1809
+ try:
1810
+ # 1. Clean session ID
1811
+ session_id = clean_session_id_at_entry(session_id)
1812
+
1813
+ # 2. Validate session and access
1814
+ session_info = agent.session_manager.get_session_info(session_id)
1815
+ if not session_info:
1816
+ raise HTTPException(status_code=404, detail="Session not found")
1817
+
1818
+ from utils.auth_utils import get_authenticated_user_id
1819
+ user_id = get_authenticated_user_id(req)
1820
+
1821
+ if session_info.get('user_id') != user_id:
1822
+ raise HTTPException(status_code=403, detail="Access denied")
1823
+
1824
+ # 3. Get workspace directory
1825
+ workspace_dir = pm().get_workspace_dir(session_id, user_id)
1826
+
1827
+ # 4. Scan for documents
1828
+ documents = []
1829
+ DOCUMENT_EXTENSIONS = {'.pdf', '.docx', '.doc', '.txt', '.md', '.csv', '.json', '.xml'}
1830
+
1831
+ for file_path in workspace_dir.rglob('*'):
1832
+ if file_path.is_file() and file_path.suffix.lower() in DOCUMENT_EXTENSIONS:
1833
+ stat = file_path.stat()
1834
+ documents.append({
1835
+ 'filename': file_path.name,
1836
+ 'path': str(file_path.relative_to(workspace_dir)),
1837
+ 'size': stat.st_size,
1838
+ 'modified': stat.st_mtime,
1839
+ 'extension': file_path.suffix.lower()
1840
+ })
1841
+
1842
+ # Sort by modification time (newest first)
1843
+ documents.sort(key=lambda x: x['modified'], reverse=True)
1844
+
1845
+ return {
1846
+ 'success': True,
1847
+ 'session_id': session_id,
1848
+ 'documents': documents,
1849
+ 'count': len(documents)
1850
+ }
1851
+
1852
+ except HTTPException:
1853
+ raise
1854
+ except Exception as e:
1855
+ logger.error(f"Error listing documents: {e}", exc_info=True)
1856
+ raise HTTPException(status_code=500, detail=str(e))