polyrob 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. webview/webgate.py +196 -0
@@ -0,0 +1,848 @@
1
+ """Goal dispatcher + ticker (W4).
2
+
3
+ Turns ``ready`` goals on the board into agent runs, bounded by concurrency, safe
4
+ under ``workers>1`` (the cron ``TickLock`` gates a tick across processes), and
5
+ feeding completions back to the W1 self-wake rail so a finished goal can forge a
6
+ follow-up turn.
7
+
8
+ Mirrors ``cron/runner.py``'s shape: a pure ``GoalDispatcher.dispatch_once`` (unit
9
+ tested with a fake board/agent) + a ``GoalTicker.run_forever`` loop the FastAPI
10
+ lifespan starts/stops exactly like the cron ticker. Gated ``GOALS_ENABLED``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import os
17
+ from typing import Any, Optional
18
+
19
+ from agents.task.goals.board import GoalBoard, Goal, STATUS_BLOCKED, STATUS_DONE, STATUS_READY
20
+ from agents.task.runtime.run_as_session import run_task_as_session as _run_task_as_session
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _goal_ev(goal, outcome: str, reason: Optional[str] = None, **extra) -> None:
26
+ """T4-03: emit a goal_run event to the durable event log (fail-open).
27
+
28
+ Goal runs previously wrote only the episodes table, never the event_log, so
29
+ `polyrob telemetry` — the tool built to answer "what ran?" — showed cron and
30
+ self-wake but not the goals that did most of the autonomous work. Mirror _cron_ev
31
+ so goal lifecycle rides the same uniform autonomy/governance stream."""
32
+ try:
33
+ from agents.task.telemetry.event_log import get_event_log, event_log_enabled
34
+ if event_log_enabled():
35
+ get_event_log().record(
36
+ "goal_run", user_id=getattr(goal, "user_id", ""),
37
+ source="goal", goal_id=getattr(goal, "id", None),
38
+ outcome=outcome, reason=reason, **extra)
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ # Tools a self-decomposed CHILD goal may inherit from its parent when it set none.
44
+ # Deliberately excludes money/social/trading tools (wallet, x402, hyperliquid,
45
+ # polymarket, twitter) so the agent can't self-grant spend/post capability by
46
+ # spawning a child goal. Server-side allowlist, NOT agent-controllable.
47
+ CHILD_INHERITABLE_TOOLS = frozenset(
48
+ {"filesystem", "task", "browser", "perplexity", "mcp", "anysite", "coding"}
49
+ )
50
+
51
+ # Safe default toolset when a goal sets no tools and has nothing inheritable.
52
+ _DEFAULT_GOAL_TOOLS = ["filesystem", "task"]
53
+
54
+ # WS-8 (compute posture): the compute tools an autonomous goal/cron run needs to
55
+ # actually build/run/serve. Provisioned ONLY at AGENT_COMPUTE_POSTURE>=1 (and each
56
+ # call still passes compute_posture_allows in-session — an owner-tenant autonomous
57
+ # run does). Sandbox-contained at posture 1; never money/social. Resolved at CALL
58
+ # time so posture 0 is byte-identical.
59
+ _COMPUTE_GOAL_TOOLS = ["code_execution", "shell", "coding"]
60
+
61
+
62
+ def _compute_posture_at_least_1() -> bool:
63
+ try:
64
+ from agents.task.constants import compute_posture
65
+ return compute_posture() >= 1
66
+ except Exception:
67
+ return False
68
+
69
+
70
+ def default_goal_tools() -> list:
71
+ """Posture-aware default goal toolset. Posture 0: ['filesystem','task']
72
+ (byte-identical). Posture>=1: + the compute tools (code_execution/shell/coding)."""
73
+ tools = list(_DEFAULT_GOAL_TOOLS)
74
+ if _compute_posture_at_least_1():
75
+ for t in _COMPUTE_GOAL_TOOLS:
76
+ if t not in tools:
77
+ tools.append(t)
78
+ return tools
79
+
80
+
81
+ def child_inheritable_tools() -> frozenset:
82
+ """Posture-aware child-inheritable set. Posture 0: the frozen module constant.
83
+ Posture>=1: + code_execution/shell so a self-decomposed compute goal isn't
84
+ tool-starved (still sandbox-contained; money/social remain excluded)."""
85
+ if _compute_posture_at_least_1():
86
+ return CHILD_INHERITABLE_TOOLS | {"code_execution", "shell"}
87
+ return CHILD_INHERITABLE_TOOLS
88
+
89
+
90
+ def effective_goal_concurrency() -> int:
91
+ """Goal in-flight cap, clamped to single-flight on a SHARED project folder.
92
+
93
+ When the installed pm() serves one project-root workspace (CLI/headless project
94
+ mode), concurrent goal runs would interleave read-modify-write edits on the same
95
+ files (the battle-test "read INDEX.md, append" corruption). Serialize them.
96
+ Keyed off the installed pm() — NOT an env var — so the multi-tenant server (whose
97
+ global pm() is per-session) keeps its full GOAL_MAX_CONCURRENT (MT-5). Fail-open
98
+ to the unclamped cap on any pm() error.
99
+ """
100
+ from agents.task.constants import AutonomyConfig
101
+ cap = AutonomyConfig.goal_max_concurrent()
102
+ try:
103
+ from agents.task.path import pm
104
+ if pm().is_project_root_workspace:
105
+ return min(cap, 1)
106
+ except Exception:
107
+ pass
108
+ return cap
109
+
110
+
111
+ def _heartbeat_interval(ttl: int) -> int:
112
+ """Heartbeat cadence for an in-flight goal claim: ttl/3, floored at 30s (F8).
113
+
114
+ Pinging at a third of the TTL gives two missed-beat margins before a claim
115
+ would expire, while staying infrequent enough to be cheap.
116
+ """
117
+ return max(30, int(ttl) // 3)
118
+
119
+
120
+ class GoalDispatcher:
121
+ def __init__(self, board: GoalBoard, task_agent: Any, *, lock_path: Optional[str] = None):
122
+ self.board = board
123
+ self.task_agent = task_agent
124
+ self.lock_path = lock_path
125
+ # Strong refs to in-flight goal runs — without this an unawaited create_task
126
+ # can be GC'd mid-run (CPython drops weakly-referenced tasks), cancelling a
127
+ # goal that "may run minutes". Cleared via done-callback.
128
+ self._inflight: set = set()
129
+ self._quota_logged = False
130
+ # §7.2 tail: consecutive planner runs that left the ready queue empty, and
131
+ # whether the resulting stall was already escalated (escalate ONCE per stall;
132
+ # both reset the moment the board refills). In-memory: resets on restart,
133
+ # which at worst re-escalates one stall after a deploy — acceptable.
134
+ self._empty_planner_runs = 0
135
+ self._empty_pipeline_escalated = False
136
+ # Budget-gate push latch: tenants for whom an owner "over budget" push has
137
+ # already gone out this over-budget episode. The durable ask is dedup-
138
+ # refreshed every tick (fine), but the owner PUSH must fire ONCE per episode,
139
+ # not every 60s tick per held goal. Cleared when the tenant is back under
140
+ # budget (a fresh episode may push again). In-memory: resets on restart.
141
+ self._budget_pushed: set = set()
142
+
143
+ async def dispatch_once(self) -> int:
144
+ """Claim and run up to GOAL_MAX_CONCURRENT ready goals. Returns #dispatched.
145
+
146
+ Cross-process safe: a single tick is gated by the cron-style TickLock so two
147
+ workers don't both fan out the same ready set. ``reclaim_stale`` runs FIRST
148
+ and UNCONDITIONALLY — before the ``GOALS_ENABLED`` gate (AU-F4.3) — so a
149
+ crashed ``running`` row isn't stuck forever if the flag was flipped off.
150
+ """
151
+ from agents.task.constants import AutonomyConfig
152
+
153
+ # AU-F4.3: reclaim expired claims BEFORE the enabled-gate so a crashed
154
+ # `running` row doesn't stay stuck forever just because GOALS_ENABLED was
155
+ # (temporarily) flipped off. reclaim_stale is a self-contained retry-safe SQL
156
+ # UPDATE (agents/task/goals/board.py) -- it needs no external tick-lock /
157
+ # workspace-lock precondition, so it's safe to run unconditionally here.
158
+ try:
159
+ self.board.reclaim_stale()
160
+ except Exception:
161
+ logger.warning("goal dispatch: reclaim_stale failed", exc_info=True)
162
+
163
+ if not AutonomyConfig.goals_enabled():
164
+ return 0
165
+
166
+ from core.interactive_gate import is_interactive_busy
167
+ if is_interactive_busy():
168
+ return 0 # a human is mid-turn; don't run a goal in the shared workspace
169
+
170
+ lock = None
171
+ if self.lock_path:
172
+ from cron.scheduler import TickLock
173
+ lock = TickLock(self.lock_path)
174
+ if not lock.acquire():
175
+ return 0
176
+ # C2: gate the dispatch decision behind the cross-process workspace lock too,
177
+ # so a 2nd `rob` process doesn't start goals in the shared CWD while another
178
+ # process's REPL is mid-turn. Non-blocking; defer the tick if contended.
179
+ from core.interactive_gate import workspace_turn_lock
180
+ _ws_lock = workspace_turn_lock(timeout=0)
181
+ try:
182
+ _ws_lock.__enter__()
183
+ except Exception:
184
+ # Fail-open: any workspace-lock contention/error defers the tick.
185
+ if lock is not None:
186
+ lock.release()
187
+ return 0
188
+ try:
189
+ # (reclaim_stale already ran above, before the enabled-gate — AU-F4.3)
190
+ limit = effective_goal_concurrency()
191
+ # GOAL_MAX_CONCURRENT is a global in-flight cap, NOT a per-tick claim
192
+ # quota. Subtract goals ALREADY RUNNING from the cross-process DB count
193
+ # (not the per-process self._inflight, which can't see other workers'
194
+ # runs) so a backlog can't grow concurrent agent sessions without bound
195
+ # — under workers>1 the per-process count let total reach cap x workers.
196
+ try:
197
+ running = self.board.count_running()
198
+ except Exception:
199
+ running = len(self._inflight) # fail-open to per-process count
200
+ slots = max(0, limit - running)
201
+ quota = AutonomyConfig.goal_daily_quota()
202
+ if quota > 0:
203
+ try:
204
+ used = self.board.count_started_since(86400)
205
+ except Exception:
206
+ used = 0 # fail-open: never let quota accounting kill dispatch
207
+ headroom = max(0, quota - used)
208
+ if headroom == 0:
209
+ if not self._quota_logged:
210
+ logger.warning(
211
+ "goal daily quota exhausted (%d started/24h >= %d) — pausing dispatch",
212
+ used, quota)
213
+ self._quota_logged = True
214
+ return 0
215
+ self._quota_logged = False
216
+ slots = min(slots, headroom)
217
+ if slots == 0:
218
+ return 0
219
+ ready = self.board.ready(limit=slots)
220
+ ttl = AutonomyConfig.goal_claim_ttl_sec()
221
+ worker = f"goal-dispatch-{os.getpid()}"
222
+ dispatched = 0
223
+ budget_aware = AutonomyConfig.budget_aware_autonomy()
224
+ for g in ready:
225
+ if budget_aware and await self._over_budget(g):
226
+ continue # held — an owner-visible ask was raised instead of burning
227
+ claimed = self.board.claim(g.id, worker, ttl_seconds=ttl)
228
+ if claimed is None:
229
+ continue # another worker won the race
230
+ t = asyncio.create_task(self._run_goal(claimed))
231
+ self._inflight.add(t)
232
+ t.add_done_callback(self._inflight.discard)
233
+ dispatched += 1
234
+ # Fire-and-forget: the ticker doesn't block on goal completion (goals may
235
+ # run minutes). The runs self-report via record_success/failure + self-wake.
236
+ try:
237
+ await self._maybe_plan(headroom_after=slots - dispatched)
238
+ except Exception:
239
+ logger.debug("planner check failed (non-fatal)", exc_info=True)
240
+ return dispatched
241
+ finally:
242
+ try:
243
+ _ws_lock.__exit__(None, None, None)
244
+ except Exception:
245
+ pass
246
+ if lock is not None:
247
+ lock.release()
248
+
249
+ async def _heartbeat_claim(self, goal_id: str, worker: str, ttl: int) -> None:
250
+ """Keep a long-running goal's claim alive (F8).
251
+
252
+ Goals run fire-and-forget for up to many minutes, but the claim TTL
253
+ (GOAL_CLAIM_TTL_SEC, default 900s) is fixed at claim time. Without a
254
+ heartbeat, a goal that runs longer than the TTL is reclaimed by
255
+ ``reclaim_stale`` and DOUBLE-dispatched. Ping the claim every ttl/3.
256
+ """
257
+ interval = _heartbeat_interval(ttl)
258
+ try:
259
+ while True:
260
+ await asyncio.sleep(interval)
261
+ try:
262
+ self.board.heartbeat(goal_id, worker, ttl_seconds=ttl)
263
+ except Exception as e: # never let heartbeat kill the run
264
+ logger.warning("goal %s heartbeat failed: %s", goal_id, e)
265
+ except asyncio.CancelledError:
266
+ pass
267
+
268
+ async def _run_goal(self, goal: Goal) -> None:
269
+ """Run one claimed goal on the task-agent core, then record + self-wake."""
270
+ session_id = None
271
+ # FIX3 (T3-M1): once the success episode row is durably recorded, a LATER
272
+ # incidental raise in this same try block (e.g. extract_outcome_line,
273
+ # self-wake) must not let the outer except's failed-write flip that row's
274
+ # outcome from "done" back to "failed" — see the guard at the bottom.
275
+ recorded_success = False
276
+ from agents.task.constants import AutonomyConfig
277
+ worker = f"goal-dispatch-{os.getpid()}"
278
+ ttl = AutonomyConfig.goal_claim_ttl_sec()
279
+ hb_task = asyncio.create_task(self._heartbeat_claim(goal.id, worker, ttl))
280
+ # C6: on a SHARED project-root workspace (local/CLI), hold the in-process
281
+ # busy gate for the RUN's full duration — not just the dispatch decision.
282
+ # dispatch_once acquires the gate only to CLAIM, then releases it before this
283
+ # fire-and-forget run; without this, a cron tick or the next goal dispatch
284
+ # would start a second file-mutating run in the SAME CWD concurrently. The
285
+ # server (per-session workspaces) does not clamp, so this is scoped to the
286
+ # shared-workspace case to avoid needlessly serializing server throughput.
287
+ # (NB: a live REPL turn does not consult this gate, so goal-vs-live-turn on
288
+ # the shared CWD is still only fully closed once the REPL waits on it.)
289
+ _shared_ws = False
290
+ try:
291
+ from agents.task.path import pm
292
+ _shared_ws = bool(pm().is_project_root_workspace)
293
+ except Exception:
294
+ _shared_ws = False
295
+ if _shared_ws:
296
+ from core.interactive_gate import mark_busy
297
+ mark_busy()
298
+ _goal_ev(goal, "started")
299
+ try:
300
+ payload = goal.payload or {}
301
+ from core.runtime_config import resolve_runtime_config
302
+ default_provider = resolve_runtime_config(None, None)[0]
303
+ provider = payload.get("provider") or default_provider
304
+ # Autonomous runs have no interactive config to pick a model, so fill the
305
+ # provider's default model from the registry when the goal doesn't pin one.
306
+ # (A None model crashes session setup downstream — '.lower()' on None.)
307
+ model = payload.get("model")
308
+ if not model:
309
+ try:
310
+ from modules.llm.llm_client_registry import get_default_model
311
+ model = get_default_model(provider)
312
+ except Exception:
313
+ model = None
314
+ from agents.task.goals.context import build_goal_run_task
315
+ objective = None
316
+ if goal.parent_id:
317
+ try:
318
+ parent = self.board.get(goal.parent_id)
319
+ if parent is not None and parent.kind == "objective":
320
+ objective = parent
321
+ except Exception:
322
+ objective = None
323
+ request = {
324
+ "task": build_goal_run_task(goal, objective),
325
+ "provider": provider,
326
+ "model": model,
327
+ "tools": self._resolve_tools(goal),
328
+ "max_steps": payload.get("max_steps", 20),
329
+ "temperature": 0.0,
330
+ "goal_id": goal.id,
331
+ }
332
+ # Route through the shared helper: create_session → run_session → refusal-check.
333
+ # H11: hard wall-clock cap (mirrors cron's per-job wait_for). max_steps alone
334
+ # doesn't bound wall time — a single hung step would occupy a slot forever. On
335
+ # timeout the TimeoutError is handled by the except below (record_failure) and
336
+ # the finally cancels the claim heartbeat, so reclaim_stale can recover the slot.
337
+ _max_run = AutonomyConfig.goal_max_run_seconds()
338
+ session_id, final = await asyncio.wait_for(
339
+ _run_task_as_session(
340
+ self.task_agent, user_id=goal.user_id, request=request, autonomous=True
341
+ ),
342
+ timeout=_max_run,
343
+ )
344
+ # Back-half: goal-specific record calls (no is_refusal needed — helper normalised).
345
+ if session_id is None:
346
+ _g = self.board.record_failure(goal.id, error="create_session returned no id")
347
+ await self._maybe_escalate_blocked(_g)
348
+ return
349
+ if final is None:
350
+ _g = self.board.record_failure(
351
+ goal.id,
352
+ error="run did not complete (refusal or empty)",
353
+ session_id=session_id,
354
+ )
355
+ await self._maybe_escalate_blocked(_g)
356
+ try:
357
+ from modules.memory.episodic import finalize_episode
358
+ await finalize_episode(
359
+ session_id=session_id, user_id=goal.user_id, kind="goal",
360
+ task=getattr(goal, "title", None), outcome="failed",
361
+ goal_id=goal.id, meta={"source": "goal"},
362
+ )
363
+ except Exception:
364
+ logger.warning("goal episodic write failed", exc_info=True)
365
+ return
366
+ # §3.1: an agent-declared 'OUTCOME: BLOCKED — <need>' is an honest
367
+ # failure exit, never a success. Checked BEFORE record_success so the
368
+ # goal routes through the breaker/escalation rail with its stated need.
369
+ from agents.task.goals.context import extract_outcome_line, parse_blocked_outcome
370
+ # Fail-open: a crash in the outcome PARSER must never fail a completed
371
+ # run (FIX3 — the success path below still records honestly).
372
+ try:
373
+ outcome = extract_outcome_line(final)
374
+ blocked_need = parse_blocked_outcome(outcome)
375
+ except Exception:
376
+ logger.debug("outcome parse failed for %s", goal.id, exc_info=True)
377
+ outcome, blocked_need = None, None
378
+ if blocked_need is not None:
379
+ await self._fail_blocked_declared(goal, session_id, outcome, blocked_need)
380
+ return
381
+ # T2-01: a run that finished the loop but never called done() (max_steps
382
+ # exhaustion, or a reply-only conversational exit) returns a non-refusal
383
+ # status string that looks identical to a genuine completion. Recording it
384
+ # as board success was the prod "marked done, never posted" failure. Inspect
385
+ # the resident orchestrator's main-agent done signal; only a POSITIVE
386
+ # "ran but no done()" (False) routes to the failure/escalation rail —
387
+ # None (undeterminable) falls through to the legacy path unchanged. The
388
+ # orchestrator is fetched once here and reused for provenance below.
389
+ orchestrator = None
390
+ try:
391
+ orchestrator = self.task_agent.get_orchestrator(session_id)
392
+ except Exception:
393
+ orchestrator = None
394
+ from agents.task.runtime.run_as_session import completed_via_done
395
+ if completed_via_done(orchestrator) is False:
396
+ await self._fail_run(
397
+ goal, session_id,
398
+ error="run ended without completing (no done() — likely ran out of steps)",
399
+ outcome=outcome)
400
+ return
401
+ # §3.2 (fail-open — never block on uncertainty): with the judge on and
402
+ # an acceptance set, 'unmet' -> record_failure (normal breaker retries);
403
+ # 'met'/'unclear'/error -> the legacy success path.
404
+ import agents.task.goals.completion_judge as _cj
405
+ acceptance = (payload.get("acceptance") or "").strip()
406
+ if AutonomyConfig.goal_completion_judge() and acceptance:
407
+ verdict, reason = await _cj.judge_goal_completion(
408
+ self.task_agent, session_id, goal, final)
409
+ if verdict == "unmet":
410
+ await self._fail_run(goal, session_id,
411
+ error=f"completion judge: {reason}"[:2000],
412
+ outcome=outcome)
413
+ return
414
+ self.board.record_success(goal.id, session_id=session_id, result=str(final)[:4000])
415
+ # Stale-completion skip: an owner may have cancelled/paused the goal
416
+ # mid-run (T2 guards keep that status through record_success — see
417
+ # test_intervention_guards.py). Re-read the row; if it isn't 'done'
418
+ # the owner's decision wins — never write an outcome or self-wake for
419
+ # a run they already walked away from. Fail-open (get() error ->
420
+ # proceed) so a board without a working get() (fakes in older tests)
421
+ # doesn't lose success recording.
422
+ try:
423
+ refreshed = self.board.get(goal.id)
424
+ except Exception:
425
+ refreshed = None
426
+ if refreshed is not None and refreshed.status != STATUS_DONE:
427
+ return
428
+ try:
429
+ from modules.memory.episodic import finalize_episode, collect_provenance
430
+ # Reuse the orchestrator fetched for the T2-01 done-check above.
431
+ prov = await collect_provenance(orchestrator)
432
+ await finalize_episode(
433
+ session_id=session_id, user_id=goal.user_id, kind="goal",
434
+ task=getattr(goal, "title", None), outcome="done", goal_id=goal.id,
435
+ summary=str(final)[:2000] if final is not None else None,
436
+ spend_usd=prov["spend_usd"], steps=prov["steps"],
437
+ artifacts=prov["artifacts"], meta={"source": "goal"},
438
+ )
439
+ recorded_success = True
440
+ _goal_ev(goal, "done", session_id=session_id,
441
+ spend_usd=prov["spend_usd"], steps=prov["steps"])
442
+ except Exception:
443
+ logger.warning("goal episodic write failed", exc_info=True)
444
+ if outcome:
445
+ try:
446
+ self.board.set_outcome(goal.id, outcome)
447
+ except Exception:
448
+ logger.debug("set_outcome failed for %s", goal.id, exc_info=True)
449
+ if AutonomyConfig.goal_self_wake_enabled():
450
+ await self._self_wake(goal, session_id, final)
451
+ except Exception as e:
452
+ logger.error("goal %s run failed: %s", goal.id, e, exc_info=True)
453
+ _goal_ev(goal, "failed", reason=str(e)[:200], session_id=session_id)
454
+ try:
455
+ _g = self.board.record_failure(goal.id, error=str(e), session_id=session_id)
456
+ await self._maybe_escalate_blocked(_g)
457
+ except Exception:
458
+ pass
459
+ if session_id and not recorded_success:
460
+ try:
461
+ from modules.memory.episodic import finalize_episode
462
+ await finalize_episode(
463
+ session_id=session_id, user_id=goal.user_id, kind="goal",
464
+ task=getattr(goal, "title", None), outcome="failed",
465
+ goal_id=goal.id, summary=str(e)[:2000],
466
+ meta={"source": "goal"},
467
+ )
468
+ except Exception:
469
+ logger.warning("goal episodic write failed", exc_info=True)
470
+ finally:
471
+ hb_task.cancel()
472
+ if _shared_ws:
473
+ from core.interactive_gate import mark_idle
474
+ mark_idle()
475
+
476
+ async def _fail_blocked_declared(self, goal: Goal, session_id: Optional[str],
477
+ outcome: Optional[str], need: str) -> None:
478
+ """§3.1: route an agent-declared BLOCKED outcome to the failure/escalation rail.
479
+
480
+ The agent already concluded retrying won't help, so after the standard
481
+ record_failure (whose CAS respects owner cancel/pause) a row that came back
482
+ 'ready' is flipped straight to 'blocked' — skipping the breaker's remaining
483
+ retries. A non-ready row means the owner intervened; their decision wins.
484
+ """
485
+ error = f"agent declared BLOCKED: {need or 'unspecified need'}"
486
+ await self._fail_run(goal, session_id, error=error, block=True, outcome=outcome)
487
+
488
+ async def _fail_run(self, goal: Goal, session_id: Optional[str], *, error: str,
489
+ block: bool = False, outcome: Optional[str] = None) -> None:
490
+ """Shared verified-failure path for a run that finished but didn't deliver.
491
+
492
+ record_failure's CAS respects owner cancel/pause; with ``block=True`` a row
493
+ that came back 'ready' is flipped straight to 'blocked' (skipping the
494
+ breaker's remaining retries — used when retrying provably won't help).
495
+ """
496
+ _g = self.board.record_failure(goal.id, error=error, session_id=session_id)
497
+ if block and getattr(_g, "status", None) == STATUS_READY:
498
+ try:
499
+ if self.board.block_from_ready(goal.id, error=error):
500
+ _g = self.board.get(goal.id) or _g
501
+ except Exception:
502
+ logger.debug("block_from_ready failed for %s", goal.id, exc_info=True)
503
+ # T4-03: surface the verified-failure outcome (blocked vs failed) in the durable
504
+ # event log so `polyrob telemetry` reflects it, not just the episodes table.
505
+ _goal_ev(goal, "blocked" if getattr(_g, "status", None) == STATUS_BLOCKED else "failed",
506
+ reason=str(error)[:200], session_id=session_id)
507
+ if outcome:
508
+ try:
509
+ self.board.set_outcome(goal.id, outcome)
510
+ except Exception:
511
+ logger.debug("set_outcome failed for %s", goal.id, exc_info=True)
512
+ await self._maybe_escalate_blocked(_g)
513
+ if session_id:
514
+ try:
515
+ from modules.memory.episodic import finalize_episode
516
+ await finalize_episode(
517
+ session_id=session_id, user_id=goal.user_id, kind="goal",
518
+ task=getattr(goal, "title", None), outcome="failed",
519
+ goal_id=goal.id, summary=error[:2000], meta={"source": "goal"},
520
+ )
521
+ except Exception:
522
+ logger.warning("goal episodic write failed", exc_info=True)
523
+
524
+ def _resolve_tools(self, goal: Goal) -> list:
525
+ """Resolve the toolset for a goal run.
526
+
527
+ Precedence: the goal's own ``payload.tools`` if set; else, for a CHILD goal
528
+ (has ``parent_id``), inherit the parent's tools intersected with
529
+ ``CHILD_INHERITABLE_TOOLS`` (so a self-decomposed goal isn't tool-starved
530
+ while never self-granting money/social tools); else the safe default.
531
+ """
532
+ payload = goal.payload or {}
533
+ own = payload.get("tools")
534
+ if own:
535
+ return own
536
+ if goal.parent_id:
537
+ try:
538
+ parent = self.board.get(goal.parent_id)
539
+ except Exception:
540
+ parent = None
541
+ if parent is not None:
542
+ parent_tools = (parent.payload or {}).get("tools") or []
543
+ inheritable = child_inheritable_tools() # posture-aware (WS-8)
544
+ inherited = [t for t in parent_tools if t in inheritable]
545
+ if inherited:
546
+ return inherited
547
+ return default_goal_tools() # posture-aware (WS-8)
548
+
549
+ async def _over_budget(self, goal) -> bool:
550
+ """True when the goal's tenant has spent >= the trailing-window budget.
551
+
552
+ Consults the unified ledger per-goal (the ledger is per-tenant; the ready
553
+ set is cross-tenant). Over budget -> raise a durable owner-visible ask and
554
+ optionally push, then hold the goal (left 'ready'). Fail-open: any ledger
555
+ error returns False so a metering hiccup never stalls dispatch."""
556
+ try:
557
+ from agents.task.constants import AutonomyConfig
558
+ budget = AutonomyConfig.autonomy_budget_usd()
559
+ if budget <= 0:
560
+ return False
561
+ window = AutonomyConfig.autonomy_budget_window_days()
562
+ from modules.credits.unified_ledger import build_ledger
563
+ ledger = await build_ledger(goal.user_id, days=window)
564
+ spent = float(ledger.get("total_spend_usd", 0.0) or 0.0)
565
+ if spent < budget:
566
+ # Back under budget — clear the push latch so a future over-budget
567
+ # episode for this tenant escalates again.
568
+ self._budget_pushed.discard(goal.user_id)
569
+ return False
570
+ except Exception:
571
+ logger.debug("budget gate check failed (fail-open)", exc_info=True)
572
+ return False
573
+ # Over budget — raise a durable, dedup-refreshing ask (owner-visible) and
574
+ # optionally push. Never claim/run the goal.
575
+ try:
576
+ self.board.create_ask(
577
+ user_id=goal.user_id,
578
+ what="Autonomous goals paused — spend budget reached",
579
+ why=(f"Spent ${spent:.2f} of ${budget:.2f} over the last {window}d; "
580
+ f"goal '{goal.title}' held. Raise AUTONOMY_BUDGET_USD or fulfill "
581
+ "this ask to resume."),
582
+ blocks_goal_ids=[goal.id],
583
+ )
584
+ except Exception:
585
+ logger.debug("budget ask creation skipped", exc_info=True)
586
+ # Owner PUSH once per over-budget episode (the ask row above is the durable,
587
+ # dedup-refreshed record; the push must not spam every tick per held goal).
588
+ if goal.user_id not in self._budget_pushed:
589
+ try:
590
+ from core.self_evolution import push_owner_message
591
+ container = getattr(self.task_agent, "container", None)
592
+ if container is not None:
593
+ await push_owner_message(
594
+ container,
595
+ f"[budget] ${spent:.2f}/${budget:.2f} spent over {window}d — autonomous "
596
+ f"goals paused. Goal '{goal.title}' held.")
597
+ self._budget_pushed.add(goal.user_id)
598
+ except Exception:
599
+ logger.debug("budget push skipped", exc_info=True)
600
+ logger.info("goal %s held: tenant over budget ($%.2f >= $%.2f)",
601
+ goal.id, spent, budget)
602
+ return True
603
+
604
+ async def _maybe_escalate_blocked(self, goal) -> None:
605
+ """§7.2: when record_failure tripped the breaker (goal now 'blocked'), surface
606
+ a concrete ask to the owner instead of letting it die silently. Fail-open."""
607
+ try:
608
+ from agents.task.goals.escalation import maybe_escalate_blocked
609
+ await maybe_escalate_blocked(self.task_agent, goal)
610
+ except Exception:
611
+ logger.debug("blocker escalation skipped", exc_info=True)
612
+ # §7.2b / T2-03 / T4-04: ALWAYS leave a TRACKED ask for a blocked goal so the
613
+ # need survives even when no owner push went out. This ask creation was gated on
614
+ # the SAME goal_blocker_escalation() flag as the push, so with the default OFF a
615
+ # blocked goal left NO ask and `owner asks/fulfill` had nothing to consume — the
616
+ # need evaporated silently (the prod "X-write gap never escalated" shape). The
617
+ # ask is silent + durable + tenant-scoped and create_ask dedup-refreshes, so it
618
+ # is safe unconditionally; only the PUSH above stays posture-gated.
619
+ try:
620
+ from agents.task.goals.board import STATUS_BLOCKED
621
+ if getattr(goal, "status", None) == STATUS_BLOCKED:
622
+ self.board.create_ask(
623
+ user_id=goal.user_id,
624
+ what=f"Unblock goal: {goal.title}",
625
+ why=(goal.last_failure_error or "repeated failures"),
626
+ blocks_goal_ids=[goal.id],
627
+ )
628
+ except Exception:
629
+ logger.debug("blocked-goal ask creation skipped", exc_info=True)
630
+
631
+ async def _self_wake(self, goal: Goal, session_id: str, final: str) -> None:
632
+ """Surface a completed goal's result (W1 rail). Fail-open.
633
+
634
+ T2-02: ``deliver_self_wake`` re-enters the goal's OWN just-finished session — an
635
+ empty room the owner never watches — so a completed goal told no one. In
636
+ addition to the (best-effort, agent-continuation) self-wake, PUSH the result to
637
+ the OWNER via ``push_owner_message`` (durable: a sink-less local owner still sees
638
+ it via ``polyrob telemetry``, per T4-04), so the owner is reliably told
639
+ regardless of whether a live session is watching. (Retargeting the
640
+ agent-continuation wake at the owner's live chat session needs a
641
+ latest-session-for-user resolver on the chat registry — deferred.)
642
+ """
643
+ try:
644
+ text = (f"✅ Background goal '{goal.title}' completed.\n"
645
+ f"Result:\n{str(final)[:1500]}")
646
+ # T2-02: tell the OWNER (surface-independent, durable).
647
+ owner_told = False
648
+ try:
649
+ from core.self_evolution import push_owner_message
650
+ owner_told = await push_owner_message(
651
+ getattr(self.task_agent, "container", None), text)
652
+ except Exception:
653
+ logger.debug("goal completion owner-push skipped for %s", goal.id,
654
+ exc_info=True)
655
+ # Best-effort agent-continuation via the existing self-wake rail.
656
+ deliver = getattr(self.task_agent, "deliver_self_wake", None)
657
+ delivered = False
658
+ if deliver is not None:
659
+ delivered = await deliver(session_id, goal.user_id, text,
660
+ metadata={"source": "goal", "goal_id": goal.id})
661
+ if not (delivered or owner_told):
662
+ # Nobody was actually told (self-wake off/dropped/budget-exhausted AND no
663
+ # owner push landed) -- don't mark the episode surfaced (FIX1: that would
664
+ # make the session-start digest silently omit this run).
665
+ return
666
+ # Task 7: the self-wake delivery IS "telling the owner" — mark this
667
+ # goal's episode surfaced so the digest doesn't repeat it. Runs after
668
+ # finalize_episode (already written earlier in _run_goal), so the row
669
+ # exists for the UPDATE to find. Scoped to the goal's own user_id
670
+ # (FIX2) since episodes are keyed on the composite (user_id,
671
+ # session_id) and a bare session_id UPDATE could flip another
672
+ # tenant's row on a collision.
673
+ try:
674
+ from modules.memory.registry import get_memory_registry
675
+ prov = get_memory_registry().active()
676
+ if prov is not None and hasattr(prov, "mark_episode_surfaced"):
677
+ prov.mark_episode_surfaced(session_id=session_id, user_id=goal.user_id)
678
+ except Exception:
679
+ logger.debug("goal self-wake surfaced-mark skipped for %s", goal.id,
680
+ exc_info=True)
681
+ except Exception as e:
682
+ logger.debug("goal self-wake skipped for %s: %s", goal.id, e)
683
+
684
+ async def _maybe_plan(self, *, headroom_after: int) -> None:
685
+ """Fire ONE planning session when the queue is thin. All gates mechanical."""
686
+ from agents.task.constants import AutonomyConfig
687
+ if not AutonomyConfig.goal_planner_enabled():
688
+ return
689
+ if headroom_after <= 0 and AutonomyConfig.goal_daily_quota() > 0:
690
+ return
691
+ min_ready = AutonomyConfig.goal_planner_min_ready()
692
+ if len(self.board.ready(limit=min_ready)) >= min_ready:
693
+ return
694
+ # any tenant with an active objective (v1: single-user)
695
+ users = {o.user_id for o in self._active_objective_owners()}
696
+ if not users:
697
+ return
698
+ last = self.board.last_planner_run_at()
699
+ cooldown = AutonomyConfig.goal_planner_cooldown_sec()
700
+ import time as _time
701
+ if last is not None and (_time.time() - last) < cooldown:
702
+ return
703
+ self.board.mark_planner_run() # BEFORE dispatch: no double-fire while running
704
+ user_id = sorted(users)[0]
705
+ t = asyncio.create_task(self._run_planner(user_id))
706
+ self._inflight.add(t)
707
+ t.add_done_callback(self._inflight.discard)
708
+
709
+ def _active_objective_owners(self):
710
+ from agents.task.goals.board import OBJ_ACTIVE
711
+ try:
712
+ rows = self.board.list(status=OBJ_ACTIVE, limit=50)
713
+ return [r for r in rows if r.kind == "objective"]
714
+ except Exception:
715
+ return []
716
+
717
+ async def _run_planner(self, user_id: str) -> None:
718
+ try:
719
+ from agents.task.constants import AutonomyConfig
720
+ from agents.task.goals.planner import (
721
+ PLANNER_MAX_STEPS, PLANNER_TOOLS, build_planner_prompt,
722
+ )
723
+ deliverables_root = None
724
+ try:
725
+ from agents.task.path import pm
726
+ if pm().is_project_root_workspace:
727
+ deliverables_root = pm().project_root
728
+ except Exception:
729
+ deliverables_root = None
730
+ prompt = build_planner_prompt(
731
+ self.board, user_id, deliverables_root,
732
+ history_n=AutonomyConfig.goal_planner_history_n())
733
+ from core.runtime_config import resolve_runtime_config
734
+ provider = resolve_runtime_config(None, None)[0]
735
+ model = None
736
+ try:
737
+ from modules.llm.llm_client_registry import get_default_model
738
+ model = get_default_model(provider)
739
+ except Exception:
740
+ model = None
741
+ request = {
742
+ "task": prompt,
743
+ "provider": provider,
744
+ "model": model,
745
+ "tools": list(PLANNER_TOOLS),
746
+ "max_steps": PLANNER_MAX_STEPS,
747
+ "temperature": 0.0,
748
+ }
749
+ session_id, final = await _run_task_as_session(
750
+ self.task_agent, user_id=user_id, request=request, autonomous=True)
751
+ logger.info("goal planner ran (session=%s): %s",
752
+ session_id, (final or "no result")[:200])
753
+ await self._maybe_escalate_empty_pipeline(user_id, planner_summary=final)
754
+ except Exception as e:
755
+ logger.error("goal planner run failed: %s", e, exc_info=True)
756
+
757
+ async def _maybe_escalate_empty_pipeline(self, user_id: str, *,
758
+ planner_summary: Optional[str] = None) -> None:
759
+ """§7.2 tail: a planner run that STILL leaves the board empty is a stall.
760
+
761
+ After ``GOAL_EMPTY_PIPELINE_ESCALATE_AFTER`` consecutive such runs, surface
762
+ the stall (with the planner's own last word — usually the concrete blocker)
763
+ to the owner exactly once. Fail-open; both counters reset on refill.
764
+ """
765
+ try:
766
+ if self.board.ready(limit=1):
767
+ self._empty_planner_runs = 0
768
+ self._empty_pipeline_escalated = False
769
+ return
770
+ self._empty_planner_runs += 1
771
+ from agents.task.constants import AutonomyConfig
772
+ if self._empty_planner_runs < AutonomyConfig.goal_empty_pipeline_escalate_after():
773
+ return
774
+ if self._empty_pipeline_escalated:
775
+ return
776
+ objective_title = None
777
+ for o in self._active_objective_owners():
778
+ if o.user_id == user_id:
779
+ objective_title = o.title
780
+ break
781
+ # T2-03/T4-04: mark the stall escalated once the threshold is reached,
782
+ # independent of whether the owner PUSH lands — the durable ask below is the
783
+ # owner-visible artifact and must be created even under the silent posture.
784
+ # (_empty_pipeline_escalated resets when the board refills, so this stays
785
+ # once-per-stall and never spams.)
786
+ self._empty_pipeline_escalated = True
787
+ from agents.task.goals.escalation import maybe_escalate_empty_pipeline
788
+ await maybe_escalate_empty_pipeline(
789
+ self.task_agent, objective_title=objective_title,
790
+ planner_summary=planner_summary)
791
+ # §7.2b: track the stall as an ask so it is fulfillable regardless of push.
792
+ try:
793
+ self.board.create_ask(
794
+ user_id=user_id,
795
+ what=f"Goal pipeline empty for '{objective_title or 'the objective'}'",
796
+ why=(planner_summary or "")[:2000],
797
+ )
798
+ except Exception:
799
+ logger.debug("empty-pipeline ask creation skipped", exc_info=True)
800
+ except Exception:
801
+ logger.debug("empty-pipeline escalation skipped", exc_info=True)
802
+
803
+
804
+ class GoalTicker:
805
+ """Periodically run dispatch_once until stopped (mirrors CronTicker)."""
806
+
807
+ def __init__(self, dispatcher: GoalDispatcher, interval_seconds: int = 60):
808
+ self.dispatcher = dispatcher
809
+ self.interval_seconds = interval_seconds
810
+
811
+ async def tick_once(self) -> int:
812
+ return await self.dispatcher.dispatch_once()
813
+
814
+ async def run_forever(self, stop_event: Optional[asyncio.Event] = None) -> None:
815
+ from core.tickers import IntervalTicker
816
+ from agents.task.constants import (
817
+ ticker_idle_backoff_enabled,
818
+ ticker_idle_backoff_max_multiplier,
819
+ )
820
+
821
+ is_active = None
822
+ max_interval = None
823
+ if ticker_idle_backoff_enabled():
824
+ is_active = lambda dispatched: bool(dispatched)
825
+ max_interval = self.interval_seconds * ticker_idle_backoff_max_multiplier()
826
+
827
+ await IntervalTicker(
828
+ self.dispatcher.dispatch_once,
829
+ self.interval_seconds,
830
+ is_active=is_active,
831
+ max_interval_seconds=max_interval,
832
+ ).run_forever(stop_event=stop_event)
833
+
834
+
835
+ def build_goal_ticker(task_agent: Any, *, data_dir: str = "data",
836
+ interval_seconds: Optional[int] = None) -> GoalTicker:
837
+ """Assemble the goal stack into a GoalTicker the app lifespan can start/stop.
838
+
839
+ Shares ``<data_dir>/goals.db`` with the agent-facing ``goal`` tool so goals the
840
+ tool creates are picked up by the ticker; the tick lock keeps it safe under
841
+ ``workers>1``.
842
+ """
843
+ from agents.task.constants import AutonomyConfig
844
+ board = GoalBoard(os.path.join(data_dir, "goals.db"))
845
+ dispatcher = GoalDispatcher(board, task_agent,
846
+ lock_path=os.path.join(data_dir, "goals.tick.lock"))
847
+ return GoalTicker(dispatcher,
848
+ interval_seconds=interval_seconds or AutonomyConfig.goal_dispatch_interval_sec())