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,1265 @@
1
+ """
2
+ AutoV2 Telemetry Service
3
+
4
+ Provides telemetry capture for the AutoV2 agent framework with optimized
5
+ event buffering, feed directory management, and LLM usage tracking.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import uuid
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Optional, Dict, Any, Union, List
14
+ import time
15
+ from datetime import datetime
16
+ import traceback
17
+ import threading
18
+
19
+ from dotenv import load_dotenv
20
+
21
+ # Try to import Posthog, but don't fail if it's not available
22
+ try:
23
+ from posthog import Posthog
24
+ POSTHOG_AVAILABLE = True
25
+ except ImportError:
26
+ Posthog = None
27
+ POSTHOG_AVAILABLE = False
28
+
29
+ from agents.task.telemetry.views import BaseTelemetryEvent, LLMRequestTelemetryEvent, AgentStepTelemetryEvent
30
+ from agents.task.telemetry.sequence import SequenceGenerator, generate_event_id, get_timestamp_ms
31
+ from agents.task.utils import SafeFileLock
32
+ from agents.task.path import get_safe_singleton
33
+ from agents.task.path import pm
34
+
35
+ # First try to import filelock package (no longer needed as we use SafeFileLock)
36
+ # The existing FileLock class is removed since we use SafeFileLock from utils
37
+
38
+ # Import logger
39
+ from agents.task.logging_config import get_task_logger
40
+ logger = get_task_logger('telemetry')
41
+
42
+ load_dotenv()
43
+
44
+
45
+ POSTHOG_EVENT_SETTINGS = {
46
+ 'process_person_profile': True,
47
+ }
48
+
49
+
50
+ class ProductTelemetry:
51
+ """
52
+ Service for capturing anonymized telemetry data.
53
+
54
+ If the environment variable `ANONYMIZED_TELEMETRY=False`, anonymized telemetry will be disabled.
55
+ """
56
+
57
+ USER_ID_PATH = str(Path.home() / '.cache' / 'auto' / 'telemetry_user_id')
58
+ # Read API key from environment with fallback for development
59
+ PROJECT_API_KEY = os.getenv('POSTHOG_API_KEY', 'phx_dev_fallback_key')
60
+ HOST = 'https://us.i.posthog.com'
61
+ UNKNOWN_USER_ID = 'UNKNOWN'
62
+
63
+ _curr_user_id = None
64
+
65
+ # Class-level feed callback — set by CLI to receive all feed events.
66
+ # Signature: (session_id: str, event: dict) -> None
67
+ _on_feed_entry = None
68
+
69
+ def __init__(self) -> None:
70
+ # CRITICAL FIX: Separate Posthog analytics from feed writing
71
+ # Feed writing is ALWAYS enabled (required for webview)
72
+ # Posthog analytics can be disabled with ANONYMIZED_TELEMETRY=false
73
+ posthog_telemetry_value = os.getenv('ANONYMIZED_TELEMETRY', 'true').lower()
74
+ self.posthog_enabled = posthog_telemetry_value == 'true'
75
+
76
+ # Feed writing is always enabled for webview functionality
77
+ self.feed_writing_enabled = True
78
+
79
+ self.debug_logging = os.getenv('BROWSER_USE_LOGGING_LEVEL', 'info').lower() == 'debug'
80
+
81
+ # Initialize logger with the global logger to avoid duplicates
82
+ try:
83
+ self.logger = logger
84
+ except NameError:
85
+ # Fallback if logger wasn't successfully initialized at the module level
86
+ self.logger = logging.getLogger('task.telemetry')
87
+
88
+ if not self.posthog_enabled:
89
+ self._posthog_client = None
90
+ self.logger.debug('Posthog telemetry disabled (feed writing still enabled)')
91
+ elif not POSTHOG_AVAILABLE:
92
+ self._posthog_client = None
93
+ self.logger.debug('Posthog not available (feed writing still enabled)')
94
+ else:
95
+ # Log at debug level to avoid duplicate messages
96
+ self.logger.debug('Posthog telemetry enabled')
97
+ self._posthog_client = Posthog(
98
+ project_api_key=self.PROJECT_API_KEY,
99
+ host=self.HOST,
100
+ disable_geoip=False,
101
+ )
102
+
103
+ # Silence posthog's logging
104
+ if not self.debug_logging:
105
+ posthog_logger = logging.getLogger('posthog')
106
+ posthog_logger.disabled = True
107
+
108
+ # Initialize sensitive data filter
109
+ self.sensitive_data = {}
110
+ try:
111
+ # Try to load sensitive data from environment
112
+ from os import environ
113
+ for key, value in environ.items():
114
+ if key.startswith("API_KEY_") or key.endswith("_KEY") or key.endswith("_SECRET") or "_TOKEN" in key:
115
+ if value and len(value) > 5:
116
+ self.sensitive_data[key] = value
117
+ except Exception as e:
118
+ self.logger.debug(f"Failed to initialize sensitive data filter: {e}")
119
+
120
+ # FIXED: Add telemetry optimization configuration
121
+ self._event_buffer = {} # Buffer events by session_id
122
+ self._buffer_size = int(os.getenv('TELEMETRY_BUFFER_SIZE', '500')) # Increased buffer size
123
+ self._buffer_timeout = float(os.getenv('TELEMETRY_BUFFER_TIMEOUT', '60.0')) # Increased flush timeout
124
+ self._last_flush_times = {} # Track last flush time per session
125
+ self._buffer_lock = threading.RLock() # Thread safety for buffer operations
126
+
127
+ # Feed retention wiring (telemetry audit 2026-07-04): _enforce_feed_retention
128
+ # was dead code; drive it from the feed writer, throttled every N writes so a
129
+ # long-running session's feed/ dir stays bounded instead of growing forever.
130
+ self._feed_write_counts = {}
131
+ self._feed_retention_every = max(1, int(os.getenv('TELEMETRY_FEED_RETENTION_EVERY', '50')))
132
+
133
+ # NEW: Add health monitoring statistics
134
+ self._health_stats = {
135
+ 'total_captures': 0,
136
+ 'failed_captures': 0,
137
+ 'duplicate_requests': 0,
138
+ 'token_estimation_fallbacks': 0,
139
+ 'successful_llm_captures': 0,
140
+ 'failed_llm_captures': 0,
141
+ 'last_capture_time': None,
142
+ 'startup_time': time.time()
143
+ }
144
+
145
+ def _is_telemetry_enabled(self) -> bool:
146
+ """Check if telemetry is enabled (Posthog analytics OR feed writing).
147
+
148
+ Returns:
149
+ True if any form of telemetry is enabled, False otherwise
150
+ """
151
+ # Telemetry is enabled if EITHER Posthog OR feed writing is enabled
152
+ return self.posthog_enabled or self.feed_writing_enabled
153
+
154
+ def _should_save_to_file(self) -> bool:
155
+ """Check if telemetry should be saved to feed files.
156
+
157
+ Returns:
158
+ True if feed writing is enabled, False otherwise
159
+ """
160
+ # CRITICAL FIX: Feed writing should always be enabled for webview
161
+ return self.feed_writing_enabled
162
+
163
+ def _get_path_for_event(self, event: BaseTelemetryEvent) -> Path:
164
+ """Get the path to save the event."""
165
+ # Get session ID
166
+ session_id = None
167
+ if hasattr(event, 'get_session_id'):
168
+ session_id = event.get_session_id()
169
+ elif hasattr(event, 'session_id'):
170
+ session_id = event.session_id
171
+ elif hasattr(event, 'agent_id') and '_' in event.agent_id:
172
+ # Try to extract session ID from agent_id
173
+ parts = event.agent_id.split('_', 1)
174
+ if len(parts) > 1:
175
+ session_id = parts[1]
176
+
177
+ if not session_id:
178
+ # Default to a global telemetry file if no session ID
179
+ return pm().data_root / f"global_{event.name}.jsonl"
180
+
181
+ # Clean the session ID for consistency
182
+ cleaned_id = pm().clean_session_id(session_id)
183
+
184
+ # Use PathManager's canonical telemetry directory
185
+ telemetry_dir = pm().get_telemetry_dir(cleaned_id)
186
+ return telemetry_dir / f"{event.name}.jsonl"
187
+
188
+ def _get_session_path_for_event(self, event: BaseTelemetryEvent) -> Path:
189
+ """Get the session path for the event."""
190
+ session_id = None
191
+ if hasattr(event, 'get_session_id'):
192
+ session_id = event.get_session_id()
193
+ elif hasattr(event, 'session_id'):
194
+ session_id = event.session_id
195
+ elif hasattr(event, 'agent_id') and '_' in event.agent_id:
196
+ # Try to extract session ID from agent_id
197
+ parts = event.agent_id.split('_', 1)
198
+ if len(parts) > 1:
199
+ session_id = parts[1]
200
+
201
+ if not session_id:
202
+ return pm().data_root
203
+
204
+ # Clean the session ID and get root path
205
+ cleaned_id = pm().clean_session_id(session_id)
206
+ return pm().get_session_root(cleaned_id)
207
+
208
+ def _ensure_telemetry_dir(self, session_id: str) -> Optional[Path]:
209
+ """Ensure telemetry directory exists for the given session.
210
+
211
+ Args:
212
+ session_id: The session ID (already cleaned)
213
+
214
+ Returns:
215
+ Path to the telemetry directory or None if creation failed
216
+ """
217
+ try:
218
+ # Clean session ID for consistency
219
+ clean_id = pm().clean_session_id(session_id)
220
+
221
+ # Get telemetry directory using PathManager's canonical method
222
+ telemetry_dir = pm().get_telemetry_dir(clean_id)
223
+ return telemetry_dir
224
+ except Exception as e:
225
+ self.logger.error(f"Failed to create telemetry directory: {e}", exc_info=True)
226
+ return None
227
+
228
+ def _sanitize_telemetry_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
229
+ """Sanitize telemetry data to remove sensitive information.
230
+
231
+ Args:
232
+ data: The telemetry data to sanitize
233
+
234
+ Returns:
235
+ Sanitized telemetry data
236
+ """
237
+ if not data:
238
+ return {}
239
+
240
+ # Create a deep copy to avoid modifying the original
241
+ import copy
242
+ sanitized = copy.deepcopy(data)
243
+
244
+ # Refresh sensitive data from environment to catch runtime-injected values
245
+ self._refresh_sensitive_data()
246
+
247
+ def _sanitize_value(value: Any) -> Any:
248
+ """Recursively sanitize a value"""
249
+ if isinstance(value, dict):
250
+ # Special handling for parameter dictionaries which often contain credentials
251
+ sanitized_dict = {}
252
+ for k, v in value.items():
253
+ # Check dictionary keys for sensitive patterns
254
+ lower_key = str(k).lower()
255
+ # List of sensitive key substrings. Keep this explicit to avoid over-filtering.
256
+ _sensitive_key_markers = [
257
+ 'api_key', 'apikey', 'access_key', 'access_token', 'client_secret',
258
+ 'secret', 'password', 'credential', 'auth_token', 'bearer_token'
259
+ ]
260
+ # Certain telemetry keys legitimately contain the word "token" (e.g. "token_count").
261
+ # Whitelist those so they are not redacted.
262
+ _safe_token_keys = {
263
+ 'token_count', 'prompt_tokens', 'completion_tokens', 'total_tokens',
264
+ 'input_tokens', 'output_tokens', 'usage_tokens'
265
+ }
266
+ if lower_key not in _safe_token_keys and any(marker in lower_key for marker in _sensitive_key_markers):
267
+ # This is likely a sensitive field, redact the value completely
268
+ sanitized_dict[k] = '<REDACTED>'
269
+ elif lower_key.endswith('_tokens') or lower_key.endswith('_count') or 'token' in lower_key:
270
+ # Handle numeric token fields specially to ensure they're preserved
271
+ if isinstance(v, (int, float)) or (isinstance(v, str) and v.isdigit()):
272
+ # Preserve numeric token count fields
273
+ sanitized_dict[k] = v
274
+ else:
275
+ # Recursively sanitize non-numeric values
276
+ sanitized_dict[k] = _sanitize_value(v)
277
+ else:
278
+ # Recursively sanitize the value
279
+ sanitized_dict[k] = _sanitize_value(v)
280
+ return sanitized_dict
281
+ elif isinstance(value, list):
282
+ return [_sanitize_value(item) for item in value]
283
+ elif isinstance(value, str):
284
+ # Redact sensitive data
285
+ sanitized_str = value
286
+
287
+ # First check known sensitive values from environment
288
+ for key, sensitive_value in self.sensitive_data.items():
289
+ if sensitive_value in sanitized_str:
290
+ sanitized_str = sanitized_str.replace(sensitive_value, f"<REDACTED_{key}>")
291
+
292
+ # Check for common credential patterns that might indicate sensitive data
293
+ import re
294
+
295
+ # API keys, tokens, and secrets (typically long alphanumeric strings)
296
+ patterns = [
297
+ # API keys/tokens with identifier
298
+ r'(api[_-]?key|token|secret|password|apikey|auth)["\']?\s*[:=]\s*["\']?([a-zA-Z0-9_\-\.]{20,})["\']?',
299
+ # Bearer tokens in Authorization headers
300
+ r'(bearer\s+)([a-zA-Z0-9_\-\.]{20,})',
301
+ # API keys in URLs
302
+ r'(key=|token=|api_key=|apikey=)([a-zA-Z0-9_\-\.]{20,})',
303
+ # AWS-style keys (alphanumeric with fixed length)
304
+ r'(AKIA[0-9A-Z]{16})',
305
+ # Typical API key patterns
306
+ r'(sk-[a-zA-Z0-9]{20,})', # OpenAI keys
307
+ r'(gh[pousr]_[A-Za-z0-9_]{36})', # GitHub tokens
308
+ r'(SG\.[a-zA-Z0-9_\-\.]{20,})', # SendGrid
309
+ r'(AIza[0-9A-Za-z\-_]{35})', # Google API
310
+ r'(ya29\.[0-9A-Za-z\-_]+)' # Google OAuth
311
+ ]
312
+
313
+ for pattern in patterns:
314
+ if re.search(pattern, sanitized_str, re.IGNORECASE):
315
+ sanitized_str = re.sub(pattern, r'\1<REDACTED>', sanitized_str, flags=re.IGNORECASE)
316
+
317
+ return sanitized_str
318
+ else:
319
+ # Handle non-serializable objects by converting them to a safe format
320
+ try:
321
+ # Test if JSON-serializable
322
+ json.dumps(value)
323
+ return value
324
+ except (TypeError, ValueError, OverflowError):
325
+ # Not directly serializable, use our helper method
326
+ return self._json_serializable(value)
327
+
328
+ # Apply sanitization recursively to the entire data structure
329
+ return _sanitize_value(sanitized)
330
+
331
+ def _refresh_sensitive_data(self) -> None:
332
+ """Refresh the sensitive data dictionary from current environment.
333
+ This allows catching API keys and tokens injected at runtime.
334
+ """
335
+ try:
336
+ # Get current environment variables
337
+ from os import environ
338
+
339
+ # Look for new or updated sensitive values
340
+ for key, value in environ.items():
341
+ # Check common patterns for sensitive environment variables
342
+ if (key.startswith("API_KEY_") or key.endswith("_KEY") or
343
+ key.endswith("_SECRET") or "_TOKEN" in key or
344
+ key.endswith("_PASSWORD") or "_CREDENTIAL" in key):
345
+ if value and len(value) > 5:
346
+ # Only add if value is non-trivial and potentially sensitive
347
+ self.sensitive_data[key] = value
348
+ except Exception as e:
349
+ self.logger.debug(f"Failed to refresh sensitive data: {e}")
350
+
351
+ def _json_serializable(self, obj: Any) -> Any:
352
+ """Convert an object to a JSON-serializable form.
353
+
354
+ Args:
355
+ obj: The object to convert
356
+
357
+ Returns:
358
+ JSON-serializable version of the object
359
+ """
360
+ if isinstance(obj, (datetime, Path)):
361
+ return str(obj)
362
+ elif hasattr(obj, "model_dump"):
363
+ try:
364
+ return obj.model_dump()
365
+ except Exception:
366
+ return str(obj)
367
+ elif hasattr(obj, "__dict__"):
368
+ try:
369
+ return {k: self._json_serializable(v) for k, v in obj.__dict__.items()
370
+ if not k.startswith('_')}
371
+ except Exception:
372
+ return str(obj)
373
+ else:
374
+ return str(obj)
375
+
376
+ def capture(self, event: BaseTelemetryEvent, session_id: Optional[str] = None) -> None:
377
+ """Capture a telemetry event.
378
+
379
+ Args:
380
+ event: The event to capture
381
+ session_id: Optional session ID override
382
+ """
383
+ # Get event name and properties
384
+ event_name = event.name
385
+ event_properties = event.properties
386
+
387
+ # Try to extract session_id from event if not provided
388
+ if not session_id:
389
+ # Try event's get_session_id() method first
390
+ if hasattr(event, 'get_session_id') and callable(getattr(event, 'get_session_id')):
391
+ session_id = event.get_session_id()
392
+
393
+ # Then try common event attributes
394
+ if not session_id:
395
+ for attr in ['session_id', 'agent_id', 'parent_session_id']:
396
+ if hasattr(event, attr) and getattr(event, attr):
397
+ session_id = getattr(event, attr)
398
+ break
399
+
400
+ # Finally try to extract from agent_id if it has session ID embedded
401
+ if not session_id and hasattr(event, 'agent_id') and getattr(event, 'agent_id'):
402
+ agent_id = getattr(event, 'agent_id')
403
+ if '_' in agent_id:
404
+ session_id = agent_id.split('_', 1)[1]
405
+
406
+ # CRITICAL FIX: Clean session ID once we've extracted it
407
+ if session_id:
408
+ from agents.task.path import pm
409
+ try:
410
+ session_id = pm().clean_session_id(session_id)
411
+ except Exception as e:
412
+ self.logger.debug(f"Failed to clean session ID: {e}")
413
+
414
+ # Use primary session persistence mechanism
415
+ if session_id:
416
+ # Check if telemetry is enabled
417
+ if not self._is_telemetry_enabled():
418
+ return
419
+
420
+ # Extract agent_id from event properties if available
421
+ agent_id = None
422
+ if hasattr(event, 'agent_id'):
423
+ agent_id = event.agent_id
424
+ elif hasattr(event, 'properties') and 'agent_id' in event.properties:
425
+ agent_id = event.properties['agent_id']
426
+
427
+ # Direct capture for efficiency when we don't need to save to disk
428
+ if not self._should_save_to_file():
429
+ self._direct_capture(event)
430
+ return
431
+
432
+ # Local telemetry saving to session file
433
+ try:
434
+ # Get properties and sanitize
435
+ # Get properties from event (it's a @property, not a method)
436
+ properties = {}
437
+ if hasattr(event, 'properties'):
438
+ properties = event.properties
439
+
440
+ # Sanitize sensitive data
441
+ sanitized_properties = self._sanitize_telemetry_data(properties)
442
+
443
+ # Create a clean telemetry event dictionary with timestamp and metadata
444
+ telemetry_event = {
445
+ "name": event.name,
446
+ "timestamp": time.time(),
447
+ "datetime": datetime.now().isoformat(),
448
+ "properties": sanitized_properties,
449
+ "agent_id": agent_id
450
+ }
451
+
452
+ # Get telemetry directory ensuring proper path handling
453
+ telemetry_dir = self._ensure_telemetry_dir(session_id)
454
+ if not telemetry_dir:
455
+ self.logger.error(f"Could not create telemetry directory for session {session_id}")
456
+ return
457
+
458
+ # FIXED: Use buffered writing to reduce filesystem I/O
459
+ # Add event to buffer instead of writing immediately
460
+ self._add_event_to_buffer(session_id, telemetry_event)
461
+
462
+ self.logger.debug(f"Telemetry event buffered for session: {session_id}")
463
+
464
+ # Persist the event into the session-specific *feed* directory so
465
+ # that real-time components (web UI, dashboards, etc.) can
466
+ # consume it. We no longer restrict which events qualify – the
467
+ # downstream formatter will gracefully ignore unknown types if
468
+ # it does not recognise them.
469
+ self._save_to_feed_directory(event, session_id)
470
+
471
+ except Exception as e:
472
+ self.logger.error(f"Failed to save telemetry event to file: {str(e)}", exc_info=True)
473
+ else:
474
+ # No session_id provided, can't save to session file
475
+ self._direct_capture(event)
476
+
477
+ def _direct_capture(self, event: BaseTelemetryEvent) -> None:
478
+ """
479
+ Directly process a telemetry event without saving to disk.
480
+
481
+ Args:
482
+ event: The telemetry event to process
483
+ """
484
+ # Always attempt to forward the event to the feed directory when a
485
+ # session context can be determined. This guarantees that even in
486
+ # setups where we skip on-disk JSONL persistence, the real-time feed
487
+ # remains comprehensive.
488
+ # Try to extract session_id from event (prefer explicit session_id over agent_id)
489
+ session_id = None
490
+ if hasattr(event, 'session_id') and getattr(event, 'session_id'):
491
+ session_id = getattr(event, 'session_id')
492
+ elif hasattr(event, 'agent_id'):
493
+ session_id = getattr(event, 'agent_id')
494
+
495
+ if session_id:
496
+ self._save_to_feed_directory(event, session_id)
497
+
498
+ def _save_to_feed_directory(self, event: BaseTelemetryEvent, session_id: str) -> None:
499
+ """
500
+ Save event data to feed directory for later consumption by other components.
501
+
502
+ Uses formatter registry to convert events to feed-friendly format.
503
+
504
+ Args:
505
+ event: The telemetry event to save
506
+ session_id: The session ID
507
+ """
508
+ try:
509
+ # ------------------------------------------------------------------
510
+ # Sanitize the provided session identifier. The caller may pass an
511
+ # *agent* identifier (e.g. "executor_<uuid>") instead of the bare
512
+ # session UUID. In that case we must first extract the real session
513
+ # ID before feeding it to the PathManager – otherwise a *new* session
514
+ # directory named "executor_<uuid>" (or "planner_<uuid>") would be
515
+ # created on disk which is exactly the bug we are fixing here.
516
+ # ------------------------------------------------------------------
517
+ base_session_id = session_id or ""
518
+ if "_" in base_session_id:
519
+ prefix, remainder = base_session_id.split("_", 1)
520
+ if prefix.lower() in [
521
+ "planner",
522
+ "executor",
523
+ "evaluator",
524
+ "agent",
525
+ ]:
526
+ # Use the part after the prefix if it looks like a UUID-like id
527
+ base_session_id = remainder or base_session_id
528
+
529
+ # Finally clean it via PathManager to ensure valid characters only
530
+ clean_id = pm().clean_session_id(base_session_id)
531
+
532
+ # Now we can safely obtain the feed directory for *the* session id
533
+ feed_dir = pm().get_subdir(clean_id, "feed")
534
+
535
+ if not feed_dir:
536
+ self.logger.error(f"Could not create feed directory for session {clean_id}")
537
+ return
538
+
539
+ # Use formatter registry to convert event to feed format
540
+ from agents.task.telemetry.formatters import get_formatter_registry
541
+ formatter_registry = get_formatter_registry()
542
+ formatter = formatter_registry.get_formatter(event.name)
543
+
544
+ # Format the event
545
+ update_data = formatter.format(event)
546
+
547
+ # Save update data to feed directory if we have it
548
+ if update_data:
549
+ # Sanitize update data
550
+ sanitized_update = self._sanitize_telemetry_data(update_data)
551
+
552
+ # Enrich with sequence number, millisecond timestamp, and unique ID
553
+ seq_gen = SequenceGenerator.get(clean_id)
554
+ sanitized_update['_seq'] = seq_gen.next()
555
+ sanitized_update['_ts_ms'] = get_timestamp_ms()
556
+ sanitized_update['_id'] = generate_event_id()
557
+
558
+ # Generate filename based on sequence (primary) and type
559
+ # Sequence ensures alphabetical sorting = chronological order
560
+ update_type = sanitized_update.get('type', 'update')
561
+ seq = sanitized_update['_seq']
562
+
563
+ # Format: {seq:06d}_{type}_{step}.json for guaranteed ordering
564
+ if 'step' in sanitized_update and update_type in ['step', 'planner', 'evaluation']:
565
+ step = sanitized_update.get('step', 0)
566
+ filename = f"{seq:06d}_{update_type}_{step:04d}.json"
567
+ else:
568
+ filename = f"{seq:06d}_{update_type}.json"
569
+
570
+ # Write update to file with atomic file operations to prevent race conditions
571
+ update_path = feed_dir / filename
572
+ temp_path = update_path.with_suffix('.tmp')
573
+ lock_path = update_path.with_suffix('.lock')
574
+
575
+ try:
576
+ with SafeFileLock(str(lock_path)):
577
+ try:
578
+ with open(temp_path, 'w') as f:
579
+ json.dump(sanitized_update, f, indent=2, default=self._json_serializable)
580
+
581
+ # Atomic rename
582
+ temp_path.replace(update_path)
583
+
584
+ # Verify file was actually created (safety check)
585
+ if not update_path.exists():
586
+ raise IOError(f"Feed file not created: {update_path}")
587
+ if update_path.stat().st_size == 0:
588
+ raise IOError(f"Feed file is empty: {update_path}")
589
+
590
+ self.logger.debug(f"Saved {update_type} update to feed file: {update_path}")
591
+
592
+ # Emit to CLI feed callback if registered
593
+ if self.__class__._on_feed_entry:
594
+ try:
595
+ self.__class__._on_feed_entry(clean_id, sanitized_update)
596
+ except Exception:
597
+ pass
598
+
599
+ # Direct emit to WebView for low-latency delivery
600
+ # Fire-and-forget after successful file write
601
+ self._emit_to_webview_sync(clean_id, sanitized_update)
602
+
603
+ # Enforce feed retention every N writes (throttled so we don't
604
+ # glob the dir on every event). Was dead code before this wiring.
605
+ try:
606
+ cnt = self._feed_write_counts.get(clean_id, 0) + 1
607
+ self._feed_write_counts[clean_id] = cnt
608
+ if cnt % self._feed_retention_every == 0:
609
+ self._enforce_feed_retention(feed_dir)
610
+ except Exception:
611
+ pass
612
+ except Exception as e:
613
+ self.logger.error(f"Failed to write feed file: {e}", exc_info=True)
614
+ if temp_path.exists():
615
+ try:
616
+ temp_path.unlink() # Clean up failed temp file
617
+ except Exception:
618
+ pass
619
+ except Exception as e:
620
+ self.logger.error(f"Failed to acquire lock for feed file: {e}", exc_info=True)
621
+
622
+ except Exception as e:
623
+ self.logger.error(f"Error in _save_to_feed_directory: {e}", exc_info=True)
624
+ # Track failure in health stats for visibility
625
+ self._health_stats['failed_captures'] += 1
626
+
627
+ # Log at CRITICAL level for production visibility
628
+ self.logger.critical(f"CRITICAL: Feed write failed for {event.name} event in session {clean_id}")
629
+
630
+ # Don't raise to prevent breaking agent execution, but make failure visible
631
+
632
+ # Note: _detect_service_for_action moved to formatters.py (BaseFeedFormatter)
633
+
634
+ def _emit_to_webview_sync(self, session_id: str, event: dict) -> None:
635
+ """Fire-and-forget HTTP POST to WebView for immediate delivery.
636
+
637
+ This enables low-latency event delivery to the WebView UI by
638
+ bypassing the file watcher. The file write is still the source
639
+ of truth - this is an optimization for live updates.
640
+
641
+ Args:
642
+ session_id: The session ID (already cleaned)
643
+ event: The enriched event dict to emit
644
+ """
645
+ try:
646
+ import requests
647
+ requests.post(
648
+ "http://127.0.0.1:5050/api/internal/emit",
649
+ json={"session_id": session_id, "event": event},
650
+ timeout=0.1 # 100ms timeout, non-blocking
651
+ )
652
+ except Exception:
653
+ # Non-critical: file write is source of truth
654
+ # File watcher will still pick up the event
655
+ pass
656
+
657
+ def _detect_provider(self, model_name: str) -> str:
658
+ """Detect the provider based on model name"""
659
+ # Use centralized provider detection
660
+ from agents.task.utils import detect_llm_provider
661
+ provider = detect_llm_provider(None, model_name)
662
+
663
+ # Map 'generic' to 'unknown' for consistency with existing code
664
+ return 'unknown' if provider == 'generic' else provider
665
+
666
+ def _calculate_cost_from_registry(
667
+ self,
668
+ model_name: str,
669
+ prompt_tokens: Optional[int] = None,
670
+ completion_tokens: Optional[int] = None,
671
+ total_tokens: Optional[int] = None,
672
+ cached_tokens: Optional[int] = None
673
+ ) -> Optional[float]:
674
+ """
675
+ Calculate cost using centralized cost_utils.
676
+
677
+ Delegates to modules/credits/cost_utils.py for consistent pricing.
678
+
679
+ Args:
680
+ model_name: The name of the model used
681
+ prompt_tokens: Number of prompt tokens (input)
682
+ completion_tokens: Number of completion tokens (output)
683
+ total_tokens: Total tokens (fallback if split not available)
684
+ cached_tokens: Number of cached prompt tokens (for prompt caching)
685
+
686
+ Returns:
687
+ Estimated cost in USD or None if cost cannot be estimated
688
+ """
689
+ from modules.credits.cost_utils import calculate_cost_from_tokens
690
+
691
+ if not model_name:
692
+ self.logger.warning("Cannot calculate cost: no model_name provided")
693
+ return None
694
+
695
+ try:
696
+ cost = calculate_cost_from_tokens(
697
+ model_name=model_name,
698
+ input_tokens=prompt_tokens,
699
+ output_tokens=completion_tokens,
700
+ total_tokens=total_tokens,
701
+ cached_tokens=cached_tokens or 0
702
+ )
703
+ if cost > 0 and cached_tokens and cached_tokens > 0:
704
+ self.logger.debug(f"Calculated cost for {model_name} with {cached_tokens} cached tokens: ${cost:.6f}")
705
+ return cost if cost > 0 else None
706
+ except Exception as e:
707
+ self.logger.debug(f"Cost calculation failed: {e}")
708
+ return None
709
+
710
+ def capture_llm_usage(self, component: str, purpose: str, model_name: str,
711
+ duration_seconds: float, success: bool,
712
+ token_count: Optional[int] = None,
713
+ session_id: Optional[str] = None,
714
+ prompt_tokens: Optional[int] = None,
715
+ completion_tokens: Optional[int] = None,
716
+ cached_tokens: Optional[int] = None,
717
+ parameters: Optional[Dict[str, Any]] = None,
718
+ agent_id: Optional[str] = None) -> str:
719
+ """
720
+ Capture detailed LLM usage statistics with standardized tracking across providers.
721
+
722
+ Args:
723
+ component: Component using the LLM (agent, planner, etc.)
724
+ purpose: Purpose of the LLM call (planning, evaluation, etc.)
725
+ model_name: Name of the model used
726
+ duration_seconds: Duration of the LLM call in seconds
727
+ success: Whether the call was successful
728
+ token_count: Total token count (if available)
729
+ session_id: Session ID for tracking
730
+ prompt_tokens: Number of prompt tokens (if available)
731
+ completion_tokens: Number of completion tokens (if available)
732
+ cached_tokens: Number of cached prompt tokens (for prompt caching cost calculation)
733
+ parameters: Dictionary of LLM parameters used (temperature, etc.)
734
+ agent_id: Optional agent ID for explicit tracking
735
+
736
+ Returns:
737
+ str: The request_id used for this telemetry entry (for potential reuse)
738
+ """
739
+ if not self._is_telemetry_enabled():
740
+ return ""
741
+
742
+ # Track health statistics
743
+ self._health_stats['total_captures'] += 1
744
+ self._health_stats['last_capture_time'] = time.time()
745
+
746
+ try:
747
+ # Validate required fields for health monitoring
748
+ required_fields = ['component', 'purpose', 'model_name', 'duration_seconds', 'success']
749
+ for field in required_fields:
750
+ field_value = locals().get(field)
751
+ if field_value is None:
752
+ self.logger.warning(f"Missing required field in LLM capture: {field}")
753
+
754
+ # Check for token data quality
755
+ if token_count is None and prompt_tokens is None and completion_tokens is None:
756
+ self._health_stats['token_estimation_fallbacks'] += 1
757
+ self.logger.warning(f"No token data provided for {model_name} request")
758
+
759
+ # Clean the session ID
760
+ clean_session_id = None
761
+ if session_id:
762
+ clean_session_id = pm().clean_session_id(session_id)
763
+
764
+ # If agent_id is provided but session_id is not, try to extract session_id from agent_id
765
+ if agent_id and not clean_session_id:
766
+ if '_' in agent_id:
767
+ parts = agent_id.split('_', 1)
768
+ if len(parts) > 1:
769
+ clean_session_id = pm().clean_session_id(parts[1])
770
+
771
+ # Use sensible defaults for missing values
772
+ parameters = parameters or {}
773
+
774
+ # ------------------------------------------------------------------
775
+ # Robust token handling – always derive *total_tokens* when we have
776
+ # the prompt/completion split but the aggregate is missing. We also
777
+ # normalise string values to integers so the downstream cost
778
+ # estimation as well as the stats aggregation receive consistent
779
+ # numeric data types.
780
+ # ------------------------------------------------------------------
781
+ try:
782
+ def _to_int(val: Any) -> int | None:
783
+ if val is None:
784
+ return None
785
+ if isinstance(val, (int, float)):
786
+ return int(val)
787
+ if isinstance(val, str) and val.isdigit():
788
+ return int(val)
789
+ return None
790
+
791
+ token_count = _to_int(token_count)
792
+ prompt_tokens = _to_int(prompt_tokens)
793
+ completion_tokens = _to_int(completion_tokens)
794
+
795
+ # Derive total when missing but both splits are available
796
+ total_tokens = token_count
797
+ if total_tokens is None and prompt_tokens is not None and completion_tokens is not None:
798
+ total_tokens = prompt_tokens + completion_tokens
799
+ except Exception as e:
800
+ self.logger.debug(f"Token normalisation failed: {e}")
801
+ total_tokens = token_count
802
+
803
+ # Add model provider information using improved detection
804
+ provider = self._detect_provider(model_name)
805
+
806
+ # Calculate cost estimate using centralized model registry
807
+ cost_estimate = self._calculate_cost_from_registry(
808
+ model_name=model_name or 'unknown',
809
+ prompt_tokens=prompt_tokens,
810
+ completion_tokens=completion_tokens,
811
+ total_tokens=total_tokens,
812
+ cached_tokens=cached_tokens
813
+ )
814
+
815
+ # Ensure a unique request ID is attached for downstream de-duplication.
816
+ # If the caller did not provide one we generate a UUID4 hexadecimal token.
817
+ if parameters and isinstance(parameters, dict) and parameters.get('request_id'):
818
+ request_id = parameters.get('request_id')
819
+ else:
820
+ import uuid
821
+ request_id = uuid.uuid4().hex
822
+ if parameters is None:
823
+ parameters = {}
824
+ parameters['request_id'] = request_id
825
+
826
+ # Create standardized event data
827
+ event_data = {
828
+ 'component': component,
829
+ 'purpose': purpose,
830
+ 'model_name': model_name or "unknown",
831
+ 'provider': provider,
832
+ 'duration_seconds': duration_seconds,
833
+ 'success': success,
834
+ 'token_count': total_tokens,
835
+ 'prompt_tokens': prompt_tokens,
836
+ 'completion_tokens': completion_tokens,
837
+ # Persist cached prompt tokens so offline cost recompute from the
838
+ # llm_usage JSON is cache-aware (cost_estimate already is). Without
839
+ # this, recompute overstates cost ~2x for cached models (e.g. GLM).
840
+ 'cached_tokens': cached_tokens or 0,
841
+ 'parameters': parameters,
842
+ 'cost_estimate': cost_estimate,
843
+ 'agent_id': agent_id,
844
+ 'request_id': request_id
845
+ }
846
+
847
+ # Create the event
848
+ from agents.task.telemetry.views import LLMRequestTelemetryEvent
849
+
850
+ event = LLMRequestTelemetryEvent(
851
+ component=component,
852
+ purpose=purpose,
853
+ model_name=model_name or "unknown",
854
+ duration_seconds=duration_seconds,
855
+ success=success,
856
+ token_count=total_tokens,
857
+ session_id=clean_session_id,
858
+ error=None if success else "error", # Differentiate success vs error
859
+ prompt_tokens=prompt_tokens,
860
+ completion_tokens=completion_tokens,
861
+ provider=provider,
862
+ parameters=parameters,
863
+ cost_estimate=cost_estimate,
864
+ agent_id=agent_id,
865
+ request_id=request_id
866
+ )
867
+
868
+ # FIX FOR DOUBLE COUNTING: Only capture to session-specific LLM usage file
869
+ # Do NOT call self.capture() which creates additional feed entries
870
+ # This eliminates the double counting issue in stats service
871
+
872
+ # Save to the separate LLM usage feed for cost tracking
873
+ try:
874
+ if clean_session_id:
875
+ # Get LLM usage directory
876
+ # Store LLM usage under data directory for consistency
877
+ data_dir = pm().get_data_dir(clean_session_id)
878
+ llm_usage_dir = data_dir / "llm_usage"
879
+
880
+ # ------------------------------------------------------------------
881
+ # Use millisecond precision to avoid filename collisions when multiple
882
+ # LLM calls happen within the same second (which is very common for
883
+ # concurrent agent setups).
884
+ # ------------------------------------------------------------------
885
+ timestamp_ms = int(time.time() * 1000)
886
+ filename = f"llm_usage_{timestamp_ms}.json"
887
+
888
+ # Write to file with atomic operations and locking
889
+ usage_path = llm_usage_dir / filename
890
+ temp_path = usage_path.with_suffix('.tmp')
891
+ lock_path = usage_path.with_suffix('.lock')
892
+
893
+ # Use filelock for thread safety
894
+ with SafeFileLock(str(lock_path)):
895
+ try:
896
+ # Ensure directory exists
897
+ llm_usage_dir.mkdir(parents=True, exist_ok=True)
898
+
899
+ # Write to temporary file with proper serialization
900
+ with open(temp_path, 'w') as f:
901
+ json.dump(self._sanitize_telemetry_data(event_data), f, indent=2, default=self._json_serializable)
902
+
903
+ # Atomic rename operation
904
+ import os
905
+ if os.name == 'nt': # Windows
906
+ if usage_path.exists():
907
+ usage_path.unlink()
908
+ temp_path.rename(usage_path)
909
+ else: # Unix
910
+ temp_path.replace(usage_path)
911
+
912
+ self.logger.debug(f"Saved LLM usage data to {usage_path}")
913
+
914
+ # Track successful capture
915
+ self._health_stats['successful_llm_captures'] += 1
916
+
917
+ except Exception as e:
918
+ self.logger.debug(f"Failed to save LLM usage data to file: {e}")
919
+ self._health_stats['failed_llm_captures'] += 1
920
+ if temp_path.exists():
921
+ try:
922
+ temp_path.unlink() # Clean up
923
+ except:
924
+ pass
925
+
926
+ # NOTE: LLM data is written ONLY to llm_usage/ directory
927
+ # stats_service.py reads from llm_usage/ as the primary source
928
+ # We no longer write to feed/ to avoid duplicate entries
929
+ # and simplify deduplication in stats aggregation
930
+
931
+ except Exception as e:
932
+ self.logger.debug(f"Failed to save LLM usage data to file: {e}")
933
+ self._health_stats['failed_llm_captures'] += 1
934
+
935
+ # Fix 4.2: Return the request_id for potential reuse
936
+ return request_id
937
+
938
+ except Exception as e:
939
+ self._health_stats['failed_captures'] += 1
940
+ self.logger.debug(f"Failed to capture LLM usage: {e}", exc_info=True)
941
+ return ""
942
+
943
+ def get_health_stats(self) -> Dict[str, Any]:
944
+ """Get telemetry health statistics.
945
+
946
+ Returns:
947
+ Dictionary containing health metrics and recommendations
948
+ """
949
+ current_time = time.time()
950
+ uptime_seconds = current_time - self._health_stats['startup_time']
951
+
952
+ # Calculate rates and percentages
953
+ total_captures = self._health_stats['total_captures']
954
+ failed_captures = self._health_stats['failed_captures']
955
+
956
+ success_rate = 0.0
957
+ if total_captures > 0:
958
+ success_rate = (total_captures - failed_captures) / total_captures
959
+
960
+ duplicate_rate = 0.0
961
+ if total_captures > 0:
962
+ duplicate_rate = self._health_stats['duplicate_requests'] / total_captures
963
+
964
+ token_fallback_rate = 0.0
965
+ if total_captures > 0:
966
+ token_fallback_rate = self._health_stats['token_estimation_fallbacks'] / total_captures
967
+
968
+ llm_capture_success_rate = 0.0
969
+ total_llm_captures = self._health_stats['successful_llm_captures'] + self._health_stats['failed_llm_captures']
970
+ if total_llm_captures > 0:
971
+ llm_capture_success_rate = self._health_stats['successful_llm_captures'] / total_llm_captures
972
+
973
+ # Generate health recommendations
974
+ recommendations = []
975
+ if success_rate < 0.95:
976
+ recommendations.append("Low telemetry capture success rate detected. Check LLM client error logs.")
977
+
978
+ if duplicate_rate > 0.05:
979
+ recommendations.append("High duplicate request rate detected. Check deduplication logic.")
980
+
981
+ if token_fallback_rate > 0.1:
982
+ recommendations.append("Frequent token estimation fallbacks. Check LLM provider token reporting.")
983
+
984
+ if llm_capture_success_rate < 0.95 and total_llm_captures > 0:
985
+ recommendations.append("Low LLM file capture success rate. Check file system permissions.")
986
+
987
+ if self._health_stats['last_capture_time'] and (current_time - self._health_stats['last_capture_time']) > 300:
988
+ recommendations.append("No recent telemetry captures. System may be idle or experiencing issues.")
989
+
990
+ # Build health status
991
+ health_status = "healthy"
992
+ if success_rate < 0.90 or llm_capture_success_rate < 0.90:
993
+ health_status = "degraded"
994
+ if success_rate < 0.75 or len(recommendations) > 2:
995
+ health_status = "unhealthy"
996
+
997
+ return {
998
+ 'status': health_status,
999
+ 'uptime_seconds': uptime_seconds,
1000
+ 'total_captures': total_captures,
1001
+ 'failed_captures': failed_captures,
1002
+ 'success_rate': round(success_rate, 4),
1003
+ 'duplicate_requests': self._health_stats['duplicate_requests'],
1004
+ 'duplicate_rate': round(duplicate_rate, 4),
1005
+ 'token_estimation_fallbacks': self._health_stats['token_estimation_fallbacks'],
1006
+ 'token_fallback_rate': round(token_fallback_rate, 4),
1007
+ 'successful_llm_captures': self._health_stats['successful_llm_captures'],
1008
+ 'failed_llm_captures': self._health_stats['failed_llm_captures'],
1009
+ 'llm_capture_success_rate': round(llm_capture_success_rate, 4),
1010
+ 'last_capture_time': self._health_stats['last_capture_time'],
1011
+ 'recommendations': recommendations,
1012
+ 'telemetry_enabled': self._is_telemetry_enabled(),
1013
+ 'posthog_enabled': self.posthog_enabled,
1014
+ 'feed_writing_enabled': self.feed_writing_enabled
1015
+ }
1016
+
1017
+ def capture_step_with_context(
1018
+ self,
1019
+ agent_id: str,
1020
+ step: int,
1021
+ actions: List[Dict[str, Any]],
1022
+ task_progress: str,
1023
+ errors: Optional[List[str]] = None,
1024
+ inputs: Optional[Dict[str, Any]] = None,
1025
+ outputs: Optional[Dict[str, Any]] = None,
1026
+ metrics: Optional[Dict[str, Any]] = None,
1027
+ agent_name: Optional[str] = None,
1028
+ agent_type: Optional[str] = None,
1029
+ current_task: Optional[str] = None,
1030
+ session_id: Optional[str] = None
1031
+ ) -> None:
1032
+ """
1033
+ Capture an agent step event with enhanced context information.
1034
+
1035
+ Args:
1036
+ agent_id: The agent's unique identifier
1037
+ step: Current step number
1038
+ actions: List of actions taken in this step
1039
+ task_progress: Brief description of the current task progress
1040
+ errors: Optional list of error messages from this step
1041
+ inputs: Optional dictionary of input data (state, context, etc.)
1042
+ outputs: Optional dictionary of output data (observations, reasoning, etc.)
1043
+ metrics: Optional dictionary of metrics for this step
1044
+ agent_name: Optional name of the agent (e.g., "planner", "executor")
1045
+ agent_type: Optional type of the agent (e.g., "Agent", "PlannerAgent")
1046
+ current_task: Optional description of the current task
1047
+ session_id: Optional session ID (can be extracted from agent_id)
1048
+ """
1049
+ if not self._is_telemetry_enabled():
1050
+ return
1051
+
1052
+ try:
1053
+ # Clean session ID if provided, or extract from agent_id
1054
+ clean_session_id = None
1055
+ if session_id:
1056
+ clean_session_id = pm().clean_session_id(session_id)
1057
+ elif agent_id and '_' in agent_id:
1058
+ # Extract session ID from agent_id (format: name_session_id)
1059
+ parts = agent_id.split('_', 1)
1060
+ if len(parts) > 1:
1061
+ clean_session_id = pm().clean_session_id(parts[1])
1062
+
1063
+ # FIXED: Initialize mutable defaults properly to avoid shared references
1064
+ errors = errors or []
1065
+ inputs = inputs or {}
1066
+ outputs = outputs or {}
1067
+ metrics = metrics or {}
1068
+
1069
+ # Combine context data into a well-structured object
1070
+ context_data = {
1071
+ 'inputs': inputs,
1072
+ 'outputs': outputs,
1073
+ 'metrics': metrics
1074
+ }
1075
+
1076
+ # Extract reasoning from outputs if available
1077
+ reasoning = ""
1078
+ if outputs and isinstance(outputs, dict):
1079
+ reasoning = outputs.get('reasoning', '')
1080
+
1081
+ # Create the telemetry event
1082
+ from agents.task.telemetry.views import AgentStepTelemetryEvent
1083
+
1084
+ event = AgentStepTelemetryEvent(
1085
+ agent_id=agent_id,
1086
+ step=step,
1087
+ actions=actions,
1088
+ step_error=errors,
1089
+ consecutive_failures=metrics.get('consecutive_failures', 0) if metrics else 0,
1090
+ agent_name=agent_name,
1091
+ agent_type=agent_type,
1092
+ task_progress=task_progress,
1093
+ current_task=current_task,
1094
+ reasoning=reasoning,
1095
+ context_data=context_data
1096
+ )
1097
+
1098
+ # Capture the event with session context
1099
+ self.capture(event, session_id=clean_session_id)
1100
+
1101
+ except Exception as e:
1102
+ self.logger.debug(f"Failed to capture agent step with context: {e}", exc_info=True)
1103
+
1104
+ def get_session_id(self) -> Optional[str]:
1105
+ """Get the session ID associated with this event.
1106
+
1107
+ Default implementation checks common attributes that might contain the session ID.
1108
+ Override this method in subclasses to provide more specific session ID extraction.
1109
+
1110
+ Returns:
1111
+ The session ID or None if not found
1112
+ """
1113
+ # Check for explicit session_id attribute
1114
+ if hasattr(self, 'session_id') and getattr(self, 'session_id'):
1115
+ # CRITICAL FIX: Always clean the session ID when retrieving it
1116
+ return pm().clean_session_id(getattr(self, 'session_id'))
1117
+
1118
+ # Check for parent_session_id attribute (used in relationship events)
1119
+ if hasattr(self, 'parent_session_id') and getattr(self, 'parent_session_id'):
1120
+ # CRITICAL FIX: Always clean the session ID when retrieving it
1121
+ return pm().clean_session_id(getattr(self, 'parent_session_id'))
1122
+
1123
+ # Try to extract from agent_id if available
1124
+ if hasattr(self, 'agent_id') and getattr(self, 'agent_id'):
1125
+ agent_id = getattr(self, 'agent_id')
1126
+ if '_' in agent_id:
1127
+ # CRITICAL FIX: Always clean the extracted session ID
1128
+ session_id = agent_id.split('_', 1)[1]
1129
+ return pm().clean_session_id(session_id)
1130
+
1131
+ return None
1132
+
1133
+ def _flush_event_buffer(self, session_id: str, force: bool = False) -> None:
1134
+ """Flush buffered events for a session to disk.
1135
+
1136
+ Args:
1137
+ session_id: Session ID to flush events for
1138
+ force: Whether to force flush regardless of buffer size/timeout
1139
+ """
1140
+ with self._buffer_lock:
1141
+ if session_id not in self._event_buffer:
1142
+ return
1143
+
1144
+ events = self._event_buffer[session_id]
1145
+ if not events:
1146
+ return
1147
+
1148
+ current_time = time.time()
1149
+ last_flush = self._last_flush_times.get(session_id, 0)
1150
+
1151
+ # Check if we should flush based on buffer size or timeout
1152
+ should_flush = (
1153
+ force or
1154
+ len(events) >= self._buffer_size or
1155
+ (current_time - last_flush) >= self._buffer_timeout
1156
+ )
1157
+
1158
+ if not should_flush:
1159
+ return
1160
+
1161
+ try:
1162
+ # Get telemetry directory
1163
+ telemetry_dir = self._ensure_telemetry_dir(session_id)
1164
+ if not telemetry_dir:
1165
+ self.logger.error(f"Could not create telemetry directory for session {session_id}")
1166
+ return
1167
+
1168
+ # Write all buffered events to JSONL file in one operation
1169
+ telemetry_file = telemetry_dir / "events.jsonl"
1170
+ lock_file = telemetry_dir / "events.jsonl.lock"
1171
+
1172
+ with SafeFileLock(str(lock_file)):
1173
+ with open(telemetry_file, "a") as f:
1174
+ for event_data in events:
1175
+ f.write(json.dumps(event_data, default=self._json_serializable) + "\n")
1176
+
1177
+ # Clear the buffer and update flush time
1178
+ self._event_buffer[session_id] = []
1179
+ self._last_flush_times[session_id] = current_time
1180
+
1181
+ self.logger.debug(f"Flushed {len(events)} telemetry events for session {session_id}")
1182
+
1183
+ # Rotate events.jsonl if it exceeds size limits
1184
+ try:
1185
+ self._rotate_events_file(telemetry_file)
1186
+ except Exception as e:
1187
+ self.logger.debug(f"Failed to rotate events file: {e}")
1188
+ except Exception as e:
1189
+ self.logger.error(f"Failed to flush telemetry buffer for session {session_id}: {e}")
1190
+
1191
+ def _add_event_to_buffer(self, session_id: str, event_data: dict) -> None:
1192
+ """Add an event to the buffer for later batch writing.
1193
+
1194
+ Args:
1195
+ session_id: Session ID to buffer event for
1196
+ event_data: Event data to buffer
1197
+ """
1198
+ with self._buffer_lock:
1199
+ if session_id not in self._event_buffer:
1200
+ self._event_buffer[session_id] = []
1201
+
1202
+ self._event_buffer[session_id].append(event_data)
1203
+
1204
+ # Check if we should flush immediately
1205
+ self._flush_event_buffer(session_id)
1206
+
1207
+ def flush_all_buffers(self) -> None:
1208
+ """Flush all buffered events to disk. Useful for cleanup."""
1209
+ with self._buffer_lock:
1210
+ for session_id in list(self._event_buffer.keys()):
1211
+ self._flush_event_buffer(session_id, force=True)
1212
+
1213
+ # ------------------------
1214
+ # Retention/rotation utils
1215
+ # ------------------------
1216
+ def _enforce_feed_retention(self, feed_dir: Path) -> None:
1217
+ """Maintain feed directory size and age limits.
1218
+
1219
+ Keeps only the most recent N files and removes files older than M hours.
1220
+ Does not touch summary files like agents.json/services.json/task.json.
1221
+ """
1222
+ try:
1223
+ max_files = int(os.getenv('TELEMETRY_FEED_MAX_FILES', '200'))
1224
+ max_age_hours = int(os.getenv('TELEMETRY_FEED_MAX_AGE_HOURS', '168')) # 7 days
1225
+ now = time.time()
1226
+
1227
+ # Collect candidate files
1228
+ protected = {'agents.json', 'services.json', 'task.json'}
1229
+ files = [p for p in feed_dir.glob('*.json') if p.name not in protected]
1230
+ # Sort newest first
1231
+ files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
1232
+
1233
+ # Remove beyond max_files
1234
+ for p in files[max_files:]:
1235
+ try:
1236
+ p.unlink()
1237
+ except Exception:
1238
+ pass
1239
+
1240
+ # Age-based cleanup
1241
+ cutoff = now - max_age_hours * 3600
1242
+ for p in files:
1243
+ try:
1244
+ if p.stat().st_mtime < cutoff:
1245
+ p.unlink()
1246
+ except Exception:
1247
+ pass
1248
+ except Exception as e:
1249
+ self.logger.debug(f"Retention enforcement error: {e}")
1250
+
1251
+ def _rotate_events_file(self, telemetry_file: Path) -> None:
1252
+ """Rotate events.jsonl when it grows too large."""
1253
+ try:
1254
+ max_bytes = int(os.getenv('TELEMETRY_EVENTS_MAX_BYTES', '10485760')) # 10MB
1255
+ if telemetry_file.exists() and telemetry_file.stat().st_size > max_bytes:
1256
+ ts = time.strftime('%Y%m%d_%H%M%S', time.localtime())
1257
+ rotated = telemetry_file.with_name(f"events_{ts}.jsonl")
1258
+ lock_file = telemetry_file.with_suffix('.lock')
1259
+ with SafeFileLock(str(lock_file)):
1260
+ # Rename existing file and create a new empty one
1261
+ telemetry_file.rename(rotated)
1262
+ telemetry_file.touch()
1263
+ self.logger.info(f"Rotated telemetry events file to {rotated}")
1264
+ except Exception as e:
1265
+ self.logger.debug(f"Events rotation error: {e}")