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,1765 @@
1
+ """ActionRegistrationMixin — Controller one-shot action registration (UP-11, verbatim
2
+ code-motion). Registers send_message/done/load_skill/session_search/memory/delegation
3
+ closures + backward-compat aliases. Methods use ``self`` (the composed Controller)
4
+ exactly as before via the MRO."""
5
+ import os
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from core.env import bool_env as _bool_env
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from tools.controller.types import ActionResult
13
+ from tools.controller.views import DoneAction, SendMessageAction
14
+ from tools.controller._helpers import build_load_skill_result, read_skill_resource_confined
15
+ from modules.llm.messages import AIMessage
16
+ from agents.task.agent.core.self_wake import FORGED_TURN_KINDS as _FORGED_TURN_KINDS
17
+
18
+
19
+ def _is_forged_or_autonomous_turn(execution_context, controller_self):
20
+ """C7/W2/SK-F10: a turn that must NEVER auto-activate or promote its own content.
21
+
22
+ True for a sub-agent / leaf turn (delegated worker, background-review reviewer),
23
+ an autonomous goal/cron/planner-spawned session, OR a forged self-wake /
24
+ async-delegation-result re-entry into the MAIN agent.
25
+
26
+ SK-F10: a self-wake or delegation-result re-entry resolves to
27
+ role='orchestrator', is_sub_agent=False — the SAME shape as a genuine owner
28
+ turn — so without a turn-kind signal it could auto-activate a skill (review
29
+ off) or self-promote its own pending draft. The run loop stamps
30
+ `execution_context.metadata["turn_kind"]` from the orchestrator's
31
+ `_forged_turn_kind` marker (set when a drained HITL message is
32
+ kind="self_wake"/"delegation_result", cleared on a genuine drained turn) —
33
+ see `agents/task/agent/core/user_ingress.py::_drain_user_messages` and
34
+ `agents/task/agent/core/step_execution.py::_build_execution_context`.
35
+ """
36
+ is_sub = bool(getattr(execution_context, 'is_sub_agent', False)
37
+ or getattr(controller_self, '_is_sub_agent', False))
38
+ # H7: default to the least-privileged role when unset (treat as forged).
39
+ role = getattr(execution_context, 'role', 'leaf')
40
+ if is_sub or role == 'leaf':
41
+ return True
42
+ metadata = getattr(execution_context, 'metadata', None) or {}
43
+ if metadata.get('turn_kind') in _FORGED_TURN_KINDS:
44
+ return True
45
+ try:
46
+ from agents.task.goals.autonomy_marker import is_autonomous
47
+ sid = (getattr(execution_context, 'session_id', None)
48
+ or getattr(controller_self, 'session_id', ''))
49
+ return bool(is_autonomous(sid))
50
+ except Exception:
51
+ return False
52
+
53
+
54
+ class ActionRegistrationMixin:
55
+ def _register_default_actions(self):
56
+ """Register core 'done' action for task completion.
57
+
58
+ The 'done' action is a CORE completion primitive that must always be available,
59
+ regardless of which tools are loaded. It opportunistically checks TodoManager
60
+ if TaskTool is available, but never blocks completion.
61
+
62
+ Todo-specific actions (task_todo_list, task_todo_add, etc.) remain in TaskTool.
63
+ """
64
+ # Register the core 'done' action
65
+ # Support multiple output models for flexibility
66
+ if self.output_model is None:
67
+ @self.registry.action(
68
+ 'Send a message to the user (status update, question, or information)',
69
+ param_model=SendMessageAction
70
+ )
71
+ async def send_message(params: SendMessageAction, execution_context=None):
72
+ """Send message to user - continuous chat mode.
73
+
74
+ When wait_for_response=True:
75
+ - Message is sent to user
76
+ - Execution STOPS (returns is_done=True)
77
+ - User replies via continuous chat whenever they want
78
+ - Session resumes with reply in context
79
+
80
+ No timeouts, no waiting loops, no retries.
81
+
82
+ SUB-AGENT ISOLATION: Sub-agents skip adding to main context and feed.
83
+ Their messages are captured in their own context and returned as output.
84
+ """
85
+ import time
86
+
87
+ # Check if this is a sub-agent (skip side effects for isolation)
88
+ is_sub_agent = execution_context.is_sub_agent if execution_context else False
89
+
90
+ if is_sub_agent:
91
+ # Sub-agent: Just log and return - output captured via normal result flow
92
+ self.logger.debug(f"💬 Sub-agent message (captured in result): {params.text[:100]}...")
93
+ return ActionResult(
94
+ extracted_content=f"Message: {params.text}",
95
+ include_in_memory=True
96
+ )
97
+
98
+ self.logger.info(f"💬 Message to user: {params.text[:100]}...")
99
+
100
+ # Add to conversation history - find the correct agent by agent_id
101
+ if hasattr(self, 'orchestrator') and self.orchestrator:
102
+ target_agent = None
103
+ agent_id = execution_context.agent_id if execution_context else None
104
+
105
+ if agent_id and agent_id in self.orchestrator.agents:
106
+ target_agent = self.orchestrator.agents[agent_id]
107
+ else:
108
+ # Fallback: use first agent (legacy behavior)
109
+ agents = list(self.orchestrator.agents.values())
110
+ if agents:
111
+ target_agent = agents[0]
112
+
113
+ if target_agent and hasattr(target_agent, 'message_manager'):
114
+ try:
115
+ target_agent.message_manager._add_message_with_tokens(
116
+ AIMessage(content=params.text),
117
+ _internal=True # Bypass single-writer enforcement for display messages
118
+ )
119
+ self.logger.debug("Added message to conversation history")
120
+ except Exception as e:
121
+ self.logger.warning(f"Could not add message to history: {e}")
122
+
123
+ # Add to session feed for WebView
124
+ if hasattr(self, 'orchestrator') and self.orchestrator:
125
+ try:
126
+ self.orchestrator.session_manager.add_to_feed(
127
+ self.session_id,
128
+ 'agent_message',
129
+ {
130
+ 'text': params.text,
131
+ 'wait_for_response': params.wait_for_response,
132
+ 'timestamp': time.time()
133
+ }
134
+ )
135
+ except Exception as e:
136
+ self.logger.debug(f"Could not add to feed: {e}")
137
+
138
+ # P1a outbound-collapse: mirror the discrete message into the unified
139
+ # MessageRouter seam (committed/partial=False) when a router + chat
140
+ # session_key are bound (later phases). No-op + fail-open otherwise.
141
+ try:
142
+ from core.surfaces.outbound_mirror import build_discrete_publish
143
+ _mirror = build_discrete_publish(
144
+ getattr(self.orchestrator, "_message_router", None),
145
+ getattr(self.orchestrator, "_chat_session_key", None),
146
+ )
147
+ await _mirror(params.text)
148
+ except Exception as e:
149
+ self.logger.debug(f"Could not mirror message to router: {e}")
150
+
151
+ # CONTINUOUS CHAT MODE: If waiting for response, stop execution
152
+ # User will reply via continuous chat, session will resume with their response
153
+ if params.wait_for_response:
154
+ # P3 (Task 3.2): guard the wait_for_response deadlock. If a chat surface
155
+ # is bound AND it cannot collect a reply (supports_interactive_ask=False),
156
+ # pausing the session would hang it forever. Degrade to a non-blocking
157
+ # notify in that case. Unknown/unbound (the legacy default) -> pause as
158
+ # before, so flag-OFF behavior is byte-identical. Lazy import: this file
159
+ # must never get `from __future__ import annotations` (registry closures).
160
+ try:
161
+ from core.surfaces.binding import surface_ask_capability
162
+ _ask_ok = surface_ask_capability(getattr(self, 'orchestrator', None))
163
+ except Exception:
164
+ _ask_ok = None
165
+ if _ask_ok is False:
166
+ self.logger.info("💬 Message sent. Surface can't collect a reply; not pausing (non-blocking notify).")
167
+ return ActionResult(
168
+ extracted_content="Message sent to user. This surface cannot collect a reply, so the task was not paused.",
169
+ include_in_memory=True,
170
+ metadata={'conversational_reply': True}
171
+ )
172
+ self.logger.info("💬 Message sent. Stopping execution to wait for user response via continuous chat")
173
+ return ActionResult(
174
+ extracted_content=f"Message sent to user. Task paused - will resume when user responds.",
175
+ include_in_memory=True,
176
+ is_done=True # Stop execution, wait for user via continuous chat
177
+ )
178
+
179
+ # Non-blocking message sent.
180
+ # R1: tag it so the run loop can recognize a turn whose only output was a
181
+ # user-facing reply (no productive tool work) and end it — otherwise a
182
+ # chat reply loops and re-greets, since non-blocking sends keep is_done False.
183
+ return ActionResult(
184
+ extracted_content=f"Message sent to user (non-blocking)",
185
+ include_in_memory=False,
186
+ metadata={'conversational_reply': True}
187
+ )
188
+
189
+ @self.registry.action('Complete the current task and provide final output', param_model=DoneAction)
190
+ async def done(params: DoneAction, execution_context=None):
191
+ """Complete the current task.
192
+
193
+ SUB-AGENT ISOLATION: Sub-agents skip adding to main context and feed.
194
+ Their completion is captured in the result and returned to parent.
195
+ """
196
+ import time
197
+
198
+ # Check if this is a sub-agent (skip side effects for isolation)
199
+ is_sub_agent = execution_context.is_sub_agent if execution_context else False
200
+ completion_msg = params.text or "Task completed"
201
+
202
+ if is_sub_agent:
203
+ # Sub-agent: Just return done - output captured via normal result flow
204
+ self.logger.debug(f"✅ Sub-agent done: {completion_msg[:100]}...")
205
+ return ActionResult(is_done=True, extracted_content=completion_msg)
206
+
207
+ # Main agent: Full processing with todos check and feed updates
208
+ # Opportunistically check todos if TaskTool available
209
+ task_tool = self.get_tool('task')
210
+ if task_tool and hasattr(task_tool, '_get_or_create_todo_manager'):
211
+ try:
212
+ todo_mgr = task_tool._get_or_create_todo_manager(
213
+ self.session_id,
214
+ getattr(self, 'user_id', None)
215
+ )
216
+ if todo_mgr:
217
+ progress = todo_mgr.get_progress()
218
+ if progress['total'] > 0 and progress['percentage'] < 80:
219
+ warning_msg = (
220
+ f"⚠️ WARNING: Completing with only {progress['percentage']:.0f}% "
221
+ f"todos done ({progress['completed']}/{progress['total']}). "
222
+ f"Consider completing more todos for thorough task completion."
223
+ )
224
+ self.logger.warning(warning_msg)
225
+ return ActionResult(
226
+ is_done=True,
227
+ extracted_content=f"{params.text}\n\n{warning_msg}"
228
+ )
229
+ except Exception as e:
230
+ self.logger.debug(f"Could not check todo progress: {e}")
231
+ # Continue anyway - don't block completion
232
+
233
+ # Add completion message to conversation history - find correct agent
234
+ if hasattr(self, 'orchestrator') and self.orchestrator:
235
+ target_agent = None
236
+ agent_id = execution_context.agent_id if execution_context else None
237
+
238
+ if agent_id and agent_id in self.orchestrator.agents:
239
+ target_agent = self.orchestrator.agents[agent_id]
240
+ else:
241
+ # Fallback: use first agent (legacy behavior)
242
+ agents = list(self.orchestrator.agents.values())
243
+ if agents:
244
+ target_agent = agents[0]
245
+
246
+ if target_agent and hasattr(target_agent, 'message_manager'):
247
+ try:
248
+ target_agent.message_manager._add_message_with_tokens(
249
+ AIMessage(content=f"✅ Task Complete\n\n{completion_msg}"),
250
+ _internal=True # Bypass single-writer enforcement for display messages
251
+ )
252
+ self.logger.info("Added completion message to conversation history")
253
+ except Exception as e:
254
+ self.logger.warning(f"Could not add completion to history: {e}")
255
+
256
+ # Add to session feed for WebView
257
+ try:
258
+ self.orchestrator.session_manager.add_to_feed(
259
+ self.session_id,
260
+ 'task_complete',
261
+ {
262
+ 'message': completion_msg,
263
+ 'timestamp': time.time()
264
+ }
265
+ )
266
+ except Exception as e:
267
+ self.logger.debug(f"Could not add completion to feed: {e}")
268
+
269
+ # C10: mirror the completion text into the unified MessageRouter seam so a
270
+ # bound chat surface (Singular Chat) delivers it once — matching send_message.
271
+ # Without this, the harness skipping its post-run deliver for bound sessions
272
+ # would silence done()-terminated turns (done doesn't otherwise reach the
273
+ # router). No-op + fail-open when unbound (build_discrete_publish → no router).
274
+ try:
275
+ from core.surfaces.outbound_mirror import build_discrete_publish
276
+ _mirror = build_discrete_publish(
277
+ getattr(self.orchestrator, "_message_router", None),
278
+ getattr(self.orchestrator, "_chat_session_key", None),
279
+ )
280
+ await _mirror(completion_msg)
281
+ except Exception as e:
282
+ self.logger.debug(f"Could not mirror completion to router: {e}")
283
+
284
+ self.logger.info(f'Task marked as done: {completion_msg}')
285
+ return ActionResult(is_done=True, extracted_content=completion_msg)
286
+
287
+ elif self.output_model is not None:
288
+ # For agents with custom output models
289
+ @self.registry.action('Complete the current task with structured output', param_model=self.output_model)
290
+ async def done(_params: BaseModel):
291
+ self.logger.info(f'Task marked as done with structured output')
292
+ return ActionResult(is_done=True)
293
+
294
+ # S-1: progressive skill disclosure. When enabled, the system injects only a
295
+ # compact <skill-catalog>; the agent pulls a skill's full body on demand with
296
+ # this tool. Off by default (skills are eager-injected full-body, no tool).
297
+ from agents.task.constants import skill_progressive_disclosure
298
+ if skill_progressive_disclosure():
299
+ class LoadSkillAction(BaseModel):
300
+ skill_id: str
301
+
302
+ @self.registry.action(
303
+ "Load a skill's full instructions on demand (see <skill-catalog>). "
304
+ "Call before doing work the skill covers.",
305
+ param_model=LoadSkillAction,
306
+ )
307
+ async def load_skill(params: LoadSkillAction, execution_context=None):
308
+ # Task 17: resolve the skill's on-disk dir (fail-open to None — an
309
+ # in-memory-only skill or a lookup error just means no resource list
310
+ # is advertised, not a broken load_skill).
311
+ skill_dir = None
312
+ uid = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
313
+ _skill_manager = None
314
+ try:
315
+ from agents.task.agent.skill_manager import get_skill_manager
316
+ _skill_manager = get_skill_manager()
317
+ skill_dir = _skill_manager.resolve_skill_dir(params.skill_id, uid)
318
+ except Exception:
319
+ skill_dir = None
320
+ result = build_load_skill_result(
321
+ getattr(self, '_session_skills', {}), params.skill_id,
322
+ activated=getattr(self, '_activated_skills', None),
323
+ skill_dir=skill_dir,
324
+ )
325
+ # SK-F1: a skill that never made it into (or was capped out of)
326
+ # _session_skills is still on disk — fall back to a tenant-scoped
327
+ # disk load via the manager's existing resolver (excludes
328
+ # `.pending/` drafts, since those live at a different path)
329
+ # instead of erroring on every skill the eager/catalog pass didn't
330
+ # preload. Only attempted on the "unknown id" failure shape.
331
+ if result.error and _skill_manager is not None:
332
+ try:
333
+ sid = (params.skill_id or "").strip().strip('"')
334
+ # SK-F1: sid is LLM-controlled and _load_skill_content does no
335
+ # traversal filtering of its own (it just Path-joins). Gate the
336
+ # fallback on resolve_skill_dir's existing guard (rejects '/',
337
+ # '\\', '..', whitespace-padding, absolute-path-shaped ids) so a
338
+ # crafted id like "../other_uid/secret-skill" cannot escape into
339
+ # another tenant's directory. A rejected id falls through with
340
+ # content="" -> same "Unknown skill_id" error shape as before,
341
+ # no exception leaks to the model.
342
+ content = ""
343
+ # P1-1: make the auto_activate gate REAL on the load path. A gated
344
+ # skill (auto_activate:false — the money/trading playbooks) is only
345
+ # loadable when the session has actually loaded its required tools;
346
+ # otherwise refuse (same "Unknown skill_id" shape) so a model that
347
+ # guesses the id cannot pull the gated playbook. A legitimately-
348
+ # available gated skill is preloaded into _session_skills via the
349
+ # catalog and never reaches this fallback.
350
+ _loaded_tool_ids = []
351
+ try:
352
+ _loaded_tool_ids = self.list_tools()
353
+ except Exception:
354
+ _loaded_tool_ids = []
355
+ _gate_ok = True
356
+ try:
357
+ _gate_ok = _skill_manager.may_load_skill(sid, tool_ids=_loaded_tool_ids, user_id=uid)
358
+ except Exception:
359
+ _gate_ok = True # fail-open on the gate check; path/tenant guards still apply
360
+ if _gate_ok and _skill_manager.resolve_skill_dir(sid, uid) is not None:
361
+ content = _skill_manager._load_skill_content(sid, user_id=uid)
362
+ if content:
363
+ from agents.task.agent.skill_manager import MatchedSkill
364
+ fallback_skill = MatchedSkill(
365
+ skill_id=sid,
366
+ priority=0,
367
+ match_reasons=["disk-fallback"],
368
+ content=content,
369
+ )
370
+ # P2-22: register the fallback-loaded skill into _session_skills
371
+ # so a follow-up read_skill_resource (which gates on membership
372
+ # there) can serve the resources this load result advertises —
373
+ # previously it refused "Call load_skill first" for a skill the
374
+ # model had JUST loaded via the disk fallback.
375
+ _ss = getattr(self, '_session_skills', None)
376
+ if isinstance(_ss, dict):
377
+ _ss[sid] = fallback_skill
378
+ result = build_load_skill_result(
379
+ {sid: fallback_skill}, sid,
380
+ activated=getattr(self, '_activated_skills', None),
381
+ skill_dir=skill_dir,
382
+ )
383
+ except Exception:
384
+ pass
385
+ if result.metadata and result.metadata.get('skill_loaded'):
386
+ self.logger.info(f"📖 Loaded skill on demand: {result.metadata['skill_loaded']}")
387
+ # W2-D: provenance/reuse metric — bumped at the closure (which has
388
+ # execution_context), NOT the pure build_load_skill_result helper.
389
+ # Fail-open: a metrics write must never break a skill load.
390
+ try:
391
+ uid = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
392
+ if uid:
393
+ from modules.skills.skill_usage import get_skill_usage_store
394
+ # SK-F1: use the actually-resolved/loaded id
395
+ # (result.metadata['skill_loaded'] is always the
396
+ # stripped/unquoted sid -- set identically by
397
+ # build_load_skill_result on both the eager and
398
+ # disk-fallback paths), not the raw params.skill_id,
399
+ # so quote/whitespace-padded ids don't fragment
400
+ # curator metrics under two different keys. The enclosing
401
+ # `if ... get('skill_loaded')` guarantees this is truthy
402
+ # (D1: the old `or params.skill_id` fallback was dead).
403
+ loaded_id = result.metadata['skill_loaded']
404
+ get_skill_usage_store().bump_load(loaded_id, uid)
405
+ except Exception:
406
+ pass
407
+ return result
408
+
409
+ # Task 17: read-only resource access for an already-loaded skill (references/
410
+ # assets, e.g. an authored skill's REFERENCE.md). NEVER executes anything —
411
+ # a skill's scripts/*.sh are readable as text, never run. Gated by the same
412
+ # SKILL_PROGRESSIVE_DISCLOSURE flag as load_skill since it's only useful once
413
+ # _session_skills is populated (refusal is a no-op otherwise).
414
+ class ReadSkillResourceAction(BaseModel):
415
+ skill_id: str
416
+ resource_path: str
417
+
418
+ @self.registry.action(
419
+ "Read a resource file (reference doc, asset, etc.) that belongs to a skill "
420
+ "you have already loaded via load_skill (see metadata.skill_resources on that "
421
+ "call's result for the available paths). Read-only — this NEVER executes "
422
+ "scripts or other files, it only returns their text content.",
423
+ param_model=ReadSkillResourceAction,
424
+ )
425
+ async def read_skill_resource(params: ReadSkillResourceAction, execution_context=None):
426
+ session_skills = getattr(self, '_session_skills', {}) or {}
427
+ sid = (params.skill_id or "").strip().strip('"')
428
+ if sid not in session_skills:
429
+ available = ", ".join(session_skills.keys()) or "(none)"
430
+ return ActionResult(
431
+ error=(
432
+ f"Skill '{sid}' is not loaded this session (available: {available}). "
433
+ "Call load_skill first."
434
+ ),
435
+ include_in_memory=True,
436
+ )
437
+ skill_dir = None
438
+ try:
439
+ from agents.task.agent.skill_manager import get_skill_manager
440
+ uid = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
441
+ skill_dir = get_skill_manager().resolve_skill_dir(sid, uid)
442
+ except Exception:
443
+ skill_dir = None
444
+ if skill_dir is None:
445
+ return ActionResult(
446
+ error=f"Skill '{sid}' has no on-disk resources available.",
447
+ include_in_memory=True,
448
+ )
449
+ ok, content = read_skill_resource_confined(skill_dir, params.resource_path)
450
+ if not ok:
451
+ return ActionResult(error=content, include_in_memory=True)
452
+ return ActionResult(extracted_content=content, include_in_memory=True)
453
+
454
+ # P0-1: agent-callable cross-session recall. Registered only when an external
455
+ # memory provider is active (MEMORY_BACKEND=sqlite); inert in the default config
456
+ # (NullMemoryProvider), so the tool never appears unless a backend is configured.
457
+ self._register_session_search_action()
458
+
459
+ # UP-09: optional bounded curated memory tool (read/add/remove), gated
460
+ # MEMORY_TOOL_ENABLED (default false) AND external provider active.
461
+ self._register_memory_tool_action()
462
+
463
+ # Task 4: agent-callable recall of the agent's OWN activity (chat/goal/cron
464
+ # episodes), gated EPISODIC_MEMORY_ENABLED AND external provider active.
465
+ self._register_recent_activity_action()
466
+
467
+ # W2: optional writable-skills tool (create/patch/delete), gated SKILLS_WRITABLE
468
+ # (default false). Lets the agent author durable procedures; quarantined to
469
+ # .pending/ + threat-scanned + tenant-confined.
470
+ self._register_skill_manage_action()
471
+
472
+ # polyrob C-write: optional evolving SELF-identity tool, gated
473
+ # SELF_CONTEXT_WRITABLE (default false; ON under POLYROB_LOCAL). Lets the agent
474
+ # refine its per-(instance,user) self.md; identity-scanned + .pending +
475
+ # tenant-confined; SOUL tier stays operator-only.
476
+ self._register_self_context_manage_action()
477
+
478
+ # Bounded owner-facts doc (USER.md-equivalent), gated OWNER_DOC_WRITABLE
479
+ # (default false; ON under POLYROB_LOCAL). Same quarantine-then-promote model.
480
+ self._register_owner_doc_manage_action()
481
+
482
+ # W7: optional read-only insights tool (authored-skill reuse %), gated
483
+ # INSIGHTS_TOOL (default false). Tenant-scoped.
484
+ self._register_insights_action()
485
+
486
+ # T3-01: agent-initiated MCP install from the reviewed catalog, gated
487
+ # MCP_SELF_INSTALL_ENABLED (default false). Owner-in-the-loop: forged/leaf
488
+ # turns refused; approver is Deny-by-default unless APPROVAL_PROVIDER is set.
489
+ self._register_mcp_install_action()
490
+
491
+ # Ensure normalize_path method exists for file operations
492
+ self._ensure_normalize_path_exists()
493
+
494
+ # Task 5: gated `message` action (owner/allowlist -> MessageRouter send),
495
+ # gated MESSAGE_TOOL_ENABLED (default false; ON under POLYROB_LOCAL).
496
+ self._register_message_action()
497
+
498
+ # Register subtask action for sub-agent delegation
499
+ self._register_subtask_action()
500
+
501
+ def _register_session_search_action(self):
502
+ """Register `session_search` when a cross-session memory backend is active.
503
+
504
+ Gives the agent on-demand recall over its own past sessions (tenant-scoped via
505
+ user_id, P0-0). Mirrors Reference's agent-callable session_search. Fail-open and
506
+ only advertised when an external provider is registered, so the default
507
+ NullMemoryProvider config is byte-identical (no new tool surfaced).
508
+ """
509
+ try:
510
+ from modules.memory.registry import get_memory_registry
511
+ provider = get_memory_registry().active()
512
+ if provider is None or not getattr(provider, 'is_external', False):
513
+ return # no external backend -> don't surface the tool
514
+ except Exception:
515
+ return
516
+
517
+ class SessionSearchAction(BaseModel):
518
+ query: str = "" # empty => browse most-recent
519
+ limit: int = 5 # provider clamps to [1, 20]
520
+ sort: Optional[str] = None # "newest" | "oldest" | None (rank)
521
+ collection: Optional[str] = None # set to search a named knowledge-base collection instead of past sessions
522
+
523
+ _SEARCH_DESC = (
524
+ "Recall your durable memory of PAST sessions. WHEN TO USE: reach for this "
525
+ "BEFORE web/filesystem on 'what did we do about X', 'where did we leave Y', "
526
+ "or 'what was I working on'. Two shapes: pass a `query` to DISCOVER relevant "
527
+ "past work (facts, decisions); leave `query` empty to BROWSE your most-recent "
528
+ "sessions. Optional `limit` (1-20) and `sort` ('newest'/'oldest'). "
529
+ "Optional `collection`: set to search a named knowledge-base collection instead "
530
+ "of past sessions (requires KB to be enabled)."
531
+ )
532
+
533
+ def _kb_on() -> bool:
534
+ try:
535
+ from agents.task.constants import AutonomyConfig
536
+ return AutonomyConfig.kb_enabled()
537
+ except Exception:
538
+ return False
539
+
540
+ async def _run_session_search(params: SessionSearchAction, execution_context=None) -> ActionResult:
541
+ # Tenant scoping + empty-user_id refusal are enforced in the provider
542
+ # (_anon_blocked, UP-03); the controller does NOT re-read MEMORY_REQUIRE_USER_ID.
543
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
544
+ session_id = getattr(execution_context, 'session_id', None) or getattr(self, 'session_id', '')
545
+
546
+ # Task 7: KB recall path — routes to kb_search when collection is set and KB is enabled.
547
+ # Fail-open: any exception falls through to the normal memory_search path below.
548
+ if params.collection and _kb_on():
549
+ try:
550
+ from modules.memory.registry import kb_search
551
+ recalled = await kb_search(
552
+ params.query, user_id=user_id,
553
+ collection=params.collection, limit=params.limit,
554
+ )
555
+ if not recalled or not recalled.strip():
556
+ return ActionResult(
557
+ extracted_content=f"No knowledge-base results for {params.query!r} in {params.collection!r}",
558
+ include_in_memory=True,
559
+ )
560
+ try:
561
+ from agents.task.agent.core.untrusted_wrap import wrap_untrusted
562
+ recalled = wrap_untrusted("knowledge_base", recalled)
563
+ except Exception:
564
+ pass # fail-open
565
+ return ActionResult(
566
+ extracted_content=f"## Recalled from knowledge base\n{recalled}",
567
+ include_in_memory=True,
568
+ )
569
+ except Exception as e:
570
+ self.logger.debug(f"kb_search failed, falling back to memory_search: {e}")
571
+ # fall through to normal session memory path
572
+
573
+ try:
574
+ from modules.memory.registry import memory_search
575
+ recalled = await memory_search(
576
+ params.query, session_id=session_id, user_id=user_id,
577
+ limit=params.limit, sort=params.sort,
578
+ )
579
+ except Exception as e:
580
+ self.logger.debug(f"session_search failed: {e}")
581
+ return ActionResult(extracted_content="No memory available.", include_in_memory=False)
582
+ if not recalled or not recalled.strip():
583
+ label = params.query if params.query else "(recent sessions)"
584
+ return ActionResult(
585
+ extracted_content=f"No past-session memory matched: {label!r}",
586
+ include_in_memory=True,
587
+ )
588
+ # W6/UP-06: recalled cross-session content may carry indirect injection
589
+ # (it can include previously-ingested web/tool output) — frame it as DATA.
590
+ # session_search is NOT in the automatic untrusted-tool set, so wrap here.
591
+ try:
592
+ from agents.task.agent.core.untrusted_wrap import wrap_untrusted
593
+ recalled = wrap_untrusted("session_search", recalled)
594
+ except Exception:
595
+ pass # fail-open: never block recall on a wrap import error
596
+ header = ("## Recalled from past sessions" if params.query
597
+ else "## Your most-recent sessions")
598
+ return ActionResult(
599
+ extracted_content=f"{header}\n{recalled}",
600
+ include_in_memory=True,
601
+ )
602
+
603
+ @self.registry.action(_SEARCH_DESC, param_model=SessionSearchAction)
604
+ async def session_search(params: SessionSearchAction, execution_context=None) -> ActionResult:
605
+ return await _run_session_search(params, execution_context)
606
+
607
+ # W6: Reference exposes the same recall under both names. Register `memory_search`
608
+ # as a thin alias (default-on via MEMORY_SEARCH_TOOL) so prompts/tools that use
609
+ # either shape resolve. Same handler, same tenant scoping — zero new surface.
610
+ try:
611
+ from agents.task.constants import AutonomyConfig
612
+ alias_on = AutonomyConfig.memory_search_tool()
613
+ except Exception:
614
+ alias_on = True
615
+ if alias_on:
616
+ @self.registry.action(_SEARCH_DESC, param_model=SessionSearchAction)
617
+ async def memory_search(params: SessionSearchAction, execution_context=None) -> ActionResult:
618
+ return await _run_session_search(params, execution_context)
619
+
620
+ def _register_recent_activity_action(self):
621
+ """Register `recent_activity`: time-ordered recall of the agent's OWN runs
622
+ (chat/goal/cron), tenant-scoped. Answers 'what did I do since X'. Only surfaced
623
+ when EPISODIC_MEMORY_ENABLED and an external provider is active (byte-identical
624
+ default config otherwise). NO `from __future__` in this module (registry-closure
625
+ landmine)."""
626
+ try:
627
+ from agents.task.constants import AutonomyConfig
628
+ if not AutonomyConfig.episodic_memory_enabled():
629
+ return
630
+ from modules.memory.registry import get_memory_registry
631
+ provider = get_memory_registry().active()
632
+ if provider is None or not getattr(provider, 'is_external', False):
633
+ return
634
+ except Exception:
635
+ return
636
+
637
+ class RecentActivityAction(BaseModel):
638
+ since: Optional[str] = None # "8h" | "2d" | "30m" | ISO8601; None => last 24h
639
+ until: Optional[str] = None
640
+ kind: Optional[str] = None # "chat" | "goal" | "cron"
641
+ limit: int = 20
642
+
643
+ _DESC = (
644
+ "Recall YOUR OWN recent activity — the goals, cron jobs, and chats you ran, "
645
+ "newest-first, with outcome and spend. USE THIS (not the filesystem, not notes) "
646
+ "for 'what did I do', 'what ran since X', 'what have I been up to'. Optional "
647
+ "`since` ('8h'/'2d'/ISO), `kind` ('goal'/'cron'/'chat'), `limit` (1-20)."
648
+ )
649
+
650
+ async def _run_recent_activity(params, execution_context=None) -> ActionResult:
651
+ from modules.memory.registry import memory_recall_episodes
652
+ from modules.memory.episodic import parse_since
653
+ import time as _t
654
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
655
+ since_ts = (parse_since(params.since) if params.since else None) or (int(_t.time()) - 24 * 3600)
656
+ until_ts = parse_since(params.until) if params.until else None
657
+ rows = await memory_recall_episodes(
658
+ user_id=user_id, since_ts=since_ts, until_ts=until_ts,
659
+ kind=params.kind, limit=params.limit, order="newest")
660
+ if not rows:
661
+ return ActionResult(
662
+ extracted_content="No recorded activity in that window.",
663
+ include_in_memory=True)
664
+ lines, total = [], 0.0
665
+ for e in rows:
666
+ total += float(e.spend_usd or 0)
667
+ arts = ", ".join(a.get("path", "") for a in (e.artifacts or [])[:3])
668
+ lines.append(
669
+ f"- {e.kind}:{e.outcome or '?'} ${float(e.spend_usd or 0):.2f} "
670
+ f"\"{(e.task or '')[:60]}\"" + (f" -> {arts}" if arts else ""))
671
+ body = "\n".join(lines) + f"\n({len(rows)} runs, ${total:.2f} total)"
672
+ try:
673
+ from agents.task.agent.core.untrusted_wrap import wrap_untrusted
674
+ body = wrap_untrusted("recent_activity", body)
675
+ except Exception:
676
+ pass
677
+ return ActionResult(extracted_content=f"## Your recent activity\n{body}",
678
+ include_in_memory=True)
679
+
680
+ @self.registry.action(_DESC, param_model=RecentActivityAction)
681
+ async def recent_activity(params: RecentActivityAction, execution_context=None) -> ActionResult:
682
+ return await _run_recent_activity(params, execution_context)
683
+
684
+ def _register_memory_tool_action(self):
685
+ """Register the bounded curated `memory` tool (UP-09) when enabled.
686
+
687
+ Gated on MEMORY_TOOL_ENABLED (default false) AND an external memory provider
688
+ with a curated store. Tenant scoping + empty-user_id refusal live in the
689
+ provider (UP-03 _anon_blocked). v1: read/add/remove only, no system-prompt
690
+ snapshot (keeps the cached prompt untouched, PR13 principle).
691
+ """
692
+ if not _bool_env("MEMORY_TOOL_ENABLED", False):
693
+ return
694
+ try:
695
+ from modules.memory.registry import get_memory_registry
696
+ provider = get_memory_registry().active()
697
+ if provider is None or not getattr(provider, 'is_external', False):
698
+ return
699
+ if not hasattr(provider, 'curated_add'):
700
+ return # provider has no curated store
701
+ except Exception:
702
+ return
703
+
704
+ from typing import Literal as _Literal
705
+
706
+ class MemoryToolAction(BaseModel):
707
+ action: _Literal["read", "add", "remove"]
708
+ content: Optional[str] = None # required for add (entry) / remove (substring)
709
+
710
+ @self.registry.action(
711
+ "Your durable, curated long-term notes (separate from automatic session "
712
+ "recall). action='add' to save a short fact worth remembering across "
713
+ "sessions; action='read' to list your notes; action='remove' to delete "
714
+ "notes containing a substring. Keep entries short and factual.",
715
+ param_model=MemoryToolAction,
716
+ )
717
+ async def memory(params: MemoryToolAction, execution_context=None) -> ActionResult:
718
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
719
+ from modules.memory.registry import get_memory_registry
720
+ prov = get_memory_registry().active()
721
+ if prov is None or not hasattr(prov, 'curated_add'):
722
+ return ActionResult(extracted_content="Memory tool unavailable.", include_in_memory=False)
723
+ if params.action == "read":
724
+ notes = await prov.curated_read(user_id)
725
+ if notes.strip():
726
+ # P1-7: curated notes are agent-authored (possibly under injection
727
+ # influence) and read back in FUTURE sessions — a durable persistence-
728
+ # laundering channel. Frame as untrusted DATA like every other recall
729
+ # surface (session_search/kb_search/recent_activity).
730
+ from agents.task.agent.core.untrusted_wrap import wrap_untrusted
731
+ content = f"## Your curated memory\n{wrap_untrusted('memory', notes)}"
732
+ else:
733
+ content = "Your curated memory is empty."
734
+ return ActionResult(extracted_content=content, include_in_memory=True)
735
+ # T4-02: curated writes shape the agent's own future recall — emit a
736
+ # first-class memory_write event so they are auditable (fail-open).
737
+ def _memory_write_ev(op: str, ok: bool, removed: int = 0):
738
+ try:
739
+ from agents.task.telemetry.memory_events import emit_memory_event
740
+ emit_memory_event(
741
+ "memory_write", user_id=user_id or "",
742
+ session_id=(getattr(execution_context, 'session_id', None)
743
+ or getattr(self, 'session_id', '') or ""),
744
+ source="memory_tool", scope="curated",
745
+ content=params.content or "", op=op, ok=ok, removed=removed)
746
+ except Exception:
747
+ pass
748
+
749
+ if params.action == "add":
750
+ ok = await prov.curated_add(user_id, params.content or "")
751
+ _memory_write_ev("add", ok)
752
+ return ActionResult(
753
+ extracted_content=("Saved to curated memory." if ok
754
+ else "Could not save (empty, over size/entry cap, or no tenant)."),
755
+ include_in_memory=True,
756
+ )
757
+ # remove
758
+ n = await prov.curated_remove(user_id, params.content or "")
759
+ _memory_write_ev("remove", n > 0, removed=n)
760
+ return ActionResult(
761
+ extracted_content=f"Removed {n} curated memory entr{'y' if n == 1 else 'ies'}.",
762
+ include_in_memory=True,
763
+ )
764
+
765
+ def _register_skill_manage_action(self):
766
+ """Register the writable-skills `skill_manage` tool (W2), gated SKILLS_WRITABLE.
767
+
768
+ Lets the agent author/refine/retire durable skills. Default OFF. All safety is
769
+ enforced in SkillWriterMixin (tenant-confined writes, validators, threat-scan,
770
+ .pending/ quarantine, atomic write, archive-never-delete). A non-user-initiated
771
+ turn can never auto-activate a skill (forced quarantine). Registered in this
772
+ module — which deliberately omits `from __future__ import annotations` so the
773
+ Registry can introspect the closure's first-param model.
774
+ """
775
+ try:
776
+ from agents.task.constants import AutonomyConfig
777
+ if not AutonomyConfig.skills_writable():
778
+ return
779
+ except Exception:
780
+ return
781
+
782
+ from typing import Literal as _Literal
783
+
784
+ class SkillManageAction(BaseModel):
785
+ action: _Literal["create", "patch", "delete", "promote"]
786
+ skill_id: str
787
+ content: Optional[str] = None # create: full SKILL.md body
788
+ old_string: Optional[str] = None # patch: exact text to replace
789
+ new_string: Optional[str] = None # patch: replacement
790
+ replace_all: bool = False
791
+ description: str = ""
792
+
793
+ @self.registry.action(
794
+ "Author your own durable SKILLS (procedures you can reload in future "
795
+ "sessions). action='create' writes a new SKILL.md (markdown, starts with a "
796
+ "# heading); action='patch' edits one by exact-string replace; "
797
+ "action='delete' archives one; action='promote' activates a pending draft "
798
+ "(owner-only — only the bound owner principal can promote; neither a "
799
+ "background/sub-agent turn nor a non-owner turn can self-promote). New/edited "
800
+ "skills are quarantined for review before they activate. Use to capture a "
801
+ "reusable procedure you just worked out.",
802
+ param_model=SkillManageAction,
803
+ )
804
+ async def skill_manage(params: SkillManageAction, execution_context=None) -> ActionResult:
805
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
806
+ if not user_id:
807
+ return ActionResult(error="skill authoring requires a user (tenant scope).",
808
+ include_in_memory=True)
809
+
810
+ # T4-06: every effected skill mutation records a first-class
811
+ # self_modification event (durable log → /telemetry + /activity). Fail-open.
812
+ def _self_mod_ev(action: str, *, pending=None, created_by: str = "", ok: bool = True):
813
+ try:
814
+ from agents.task.telemetry.self_events import emit_self_modification
815
+ emit_self_modification(
816
+ kind="skill", action=action, item_id=params.skill_id,
817
+ user_id=user_id or "",
818
+ session_id=(getattr(execution_context, 'session_id', None)
819
+ or getattr(self, 'session_id', '') or ""),
820
+ pending=pending, created_by=created_by,
821
+ source="skill_manage", ok=ok)
822
+ except Exception:
823
+ pass
824
+ # A forged (non-user-initiated) turn must never auto-activate a skill. The
825
+ # RELIABLE signal is the execution context: the background-review reviewer
826
+ # (and any delegated worker) runs as a sub-agent / leaf role — those fields
827
+ # ARE set in _build_execution_context. A self-wake/delegation-result
828
+ # re-entry into the MAIN agent is additionally detected via
829
+ # `execution_context.metadata['turn_kind']` being one of `FORGED_TURN_KINDS`
830
+ # (SK-F10, stamped live from the drained HITL message kind — see
831
+ # `_is_forged_or_autonomous_turn` above). A sub-agent/leaf author → background_review (ALWAYS
832
+ # quarantined); a normal interactive main-agent turn → agent (follows
833
+ # SKILLS_WRITABLE_REQUIRE_REVIEW).
834
+ from agents.task.agent.skill_writer import PROVENANCE_AGENT, PROVENANCE_BACKGROUND
835
+ # C7: fold autonomous (goal/cron) runs into "forged" so they can't
836
+ # auto-activate or self-promote a skill either.
837
+ is_forged = _is_forged_or_autonomous_turn(execution_context, self)
838
+ created_by = PROVENANCE_BACKGROUND if is_forged else PROVENANCE_AGENT
839
+ try:
840
+ from agents.task.agent.skill_manager import get_skill_manager
841
+ sm = get_skill_manager()
842
+ if params.action == "promote":
843
+ # Owner-gated (T3-02): promoting a pending draft into ACTIVE content
844
+ # is the entire security boundary of the writable-skills quarantine —
845
+ # an active skill auto-activates in future sessions. A forged turn is
846
+ # blocked, AND (mirroring self_context_manage promote, permissions
847
+ # audit F4) a non-owner genuine turn is blocked too: otherwise the
848
+ # agent could `create` (-> .pending under REQUIRE_REVIEW) then
849
+ # `promote` in the SAME turn, activating a body the owner never saw —
850
+ # e.g. an injected "author skill X and promote it" from fetched content.
851
+ # is_owner_local_safe is the surface-independent owner check (a
852
+ # forgeable network sender's uid is never the local tenant).
853
+ owner_ok = False
854
+ try:
855
+ from agents.task.constants import local_mode_enabled
856
+ from core.instance import is_owner_local_safe, resolve_owner_principal
857
+ owner_ok = is_owner_local_safe(
858
+ user_id, owner_principal=resolve_owner_principal(),
859
+ local_enabled=local_mode_enabled())
860
+ except Exception:
861
+ owner_ok = False
862
+ if is_forged or not owner_ok:
863
+ return ActionResult(
864
+ error="promote is owner-only; your pending draft awaits operator review.",
865
+ include_in_memory=True)
866
+ res = sm.promote_pending_skill(params.skill_id, user_id=user_id,
867
+ description=params.description)
868
+ if not res.ok:
869
+ return ActionResult(error=f"promote failed: {'; '.join(res.errors)}",
870
+ include_in_memory=True)
871
+ _self_mod_ev("promote", pending=False, created_by="owner")
872
+ return ActionResult(
873
+ extracted_content=f"Skill `{params.skill_id}` promoted (active next session).",
874
+ include_in_memory=True)
875
+ if params.action == "create":
876
+ if not params.content:
877
+ return ActionResult(error="create requires `content`.", include_in_memory=True)
878
+ res = sm.create_skill(params.skill_id, params.content, user_id=user_id,
879
+ description=params.description, created_by=created_by)
880
+ elif params.action == "patch":
881
+ if params.old_string is None or params.new_string is None:
882
+ return ActionResult(error="patch requires `old_string` and `new_string`.",
883
+ include_in_memory=True)
884
+ res = sm.patch_skill(params.skill_id, user_id=user_id,
885
+ old_string=params.old_string, new_string=params.new_string,
886
+ replace_all=params.replace_all, created_by=created_by)
887
+ else: # delete
888
+ ok = sm.delete_skill(params.skill_id, user_id=user_id, created_by=created_by)
889
+ if ok:
890
+ _self_mod_ev("delete", created_by=created_by)
891
+ return ActionResult(
892
+ extracted_content=(f"Archived skill `{params.skill_id}`." if ok
893
+ else f"No skill `{params.skill_id}` to archive."),
894
+ include_in_memory=True,
895
+ )
896
+ except Exception as e:
897
+ self.logger.debug(f"skill_manage failed: {e}")
898
+ return ActionResult(error=f"skill_manage failed: {e}", include_in_memory=True)
899
+
900
+ if not res.ok:
901
+ return ActionResult(error=f"Skill rejected: {'; '.join(res.errors)}",
902
+ include_in_memory=True)
903
+ where = "pending review" if res.pending else "active"
904
+ _self_mod_ev(params.action, pending=bool(res.pending), created_by=created_by)
905
+ # §7.1: notify the owner when a skill lands in pending (fail-open, gated).
906
+ if res.pending:
907
+ try:
908
+ from core import self_evolution as _se
909
+ from core.instance import resolve_instance_id as _rii
910
+ _cfg = getattr(getattr(self, 'container', None), 'config', None)
911
+ _dd = getattr(_cfg, 'data_dir', 'data') or 'data'
912
+ await _se.maybe_notify_owner_pending(
913
+ getattr(self, 'container', None), user_id,
914
+ home_dir=_dd, instance_id=_rii(), skill_manager=sm)
915
+ except Exception as _e:
916
+ self.logger.debug(f"self-evolution notify skipped: {_e}")
917
+ return ActionResult(
918
+ extracted_content=f"Skill `{params.skill_id}` saved ({where}).",
919
+ include_in_memory=True,
920
+ )
921
+
922
+ def _register_message_action(self):
923
+ """Register the gated `message` action: resolve the target's tier
924
+ (owner/allowlisted/denied) and route an approved send through the
925
+ existing MessageRouter. Gated MESSAGE_TOOL_ENABLED (default OFF; ON
926
+ under POLYROB_LOCAL). Registered in this module — no `from __future__
927
+ import annotations` (registry-closure introspection)."""
928
+ try:
929
+ from agents.task.constants import message_tool_enabled
930
+ if not message_tool_enabled():
931
+ return
932
+ except Exception:
933
+ return
934
+
935
+ from tools.controller.message_send import perform_message_send
936
+ from tools.controller.views import MessageTargetAction
937
+
938
+ @self.registry.action(
939
+ 'Send a message to a specific chat/recipient on a given surface '
940
+ '(telegram/email/whatsapp). Only the owner and owner-allowlisted targets '
941
+ 'are permitted; other targets are denied.',
942
+ param_model=MessageTargetAction,
943
+ )
944
+ async def message(params: MessageTargetAction, execution_context=None) -> ActionResult:
945
+ import os
946
+ from core.instance import resolve_owner_telegram_id, resolve_owner_email
947
+
948
+ # Forged/untrusted/autonomous turns must not reach arbitrary targets
949
+ # (sub-agent, self-wake/delegation-result re-entry into the main agent,
950
+ # or an autonomous goal/cron/planner-spawned session).
951
+ if _is_forged_or_autonomous_turn(execution_context, self):
952
+ return ActionResult(
953
+ extracted_content=(
954
+ "message: not permitted for forged/autonomous turns "
955
+ "(owner must be in the loop)"),
956
+ include_in_memory=True)
957
+
958
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None) or ""
959
+ container = getattr(self, "container", None)
960
+ router = container.get_service("message_router") if container else None
961
+ allowlist = container.get_service("outbound_allowlist") if container else None
962
+
963
+ owner_targets = {}
964
+ tid = resolve_owner_telegram_id(os.environ)
965
+ if tid:
966
+ owner_targets["telegram"] = str(tid)
967
+ oem = resolve_owner_email(os.environ)
968
+ if oem:
969
+ owner_targets["email"] = oem
970
+
971
+ res = await perform_message_send(
972
+ router=router, allowlist=allowlist, owner_targets=owner_targets,
973
+ user_id=user_id, surface=params.surface, target=params.target,
974
+ text=params.text, action=params.action, reply_to=params.reply_to,
975
+ message_id=params.message_id)
976
+ return ActionResult(extracted_content=(
977
+ f"message[{res['tier']}] -> {params.surface}:{params.target} "
978
+ f"{'OK' if res['success'] else 'FAILED: ' + (res.get('error') or '')}"),
979
+ include_in_memory=True)
980
+
981
+ def _register_self_context_manage_action(self):
982
+ """Register the evolving SELF-identity tool `self_context_manage`, gated
983
+ SELF_CONTEXT_WRITABLE (default OFF; ON under POLYROB_LOCAL).
984
+
985
+ Lets the agent refine its own per-(instance,user) ``self.md`` — the learned
986
+ "how I work with this user" layer. Safety lives in SelfContextWriter:
987
+ tenant-confined + anon-blocked, identity-scanned fail-CLOSED (self-voice
988
+ subversion + invisible-unicode), over-cap ERRORS (forces consolidation),
989
+ forged turns forced to .pending and barred from active docs, atomic write,
990
+ archive-never-delete. The SOUL tier (identity.md/operating.md) is NEVER
991
+ reachable here — it stays operator-only. Writes apply NEXT session (the
992
+ foundation snapshot is frozen at session start). Registered in this module —
993
+ no `from __future__ import annotations` (registry-closure introspection)."""
994
+ try:
995
+ from agents.task.constants import AutonomyConfig
996
+ if not AutonomyConfig.self_context_writable():
997
+ return
998
+ except Exception:
999
+ return
1000
+
1001
+ from typing import Literal as _Literal
1002
+
1003
+ class SelfContextManageAction(BaseModel):
1004
+ action: _Literal["update", "patch", "read", "promote"]
1005
+ content: Optional[str] = None # update: full self.md body (≤2200 chars)
1006
+ old_string: Optional[str] = None # patch: exact text to replace
1007
+ new_string: Optional[str] = None # patch: replacement
1008
+ replace_all: bool = False
1009
+
1010
+ @self.registry.action(
1011
+ "Refine your evolving SELF context — durable notes about how you work with "
1012
+ "THIS user (preferences, conventions, what you've learned). action='read' "
1013
+ "returns the current text; action='update' replaces it (≤2200 chars — "
1014
+ "consolidate, don't sprawl); action='patch' edits by exact-string replace; "
1015
+ "action='promote' activates your pending draft (owner-only). Updates/patches "
1016
+ "are QUARANTINED for review and apply next session. This is NOT your core "
1017
+ "identity/boundaries (those are operator-owned).",
1018
+ param_model=SelfContextManageAction,
1019
+ )
1020
+ async def self_context_manage(params: SelfContextManageAction, execution_context=None) -> ActionResult:
1021
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
1022
+ if not user_id:
1023
+ return ActionResult(error="self-context requires a user (tenant scope).",
1024
+ include_in_memory=True)
1025
+
1026
+ # T4-06: every effected self-context mutation records a first-class
1027
+ # self_modification event (durable log → /telemetry + /activity). Fail-open.
1028
+ def _self_mod_ev(action: str, *, pending=None, created_by: str = "", ok: bool = True):
1029
+ try:
1030
+ from agents.task.telemetry.self_events import emit_self_modification
1031
+ emit_self_modification(
1032
+ kind="self_context", action=action, item_id=user_id or "",
1033
+ user_id=user_id or "",
1034
+ session_id=(getattr(execution_context, 'session_id', None)
1035
+ or getattr(self, 'session_id', '') or ""),
1036
+ pending=pending, created_by=created_by,
1037
+ source="self_context_manage", ok=ok)
1038
+ except Exception:
1039
+ pass
1040
+ # Resolve the instance home dir (same as construction).
1041
+ _cfg = getattr(getattr(self, 'container', None), 'config', None)
1042
+ data_dir = getattr(_cfg, 'data_dir', 'data') or 'data'
1043
+ try:
1044
+ from core.instance import resolve_instance_id
1045
+ from core.self_context_writer import (
1046
+ SelfContextWriter, PROVENANCE_AGENT, PROVENANCE_BACKGROUND,
1047
+ )
1048
+ writer = SelfContextWriter(data_dir, instance_id=resolve_instance_id())
1049
+ except Exception as e:
1050
+ self.logger.debug(f"self_context_manage init failed: {e}")
1051
+ return ActionResult(error=f"self_context_manage unavailable: {e}", include_in_memory=True)
1052
+
1053
+ if params.action == "read":
1054
+ body = writer.read(user_id)
1055
+ # Apply the same load-side [BLOCKED] guard as session-start injection
1056
+ # (load_self_doc) so a direct-FS-poisoned doc is never returned raw to
1057
+ # the model mid-session. Fail-closed on a missing/raising scanner.
1058
+ if body:
1059
+ try:
1060
+ from modules.memory.task.threat_scan import is_identity_suspicious
1061
+ if is_identity_suspicious(body):
1062
+ body = "[BLOCKED: self-context failed the identity safety scan]"
1063
+ except Exception:
1064
+ body = "[BLOCKED: identity scanner unavailable]"
1065
+ return ActionResult(
1066
+ extracted_content=(body or "(no self-context yet)"),
1067
+ include_in_memory=True,
1068
+ )
1069
+
1070
+ # Forged/autonomous detection (C7): a sub-agent/leaf OR an autonomous
1071
+ # goal/cron/planner-spawned run must never promote its own pending
1072
+ # self-context (autonomous top-level runs are owner_ok under POLYROB_LOCAL).
1073
+ is_forged = _is_forged_or_autonomous_turn(execution_context, self)
1074
+
1075
+ if params.action == "promote":
1076
+ # Activation is OWNER-only (Phase D). The caller is the owner when this
1077
+ # is the single-user local CLI OR their user_id matches the bound owner
1078
+ # principal (POLYROB_OWNER_USER_ID / first SURFACE_SUPER_ADMIN_USER_IDS).
1079
+ # A non-owner or any forged turn can never self-promote — that is what
1080
+ # keeps a self-wake / injected / sub-agent turn from activating its own
1081
+ # pending identity, on the server as well as locally.
1082
+ try:
1083
+ from agents.task.constants import local_mode_enabled
1084
+ from core.instance import is_owner_local_safe, resolve_owner_principal
1085
+ # The local bypass is honored ONLY for the genuine single-user local
1086
+ # operator tenant — NOT any uid under the global POLYROB_LOCAL flag.
1087
+ # This action runs inside a session and has no surface id, so it can't
1088
+ # use the _LOCAL_OWNER_SURFACES filter that access.py/pairing.py apply;
1089
+ # is_owner_local_safe is the surface-independent equivalent (a forgeable
1090
+ # network sender's uid is never the local tenant). See permissions audit F4.
1091
+ owner_ok = is_owner_local_safe(
1092
+ user_id, owner_principal=resolve_owner_principal(),
1093
+ local_enabled=local_mode_enabled())
1094
+ except Exception:
1095
+ owner_ok = False
1096
+ if is_forged or not owner_ok:
1097
+ return ActionResult(
1098
+ error="promote is owner-only; your pending self-context awaits operator review.",
1099
+ include_in_memory=True)
1100
+ res = writer.promote(user_id=user_id)
1101
+ if not res.ok:
1102
+ return ActionResult(error=f"Promote failed: {'; '.join(res.errors)}",
1103
+ include_in_memory=True)
1104
+ _self_mod_ev("promote", pending=False, created_by="owner")
1105
+ return ActionResult(extracted_content="Self-context promoted (active next session).",
1106
+ include_in_memory=True)
1107
+
1108
+ # update / patch: ALWAYS quarantine to .pending (pending=True below) — the
1109
+ # action never writes the active doc directly; activation is the owner-gated
1110
+ # `promote` above. `created_by` still reflects real forged status so the
1111
+ # writer additionally bars a forged turn from even reading/patching an active
1112
+ # doc, while a normal turn may patch the active doc INTO a pending edit.
1113
+ created_by = PROVENANCE_BACKGROUND if is_forged else PROVENANCE_AGENT
1114
+ try:
1115
+ if params.action == "update":
1116
+ if not params.content:
1117
+ return ActionResult(error="update requires `content`.", include_in_memory=True)
1118
+ res = writer.propose(params.content, user_id=user_id, created_by=created_by,
1119
+ pending=True)
1120
+ else: # patch
1121
+ if params.old_string is None or params.new_string is None:
1122
+ return ActionResult(error="patch requires `old_string` and `new_string`.",
1123
+ include_in_memory=True)
1124
+ res = writer.patch(user_id=user_id, old_string=params.old_string,
1125
+ new_string=params.new_string, replace_all=params.replace_all,
1126
+ created_by=created_by, pending=True)
1127
+ except Exception as e:
1128
+ self.logger.debug(f"self_context_manage failed: {e}")
1129
+ return ActionResult(error=f"self_context_manage failed: {e}", include_in_memory=True)
1130
+
1131
+ if not res.ok:
1132
+ return ActionResult(error=f"Self-context rejected: {'; '.join(res.errors)}",
1133
+ include_in_memory=True)
1134
+ _self_mod_ev(params.action, pending=True, created_by=created_by)
1135
+ # §7.1: proactively tell the owner a proposal is waiting (fail-open,
1136
+ # gated SELF_EVOLUTION_TRANSPARENCY). Closes the "owner never told" gap.
1137
+ try:
1138
+ from core import self_evolution as _se
1139
+ await _se.maybe_notify_owner_pending(
1140
+ getattr(self, 'container', None), user_id,
1141
+ home_dir=data_dir, instance_id=resolve_instance_id())
1142
+ except Exception as _e:
1143
+ self.logger.debug(f"self-evolution notify skipped: {_e}")
1144
+ return ActionResult(
1145
+ extracted_content="Self-context saved (pending review; applies next session).",
1146
+ include_in_memory=True,
1147
+ )
1148
+
1149
+ def _register_owner_doc_manage_action(self):
1150
+ """Register the bounded owner-facts tool `owner_doc_manage`, gated
1151
+ OWNER_DOC_WRITABLE (default OFF; ON under POLYROB_LOCAL).
1152
+
1153
+ Lets the agent maintain a small per-(instance,user) ``owner.md`` — durable
1154
+ facts/preferences about the OWNER, injected each session alongside SOUL/SELF.
1155
+ Same safety as self-context (OwnerDocWriter): tenant-confined + anon-blocked,
1156
+ identity-scanned fail-CLOSED, over-cap ERRORS, forged turns forced .pending
1157
+ and barred from active docs, atomic write, archive-never-delete. Writes apply
1158
+ NEXT session. No `from __future__ import annotations` (registry-closure
1159
+ introspection)."""
1160
+ try:
1161
+ from agents.task.constants import AutonomyConfig
1162
+ if not AutonomyConfig.owner_doc_writable():
1163
+ return
1164
+ except Exception:
1165
+ return
1166
+
1167
+ from typing import Literal as _Literal
1168
+
1169
+ class OwnerDocManageAction(BaseModel):
1170
+ action: _Literal["update", "patch", "read", "promote"]
1171
+ content: Optional[str] = None # update: full owner.md body (≤1600 chars)
1172
+ old_string: Optional[str] = None # patch: exact text to replace
1173
+ new_string: Optional[str] = None # patch: replacement
1174
+ replace_all: bool = False
1175
+
1176
+ @self.registry.action(
1177
+ "Maintain durable facts about your OWNER — their preferences, timezone, "
1178
+ "projects, how they like to be helped (a small owner.md, ≤1600 chars). "
1179
+ "action='read' returns it; action='update' replaces it (consolidate, keep "
1180
+ "only durable facts); action='patch' edits by exact-string replace; "
1181
+ "action='promote' activates your pending draft (owner-only). Updates/patches "
1182
+ "are QUARANTINED for review and apply next session.",
1183
+ param_model=OwnerDocManageAction,
1184
+ )
1185
+ async def owner_doc_manage(params: OwnerDocManageAction, execution_context=None) -> ActionResult:
1186
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
1187
+ if not user_id:
1188
+ return ActionResult(error="owner-facts doc requires a user (tenant scope).",
1189
+ include_in_memory=True)
1190
+
1191
+ def _self_mod_ev(action: str, *, pending=None, created_by: str = "", ok: bool = True):
1192
+ try:
1193
+ from agents.task.telemetry.self_events import emit_self_modification
1194
+ emit_self_modification(
1195
+ kind="owner_doc", action=action, item_id=user_id or "",
1196
+ user_id=user_id or "",
1197
+ session_id=(getattr(execution_context, 'session_id', None)
1198
+ or getattr(self, 'session_id', '') or ""),
1199
+ pending=pending, created_by=created_by,
1200
+ source="owner_doc_manage", ok=ok)
1201
+ except Exception:
1202
+ pass
1203
+
1204
+ _cfg = getattr(getattr(self, 'container', None), 'config', None)
1205
+ data_dir = getattr(_cfg, 'data_dir', 'data') or 'data'
1206
+ try:
1207
+ from core.instance import resolve_instance_id
1208
+ from core.owner_doc_writer import (
1209
+ OwnerDocWriter, PROVENANCE_AGENT, PROVENANCE_BACKGROUND,
1210
+ )
1211
+ writer = OwnerDocWriter(data_dir, instance_id=resolve_instance_id())
1212
+ except Exception as e:
1213
+ self.logger.debug(f"owner_doc_manage init failed: {e}")
1214
+ return ActionResult(error=f"owner_doc_manage unavailable: {e}", include_in_memory=True)
1215
+
1216
+ if params.action == "read":
1217
+ body = writer.read(user_id)
1218
+ if body:
1219
+ try:
1220
+ from modules.memory.task.threat_scan import is_identity_suspicious
1221
+ if is_identity_suspicious(body):
1222
+ body = "[BLOCKED: owner-facts doc failed the identity safety scan]"
1223
+ except Exception:
1224
+ body = "[BLOCKED: identity scanner unavailable]"
1225
+ return ActionResult(
1226
+ extracted_content=(body or "(no owner-facts doc yet)"),
1227
+ include_in_memory=True,
1228
+ )
1229
+
1230
+ is_forged = _is_forged_or_autonomous_turn(execution_context, self)
1231
+
1232
+ if params.action == "promote":
1233
+ try:
1234
+ from agents.task.constants import local_mode_enabled
1235
+ from core.instance import is_owner_local_safe, resolve_owner_principal
1236
+ owner_ok = is_owner_local_safe(
1237
+ user_id, owner_principal=resolve_owner_principal(),
1238
+ local_enabled=local_mode_enabled())
1239
+ except Exception:
1240
+ owner_ok = False
1241
+ if is_forged or not owner_ok:
1242
+ return ActionResult(
1243
+ error="promote is owner-only; your pending owner-facts doc awaits operator review.",
1244
+ include_in_memory=True)
1245
+ res = writer.promote(user_id=user_id)
1246
+ if not res.ok:
1247
+ return ActionResult(error=f"Promote failed: {'; '.join(res.errors)}",
1248
+ include_in_memory=True)
1249
+ _self_mod_ev("promote", pending=False, created_by="owner")
1250
+ return ActionResult(extracted_content="Owner-facts doc promoted (active next session).",
1251
+ include_in_memory=True)
1252
+
1253
+ created_by = PROVENANCE_BACKGROUND if is_forged else PROVENANCE_AGENT
1254
+ try:
1255
+ if params.action == "update":
1256
+ if not params.content:
1257
+ return ActionResult(error="update requires `content`.", include_in_memory=True)
1258
+ res = writer.propose(params.content, user_id=user_id, created_by=created_by,
1259
+ pending=True)
1260
+ else: # patch
1261
+ if params.old_string is None or params.new_string is None:
1262
+ return ActionResult(error="patch requires `old_string` and `new_string`.",
1263
+ include_in_memory=True)
1264
+ res = writer.patch(user_id=user_id, old_string=params.old_string,
1265
+ new_string=params.new_string, replace_all=params.replace_all,
1266
+ created_by=created_by, pending=True)
1267
+ except Exception as e:
1268
+ self.logger.debug(f"owner_doc_manage failed: {e}")
1269
+ return ActionResult(error=f"owner_doc_manage failed: {e}", include_in_memory=True)
1270
+
1271
+ if not res.ok:
1272
+ return ActionResult(error=f"Owner-facts doc rejected: {'; '.join(res.errors)}",
1273
+ include_in_memory=True)
1274
+ _self_mod_ev(params.action, pending=True, created_by=created_by)
1275
+ try:
1276
+ from core import self_evolution as _se
1277
+ await _se.maybe_notify_owner_pending(
1278
+ getattr(self, 'container', None), user_id,
1279
+ home_dir=data_dir, instance_id=resolve_instance_id())
1280
+ except Exception as _e:
1281
+ self.logger.debug(f"self-evolution notify skipped: {_e}")
1282
+ return ActionResult(
1283
+ extracted_content="Owner-facts doc saved (pending review; applies next session).",
1284
+ include_in_memory=True,
1285
+ )
1286
+
1287
+ def _register_mcp_install_action(self):
1288
+ """Register the agent-callable `mcp_install` action (T3-01/W4-1).
1289
+
1290
+ Wires the previously-orphaned tools/mcp/self_install.py::perform_mcp_install
1291
+ pipeline (gate → allowlist → screen → approve → add_server → persist).
1292
+ Safety posture:
1293
+ - action only registered when MCP_SELF_INSTALL_ENABLED=true (default OFF);
1294
+ - a forged/leaf/autonomous turn can never install (owner in the loop);
1295
+ - the approver is resolved EXPLICITLY: Deny-by-default unless the operator
1296
+ set APPROVAL_PROVIDER — the enable flag alone never silently auto-approves;
1297
+ - only NAMED, reviewed catalog entries install (builtins + the operator's
1298
+ MCP_INSTALL_CATALOG_FILE, T3-03) — never an agent-written config;
1299
+ - persisted via the tenant-scoped user_mcp_service row (T3-04), NEVER the
1300
+ global config/mcp_config.json; stdio entries stay session-only;
1301
+ - direct {server}_{tool} actions re-registered post-install (T3-05) so the
1302
+ new tools are callable the SAME session;
1303
+ - every attempt writes an event_log audit row.
1304
+ Registered in this module — no `from __future__ import annotations`
1305
+ (registry-closure introspection).
1306
+ """
1307
+ try:
1308
+ from tools.mcp.self_install import self_install_enabled
1309
+ if not self_install_enabled():
1310
+ return
1311
+ except Exception:
1312
+ return
1313
+
1314
+ from typing import Literal as _Literal
1315
+
1316
+ class MCPInstallAction(BaseModel):
1317
+ action: _Literal["list", "install"] = "install"
1318
+ server_id: Optional[str] = None
1319
+
1320
+ @self.registry.action(
1321
+ "Install a vetted MCP server from the reviewed catalog to gain new tools "
1322
+ "this session. action='list' shows the installable catalog; "
1323
+ "action='install' with server_id installs one (owner approval required). "
1324
+ "Only named catalog entries can be installed — never an arbitrary config.",
1325
+ param_model=MCPInstallAction,
1326
+ )
1327
+ async def mcp_install(params: MCPInstallAction, execution_context=None) -> ActionResult:
1328
+ import os as _os
1329
+
1330
+ from tools.mcp.catalog import MCPCatalog
1331
+ from tools.mcp.self_install import perform_mcp_install
1332
+
1333
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None) or ""
1334
+ session_id = (getattr(execution_context, 'session_id', None)
1335
+ or getattr(self, 'session_id', '') or "")
1336
+ catalog = MCPCatalog()
1337
+
1338
+ def _audit(server_id: str, outcome: str, **extra):
1339
+ try:
1340
+ from agents.task.telemetry.event_log import event_log_enabled, get_event_log
1341
+ if event_log_enabled():
1342
+ get_event_log().record(
1343
+ "mcp_install", user_id=user_id, session_id=session_id,
1344
+ source="mcp_install", server_id=server_id, outcome=outcome,
1345
+ **extra)
1346
+ except Exception:
1347
+ pass
1348
+
1349
+ if params.action == "list":
1350
+ lines = ["Installable MCP servers (reviewed catalog):"]
1351
+ for sid in catalog.ids():
1352
+ e = catalog.get(sid)
1353
+ lines.append(f"- {sid} [{e.trust}] ({e.transport}): {e.description}")
1354
+ return ActionResult(extracted_content="\n".join(lines), include_in_memory=True)
1355
+
1356
+ if not params.server_id:
1357
+ return ActionResult(error="install requires `server_id` (see action='list').",
1358
+ include_in_memory=True)
1359
+
1360
+ # Owner in the loop: a self-wake/delegated/autonomous turn never installs.
1361
+ if _is_forged_or_autonomous_turn(execution_context, self):
1362
+ _audit(params.server_id, "refused_forged_turn")
1363
+ return ActionResult(
1364
+ extracted_content=(
1365
+ "mcp_install: not permitted for forged/autonomous turns — ask the "
1366
+ "owner to install it (the owner must be in the loop for new tools)."),
1367
+ include_in_memory=True)
1368
+
1369
+ # The MCP subsystem must be loaded this session to host the new server.
1370
+ mcp_info = (self._tools or {}).get("mcp")
1371
+ mcp_tool = getattr(mcp_info, "instance", None) if mcp_info else None
1372
+ server_manager = getattr(mcp_tool, "server_manager", None) if mcp_tool else None
1373
+ if server_manager is None:
1374
+ _audit(params.server_id, "refused_no_mcp_tool")
1375
+ return ActionResult(
1376
+ error="mcp_install: the mcp tool is not loaded this session "
1377
+ "(tool_ids must include 'mcp').",
1378
+ include_in_memory=True)
1379
+
1380
+ # Explicit approver: Deny-by-default unless the operator opted into a
1381
+ # provider — MCP_SELF_INSTALL_ENABLED alone never silently auto-approves.
1382
+ from tools.controller.approval import (
1383
+ DenyByDefaultApprover, get_approval_provider_or_deny)
1384
+ _prov_name = (_os.getenv("APPROVAL_PROVIDER") or "").strip()
1385
+ provider = (get_approval_provider_or_deny(_prov_name)
1386
+ if _prov_name else DenyByDefaultApprover())
1387
+
1388
+ ok, msg = await perform_mcp_install(
1389
+ params.server_id,
1390
+ catalog=catalog,
1391
+ server_manager=server_manager,
1392
+ approve=provider.request,
1393
+ persist=None, # tenant-scoped persist handled below (T3-04)
1394
+ context=execution_context,
1395
+ )
1396
+ if not ok:
1397
+ _audit(params.server_id, "failed", reason=msg)
1398
+ return ActionResult(error=msg, include_in_memory=True)
1399
+
1400
+ # T3-05: surface the new server's tools as direct actions THIS session.
1401
+ try:
1402
+ await self._register_mcp_tools_as_direct_actions(mcp_tool)
1403
+ except Exception as e:
1404
+ self.logger.debug(f"mcp_install: direct-action re-register skipped: {e}")
1405
+
1406
+ # T3-04: persist tenant-scoped (url transports only — user_mcp_service
1407
+ # blocks stdio by design; a stdio entry stays session-only).
1408
+ persisted = False
1409
+ entry = catalog.get(params.server_id)
1410
+ try:
1411
+ ums = (self.container.get_service("user_mcp_service")
1412
+ if getattr(self, "container", None) else None)
1413
+ if ums is not None and entry is not None and entry.url:
1414
+ res = await ums.add_server(
1415
+ user_id, params.server_id, entry.url,
1416
+ server_type=entry.transport if entry.transport in ("sse", "http") else "sse",
1417
+ display_name=entry.description or params.server_id,
1418
+ )
1419
+ persisted = bool(getattr(res, "success", False))
1420
+ except Exception as e:
1421
+ self.logger.debug(f"mcp_install: tenant persist skipped: {e}")
1422
+
1423
+ _audit(params.server_id, "installed", persisted=persisted)
1424
+ tail = " Persisted for future sessions." if persisted else " Session-only (not persisted)."
1425
+ return ActionResult(extracted_content=msg + tail, include_in_memory=True)
1426
+
1427
+ def _register_insights_action(self):
1428
+ """Register the read-only `insights` tool (W7), gated INSIGHTS_TOOL.
1429
+
1430
+ Reports whether the agent's self-authored skills actually get reused — the
1431
+ measurement the writable-skills safety brief requires. Tenant-scoped, no writes.
1432
+ """
1433
+ try:
1434
+ from agents.task.constants import AutonomyConfig
1435
+ if not AutonomyConfig.insights_tool():
1436
+ return
1437
+ except Exception:
1438
+ return
1439
+
1440
+ class InsightsAction(BaseModel):
1441
+ pass
1442
+
1443
+ @self.registry.action(
1444
+ "Show insights about your own learning: how many durable skills you've "
1445
+ "authored and how often you reuse them (authored-skill reuse rate).",
1446
+ param_model=InsightsAction,
1447
+ )
1448
+ async def insights(params: InsightsAction, execution_context=None) -> ActionResult:
1449
+ user_id = getattr(execution_context, 'user_id', None) or getattr(self, 'user_id', None)
1450
+ try:
1451
+ from modules.skills.skill_usage import get_skill_usage_store
1452
+ summary = get_skill_usage_store().authored_reuse_summary(user_id=user_id)
1453
+ except Exception as e:
1454
+ self.logger.debug(f"insights failed: {e}")
1455
+ return ActionResult(extracted_content="No insights available.", include_in_memory=False)
1456
+ rate = round(summary["reuse_rate"] * 100)
1457
+ top = ", ".join(f"{t['skill_id']}({t['loads']})" for t in summary["top"][:5]) or "—"
1458
+ return ActionResult(
1459
+ extracted_content=(
1460
+ f"## Skill insights\n"
1461
+ f"- authored skills: {summary['authored_total']}\n"
1462
+ f"- reused at least once: {summary['authored_reused']} ({rate}%)\n"
1463
+ f"- by author: {summary['by_author']}\n"
1464
+ f"- most-used: {top}"
1465
+ ),
1466
+ include_in_memory=True,
1467
+ )
1468
+
1469
+ def _register_subtask_action(self):
1470
+ """Register subtask action for sub-agent delegation.
1471
+
1472
+ Allows the main agent to spawn sub-agents for specialized tasks.
1473
+ Only registers actions when sub-agents are enabled to prevent
1474
+ leaking sub-agent instructions to the LLM when disabled.
1475
+ """
1476
+ # Gate registration on sub-agents being enabled
1477
+ # This prevents the LLM from seeing subtask tools when disabled
1478
+ from agents.task.constants import TimeoutConfig
1479
+ if not TimeoutConfig.get_sub_agents_enabled():
1480
+ self.logger.debug("Sub-agents disabled - skipping subtask action registration")
1481
+ return
1482
+
1483
+ from tools.controller.views import SubtaskAction, ParallelSubtasksAction, DelegateTaskAction
1484
+ from tools.controller.delegation import evaluate_delegation
1485
+
1486
+ # UP-10 2.4: single gated delegation core. subtask / parallel_subtasks /
1487
+ # delegate_task all route through _delegate so the role+depth gate
1488
+ # (evaluate_delegation) is enforced for ALL three verbs — previously only
1489
+ # delegate_task ran the gate, so a leaf could still delegate via subtask.
1490
+ async def _delegate(*, goal=None, tasks=None, role="leaf", profile="executor",
1491
+ max_steps=30, background=False, model=None, provider=None,
1492
+ execution_context=None, label="delegate_task") -> ActionResult:
1493
+ """Gated delegation dispatch. Provide goal XOR tasks (list of SubtaskAction).
1494
+
1495
+ UP-12: background=True (goal-only) detaches the child and returns immediately;
1496
+ its result re-enters the session as a new turn when it finishes.
1497
+
1498
+ B4: optional per-sub-agent `model`/`provider` builds an isolated child LLM
1499
+ instead of inheriting the parent's. Goal shape: `model` applies to the one
1500
+ spawned child. Tasks shape: each SubtaskAction's own `model` wins; a task
1501
+ without one falls back to the top-level `model` (else inherits the parent's
1502
+ LLM, unchanged). Not supported together with `background=true` in v1 — the
1503
+ async delegation registry doesn't yet thread a model through, so that
1504
+ combination is rejected before dispatch rather than silently ignored.
1505
+ """
1506
+ from agents.task.constants import TimeoutConfig
1507
+
1508
+ # B4: `provider` only has meaning paired with a `model`. Reject a
1509
+ # provider given WITHOUT a model (top level, or on any parallel task)
1510
+ # with a clear error instead of silently discarding it.
1511
+ if provider and not model:
1512
+ return ActionResult(
1513
+ error="`provider` requires `model` — a provider without a model is "
1514
+ "discarded, so this is rejected. Set both, or neither.",
1515
+ include_in_memory=True,
1516
+ )
1517
+ if tasks:
1518
+ for _i, _st in enumerate(tasks):
1519
+ if getattr(_st, "provider", None) and not getattr(_st, "model", None):
1520
+ return ActionResult(
1521
+ error=f"task[{_i}] sets `provider` without `model` — a provider "
1522
+ "without a model is discarded. Set both, or neither.",
1523
+ include_in_memory=True,
1524
+ )
1525
+
1526
+ caller_is_sub = execution_context.is_sub_agent if execution_context else False
1527
+ # H7: fail closed to the least-privileged role — a delegation call with no
1528
+ # context (or a context missing role) must NOT inherit orchestrator privilege.
1529
+ caller_role = getattr(execution_context, 'role', 'leaf') if execution_context else 'leaf'
1530
+
1531
+ decision = evaluate_delegation(
1532
+ enabled=TimeoutConfig.get_sub_agents_enabled(),
1533
+ caller_is_sub_agent=caller_is_sub,
1534
+ caller_role=caller_role,
1535
+ requested_child_role=role,
1536
+ max_depth=TimeoutConfig.get_max_sub_agent_depth(),
1537
+ )
1538
+ if not decision.allowed:
1539
+ self.logger.info(f"🚫 {label} denied: {decision.reason}")
1540
+ return ActionResult(error=decision.reason, include_in_memory=True)
1541
+
1542
+ # Resolve parent agent id from context (fallback to first active agent)
1543
+ parent_agent_id = None
1544
+ if execution_context and getattr(execution_context, 'agent_id', None):
1545
+ parent_agent_id = execution_context.agent_id
1546
+ elif getattr(self, 'orchestrator', None) and getattr(self.orchestrator, 'agents', None):
1547
+ parent_agent_id = list(self.orchestrator.agents.keys())[0]
1548
+ if not parent_agent_id:
1549
+ return ActionResult(error="Cannot delegate: no parent agent context", include_in_memory=True)
1550
+
1551
+ mgr = getattr(self.orchestrator, 'sub_agent_manager', None) if getattr(self, 'orchestrator', None) else None
1552
+ if not mgr:
1553
+ return ActionResult(error="Sub-agent system not initialized", include_in_memory=True)
1554
+
1555
+ # B4: background delegation doesn't (yet) thread a model override through the
1556
+ # async delegation registry — reject cleanly before dispatch rather than
1557
+ # silently ignoring the requested model.
1558
+ if background and model:
1559
+ return ActionResult(
1560
+ error="model override not yet supported for background delegation "
1561
+ "(background=true always inherits the parent's model)",
1562
+ include_in_memory=True,
1563
+ )
1564
+
1565
+ # UP-12: background dispatch (goal-only) — detach and return immediately.
1566
+ if background and goal:
1567
+ registry = getattr(self.orchestrator, 'async_delegation', None) if getattr(self, 'orchestrator', None) else None
1568
+ if registry is None:
1569
+ return ActionResult(error="Background delegation not available in this session", include_in_memory=True)
1570
+ deleg = await registry.dispatch(
1571
+ goal=goal, profile=profile, max_steps=max_steps,
1572
+ parent_agent_id=parent_agent_id, caller_is_sub=caller_is_sub,
1573
+ )
1574
+ if deleg.get("status") == "rejected":
1575
+ return ActionResult(error=deleg["error"], include_in_memory=True)
1576
+ return ActionResult(
1577
+ extracted_content=(
1578
+ f"## Background task dispatched 🛫\n\n"
1579
+ f"**delegation_id:** {deleg['delegation_id']}\n**goal:** {goal}\n\n"
1580
+ "The task is running in the background; its result will arrive as a new "
1581
+ "message when it finishes. Continue with other work."
1582
+ ),
1583
+ include_in_memory=True,
1584
+ )
1585
+
1586
+ def _resolve_parent_agent():
1587
+ """Look up the parent Agent object (for building a child LLM). Mirrors
1588
+ the dual lookup pattern used elsewhere (plain id, then id_sessionid)."""
1589
+ orch = getattr(self, 'orchestrator', None)
1590
+ agents = getattr(orch, 'agents', None) if orch else None
1591
+ if not agents:
1592
+ return None
1593
+ session_id = getattr(orch, 'session_id', '')
1594
+ for lookup_id in (parent_agent_id, f"{parent_agent_id}_{session_id}"):
1595
+ if lookup_id in agents:
1596
+ return agents[lookup_id]
1597
+ return None
1598
+
1599
+ async def _build_child_llm(m, p):
1600
+ """Build an isolated child LLM for model `m` (+ optional provider `p`).
1601
+
1602
+ Returns (llm, error_message) — error_message is None on success.
1603
+ Isolated so the child never shares the parent's cached HTTP client.
1604
+ """
1605
+ parent_agent = _resolve_parent_agent()
1606
+ build_fn = getattr(parent_agent, '_create_llm_from_config_async', None) if parent_agent else None
1607
+ if build_fn is None:
1608
+ return None, f"Could not build sub-agent model '{m}': parent agent unavailable"
1609
+ cfg = {"model": m}
1610
+ if p:
1611
+ cfg["provider"] = p
1612
+ try:
1613
+ child_llm = await build_fn(cfg, isolated=True)
1614
+ except Exception as e:
1615
+ self.logger.error(f"{label}: failed to build child LLM for '{m}': {e}", exc_info=True)
1616
+ return None, f"Could not build sub-agent model '{m}': {e}"
1617
+ if child_llm is None:
1618
+ return None, f"Could not build sub-agent model '{m}'"
1619
+ return child_llm, None
1620
+
1621
+ try:
1622
+ if goal:
1623
+ child_llm = None
1624
+ if model:
1625
+ child_llm, build_err = await _build_child_llm(model, provider)
1626
+ if build_err:
1627
+ return ActionResult(error=build_err, include_in_memory=True)
1628
+
1629
+ self.logger.info(f"🚀 {label} (goal): {goal[:100]}... (profile: {profile})")
1630
+ result = await mgr.run_subtask(
1631
+ task=goal,
1632
+ parent_agent_id=parent_agent_id,
1633
+ profile_id=profile,
1634
+ max_steps=max_steps,
1635
+ parent_llm=child_llm,
1636
+ is_parent_sub_agent=caller_is_sub,
1637
+ )
1638
+ if result.success:
1639
+ return ActionResult(
1640
+ extracted_content=(
1641
+ f"## Delegated goal completed ✅\n\n**Goal:** {goal}\n\n"
1642
+ f"**Result:**\n{result.output}"
1643
+ ),
1644
+ include_in_memory=True,
1645
+ )
1646
+ return ActionResult(error=f"Delegated goal failed: {result.error}", include_in_memory=True)
1647
+
1648
+ # tasks shape -> parallel. Per-task `model` wins; a task without its own
1649
+ # falls back to the top-level `model` (if set), else inherits the parent's
1650
+ # LLM unchanged. All required child LLMs are built BEFORE dispatch so a
1651
+ # bad model name aborts the whole call rather than a silent partial run.
1652
+ self.logger.info(f"🚀 {label} (parallel): {len(tasks)} tasks")
1653
+ subtask_dicts = []
1654
+ for st in tasks:
1655
+ st_model = st.model or model
1656
+ st_provider = st.provider if st.model else provider
1657
+ st_llm = None
1658
+ if st_model:
1659
+ st_llm, build_err = await _build_child_llm(st_model, st_provider)
1660
+ if build_err:
1661
+ return ActionResult(error=build_err, include_in_memory=True)
1662
+ subtask_dicts.append({
1663
+ 'task': st.task, 'profile': st.profile, 'max_steps': st.max_steps,
1664
+ 'llm': st_llm,
1665
+ })
1666
+ results = await mgr.run_parallel_subtasks(
1667
+ subtasks=subtask_dicts,
1668
+ parent_agent_id=parent_agent_id,
1669
+ )
1670
+ output = mgr.format_results_for_prompt(results)
1671
+ successful = sum(1 for r in results if r.success)
1672
+ return ActionResult(
1673
+ extracted_content=(
1674
+ f"## Delegated tasks complete\n\n"
1675
+ f"**Results:** {successful}/{len(results)} succeeded\n\n{output}"
1676
+ ),
1677
+ include_in_memory=True,
1678
+ )
1679
+ except Exception as e:
1680
+ self.logger.error(f"{label} failed: {e}", exc_info=True)
1681
+ return ActionResult(error=f"{label} error: {str(e)}", include_in_memory=True)
1682
+
1683
+ # Expose the gated core so UP-12 (background delegation) and tests can reach it.
1684
+ self._delegate_core = _delegate
1685
+
1686
+ # One-shot deprecation notices for the legacy verbs.
1687
+ _deprecation_warned = {"subtask": False, "parallel_subtasks": False}
1688
+
1689
+ @self.registry.action(
1690
+ 'Delegate a subtask to a sub-agent for focused execution '
1691
+ '(deprecated alias of delegate_task; use delegate_task with goal=...)',
1692
+ param_model=SubtaskAction
1693
+ )
1694
+ async def subtask(params: SubtaskAction, execution_context=None) -> ActionResult:
1695
+ """Deprecated alias — forwards into the gated delegate_task core."""
1696
+ if not _deprecation_warned["subtask"]:
1697
+ _deprecation_warned["subtask"] = True
1698
+ self.logger.info("subtask is deprecated; use delegate_task (goal=...)")
1699
+ return await _delegate(
1700
+ goal=params.task, profile=params.profile, max_steps=params.max_steps,
1701
+ execution_context=execution_context, label="subtask",
1702
+ )
1703
+
1704
+ @self.registry.action(
1705
+ 'Run multiple subtasks in parallel using sub-agents '
1706
+ '(deprecated alias of delegate_task; use delegate_task with tasks=[...])',
1707
+ param_model=ParallelSubtasksAction
1708
+ )
1709
+ async def parallel_subtasks(params: ParallelSubtasksAction, execution_context=None) -> ActionResult:
1710
+ """Deprecated alias — forwards into the gated delegate_task core."""
1711
+ if not _deprecation_warned["parallel_subtasks"]:
1712
+ _deprecation_warned["parallel_subtasks"] = True
1713
+ self.logger.info("parallel_subtasks is deprecated; use delegate_task (tasks=[...])")
1714
+ return await _delegate(
1715
+ tasks=params.subtasks, execution_context=execution_context, label="parallel_subtasks",
1716
+ )
1717
+
1718
+ # Unified Reference-style delegation surface (roadmap P1): one ergonomic tool
1719
+ # (goal XOR tasks) with an explicit role/depth gate.
1720
+ @self.registry.action(
1721
+ 'Delegate work to sub-agent(s): one focused "goal", or 2-5 parallel "tasks". '
1722
+ 'Expensive. Synchronous by default (blocks this turn until done). Set '
1723
+ 'background=true (goal only) to run it detached and get the result as a new '
1724
+ 'message later — durable across the turn but NOT across restart (use the '
1725
+ 'scheduler for durable scheduled work). Set "model" (and optionally '
1726
+ '"provider") to run the sub-agent on a different model than yours — e.g. a '
1727
+ 'cheaper/faster model for a simple task; omit to inherit your model. With '
1728
+ '"tasks", each task can set its own "model", falling back to the top-level '
1729
+ '"model" if unset. Not supported together with background=true.',
1730
+ param_model=DelegateTaskAction
1731
+ )
1732
+ async def delegate_task(params: DelegateTaskAction, execution_context=None) -> ActionResult:
1733
+ """Spawn sub-agent(s) for delegated work, gated by role + depth."""
1734
+ return await _delegate(
1735
+ goal=params.goal, tasks=params.tasks, role=params.role,
1736
+ profile=params.profile, max_steps=params.max_steps,
1737
+ background=params.background, model=params.model, provider=params.provider,
1738
+ execution_context=execution_context, label="delegate_task",
1739
+ )
1740
+
1741
+ def _register_backward_compat_aliases(self):
1742
+ """Register backward compatibility aliases for renamed task actions.
1743
+
1744
+ This allows LLMs to use old todo action names (todo_list, todo_add, etc.)
1745
+ which automatically map to new namespaced names (task_todo_list, task_todo_add, etc.).
1746
+
1747
+ NOTE: 'done' is NOT aliased - it's a core Controller action, not part of TaskTool.
1748
+ """
1749
+ aliases = {
1750
+ # Todo action aliases (old -> new)
1751
+ 'todo_list': 'task_todo_list',
1752
+ 'todo_add': 'task_todo_add',
1753
+ 'todo_complete': 'task_todo_complete',
1754
+ 'todo_progress': 'task_todo_progress',
1755
+ 'todo_next': 'task_todo_next',
1756
+ # NOTE: 'done' is NOT here - it's a native Controller action
1757
+ }
1758
+
1759
+ for old_name, new_name in aliases.items():
1760
+ # Use thread-safe create_alias method from Registry
1761
+ if self.registry.create_alias(old_name, new_name):
1762
+ self.logger.debug(f"Created backward compat alias: {old_name} -> {new_name}")
1763
+ else:
1764
+ # Don't warn if task tool not loaded yet - just skip
1765
+ self.logger.debug(f"Skipping alias {old_name} -> {new_name}: target not registered yet")