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.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- 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}")
|