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,2398 @@
|
|
|
1
|
+
"""TaskAgent - Minimal wrapper for task automation package.
|
|
2
|
+
|
|
3
|
+
This is a thin wrapper that delegates all real work to the task package components:
|
|
4
|
+
- SessionManager: Handles all session tracking and lifecycle
|
|
5
|
+
- SessionOrchestrator: Handles agent coordination and execution
|
|
6
|
+
- Controller: Handles tool management and action execution
|
|
7
|
+
|
|
8
|
+
Key principle: NO DUPLICATION - all logic exists in the task package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
import re
|
|
15
|
+
import json
|
|
16
|
+
from typing import Optional, Dict, Any, Union, List
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from agents.base_agent import BaseAgent
|
|
22
|
+
from agents.task.session_registry import SessionRegistry
|
|
23
|
+
from core.exceptions import AgentError, SessionOwnershipError
|
|
24
|
+
from core.exceptions import InsufficientCreditsError
|
|
25
|
+
from core.exceptions import MessageQueueFullError
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Session limits per user
|
|
30
|
+
# Defensive fallback ONLY for the getattr below; the runtime SSOT is the config
|
|
31
|
+
# field BotConfig.max_sessions_per_user (core/config.py, default 10). Keep this in
|
|
32
|
+
# sync with that default. (B3: was previously shadowed by a stray, unused
|
|
33
|
+
# constants.MAX_SESSIONS_PER_USER=100 — removed.)
|
|
34
|
+
MAX_SESSIONS_PER_USER = 10
|
|
35
|
+
|
|
36
|
+
#: Strong references to in-flight fire-and-forget self-wake ``run_session`` tasks
|
|
37
|
+
#: (AU-F3.1). asyncio only holds a WEAK reference to a task created via
|
|
38
|
+
#: ``asyncio.create_task`` -- without this, the task object can be garbage-collected
|
|
39
|
+
#: mid-run (a well-known asyncio footgun), silently dropping the self-wake dispatch.
|
|
40
|
+
#: Mirrors ``core/autonomy_runtime.py::_BACKGROUND_TASKS``. Self-cleans via
|
|
41
|
+
#: ``add_done_callback``.
|
|
42
|
+
_SELF_WAKE_TASKS: set = set()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SessionRequest:
|
|
47
|
+
"""Model for session configuration."""
|
|
48
|
+
task: str
|
|
49
|
+
model: str = "gpt-5"
|
|
50
|
+
provider: str = "openai"
|
|
51
|
+
tools: List[str] = None
|
|
52
|
+
max_steps: int = 50
|
|
53
|
+
temperature: float = 0.0
|
|
54
|
+
use_vision: bool = True
|
|
55
|
+
session_config: Optional[Dict[str, Any]] = None
|
|
56
|
+
|
|
57
|
+
def __post_init__(self):
|
|
58
|
+
if self.tools is None:
|
|
59
|
+
self.tools = ["browser", "filesystem", "task"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_chat_runtime(env=None):
|
|
63
|
+
"""Resolve (provider, model) for chat_once via the shared core resolver (Seam 2).
|
|
64
|
+
|
|
65
|
+
Precedence: CHAT_PROVIDER/CHAT_MODEL or DEFAULT_PROVIDER/DEFAULT_MODEL pin >
|
|
66
|
+
the historical openai/gpt-5 default (used only if an OpenAI key is present) >
|
|
67
|
+
first keyed provider (canonical order) > openai/gpt-5 last resort. The model is
|
|
68
|
+
always filled to match the resolved provider, so a first-keyed/anthropic-pinned
|
|
69
|
+
provider never inherits SessionRequest's gpt-5 default.
|
|
70
|
+
"""
|
|
71
|
+
import os as _os
|
|
72
|
+
from core.runtime_config import resolve_runtime_config
|
|
73
|
+
from modules.llm.llm_client_registry import get_default_model
|
|
74
|
+
|
|
75
|
+
env = _os.environ if env is None else env
|
|
76
|
+
pinned_provider = env.get("CHAT_PROVIDER") or env.get("DEFAULT_PROVIDER")
|
|
77
|
+
pinned_model = env.get("CHAT_MODEL") or env.get("DEFAULT_MODEL")
|
|
78
|
+
provider, model = resolve_runtime_config(
|
|
79
|
+
None,
|
|
80
|
+
None,
|
|
81
|
+
env=env,
|
|
82
|
+
pinned_provider=pinned_provider,
|
|
83
|
+
pinned_model=pinned_model,
|
|
84
|
+
cli_store_default=("openai", "gpt-5"),
|
|
85
|
+
last_resort=("openai", "gpt-5"),
|
|
86
|
+
)
|
|
87
|
+
if not model:
|
|
88
|
+
model = get_default_model(provider)
|
|
89
|
+
return provider, model
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TaskAgent(BaseAgent):
|
|
93
|
+
"""Minimal task automation wrapper.
|
|
94
|
+
|
|
95
|
+
Core responsibilities:
|
|
96
|
+
1. Verify task package availability
|
|
97
|
+
2. Create/run sessions via task package
|
|
98
|
+
3. Report status from SessionManager
|
|
99
|
+
4. Route messages to active sessions
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, name: str = "task_agent", config=None, container=None):
|
|
103
|
+
"""Initialize task agent wrapper."""
|
|
104
|
+
# Get container if not provided - CRITICAL for tools
|
|
105
|
+
if not container:
|
|
106
|
+
from core.container import DependencyContainer
|
|
107
|
+
container = DependencyContainer.get_instance()
|
|
108
|
+
|
|
109
|
+
# Get config if not provided
|
|
110
|
+
if not config:
|
|
111
|
+
from core.config import BotConfig
|
|
112
|
+
config = BotConfig()
|
|
113
|
+
|
|
114
|
+
super().__init__(
|
|
115
|
+
name=name,
|
|
116
|
+
config=config,
|
|
117
|
+
container=container
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Store capabilities
|
|
121
|
+
self.capabilities = ["automation", "browser", "planning", "multi-agent"]
|
|
122
|
+
self.description = "Task automation agent"
|
|
123
|
+
|
|
124
|
+
# Core components from task package
|
|
125
|
+
self.session_manager = None
|
|
126
|
+
self.task_available = False
|
|
127
|
+
self._initialized = False
|
|
128
|
+
|
|
129
|
+
# Handles for the fire-and-forget periodic background loops so they can be
|
|
130
|
+
# cancelled deterministically via aclose() — otherwise they are GC'd on
|
|
131
|
+
# event-loop teardown ("Task was destroyed but it is pending" / "I/O
|
|
132
|
+
# operation on closed file"), which is noise in prod and pollution in tests.
|
|
133
|
+
self._bg_tasks: list = []
|
|
134
|
+
|
|
135
|
+
# Active orchestrators, keyed by session_id, behind a registry interface.
|
|
136
|
+
# Default: in-process dict (the reason UVICORN_WORKERS=1). Opt in to the
|
|
137
|
+
# SQLite-backed registry (cross-process visibility for workers>1) with
|
|
138
|
+
# SESSION_REGISTRY_BACKEND=sqlite — P6. Drop-in compatible interface.
|
|
139
|
+
self._registry = self._build_registry(config)
|
|
140
|
+
|
|
141
|
+
# Memory management with TTL and LRU eviction (from config)
|
|
142
|
+
self.session_ttl_seconds = config.session_ttl_seconds
|
|
143
|
+
self.max_sessions_in_memory = config.max_sessions_in_memory
|
|
144
|
+
self.cleanup_interval = config.session_cleanup_interval
|
|
145
|
+
# Shorter TTL for never-run 'created' sessions (keyed off created_at, not the
|
|
146
|
+
# run-path activity clock) so they stop leaking the per-user session limit.
|
|
147
|
+
self.created_session_ttl_seconds = getattr(
|
|
148
|
+
config, 'created_session_ttl_seconds', 3600
|
|
149
|
+
)
|
|
150
|
+
self._session_last_activity = {} # session_id → timestamp
|
|
151
|
+
|
|
152
|
+
# Session execution locks for concurrency control
|
|
153
|
+
self._session_execution_locks = {} # session_id → asyncio.Lock
|
|
154
|
+
self._recreate_locks = {} # session_id → asyncio.Lock (serializes orchestrator recreation)
|
|
155
|
+
|
|
156
|
+
# Compatibility attributes for API
|
|
157
|
+
self.active_sessions = {} # Will be populated from SessionManager
|
|
158
|
+
self.user_sessions = {} # Maps user_id to active session_id
|
|
159
|
+
# S2 (chat consolidation): durable map (user_id, chat_id) -> session_id so
|
|
160
|
+
# chat_once continues the same conversation across stateless HTTP calls
|
|
161
|
+
# (keyed finer-grained than user_sessions, which keys by user only).
|
|
162
|
+
self._chat_sessions = {} # "chat:{user_id}:{chat_id}" -> session_id
|
|
163
|
+
# Per-chat-key locks so concurrent chat_once() calls for the SAME
|
|
164
|
+
# (user_id, chat_id) serialize: without this, two in-flight HTTP turns
|
|
165
|
+
# could both create a session (one leaks) or read each other's reply off
|
|
166
|
+
# the shared history. Lazily populated, keyed by _chat_key().
|
|
167
|
+
self._chat_locks: Dict[str, "asyncio.Lock"] = {}
|
|
168
|
+
|
|
169
|
+
# Initialize telemetry for continuous chat tracking
|
|
170
|
+
try:
|
|
171
|
+
from agents.task.telemetry.manager import TelemetryManager
|
|
172
|
+
self.telemetry = TelemetryManager(
|
|
173
|
+
session_id="task_agent",
|
|
174
|
+
agent_id="continuous_chat"
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.debug(f"Failed to initialize telemetry: {e}")
|
|
178
|
+
self.telemetry = None
|
|
179
|
+
|
|
180
|
+
# --- Orchestrator registry accessors (public API) ---
|
|
181
|
+
#
|
|
182
|
+
# External callers (HTTP layer, tests) must use these instead of reaching
|
|
183
|
+
# into the registry dict directly, so the storage can be swapped without
|
|
184
|
+
# touching them. See agents/task/session_registry.py.
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _build_registry(config):
|
|
188
|
+
"""Pick the registry backend (P6). Defaults to the in-process dict so
|
|
189
|
+
production (UVICORN_WORKERS=1) is unchanged; SESSION_REGISTRY_BACKEND=sqlite
|
|
190
|
+
opts into the cross-process SQLite registry."""
|
|
191
|
+
import os
|
|
192
|
+
backend = os.getenv("SESSION_REGISTRY_BACKEND", "memory").strip().lower()
|
|
193
|
+
if backend == "sqlite":
|
|
194
|
+
from agents.task.sqlite_session_registry import SqliteSessionRegistry
|
|
195
|
+
data_dir = getattr(config, "data_dir", "data") if config else "data"
|
|
196
|
+
return SqliteSessionRegistry(os.path.join(data_dir, "session_registry.db"))
|
|
197
|
+
return SessionRegistry()
|
|
198
|
+
|
|
199
|
+
def get_orchestrator(self, session_id: str):
|
|
200
|
+
"""Return the active orchestrator for ``session_id`` or ``None``."""
|
|
201
|
+
return self._registry.get(session_id)
|
|
202
|
+
|
|
203
|
+
def route_session(self, session_id: str):
|
|
204
|
+
"""Cross-worker routing decision (P6) — a SessionRoute (LOCAL/REMOTE/MISSING).
|
|
205
|
+
Lets the API distinguish 'owned by another worker' from a true 404 when
|
|
206
|
+
SESSION_REGISTRY_BACKEND=sqlite and UVICORN_WORKERS>1. With the in-process
|
|
207
|
+
registry it is always LOCAL or MISSING."""
|
|
208
|
+
if hasattr(self._registry, "route"):
|
|
209
|
+
return self._registry.route(session_id)
|
|
210
|
+
# ultra-defensive fallback for a registry without route()
|
|
211
|
+
from agents.task.session_route import SessionRoute, LOCAL, MISSING
|
|
212
|
+
orch = self._registry.get(session_id)
|
|
213
|
+
return SessionRoute(status=LOCAL, orchestrator=orch) if orch else SessionRoute(status=MISSING)
|
|
214
|
+
|
|
215
|
+
def register_orchestrator(self, session_id: str, orchestrator) -> None:
|
|
216
|
+
"""Register (or replace) the orchestrator for ``session_id``."""
|
|
217
|
+
self._registry.register(session_id, orchestrator)
|
|
218
|
+
# SA-01: give the orchestrator a bound "re-run my loop" kick so a background
|
|
219
|
+
# delegation that completes into an already-idle session can drain its result
|
|
220
|
+
# instead of parking it forever. run_session refuses concurrent execution, so
|
|
221
|
+
# the kick is a safe no-op when a loop is still active. Set here (the single
|
|
222
|
+
# registration seam) so every registration path — create + recreate-from-disk —
|
|
223
|
+
# gets it. Fail-open: a kick-wiring error must never block registration.
|
|
224
|
+
try:
|
|
225
|
+
uid = getattr(orchestrator, "user_id", None)
|
|
226
|
+
|
|
227
|
+
async def _wake_kick():
|
|
228
|
+
try:
|
|
229
|
+
await self.run_session(uid, session_id)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
orchestrator._wake_kick = _wake_kick
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
def remove_orchestrator(self, session_id: str):
|
|
238
|
+
"""Remove and return the orchestrator for ``session_id`` (``None`` if absent)."""
|
|
239
|
+
return self._registry.remove(session_id)
|
|
240
|
+
|
|
241
|
+
def heartbeat_session(self, session_id: str) -> None:
|
|
242
|
+
"""Refresh the registry's liveness clock for ``session_id`` (P6).
|
|
243
|
+
|
|
244
|
+
No-op for the in-process registry (which has no heartbeat). For the SQLite
|
|
245
|
+
backend this bumps ``last_seen_at`` so the periodic reaper spares live
|
|
246
|
+
sessions. Fail-open — a heartbeat must never disrupt the run loop."""
|
|
247
|
+
try:
|
|
248
|
+
registry = self._registry
|
|
249
|
+
if hasattr(registry, "heartbeat"):
|
|
250
|
+
registry.heartbeat(session_id)
|
|
251
|
+
except Exception:
|
|
252
|
+
# Fail-open: heartbeat failure is non-critical, continue without it
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
def active_orchestrators(self) -> List[Any]:
|
|
256
|
+
"""Snapshot list of the currently-registered orchestrators."""
|
|
257
|
+
return self._registry.values()
|
|
258
|
+
|
|
259
|
+
def active_session_count(self) -> int:
|
|
260
|
+
"""Number of orchestrators currently held in memory."""
|
|
261
|
+
return self._registry.count()
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def _active_orchestrators(self) -> Dict[str, Any]:
|
|
265
|
+
"""Backward-compat view of the registry's underlying dict.
|
|
266
|
+
|
|
267
|
+
Retained for existing tests/callers that mutate the dict directly.
|
|
268
|
+
New code should use get_orchestrator/register_orchestrator/remove_orchestrator.
|
|
269
|
+
"""
|
|
270
|
+
return self._registry._orchestrators
|
|
271
|
+
|
|
272
|
+
@_active_orchestrators.setter
|
|
273
|
+
def _active_orchestrators(self, value: Dict[str, Any]) -> None:
|
|
274
|
+
self._registry._orchestrators = value
|
|
275
|
+
|
|
276
|
+
async def _initialize(self) -> None:
|
|
277
|
+
"""Initialize and verify task package availability."""
|
|
278
|
+
if self._initialized:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
await super()._initialize()
|
|
282
|
+
|
|
283
|
+
# Check task package and get SessionManager
|
|
284
|
+
try:
|
|
285
|
+
from agents.task.agent.session import get_session_manager
|
|
286
|
+
|
|
287
|
+
self.session_manager = get_session_manager()
|
|
288
|
+
self.task_available = True
|
|
289
|
+
|
|
290
|
+
# Register in container for other services
|
|
291
|
+
if self.container and not self.container.has_service('session_manager'):
|
|
292
|
+
self.container.register_service('session_manager', self.session_manager)
|
|
293
|
+
|
|
294
|
+
# P1b-0: install the Singular Chat outbound bus on the shared container
|
|
295
|
+
# so create_session's binding + the P1a mirrors + cron/delivery's sink
|
|
296
|
+
# have a router to reach. Flag-gated (default OFF -> no-op, byte-identical)
|
|
297
|
+
# and fail-open (a bus error never breaks TaskAgent startup).
|
|
298
|
+
if self.container:
|
|
299
|
+
try:
|
|
300
|
+
from core.surfaces.bootstrap import install_surface_bus
|
|
301
|
+
# SB-04: do NOT pass a hardcoded "data/surfaces.db". This install
|
|
302
|
+
# runs first (during build_cli_container) and is idempotent, so a
|
|
303
|
+
# hardcoded path pinned the live outbound-allowlist/router DB to
|
|
304
|
+
# ./data/surfaces.db while `polyrob owner allow` writes
|
|
305
|
+
# <data_home>/surfaces.db (cwd/.polyrob or POLYROB_DATA_DIR) — a
|
|
306
|
+
# split-brain that left the P1 `message` allowlist un-configurable
|
|
307
|
+
# from the CLI admin surface. Let the config-aware default
|
|
308
|
+
# (<container.config.data_dir>/surfaces.db) resolve so both sides
|
|
309
|
+
# share one DB under the same data-home isolation.
|
|
310
|
+
install_surface_bus(self.container)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.debug(f"surface bus install skipped: {e}")
|
|
313
|
+
|
|
314
|
+
logger.info(f"✓ TaskAgent initialized with SessionManager")
|
|
315
|
+
|
|
316
|
+
# Start background cleanup task for memory eviction
|
|
317
|
+
self._bg_tasks.append(asyncio.create_task(self._periodic_cleanup()))
|
|
318
|
+
logger.info(f"✓ Started periodic cleanup task (interval: {self.cleanup_interval}s, TTL: {self.session_ttl_seconds}s)")
|
|
319
|
+
|
|
320
|
+
# Start workspace cleanup task
|
|
321
|
+
self._bg_tasks.append(asyncio.create_task(self._periodic_workspace_cleanup()))
|
|
322
|
+
logger.info("✓ Started periodic workspace cleanup task")
|
|
323
|
+
|
|
324
|
+
except ImportError as e:
|
|
325
|
+
logger.error(f"Task package not available: {e}")
|
|
326
|
+
self.task_available = False
|
|
327
|
+
|
|
328
|
+
self._initialized = True
|
|
329
|
+
|
|
330
|
+
def _assert_session_owner(self, session_id: Optional[str], user_id: str) -> None:
|
|
331
|
+
"""C4: refuse to reuse a session_id that already belongs to another user.
|
|
332
|
+
|
|
333
|
+
Allows: no session_id (generated), a brand-new id, an id with no recorded
|
|
334
|
+
owner (legacy), or the caller's own id. Raises SessionOwnershipError only on
|
|
335
|
+
a real cross-user collision.
|
|
336
|
+
"""
|
|
337
|
+
if not session_id:
|
|
338
|
+
return
|
|
339
|
+
existing = self.session_manager.get_session_info(session_id)
|
|
340
|
+
if not existing:
|
|
341
|
+
return
|
|
342
|
+
owner = existing.get('user_id')
|
|
343
|
+
if owner and user_id and owner != user_id:
|
|
344
|
+
raise SessionOwnershipError(
|
|
345
|
+
f"Session {session_id} belongs to another user"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
async def create_session(
|
|
349
|
+
self,
|
|
350
|
+
user_id: str,
|
|
351
|
+
request: Union[str, Dict[str, Any], SessionRequest],
|
|
352
|
+
session_id: Optional[str] = None,
|
|
353
|
+
skip_credit_check: bool = False,
|
|
354
|
+
on_stream_chunk=None,
|
|
355
|
+
**kwargs
|
|
356
|
+
) -> Dict[str, Any]:
|
|
357
|
+
"""Create a new task session with credit pre-validation.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
user_id: User creating the session
|
|
361
|
+
request: Task string, request dict, or SessionRequest object
|
|
362
|
+
session_id: Optional session ID
|
|
363
|
+
skip_credit_check: If True, skip credit validation (for admin/already-verified)
|
|
364
|
+
on_stream_chunk: Optional streaming callback override. When provided,
|
|
365
|
+
replaces the default webview stream callback. Used by CLI to print
|
|
366
|
+
chunks to stdout. Signature: (session_id, agent_id, chunk, step) -> Awaitable[None]
|
|
367
|
+
**kwargs: Additional parameters
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Session info dictionary
|
|
371
|
+
"""
|
|
372
|
+
if not self._initialized:
|
|
373
|
+
await self._initialize()
|
|
374
|
+
|
|
375
|
+
if not self.task_available:
|
|
376
|
+
raise AgentError("Task package not available")
|
|
377
|
+
|
|
378
|
+
# Pre-validate credits before creating session (unless bypassed)
|
|
379
|
+
if not skip_credit_check:
|
|
380
|
+
await self._validate_user_credits(user_id)
|
|
381
|
+
|
|
382
|
+
# B2: reap never-run 'created' sessions on-demand BEFORE the cap check.
|
|
383
|
+
# The periodic cleanup task only ticks in a long-lived server process; a
|
|
384
|
+
# one-shot `polyrob run` exits before it fires, so stale 'created' slots from
|
|
385
|
+
# prior runs (loaded from disk on startup) would false-positive the cap.
|
|
386
|
+
try:
|
|
387
|
+
await self._cleanup_stale_created_sessions()
|
|
388
|
+
except Exception:
|
|
389
|
+
logger.debug("on-demand stale-session reap failed (non-fatal)", exc_info=True)
|
|
390
|
+
|
|
391
|
+
# Check per-user session limit
|
|
392
|
+
if not self._check_user_session_limit(user_id):
|
|
393
|
+
raise AgentError(
|
|
394
|
+
f"Session limit reached for user {user_id}. "
|
|
395
|
+
f"Please complete existing sessions before creating new ones."
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Parse request into standard format
|
|
399
|
+
if isinstance(request, str):
|
|
400
|
+
session_request = SessionRequest(task=request)
|
|
401
|
+
elif isinstance(request, SessionRequest):
|
|
402
|
+
session_request = request
|
|
403
|
+
else:
|
|
404
|
+
# Dict format from API
|
|
405
|
+
session_request = SessionRequest(
|
|
406
|
+
task=request.get('task', ''),
|
|
407
|
+
model=request.get('model', 'gpt-5'),
|
|
408
|
+
provider=request.get('provider', 'openai'),
|
|
409
|
+
tools=request.get('tools', ['browser', 'filesystem', 'task']),
|
|
410
|
+
max_steps=request.get('max_steps', 50),
|
|
411
|
+
temperature=request.get('temperature', 0.0),
|
|
412
|
+
use_vision=request.get('use_vision', True),
|
|
413
|
+
session_config=request.get('session_config')
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# SECURITY (C4): a client may supply a custom session_id (CLI/API/A2A). If
|
|
417
|
+
# that id already belongs to a DIFFERENT user, refuse — otherwise we would
|
|
418
|
+
# build a fresh orchestrator over theirs (register_orchestrator overwrite =
|
|
419
|
+
# DoS) and stomp their task/model/tools metadata.
|
|
420
|
+
self._assert_session_owner(session_id, user_id)
|
|
421
|
+
|
|
422
|
+
# Generate session ID if needed
|
|
423
|
+
if not session_id:
|
|
424
|
+
session_id = str(uuid.uuid4())
|
|
425
|
+
|
|
426
|
+
# Create session via SessionManager
|
|
427
|
+
actual_id = self.session_manager.create_session(session_id, user_id)
|
|
428
|
+
|
|
429
|
+
# Create orchestrator for this session
|
|
430
|
+
from agents.task.agent.orchestrator import SessionOrchestrator
|
|
431
|
+
|
|
432
|
+
# Streaming callback resolution: caller-provided > webview > None (headless).
|
|
433
|
+
stream_callback = on_stream_chunk
|
|
434
|
+
if stream_callback is None:
|
|
435
|
+
try:
|
|
436
|
+
from agents.task.utils_webview import make_webview_stream_callback
|
|
437
|
+
stream_callback = make_webview_stream_callback()
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.debug(f"Webview streaming unavailable, running headless: {e}")
|
|
440
|
+
|
|
441
|
+
orchestrator = SessionOrchestrator(
|
|
442
|
+
session_id=actual_id,
|
|
443
|
+
user_id=user_id,
|
|
444
|
+
container=self.container, # Pass container for tools
|
|
445
|
+
on_stream_chunk=stream_callback,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# P1b-2: bind this session's orchestrator to the Singular Chat outbound bus
|
|
449
|
+
# BEFORE initialize() — _register_stream_callback captures the router+key by
|
|
450
|
+
# value, so binding after init would be a permanent no-op. Flag-gated +
|
|
451
|
+
# fail-open: with the flag OFF or no chat_session_key (legacy callers pass
|
|
452
|
+
# neither), this touches nothing and the legacy callback path is unchanged.
|
|
453
|
+
try:
|
|
454
|
+
from core.surfaces.binding import bind_chat_surface
|
|
455
|
+
bind_chat_surface(
|
|
456
|
+
orchestrator, self.container,
|
|
457
|
+
session_source=kwargs.get("session_source"),
|
|
458
|
+
chat_session_key=kwargs.get("chat_session_key"),
|
|
459
|
+
session_id=actual_id, user_id=user_id,
|
|
460
|
+
)
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.debug(f"chat-surface bind skipped: {e}")
|
|
463
|
+
|
|
464
|
+
# Initialize orchestrator with tools. A surface may pass an explicit `tool_ids`
|
|
465
|
+
# override (e.g. the telegram owner-interactive toolset) WITHOUT going through
|
|
466
|
+
# the request-dict path, so provider/model resolution stays untouched.
|
|
467
|
+
tool_ids_override = kwargs.get("tool_ids")
|
|
468
|
+
await orchestrator.initialize(
|
|
469
|
+
tool_ids=tool_ids_override or session_request.tools,
|
|
470
|
+
tools_config=session_request.session_config.get('tools_config', {}) if session_request.session_config else {}
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Store orchestrator reference
|
|
474
|
+
self.register_orchestrator(actual_id, orchestrator)
|
|
475
|
+
|
|
476
|
+
# Store full session metadata with config for API compatibility
|
|
477
|
+
self.session_manager.update_session_metadata(actual_id, {
|
|
478
|
+
'task': session_request.task,
|
|
479
|
+
'model': session_request.model,
|
|
480
|
+
'tools': session_request.tools,
|
|
481
|
+
'config': {
|
|
482
|
+
'model': session_request.model,
|
|
483
|
+
'provider': session_request.provider,
|
|
484
|
+
'tools': session_request.tools,
|
|
485
|
+
'max_steps': session_request.max_steps,
|
|
486
|
+
'temperature': session_request.temperature,
|
|
487
|
+
'use_vision': session_request.use_vision,
|
|
488
|
+
'tools_config': session_request.session_config.get('tools_config', {}) if session_request.session_config else {}
|
|
489
|
+
},
|
|
490
|
+
'request': session_request.__dict__,
|
|
491
|
+
'created_at': datetime.now().isoformat(),
|
|
492
|
+
'status': 'created',
|
|
493
|
+
'orchestrator_ready': True
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
# Save task to dedicated file for webview (uses SessionManager helper)
|
|
497
|
+
try:
|
|
498
|
+
self.session_manager._save_summary_file(actual_id, 'task.json', {
|
|
499
|
+
"task": session_request.task,
|
|
500
|
+
"model": session_request.model,
|
|
501
|
+
"provider": session_request.provider,
|
|
502
|
+
"created_at": datetime.now().isoformat()
|
|
503
|
+
})
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.warning(f"Failed to create task.json: {e}")
|
|
506
|
+
|
|
507
|
+
# Update user_sessions mapping for API compatibility
|
|
508
|
+
self.user_sessions[user_id] = actual_id
|
|
509
|
+
|
|
510
|
+
logger.info(f"Created session {actual_id} with orchestrator for user {user_id}")
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
'id': actual_id,
|
|
514
|
+
'user_id': user_id,
|
|
515
|
+
'task': session_request.task,
|
|
516
|
+
'status': 'created',
|
|
517
|
+
'model': session_request.model,
|
|
518
|
+
'tools': session_request.tools,
|
|
519
|
+
'config': {
|
|
520
|
+
'model': session_request.model,
|
|
521
|
+
'provider': session_request.provider,
|
|
522
|
+
'tools': session_request.tools,
|
|
523
|
+
'max_steps': session_request.max_steps,
|
|
524
|
+
'temperature': session_request.temperature,
|
|
525
|
+
'use_vision': session_request.use_vision
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
def _rebind_recreated_chat(self, orchestrator, session_id: str, user_id: str) -> None:
|
|
530
|
+
"""Re-attach the outbound chat surface to a recreated orchestrator (#0).
|
|
531
|
+
|
|
532
|
+
Recreation rebuilds the orchestrator without `_message_router`/`_chat_session_key`,
|
|
533
|
+
so a resumed chat's replies would be silently dropped (the streaming + send_message
|
|
534
|
+
seams read those by getattr→None). Reverse-look-up the chat binding and re-bind.
|
|
535
|
+
MUST run BEFORE orchestrator.initialize()/create_agent — the stream callback
|
|
536
|
+
captures the router by value. Fail-open; no-op for non-chat sessions / bus off."""
|
|
537
|
+
try:
|
|
538
|
+
if not self.container:
|
|
539
|
+
return
|
|
540
|
+
registry = self.container.get_service("session_chat_registry")
|
|
541
|
+
if registry is None or not hasattr(registry, "resolve_by_session_id"):
|
|
542
|
+
return
|
|
543
|
+
row = registry.resolve_by_session_id(session_id)
|
|
544
|
+
if not row:
|
|
545
|
+
return # not a chat-bound session
|
|
546
|
+
from core.surfaces.binding import bind_chat_surface
|
|
547
|
+
from core.surfaces.envelopes import SessionSource
|
|
548
|
+
src = SessionSource(
|
|
549
|
+
surface_id=row.get("surface_id"),
|
|
550
|
+
chat_id=row.get("chat_id"),
|
|
551
|
+
chat_type="dm",
|
|
552
|
+
)
|
|
553
|
+
bind_chat_surface(
|
|
554
|
+
orchestrator, self.container,
|
|
555
|
+
session_source=src,
|
|
556
|
+
chat_session_key=row.get("session_key"),
|
|
557
|
+
session_id=session_id,
|
|
558
|
+
user_id=user_id,
|
|
559
|
+
)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.debug(f"_rebind_recreated_chat failed for {session_id}: {e}")
|
|
562
|
+
|
|
563
|
+
def touch_chat_binding(self, session_key: str) -> None:
|
|
564
|
+
"""Bump a chat binding's last-activity clock (idle boundary, a1). Fail-open;
|
|
565
|
+
resolves the session_chat_registry from the container. No-op if the bus is off."""
|
|
566
|
+
try:
|
|
567
|
+
if not self.container:
|
|
568
|
+
return
|
|
569
|
+
registry = self.container.get_service("session_chat_registry")
|
|
570
|
+
if registry is not None:
|
|
571
|
+
registry.touch(session_key)
|
|
572
|
+
except Exception as e:
|
|
573
|
+
logger.debug(f"touch_chat_binding failed for {session_key}: {e}")
|
|
574
|
+
|
|
575
|
+
async def _resolve_or_recreate(
|
|
576
|
+
self, session_id: str, session_info: Dict[str, Any]
|
|
577
|
+
) -> Optional[Any]:
|
|
578
|
+
"""Return the resident orchestrator for ``session_id``, recreating it from disk
|
|
579
|
+
under a per-session lock if it was evicted (a2-complete).
|
|
580
|
+
|
|
581
|
+
ALL recreation paths route through here so two callers (e.g. a STEER message and
|
|
582
|
+
a self-wake) racing on the same evicted session can't double-build the
|
|
583
|
+
orchestrator and orphan one. A resident session returns immediately WITHOUT
|
|
584
|
+
taking the lock, so this never blocks queuing into an already-running session.
|
|
585
|
+
The lock is a dedicated ``_recreate_locks`` entry — not the execution lock."""
|
|
586
|
+
orchestrator = self._registry.get(session_id)
|
|
587
|
+
if orchestrator:
|
|
588
|
+
return orchestrator
|
|
589
|
+
if not hasattr(self, "_recreate_locks"):
|
|
590
|
+
self._recreate_locks = {}
|
|
591
|
+
lock = self._recreate_locks.setdefault(session_id, asyncio.Lock())
|
|
592
|
+
async with lock:
|
|
593
|
+
orchestrator = self._registry.get(session_id) # re-check under lock
|
|
594
|
+
if not orchestrator:
|
|
595
|
+
orchestrator = await self._recreate_orchestrator(session_id, session_info)
|
|
596
|
+
# If recreation failed the session never becomes resident, so _evict_session's
|
|
597
|
+
# lock-pop (gated on a live orchestrator) will never fire — drop the lock here so
|
|
598
|
+
# a stream of failed recreations can't leak one Lock per distinct session_id.
|
|
599
|
+
if orchestrator is None and not lock.locked():
|
|
600
|
+
self._recreate_locks.pop(session_id, None)
|
|
601
|
+
return orchestrator
|
|
602
|
+
|
|
603
|
+
def unbind_chat(self, session_key: str) -> None:
|
|
604
|
+
"""Drop a chat<->session binding (explicit /new). The next message from that
|
|
605
|
+
chat then routes cold (fresh session) instead of STEERing into the old thread.
|
|
606
|
+
Fail-open; no-op if the singular-chat bus is off (a4)."""
|
|
607
|
+
try:
|
|
608
|
+
if not self.container:
|
|
609
|
+
return
|
|
610
|
+
registry = self.container.get_service("session_chat_registry")
|
|
611
|
+
if registry is not None:
|
|
612
|
+
registry.delete(session_key)
|
|
613
|
+
except Exception as e:
|
|
614
|
+
logger.debug(f"unbind_chat failed for {session_key}: {e}")
|
|
615
|
+
|
|
616
|
+
async def ensure_session_and_deliver(
|
|
617
|
+
self,
|
|
618
|
+
user_id: str,
|
|
619
|
+
session_id: str,
|
|
620
|
+
text: str,
|
|
621
|
+
*,
|
|
622
|
+
kind: str = "comment",
|
|
623
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
624
|
+
) -> str:
|
|
625
|
+
"""Resident-or-recreate the BOUND session, then queue a user message into it.
|
|
626
|
+
|
|
627
|
+
Reuses the self-wake "resident-or-recreatable" rail: if the orchestrator was
|
|
628
|
+
evicted, ``_recreate_orchestrator`` restores it from disk (``load_from_disk``
|
|
629
|
+
rehydrates the full message history).
|
|
630
|
+
|
|
631
|
+
Returns one of (a-MED2):
|
|
632
|
+
- ``"delivered"`` — queued; the caller then runs the session to process it.
|
|
633
|
+
- ``"busy"`` — the session is alive but its queue is full; the caller
|
|
634
|
+
should tell the user "still working", NOT mint a fresh one.
|
|
635
|
+
- ``"gone"`` — truly gone (no on-disk metadata / wrong tenant / not
|
|
636
|
+
recreatable); the caller falls back to a fresh session.
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
640
|
+
if not session_info:
|
|
641
|
+
return "gone"
|
|
642
|
+
# Tenant guard: never deliver into another user's session.
|
|
643
|
+
owner = session_info.get("user_id")
|
|
644
|
+
if user_id is not None and owner is not None and owner != user_id:
|
|
645
|
+
logger.warning(
|
|
646
|
+
f"ensure_session_and_deliver: user mismatch for {session_id} "
|
|
647
|
+
f"(owner={owner}, caller={user_id}) — refusing"
|
|
648
|
+
)
|
|
649
|
+
return "gone"
|
|
650
|
+
orchestrator = await self._resolve_or_recreate(session_id, session_info)
|
|
651
|
+
if not orchestrator:
|
|
652
|
+
return "gone"
|
|
653
|
+
try:
|
|
654
|
+
await orchestrator.submit_user_message(
|
|
655
|
+
agent_id=None, text=text, kind=kind, metadata=metadata,
|
|
656
|
+
)
|
|
657
|
+
except MessageQueueFullError:
|
|
658
|
+
# The session is resident and processing — its queue is just saturated.
|
|
659
|
+
# Surfacing "busy" keeps the user on the SAME thread instead of dropping
|
|
660
|
+
# them into a fresh, amnesiac session.
|
|
661
|
+
logger.info(
|
|
662
|
+
f"ensure_session_and_deliver: queue full for {session_id} — busy"
|
|
663
|
+
)
|
|
664
|
+
return "busy"
|
|
665
|
+
return "delivered"
|
|
666
|
+
except Exception as e:
|
|
667
|
+
logger.error(
|
|
668
|
+
f"ensure_session_and_deliver failed for {session_id}: {e}", exc_info=True
|
|
669
|
+
)
|
|
670
|
+
return "gone"
|
|
671
|
+
|
|
672
|
+
def _session_has_pending_input(self, session_id: str) -> bool:
|
|
673
|
+
"""True if the session has genuine queued input waiting to be processed.
|
|
674
|
+
|
|
675
|
+
Checks the orchestrator's pre-agent pending queue and every resident
|
|
676
|
+
agent's HITL queue. An evicted orchestrator (not in the registry) has
|
|
677
|
+
nothing queued in memory by definition — recreation paths queue their
|
|
678
|
+
message first via ensure_session_and_deliver. Fail-open: on any error,
|
|
679
|
+
report input present so legacy behaviour (run) is preserved.
|
|
680
|
+
"""
|
|
681
|
+
orchestrator = self._registry.get(session_id)
|
|
682
|
+
if not orchestrator:
|
|
683
|
+
return False
|
|
684
|
+
try:
|
|
685
|
+
if getattr(orchestrator, '_pending_messages', None):
|
|
686
|
+
return True
|
|
687
|
+
for agent in (getattr(orchestrator, 'agents', None) or {}).values():
|
|
688
|
+
hitl = getattr(agent, 'hitl_manager', None)
|
|
689
|
+
if hitl is not None and hitl.get_queue_size() > 0:
|
|
690
|
+
return True
|
|
691
|
+
except Exception as e:
|
|
692
|
+
logger.debug(f"_session_has_pending_input({session_id}) failed: {e}")
|
|
693
|
+
return True # when in doubt, run (legacy behaviour)
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
async def run_session(
|
|
697
|
+
self,
|
|
698
|
+
user_id: str,
|
|
699
|
+
session_id: Optional[str] = None
|
|
700
|
+
) -> str:
|
|
701
|
+
"""Run a task session with concurrency protection.
|
|
702
|
+
|
|
703
|
+
This method encapsulates the entire execution flow:
|
|
704
|
+
1. Acquire execution lock for the session (prevents race conditions)
|
|
705
|
+
2. Get orchestrator (already initialized during create_session)
|
|
706
|
+
3. Get LLM client
|
|
707
|
+
4. Create agent
|
|
708
|
+
5. Execute session
|
|
709
|
+
|
|
710
|
+
All complexity is handled internally by the orchestrator.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
user_id: User ID
|
|
714
|
+
session_id: Session to run (or latest if None)
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
Result message
|
|
718
|
+
"""
|
|
719
|
+
if not self.task_available:
|
|
720
|
+
return "Task package not available"
|
|
721
|
+
|
|
722
|
+
# Get session
|
|
723
|
+
if not session_id:
|
|
724
|
+
sessions = self.session_manager.get_active_sessions(user_id)
|
|
725
|
+
if not sessions:
|
|
726
|
+
return "No active session found"
|
|
727
|
+
session_id = sessions[-1]
|
|
728
|
+
|
|
729
|
+
# CONCURRENCY PROTECTION (DUAL-LOCK PATTERN):
|
|
730
|
+
# ==========================================
|
|
731
|
+
# PRIMARY DEFENSE: Execution lock (prevents concurrent execution)
|
|
732
|
+
# SECONDARY DEFENSE: Status transition check (validates state machine integrity)
|
|
733
|
+
#
|
|
734
|
+
# Get or create lock for this session
|
|
735
|
+
if session_id not in self._session_execution_locks:
|
|
736
|
+
self._session_execution_locks[session_id] = asyncio.Lock()
|
|
737
|
+
|
|
738
|
+
# Acquire lock - only one execution at a time per session
|
|
739
|
+
# This is the PRIMARY protection against race conditions
|
|
740
|
+
async with self._session_execution_locks[session_id]:
|
|
741
|
+
return await self._run_session_impl(user_id, session_id)
|
|
742
|
+
|
|
743
|
+
async def _run_session_impl(
|
|
744
|
+
self,
|
|
745
|
+
user_id: str,
|
|
746
|
+
session_id: str
|
|
747
|
+
) -> str:
|
|
748
|
+
"""Internal session execution implementation.
|
|
749
|
+
|
|
750
|
+
This is the actual implementation separated for lock management.
|
|
751
|
+
Should only be called from run_session() which holds the execution lock.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
user_id: User ID
|
|
755
|
+
session_id: Session ID to run
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
Result message
|
|
759
|
+
"""
|
|
760
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
761
|
+
if not session_info or session_info.get('user_id') != user_id:
|
|
762
|
+
return "Session not found or unauthorized"
|
|
763
|
+
|
|
764
|
+
# Get current status
|
|
765
|
+
current_status = session_info.get('status', 'unknown')
|
|
766
|
+
|
|
767
|
+
# P2/P3 (2026-07-02): kill the "resume-to-check" model. A COMPLETED session
|
|
768
|
+
# only re-runs when genuine queued input exists — every legitimate resume
|
|
769
|
+
# path (STEER, continuation, self-wake, delegation-result) queues its
|
|
770
|
+
# message BEFORE calling run_session. Without this gate, a no-input resume
|
|
771
|
+
# burned an LLM call, concluded "No new user input", and appended another
|
|
772
|
+
# wall of no-op done-turns to the persisted history (prod fa1212de).
|
|
773
|
+
# No status churn, and an evicted orchestrator is NOT recreated just to
|
|
774
|
+
# discover there is nothing to do.
|
|
775
|
+
if current_status == 'completed' and not self._session_has_pending_input(session_id):
|
|
776
|
+
logger.info(
|
|
777
|
+
f"Session {session_id} is completed with no pending input — "
|
|
778
|
+
f"skipping no-op resume"
|
|
779
|
+
)
|
|
780
|
+
return "No new input; session remains completed"
|
|
781
|
+
|
|
782
|
+
# SECONDARY DEFENSE: Status transition validation
|
|
783
|
+
# ================================================
|
|
784
|
+
# This provides:
|
|
785
|
+
# 1. Additional safety (defense-in-depth)
|
|
786
|
+
# 2. Status state machine integrity
|
|
787
|
+
# 3. Detection of logic errors that bypass the lock
|
|
788
|
+
#
|
|
789
|
+
# NOTE: The execution lock (line 312) is PRIMARY - this is SECONDARY verification
|
|
790
|
+
# Include 'error' for backward compatibility with sessions that used old status
|
|
791
|
+
if current_status in ['completed', 'suspended', 'failed', 'error']:
|
|
792
|
+
# CONTINUOUS CHAT: completed/suspended/failed/error → resumed → running
|
|
793
|
+
# Valid transitions require two steps for these statuses
|
|
794
|
+
if not self.session_manager.try_transition_status(session_id, current_status, 'resumed'):
|
|
795
|
+
logger.warning(f"Session {session_id} is already being resumed (concurrent call)")
|
|
796
|
+
return "Session is already executing"
|
|
797
|
+
# Update current status for next transition
|
|
798
|
+
current_status = 'resumed'
|
|
799
|
+
|
|
800
|
+
if current_status == 'resumed':
|
|
801
|
+
# Try: resumed → running
|
|
802
|
+
if not self.session_manager.try_transition_status(session_id, 'resumed', 'running'):
|
|
803
|
+
logger.warning(f"Session {session_id} is already running (concurrent call)")
|
|
804
|
+
return "Session is already executing"
|
|
805
|
+
|
|
806
|
+
# Track session resume telemetry
|
|
807
|
+
if self.telemetry:
|
|
808
|
+
try:
|
|
809
|
+
# Get message queue size if available
|
|
810
|
+
queue_size = 0
|
|
811
|
+
orchestrator = self._registry.get(session_id)
|
|
812
|
+
if orchestrator and orchestrator.agents:
|
|
813
|
+
agent = next(iter(orchestrator.agents.values()))
|
|
814
|
+
if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
|
|
815
|
+
queue_size = agent.hitl_manager.get_queue_size()
|
|
816
|
+
|
|
817
|
+
self.telemetry.capture_event(
|
|
818
|
+
event_type="session_resume",
|
|
819
|
+
data={
|
|
820
|
+
"session_id": session_id,
|
|
821
|
+
"previous_status": current_status,
|
|
822
|
+
"message_queue_size": queue_size,
|
|
823
|
+
"concurrent_sessions": self._registry.count()
|
|
824
|
+
}
|
|
825
|
+
)
|
|
826
|
+
except Exception as e:
|
|
827
|
+
logger.debug(f"Failed to emit session resume telemetry: {e}")
|
|
828
|
+
elif current_status in ('created', 'initializing'):
|
|
829
|
+
# 'created' = fresh session; 'initializing' = the orchestrator just (re)created it
|
|
830
|
+
# (orchestrator.__init__ sets status='initializing'). A warm STEER resume — e.g. the
|
|
831
|
+
# owner sending a voice/text message to continue a suspended session — lands in
|
|
832
|
+
# 'initializing', and WITHOUT this case it dead-ends at the else below ("Cannot run
|
|
833
|
+
# session in status: initializing") so Rob never replies. Both are pre-run states →
|
|
834
|
+
# transition to running. try_transition_status is a compare-and-swap, so it's safe.
|
|
835
|
+
if not self.session_manager.try_transition_status(session_id, current_status, 'running'):
|
|
836
|
+
logger.warning(f"Session {session_id} is already being processed")
|
|
837
|
+
return "Session is already executing"
|
|
838
|
+
elif current_status == 'running':
|
|
839
|
+
# Already running!
|
|
840
|
+
logger.warning(f"Session {session_id} is already running")
|
|
841
|
+
return "Session is already executing"
|
|
842
|
+
elif current_status == 'active':
|
|
843
|
+
# Legacy status, try to transition
|
|
844
|
+
if not self.session_manager.try_transition_status(session_id, 'active', 'running'):
|
|
845
|
+
logger.warning(f"Session {session_id} is already being processed")
|
|
846
|
+
return "Session is already executing"
|
|
847
|
+
else:
|
|
848
|
+
logger.warning(f"Cannot run session {session_id} in status: {current_status}")
|
|
849
|
+
return f"Cannot run session in status: {current_status}"
|
|
850
|
+
|
|
851
|
+
# EXCLUSIVE EXECUTION ACQUIRED
|
|
852
|
+
# =============================
|
|
853
|
+
# Protected by:
|
|
854
|
+
# ✅ Execution lock (PRIMARY - held throughout this method)
|
|
855
|
+
# ✅ Status transition (SECONDARY - validated state machine)
|
|
856
|
+
#
|
|
857
|
+
# Update activity time
|
|
858
|
+
self._session_last_activity[session_id] = time.time()
|
|
859
|
+
|
|
860
|
+
orchestrator = None
|
|
861
|
+
final_status = None # Track final status to pass to cleanup
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
# Get orchestrator (should already exist from create_session); recreate
|
|
865
|
+
# from disk under the shared per-session lock if it was evicted (a2).
|
|
866
|
+
orchestrator = self._registry.get(session_id)
|
|
867
|
+
if not orchestrator:
|
|
868
|
+
logger.info(f"No orchestrator in memory for {session_id}, attempting recreation")
|
|
869
|
+
orchestrator = await self._resolve_or_recreate(session_id, session_info)
|
|
870
|
+
if not orchestrator:
|
|
871
|
+
raise RuntimeError(f"Failed to recreate orchestrator for session {session_id}")
|
|
872
|
+
|
|
873
|
+
# Get request details
|
|
874
|
+
request = session_info.get('request', {})
|
|
875
|
+
task = request.get('task', '')
|
|
876
|
+
|
|
877
|
+
# Check if agent already exists (for continuous task execution)
|
|
878
|
+
# IMPORTANT: Use just the agent name, orchestrator will format the full ID
|
|
879
|
+
agent_name = "executor"
|
|
880
|
+
|
|
881
|
+
# Try to get existing agent using orchestrator's registry
|
|
882
|
+
# The orchestrator stores agents as "executor_{session_id}"
|
|
883
|
+
agent_id = f"{agent_name}_{session_id}"
|
|
884
|
+
existing_agent = orchestrator.agents.get(agent_id)
|
|
885
|
+
|
|
886
|
+
if existing_agent:
|
|
887
|
+
# ✅ REUSE: Continuous task - reuse existing agent with all history
|
|
888
|
+
logger.info(f"♻️ Reusing existing agent for continuous task execution: {agent_id}")
|
|
889
|
+
agent = existing_agent
|
|
890
|
+
|
|
891
|
+
# CRITICAL FIX: Reset agent state for continuation
|
|
892
|
+
# Without this, _last_result still has is_done=True from previous run,
|
|
893
|
+
# causing the agent to exit immediately without processing new messages
|
|
894
|
+
if hasattr(agent, 'reset_for_continuation'):
|
|
895
|
+
agent.reset_for_continuation()
|
|
896
|
+
else:
|
|
897
|
+
# Fallback for older agent versions
|
|
898
|
+
logger.warning(f"Agent {agent_id} missing reset_for_continuation, manual reset")
|
|
899
|
+
agent._last_result = None
|
|
900
|
+
agent._cancelled = False
|
|
901
|
+
if hasattr(agent, 'state'):
|
|
902
|
+
agent.state.stopped = False
|
|
903
|
+
agent.state.done = False
|
|
904
|
+
|
|
905
|
+
# Log context for debugging
|
|
906
|
+
if hasattr(agent, 'message_manager') and hasattr(agent.message_manager, 'history'):
|
|
907
|
+
messages_count = len(agent.message_manager.history.messages)
|
|
908
|
+
logger.info(f"Agent has {messages_count} messages in history")
|
|
909
|
+
else:
|
|
910
|
+
# ✅ CREATE: First task in session
|
|
911
|
+
logger.info(f"🆕 Creating new agent for session {session_id}")
|
|
912
|
+
|
|
913
|
+
# Get LLM - simplified approach
|
|
914
|
+
llm = await self._get_llm_for_request(request)
|
|
915
|
+
|
|
916
|
+
agent = await orchestrator.create_agent(
|
|
917
|
+
task=task,
|
|
918
|
+
llm=llm,
|
|
919
|
+
agent_name=agent_name, # ← Use simple name, orchestrator adds session_id
|
|
920
|
+
use_vision=request.get('use_vision', True),
|
|
921
|
+
max_actions_per_step=10,
|
|
922
|
+
session_config=request.get('session_config')
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# Execute - orchestrator handles everything
|
|
926
|
+
results = await orchestrator.execute_session(
|
|
927
|
+
agent_sequence=[agent.agent_id],
|
|
928
|
+
max_steps_per_agent={
|
|
929
|
+
agent.agent_id: request.get('max_steps', 100)
|
|
930
|
+
}
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Check results and set status ONCE
|
|
934
|
+
agent_result = results.get(agent.agent_id, {})
|
|
935
|
+
result_status = agent_result.get('status', 'error')
|
|
936
|
+
|
|
937
|
+
if result_status == 'completed':
|
|
938
|
+
final_status = 'completed'
|
|
939
|
+
self.session_manager.update_session_status(session_id, final_status)
|
|
940
|
+
return "Session completed successfully"
|
|
941
|
+
elif result_status == 'stopped':
|
|
942
|
+
# Agent was explicitly stopped/cancelled by user
|
|
943
|
+
final_status = 'cancelled'
|
|
944
|
+
self.session_manager.update_session_status(session_id, final_status)
|
|
945
|
+
return "Session cancelled by user"
|
|
946
|
+
else:
|
|
947
|
+
# Error or unknown status
|
|
948
|
+
final_status = 'failed'
|
|
949
|
+
error_msg = agent_result.get('error', 'Unknown error')
|
|
950
|
+
self.session_manager.update_session_status(session_id, final_status)
|
|
951
|
+
return f"Session failed: {error_msg}"
|
|
952
|
+
|
|
953
|
+
except InsufficientCreditsError as e:
|
|
954
|
+
# Special handling for billing errors - suspend rather than fail
|
|
955
|
+
logger.warning(f"Session {session_id} suspended due to insufficient credits: {e}")
|
|
956
|
+
|
|
957
|
+
final_status = 'suspended'
|
|
958
|
+
self._session_last_activity[session_id] = time.time()
|
|
959
|
+
|
|
960
|
+
# Update status and metadata with user-friendly message
|
|
961
|
+
self.session_manager.update_session_status(session_id, final_status)
|
|
962
|
+
self.session_manager.update_session_metadata(session_id, {
|
|
963
|
+
'error': str(e),
|
|
964
|
+
'error_time': datetime.now().isoformat(),
|
|
965
|
+
'error_type': 'InsufficientCreditsError',
|
|
966
|
+
'suspension_reason': 'insufficient_credits',
|
|
967
|
+
'credits_required': e.required,
|
|
968
|
+
'credits_available': e.available,
|
|
969
|
+
'resume_instructions': 'Add credits at /api/payments/deposit to resume'
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
# Don't re-raise - return gracefully with suspension message
|
|
973
|
+
return f"Session suspended: {str(e)}. Add credits to resume."
|
|
974
|
+
|
|
975
|
+
except Exception as e:
|
|
976
|
+
logger.error(f"Session {session_id} failed: {e}", exc_info=True)
|
|
977
|
+
|
|
978
|
+
# Set final status to 'failed' (matches SessionStatus enum)
|
|
979
|
+
final_status = 'failed'
|
|
980
|
+
|
|
981
|
+
# Update activity time even on error
|
|
982
|
+
self._session_last_activity[session_id] = time.time()
|
|
983
|
+
|
|
984
|
+
# Update status and metadata
|
|
985
|
+
self.session_manager.update_session_status(session_id, final_status)
|
|
986
|
+
self.session_manager.update_session_metadata(session_id, {
|
|
987
|
+
'error': str(e),
|
|
988
|
+
'error_time': datetime.now().isoformat(),
|
|
989
|
+
'error_type': type(e).__name__
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
# Re-raise for proper error handling
|
|
993
|
+
raise
|
|
994
|
+
finally:
|
|
995
|
+
# NOTE: For continuous chat, we keep the orchestrator and agent alive
|
|
996
|
+
# but release browser contexts to free resources (can be reacquired on next run)
|
|
997
|
+
if orchestrator:
|
|
998
|
+
try:
|
|
999
|
+
# STEP 1: Save message history BEFORE cleanup (BUG FIX #2)
|
|
1000
|
+
# This ensures conversation context is preserved for continuous chat
|
|
1001
|
+
try:
|
|
1002
|
+
for agent in orchestrator.agents.values():
|
|
1003
|
+
if hasattr(agent, 'message_manager') and agent.message_manager:
|
|
1004
|
+
try:
|
|
1005
|
+
agent.message_manager.save_to_disk(
|
|
1006
|
+
session_id=session_id,
|
|
1007
|
+
user_id=orchestrator.user_id
|
|
1008
|
+
)
|
|
1009
|
+
logger.info(f"💾 Saved message history for agent {agent.agent_id}")
|
|
1010
|
+
except Exception as e:
|
|
1011
|
+
logger.error(f"Failed to save message history for agent {agent.agent_id}: {e}")
|
|
1012
|
+
except Exception as e:
|
|
1013
|
+
logger.error(f"Failed to save message histories during completion: {e}")
|
|
1014
|
+
|
|
1015
|
+
# STEP 2: Release browser contexts but PRESERVE agents for continuous chat
|
|
1016
|
+
# Pass the final_status to cleanup to prevent overwriting
|
|
1017
|
+
await orchestrator.cleanup(
|
|
1018
|
+
preserve_workspace=True,
|
|
1019
|
+
preserve_agents=True, # Keep agents in registry for continuous chat
|
|
1020
|
+
status=final_status or 'completed', # Use determined status or default
|
|
1021
|
+
full_cleanup=False # Keep for continuous chat
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# Verify agent persistence
|
|
1025
|
+
agent_ids = list(orchestrator.agents.keys())
|
|
1026
|
+
if agent_ids:
|
|
1027
|
+
logger.info(
|
|
1028
|
+
f"Session {session_id} finished with status '{final_status}'. "
|
|
1029
|
+
f"Orchestrator kept alive with {len(agent_ids)} agent(s) for continuous chat"
|
|
1030
|
+
)
|
|
1031
|
+
else:
|
|
1032
|
+
logger.warning(
|
|
1033
|
+
f"Session {session_id} finished but no agents in registry. "
|
|
1034
|
+
f"Next message may trigger agent recreation."
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
# Update activity time after completion
|
|
1038
|
+
self._session_last_activity[session_id] = time.time()
|
|
1039
|
+
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
logger.error(f"Error in cleanup for {session_id}: {e}")
|
|
1042
|
+
|
|
1043
|
+
async def _get_llm_for_request(self, request: Dict[str, Any]):
|
|
1044
|
+
"""Get LLM using canonical LLMManager.get_chat_model() method.
|
|
1045
|
+
|
|
1046
|
+
Uses the existing LLMManager utility which handles:
|
|
1047
|
+
- Client lookup and initialization
|
|
1048
|
+
- Token limits from model_registry
|
|
1049
|
+
- Native chat model creation
|
|
1050
|
+
|
|
1051
|
+
If the requested provider is unavailable, automatically falls back
|
|
1052
|
+
to the next available provider in the fallback hierarchy.
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
request: Request dictionary with provider, model, temperature
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
Native BaseChatModel instance
|
|
1059
|
+
|
|
1060
|
+
Raises:
|
|
1061
|
+
RuntimeError: If LLMManager not available or ALL providers failed
|
|
1062
|
+
"""
|
|
1063
|
+
# Get LLM manager from container (single source of truth)
|
|
1064
|
+
llm_manager = self.container.get_service('llm')
|
|
1065
|
+
if not llm_manager:
|
|
1066
|
+
raise RuntimeError("LLMManager not available in container")
|
|
1067
|
+
|
|
1068
|
+
provider = request.get('provider', 'openai')
|
|
1069
|
+
model = request.get('model', 'gpt-5')
|
|
1070
|
+
temperature = request.get('temperature', 0.0)
|
|
1071
|
+
|
|
1072
|
+
# Try the requested provider first
|
|
1073
|
+
try:
|
|
1074
|
+
return await llm_manager.get_chat_model(
|
|
1075
|
+
provider=provider,
|
|
1076
|
+
model=model,
|
|
1077
|
+
temperature=temperature
|
|
1078
|
+
)
|
|
1079
|
+
except ValueError as e:
|
|
1080
|
+
# Provider not available - try fallback
|
|
1081
|
+
logger.warning(
|
|
1082
|
+
f"⚠️ Requested provider '{provider}' unavailable: {e}. "
|
|
1083
|
+
f"Attempting automatic fallback..."
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# Use the fallback method to get next available provider
|
|
1087
|
+
fallback_llm = await llm_manager.get_fallback_chat_model(
|
|
1088
|
+
exclude_providers=[provider],
|
|
1089
|
+
original_model=model,
|
|
1090
|
+
temperature=temperature
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
if fallback_llm:
|
|
1094
|
+
fallback_model = getattr(fallback_llm, 'model_name', 'unknown')
|
|
1095
|
+
logger.info(f"✅ Using fallback LLM: {fallback_model}")
|
|
1096
|
+
return fallback_llm
|
|
1097
|
+
else:
|
|
1098
|
+
logger.error(f"❌ No fallback LLM available after {provider} failed")
|
|
1099
|
+
raise RuntimeError(
|
|
1100
|
+
f"LLM provider '{provider}' is not available and no fallback providers "
|
|
1101
|
+
f"could be initialized. Please check your API keys and configuration."
|
|
1102
|
+
)
|
|
1103
|
+
except Exception as e:
|
|
1104
|
+
logger.error(f"Failed to get LLM for request: {e}", exc_info=True)
|
|
1105
|
+
raise
|
|
1106
|
+
|
|
1107
|
+
async def get_session_status(
|
|
1108
|
+
self,
|
|
1109
|
+
user_id: str,
|
|
1110
|
+
session_id: Optional[str] = None
|
|
1111
|
+
) -> Dict[str, Any]:
|
|
1112
|
+
"""Get session status.
|
|
1113
|
+
|
|
1114
|
+
Args:
|
|
1115
|
+
user_id: User ID
|
|
1116
|
+
session_id: Optional specific session
|
|
1117
|
+
|
|
1118
|
+
Returns:
|
|
1119
|
+
Status dictionary
|
|
1120
|
+
"""
|
|
1121
|
+
if not self.task_available:
|
|
1122
|
+
return {
|
|
1123
|
+
'found': False,
|
|
1124
|
+
'message': 'Task package not available'
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if session_id:
|
|
1128
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
1129
|
+
if session_info and session_info.get('user_id') == user_id:
|
|
1130
|
+
return {
|
|
1131
|
+
'found': True,
|
|
1132
|
+
'id': session_id,
|
|
1133
|
+
'status': session_info.get('status'),
|
|
1134
|
+
'task': session_info.get('task'),
|
|
1135
|
+
'created_at': session_info.get('created_at')
|
|
1136
|
+
}
|
|
1137
|
+
else:
|
|
1138
|
+
# Get latest session
|
|
1139
|
+
sessions = self.session_manager.get_active_sessions(user_id)
|
|
1140
|
+
if sessions:
|
|
1141
|
+
return await self.get_session_status(user_id, sessions[-1])
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
'found': False,
|
|
1145
|
+
'message': 'No session found'
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async def cancel_session(
|
|
1149
|
+
self,
|
|
1150
|
+
user_id: str,
|
|
1151
|
+
force: bool = False,
|
|
1152
|
+
session_id: Optional[str] = None
|
|
1153
|
+
) -> bool:
|
|
1154
|
+
"""Cancel a session.
|
|
1155
|
+
|
|
1156
|
+
Args:
|
|
1157
|
+
user_id: User ID
|
|
1158
|
+
force: Force cancellation
|
|
1159
|
+
session_id: Session to cancel (or latest)
|
|
1160
|
+
|
|
1161
|
+
Returns:
|
|
1162
|
+
True if cancelled
|
|
1163
|
+
"""
|
|
1164
|
+
if not self.task_available:
|
|
1165
|
+
return False
|
|
1166
|
+
|
|
1167
|
+
if not session_id:
|
|
1168
|
+
sessions = self.session_manager.get_active_sessions(user_id)
|
|
1169
|
+
if not sessions:
|
|
1170
|
+
return False
|
|
1171
|
+
session_id = sessions[-1]
|
|
1172
|
+
|
|
1173
|
+
# Verify ownership
|
|
1174
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
1175
|
+
if not session_info or session_info.get('user_id') != user_id:
|
|
1176
|
+
return False
|
|
1177
|
+
|
|
1178
|
+
# FIX: Actually stop the running session by calling orchestrator.cancel()
|
|
1179
|
+
# This sets the cancellation flag on all agents, causing them to stop at the next step
|
|
1180
|
+
orchestrator = self._registry.get(session_id)
|
|
1181
|
+
if orchestrator:
|
|
1182
|
+
try:
|
|
1183
|
+
orchestrator.cancel()
|
|
1184
|
+
logger.info(f"Called cancel() on orchestrator for session {session_id}")
|
|
1185
|
+
except Exception as e:
|
|
1186
|
+
logger.error(f"Error calling orchestrator.cancel(): {e}")
|
|
1187
|
+
|
|
1188
|
+
# F3: persist the terminal status unconditionally (idempotent;
|
|
1189
|
+
# CREATED→CANCELLED is a valid transition per session.py). orchestrator
|
|
1190
|
+
# .cancel() only flips an in-memory flag — it does NOT touch the
|
|
1191
|
+
# session's persisted status — so without this an interactively-cancelled
|
|
1192
|
+
# session would leak as 'created' on disk. Flipping it here removes the
|
|
1193
|
+
# fragile two-hop persistence (the previous else-branch only updated
|
|
1194
|
+
# status when the session was NOT running).
|
|
1195
|
+
self.session_manager.update_session_status(session_id, 'cancelled')
|
|
1196
|
+
logger.debug(f"Session {session_id} status set to cancelled")
|
|
1197
|
+
|
|
1198
|
+
if force:
|
|
1199
|
+
self.session_manager.cleanup_session(session_id, delete_files=False)
|
|
1200
|
+
|
|
1201
|
+
logger.info(f"Cancelled session {session_id}")
|
|
1202
|
+
return True
|
|
1203
|
+
|
|
1204
|
+
async def pause_session(self, user_id: str, session_id: str) -> bool:
|
|
1205
|
+
"""Pause (suspend) a session — an honest status op, not a hard stop.
|
|
1206
|
+
|
|
1207
|
+
Only a RUNNING session can suspend (per session.py's transition table:
|
|
1208
|
+
RUNNING -> SUSPENDED). Returns True on a successful transition, False if the
|
|
1209
|
+
session is unknown, not owned by ``user_id``, or not in a suspendable state.
|
|
1210
|
+
Does NOT interrupt an in-flight step — use ``cancel_session`` for that.
|
|
1211
|
+
"""
|
|
1212
|
+
if not self.task_available:
|
|
1213
|
+
return False
|
|
1214
|
+
info = self.session_manager.get_session_info(session_id)
|
|
1215
|
+
if not info or info.get('user_id') != user_id:
|
|
1216
|
+
return False
|
|
1217
|
+
return self.session_manager.try_transition_status(
|
|
1218
|
+
session_id, info.get('status'), 'suspended'
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
async def resume_session(self, user_id: str, session_id: str) -> bool:
|
|
1222
|
+
"""Mark a suspended/failed/completed session resumable (-> RESUMED).
|
|
1223
|
+
|
|
1224
|
+
Flips the persisted status so the normal resume path
|
|
1225
|
+
(try_transition_status RESUMED -> RUNNING inside the run loop) will pick it
|
|
1226
|
+
up; actually continuing execution still requires ``rob run``/attach. Returns
|
|
1227
|
+
False for an unknown/foreign session or one not in a resumable state.
|
|
1228
|
+
"""
|
|
1229
|
+
if not self.task_available:
|
|
1230
|
+
return False
|
|
1231
|
+
info = self.session_manager.get_session_info(session_id)
|
|
1232
|
+
if not info or info.get('user_id') != user_id:
|
|
1233
|
+
return False
|
|
1234
|
+
cur = info.get('status')
|
|
1235
|
+
if cur not in ('suspended', 'failed', 'completed'):
|
|
1236
|
+
return False
|
|
1237
|
+
return self.session_manager.try_transition_status(session_id, cur, 'resumed')
|
|
1238
|
+
|
|
1239
|
+
async def process_user_message(
|
|
1240
|
+
self,
|
|
1241
|
+
user_id: str,
|
|
1242
|
+
input_text: str,
|
|
1243
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
1244
|
+
) -> str:
|
|
1245
|
+
"""Process user input - supports continuous chat.
|
|
1246
|
+
|
|
1247
|
+
Flow:
|
|
1248
|
+
1. Check for existing session (even if completed)
|
|
1249
|
+
2. If exists: Queue message and resume that session
|
|
1250
|
+
3. If not: Create new session
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
user_id: User ID
|
|
1254
|
+
input_text: User's message
|
|
1255
|
+
metadata: Optional metadata
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
Response message
|
|
1259
|
+
"""
|
|
1260
|
+
if not self._initialized:
|
|
1261
|
+
await self._initialize()
|
|
1262
|
+
|
|
1263
|
+
if not self.task_available:
|
|
1264
|
+
return "Task automation not available"
|
|
1265
|
+
|
|
1266
|
+
text = input_text.strip()
|
|
1267
|
+
|
|
1268
|
+
# Handle commands
|
|
1269
|
+
if text.startswith('/v2_status'):
|
|
1270
|
+
status = await self.get_session_status(user_id)
|
|
1271
|
+
if status['found']:
|
|
1272
|
+
return f"Session: {status['id']}\nStatus: {status['status']}\nTask: {status['task']}"
|
|
1273
|
+
return "No active session"
|
|
1274
|
+
|
|
1275
|
+
if text.startswith('/v2_cancel'):
|
|
1276
|
+
if await self.cancel_session(user_id, force=True):
|
|
1277
|
+
return "Session cancelled"
|
|
1278
|
+
return "No session to cancel"
|
|
1279
|
+
|
|
1280
|
+
if not text:
|
|
1281
|
+
return "Please provide a task description"
|
|
1282
|
+
|
|
1283
|
+
try:
|
|
1284
|
+
# CONTINUOUS CHAT: Check for existing session first
|
|
1285
|
+
# User might be continuing a conversation from seconds/hours/months ago
|
|
1286
|
+
existing_session_id = None
|
|
1287
|
+
# Set when an existing session could not be resumed, so the new-session
|
|
1288
|
+
# path can tell the user instead of silently pretending to continue (B2).
|
|
1289
|
+
resume_failed_id = None
|
|
1290
|
+
|
|
1291
|
+
# Try to get user's latest session (even if completed/suspended)
|
|
1292
|
+
if user_id in self.user_sessions:
|
|
1293
|
+
existing_session_id = self.user_sessions[user_id]
|
|
1294
|
+
else:
|
|
1295
|
+
# Check SessionManager for any sessions for this user
|
|
1296
|
+
all_sessions = self.session_manager._sessions
|
|
1297
|
+
user_session_ids = [sid for sid, info in all_sessions.items()
|
|
1298
|
+
if info.get('user_id') == user_id]
|
|
1299
|
+
if user_session_ids:
|
|
1300
|
+
# Get most recent
|
|
1301
|
+
existing_session_id = user_session_ids[-1]
|
|
1302
|
+
|
|
1303
|
+
# If existing session found, queue message and resume
|
|
1304
|
+
if existing_session_id:
|
|
1305
|
+
session_info = self.session_manager.get_session_info(existing_session_id)
|
|
1306
|
+
if session_info:
|
|
1307
|
+
logger.info(f"💬 Continuing session {existing_session_id} (status: {session_info.get('status')})")
|
|
1308
|
+
|
|
1309
|
+
# W1: a genuine user turn clears the self-wake re-entry budget so
|
|
1310
|
+
# the agent can again earn forged continuations on its own work.
|
|
1311
|
+
try:
|
|
1312
|
+
from agents.task.constants import AutonomyConfig
|
|
1313
|
+
if AutonomyConfig.self_wake_enabled():
|
|
1314
|
+
from agents.task.agent.core.self_wake import get_reentry_budget
|
|
1315
|
+
get_reentry_budget().reset(existing_session_id)
|
|
1316
|
+
except Exception:
|
|
1317
|
+
# Fail-open: reentry budget reset failure is non-critical
|
|
1318
|
+
pass
|
|
1319
|
+
|
|
1320
|
+
# Get or recreate orchestrator (shared per-session lock, a2)
|
|
1321
|
+
orchestrator = await self._resolve_or_recreate(existing_session_id, session_info)
|
|
1322
|
+
|
|
1323
|
+
if orchestrator:
|
|
1324
|
+
# Queue message to agent via orchestrator
|
|
1325
|
+
try:
|
|
1326
|
+
await orchestrator.submit_user_message(
|
|
1327
|
+
agent_id=None, # Routes to first agent
|
|
1328
|
+
text=text,
|
|
1329
|
+
kind="continuation",
|
|
1330
|
+
metadata=metadata or {}
|
|
1331
|
+
)
|
|
1332
|
+
logger.info(f"✅ Queued message to session {existing_session_id}")
|
|
1333
|
+
except Exception as e:
|
|
1334
|
+
logger.error(f"Failed to queue message: {e}")
|
|
1335
|
+
|
|
1336
|
+
# Resume session execution
|
|
1337
|
+
asyncio.create_task(self.run_session(user_id, existing_session_id))
|
|
1338
|
+
|
|
1339
|
+
return f"💬 Message sent to existing session\nSession: {existing_session_id}"
|
|
1340
|
+
else:
|
|
1341
|
+
# Could not resume (orchestrator missing AND recreation failed).
|
|
1342
|
+
# Fresh-start-with-notice: rather than silently spawn a duplicate,
|
|
1343
|
+
# retire the un-recreatable session from in-memory tracking so it
|
|
1344
|
+
# stops leaking the per-user limit and isn't re-selected next
|
|
1345
|
+
# message — history stays on disk (delete_files=False, recoverable
|
|
1346
|
+
# by id) — then fall through to create a new session and tell the
|
|
1347
|
+
# user what happened.
|
|
1348
|
+
logger.warning(
|
|
1349
|
+
f"Could not resume session {existing_session_id} "
|
|
1350
|
+
f"(orchestrator recreation failed); starting a new session"
|
|
1351
|
+
)
|
|
1352
|
+
resume_failed_id = existing_session_id
|
|
1353
|
+
self.session_manager.cleanup_session(existing_session_id, delete_files=False)
|
|
1354
|
+
self._session_last_activity.pop(existing_session_id, None)
|
|
1355
|
+
self._session_execution_locks.pop(existing_session_id, None)
|
|
1356
|
+
|
|
1357
|
+
# No existing session or failed to resume - create new session
|
|
1358
|
+
logger.info(f"🆕 Creating new session for user {user_id}")
|
|
1359
|
+
session = await self.create_session(user_id, text)
|
|
1360
|
+
|
|
1361
|
+
# Run in background
|
|
1362
|
+
asyncio.create_task(self.run_session(user_id, session['id']))
|
|
1363
|
+
|
|
1364
|
+
if resume_failed_id:
|
|
1365
|
+
return (
|
|
1366
|
+
f"⚠️ Couldn't resume your previous session ({resume_failed_id[:8]}…), "
|
|
1367
|
+
f"so I started a new one.\nSession: {session['id']}"
|
|
1368
|
+
)
|
|
1369
|
+
return f"🤖 Task started: {text[:100]}...\nSession: {session['id']}"
|
|
1370
|
+
|
|
1371
|
+
except Exception as e:
|
|
1372
|
+
logger.error(f"Failed to process message: {e}")
|
|
1373
|
+
return f"Error: {str(e)}"
|
|
1374
|
+
|
|
1375
|
+
# ------------------------------------------------------------------
|
|
1376
|
+
# S2 (chat consolidation): synchronous chat adapter
|
|
1377
|
+
# ------------------------------------------------------------------
|
|
1378
|
+
@staticmethod
|
|
1379
|
+
def _chat_key(user_id: str, chat_id: Optional[str]) -> str:
|
|
1380
|
+
"""Durable conversation key. Falls back to user_id when no chat_id is
|
|
1381
|
+
given, mirroring the legacy ChatAgent's one-thread-per-user behavior."""
|
|
1382
|
+
return f"chat:{user_id}:{chat_id or user_id}"
|
|
1383
|
+
|
|
1384
|
+
def _chat_tool_ids(self) -> List[str]:
|
|
1385
|
+
from agents.task.constants import CHAT_TOOL_IDS
|
|
1386
|
+
return list(CHAT_TOOL_IDS)
|
|
1387
|
+
|
|
1388
|
+
async def _resolve_chat_persona(self) -> str:
|
|
1389
|
+
"""Resolve the persona via the surface-shared resolver (T1-07): explicit
|
|
1390
|
+
POLYROB_PERSONA (template key or literal) > default character > "".
|
|
1391
|
+
Fail-open to "" (off-path byte-identical)."""
|
|
1392
|
+
try:
|
|
1393
|
+
from agents.personality.persona_resolver import resolve_persona
|
|
1394
|
+
return await resolve_persona(container=self.container)
|
|
1395
|
+
except Exception as e:
|
|
1396
|
+
logger.debug(f"chat persona resolve skipped: {e}")
|
|
1397
|
+
return ""
|
|
1398
|
+
|
|
1399
|
+
@staticmethod
|
|
1400
|
+
def _looks_like_brain_state(text) -> bool:
|
|
1401
|
+
"""True when *text* is agent brain-state telemetry, not a chat reply.
|
|
1402
|
+
|
|
1403
|
+
Mirrors cli.ui.dialog.is_brain_state WITHOUT importing the CLI layer
|
|
1404
|
+
(agents must not depend on cli). Recognises the JSON shapes
|
|
1405
|
+
({"current_state": {...}} and >=2 bare brain keys, tolerating a leading
|
|
1406
|
+
```json fence) and the Memory:/Next: text echo. Live-caught on GLM, whose
|
|
1407
|
+
raw brain-JSON content can be the last AIMessage in history.
|
|
1408
|
+
"""
|
|
1409
|
+
if not text:
|
|
1410
|
+
return False
|
|
1411
|
+
t = str(text).strip()
|
|
1412
|
+
# strip a single enclosing markdown code fence (DeepSeek JSON-fallback path)
|
|
1413
|
+
if t.startswith("```") or t.startswith("~~~"):
|
|
1414
|
+
m = re.match(r"^\s*(?:```|~~~)[^\n]*\n(.*?)\n?(?:```|~~~)\s*$", t, re.DOTALL)
|
|
1415
|
+
if m:
|
|
1416
|
+
t = m.group(1).strip()
|
|
1417
|
+
_brain_keys = {"current_state", "next_goal", "evaluation_previous_goal",
|
|
1418
|
+
"page_summary", "memory", "reasoning", "macro_goal", "subgoal"}
|
|
1419
|
+
if t.startswith("{"):
|
|
1420
|
+
try:
|
|
1421
|
+
obj, _end = json.JSONDecoder().raw_decode(t)
|
|
1422
|
+
except (ValueError, TypeError):
|
|
1423
|
+
obj = None
|
|
1424
|
+
if isinstance(obj, dict):
|
|
1425
|
+
if isinstance(obj.get("current_state"), dict):
|
|
1426
|
+
return True
|
|
1427
|
+
if sum(1 for k in obj if k in _brain_keys) >= 2:
|
|
1428
|
+
return True
|
|
1429
|
+
# Memory:/Next: echo form (require both so prose with a stray header isn't caught)
|
|
1430
|
+
has_mem = re.search(r"(?im)^\s*memory\s*:", t)
|
|
1431
|
+
has_next = re.search(r"(?im)^\s*next(?:[_ ]?goal)?\s*:", t)
|
|
1432
|
+
return bool(has_mem and has_next)
|
|
1433
|
+
|
|
1434
|
+
def _extract_chat_reply(self, session_id: str) -> str:
|
|
1435
|
+
"""Return the agent's ACTUAL last assistant reply for a finished turn.
|
|
1436
|
+
|
|
1437
|
+
Priority (corrected after a live GLM brain-JSON leak):
|
|
1438
|
+
1. The clean done() output — history.final_result() when is_done() — since
|
|
1439
|
+
done() adds its clean message to history BEFORE the atomic add of the
|
|
1440
|
+
model's raw content (which may be brain-state JSON), so "last AIMessage"
|
|
1441
|
+
alone would capture telemetry.
|
|
1442
|
+
2. The conversational/send path: the last NON-brain AIMessage (the real
|
|
1443
|
+
send_message text; ActionResult.extracted_content is only a placeholder).
|
|
1444
|
+
3. final_result() as a last resort.
|
|
1445
|
+
Brain-state telemetry is never returned; the generic run_session string never is.
|
|
1446
|
+
"""
|
|
1447
|
+
try:
|
|
1448
|
+
orch = self._registry.get(session_id)
|
|
1449
|
+
if not orch or not getattr(orch, 'agents', None):
|
|
1450
|
+
return ""
|
|
1451
|
+
agent = next(iter(orch.agents.values()), None)
|
|
1452
|
+
if agent is None:
|
|
1453
|
+
return ""
|
|
1454
|
+
|
|
1455
|
+
# 1) clean done() output
|
|
1456
|
+
try:
|
|
1457
|
+
hist = agent.state.history
|
|
1458
|
+
if hist.is_done():
|
|
1459
|
+
fr = hist.final_result()
|
|
1460
|
+
if fr and not self._looks_like_brain_state(fr):
|
|
1461
|
+
return str(fr).strip()
|
|
1462
|
+
except Exception:
|
|
1463
|
+
# Fail-open: done() output extraction failure is non-critical
|
|
1464
|
+
pass
|
|
1465
|
+
|
|
1466
|
+
# 2) last non-brain AIMessage (conversational/send path)
|
|
1467
|
+
mm = getattr(agent, 'message_manager', None)
|
|
1468
|
+
if mm is not None:
|
|
1469
|
+
try:
|
|
1470
|
+
from modules.llm.messages import AIMessage
|
|
1471
|
+
for managed in reversed(list(mm.history.messages)):
|
|
1472
|
+
msg = getattr(managed, 'message', None)
|
|
1473
|
+
content = getattr(msg, 'content', None)
|
|
1474
|
+
if isinstance(msg, AIMessage) and isinstance(content, str) and content.strip():
|
|
1475
|
+
text = content.strip()
|
|
1476
|
+
prefix = "✅ Task Complete\n\n"
|
|
1477
|
+
if text.startswith(prefix):
|
|
1478
|
+
text = text[len(prefix):].strip()
|
|
1479
|
+
if text and not self._looks_like_brain_state(text):
|
|
1480
|
+
return text
|
|
1481
|
+
except Exception as e:
|
|
1482
|
+
logger.debug(f"chat reply scan failed: {e}")
|
|
1483
|
+
|
|
1484
|
+
# 3) last-resort fallback
|
|
1485
|
+
try:
|
|
1486
|
+
fr = agent.state.history.final_result()
|
|
1487
|
+
if fr and not self._looks_like_brain_state(fr):
|
|
1488
|
+
return str(fr).strip()
|
|
1489
|
+
except Exception:
|
|
1490
|
+
# Fail-open: history scan failure is non-critical, try next fallback
|
|
1491
|
+
pass
|
|
1492
|
+
except Exception as e:
|
|
1493
|
+
logger.debug(f"_extract_chat_reply failed: {e}")
|
|
1494
|
+
return ""
|
|
1495
|
+
|
|
1496
|
+
async def chat_once(
|
|
1497
|
+
self,
|
|
1498
|
+
user_id: str,
|
|
1499
|
+
text: str,
|
|
1500
|
+
chat_id: Optional[str] = None,
|
|
1501
|
+
provider: Optional[str] = None,
|
|
1502
|
+
model: Optional[str] = None,
|
|
1503
|
+
) -> str:
|
|
1504
|
+
"""Run ONE synchronous chat turn on the unified task agent and return the
|
|
1505
|
+
assistant's reply text.
|
|
1506
|
+
|
|
1507
|
+
This is the synchronous counterpart to the fire-and-forget
|
|
1508
|
+
process_user_message: it awaits run_session and returns the real reply
|
|
1509
|
+
(not the "Task started…" ack, not run_session's generic completion
|
|
1510
|
+
string). It reuses a durable session keyed by (user_id, chat_id) so a
|
|
1511
|
+
follow-up continues the same conversation, and runs tool-light.
|
|
1512
|
+
|
|
1513
|
+
`provider`/`model` (B3) are an optional PER-REQUEST model override —
|
|
1514
|
+
e.g. from the OpenAI-compat `/v1/chat/completions` `body.model` field
|
|
1515
|
+
via `map_model()`. When set, they steer this turn's model (and, since
|
|
1516
|
+
chat sessions are durable, every subsequent turn of this (user_id,
|
|
1517
|
+
chat_id) session until overridden again). See `_chat_once_locked` for
|
|
1518
|
+
how the override is applied (live swap_model for a reused session;
|
|
1519
|
+
baked into the SessionRequest for a brand-new one).
|
|
1520
|
+
"""
|
|
1521
|
+
if not self._initialized:
|
|
1522
|
+
await self._initialize()
|
|
1523
|
+
if not self.task_available:
|
|
1524
|
+
return "Task automation not available"
|
|
1525
|
+
|
|
1526
|
+
text = (text or "").strip()
|
|
1527
|
+
if not text:
|
|
1528
|
+
return "Please provide a message"
|
|
1529
|
+
|
|
1530
|
+
key = self._chat_key(user_id, chat_id)
|
|
1531
|
+
locks = getattr(self, "_chat_locks", None)
|
|
1532
|
+
if locks is None:
|
|
1533
|
+
locks = {}
|
|
1534
|
+
self._chat_locks = locks
|
|
1535
|
+
lock = locks.get(key) or locks.setdefault(key, asyncio.Lock())
|
|
1536
|
+
try:
|
|
1537
|
+
async with lock:
|
|
1538
|
+
result = await self._chat_once_locked(
|
|
1539
|
+
user_id, text, key, provider=provider, model=model,
|
|
1540
|
+
)
|
|
1541
|
+
finally:
|
|
1542
|
+
# Evict the lock only if this turn left no active session mapping
|
|
1543
|
+
# for `key` (e.g. session creation raised or failed) -- a
|
|
1544
|
+
# successful turn always re-populates self._chat_sessions[key], so
|
|
1545
|
+
# this is a no-op on the common path. Safe without extra locking:
|
|
1546
|
+
# no `await` sits between the membership check and the pop, so
|
|
1547
|
+
# asyncio cannot interleave another task's get-or-create for the
|
|
1548
|
+
# same key in between (task switches only happen at await points).
|
|
1549
|
+
if key not in self._chat_sessions:
|
|
1550
|
+
locks.pop(key, None)
|
|
1551
|
+
return result
|
|
1552
|
+
|
|
1553
|
+
async def _maybe_swap_chat_model(
|
|
1554
|
+
self, orch, provider: Optional[str], model: str,
|
|
1555
|
+
) -> None:
|
|
1556
|
+
"""Apply a B3 per-request model override to a REUSED session's live
|
|
1557
|
+
agent, idempotently.
|
|
1558
|
+
|
|
1559
|
+
Uses the same accessor `_extract_chat_reply` uses (`orch.agents` is a
|
|
1560
|
+
dict; chat sessions run a single agent, so the first value is it). A
|
|
1561
|
+
no-op when no live agent exists yet (e.g. mid-creation) or when the
|
|
1562
|
+
requested (provider, model) already matches the agent's current
|
|
1563
|
+
SSOT (`model_name`/`llm_provider`) — a falsy `provider` never counts
|
|
1564
|
+
as "different", it means swap_model should auto-detect. This guard
|
|
1565
|
+
is what stops an unchanged `body.model` from rebuilding the LLM on
|
|
1566
|
+
every single request. A failed swap is logged and the turn proceeds
|
|
1567
|
+
on the agent's current (unchanged) model.
|
|
1568
|
+
"""
|
|
1569
|
+
agent = next(iter(orch.agents.values()), None) if getattr(orch, "agents", None) else None
|
|
1570
|
+
if agent is None or not hasattr(agent, "swap_model"):
|
|
1571
|
+
return
|
|
1572
|
+
same_model = getattr(agent, "model_name", None) == model
|
|
1573
|
+
# A never-swapped agent never has `.llm_provider` set to the request's
|
|
1574
|
+
# provider label the way swap_model would — it may be None (older builds)
|
|
1575
|
+
# or only the Agent-level mirror. Fall back to the MessageManager-backed
|
|
1576
|
+
# `provider_name` SSOT so an unchanged model doesn't rebuild the LLM on
|
|
1577
|
+
# every request (B3 idempotence). A falsy request provider never counts
|
|
1578
|
+
# as "different" (auto-detect).
|
|
1579
|
+
current_provider = (
|
|
1580
|
+
getattr(agent, "llm_provider", None) or getattr(agent, "provider_name", None)
|
|
1581
|
+
)
|
|
1582
|
+
same_provider = (not provider) or current_provider == provider
|
|
1583
|
+
if same_model and same_provider:
|
|
1584
|
+
return
|
|
1585
|
+
# A failed swap must warn + proceed on the current model — an exception
|
|
1586
|
+
# from swap_model (e.g. a provider build blowing up) must never 500 the
|
|
1587
|
+
# /v1 request (the plan's contract: failed swap = warn + proceed).
|
|
1588
|
+
try:
|
|
1589
|
+
res = await agent.swap_model(provider, model)
|
|
1590
|
+
except Exception as e:
|
|
1591
|
+
logger.warning(
|
|
1592
|
+
f"per-request model swap raised: {e} — "
|
|
1593
|
+
f"turn continues on {getattr(agent, 'model_name', '?')}"
|
|
1594
|
+
)
|
|
1595
|
+
return
|
|
1596
|
+
if not res.get("ok"):
|
|
1597
|
+
logger.warning(
|
|
1598
|
+
f"per-request model swap failed: {res.get('error')} — "
|
|
1599
|
+
f"turn continues on {getattr(agent, 'model_name', '?')}"
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
async def _chat_once_locked(
|
|
1603
|
+
self,
|
|
1604
|
+
user_id: str,
|
|
1605
|
+
text: str,
|
|
1606
|
+
key: str,
|
|
1607
|
+
provider: Optional[str] = None,
|
|
1608
|
+
model: Optional[str] = None,
|
|
1609
|
+
) -> str:
|
|
1610
|
+
"""The body of chat_once, run under the per-chat-key lock (see chat_once)."""
|
|
1611
|
+
session_id = self._chat_sessions.get(key)
|
|
1612
|
+
|
|
1613
|
+
# Validate a remembered session still exists; else drop and recreate.
|
|
1614
|
+
if session_id and not self.session_manager.get_session_info(session_id):
|
|
1615
|
+
self._chat_sessions.pop(key, None)
|
|
1616
|
+
session_id = None
|
|
1617
|
+
|
|
1618
|
+
# C1: expand @file/@folder/@diff/@url references (opt-in, fail-soft).
|
|
1619
|
+
# Use the session workspace when available; fall back to CWD for new sessions.
|
|
1620
|
+
try:
|
|
1621
|
+
from agents.task.constants import AutonomyConfig
|
|
1622
|
+
if AutonomyConfig.context_references_enabled():
|
|
1623
|
+
from agents.task.agent.messages.context_references import (
|
|
1624
|
+
preprocess_context_references,
|
|
1625
|
+
)
|
|
1626
|
+
from agents.task.path import pm
|
|
1627
|
+
import os as _os
|
|
1628
|
+
_root = str(pm().get_workspace_dir(session_id, user_id)) if session_id else _os.getcwd()
|
|
1629
|
+
text = preprocess_context_references(
|
|
1630
|
+
text, root=_root, confine_to_root=True, allow_filesystem=True
|
|
1631
|
+
)
|
|
1632
|
+
except Exception:
|
|
1633
|
+
pass # fail-soft: leave text unchanged
|
|
1634
|
+
|
|
1635
|
+
persona = await self._resolve_chat_persona()
|
|
1636
|
+
|
|
1637
|
+
if session_id:
|
|
1638
|
+
# Reuse: queue a continuation and resume (await, unlike the
|
|
1639
|
+
# background process_user_message path).
|
|
1640
|
+
orch = self._registry.get(session_id)
|
|
1641
|
+
if not orch:
|
|
1642
|
+
info = self.session_manager.get_session_info(session_id)
|
|
1643
|
+
orch = await self._resolve_or_recreate(session_id, info) if info else None
|
|
1644
|
+
if orch:
|
|
1645
|
+
if persona:
|
|
1646
|
+
orch._persona_block = persona
|
|
1647
|
+
if model:
|
|
1648
|
+
await self._maybe_swap_chat_model(orch, provider, model)
|
|
1649
|
+
try:
|
|
1650
|
+
await orch.submit_user_message(
|
|
1651
|
+
agent_id=None, text=text, kind="continuation",
|
|
1652
|
+
)
|
|
1653
|
+
except Exception as e:
|
|
1654
|
+
logger.warning(f"chat_once: failed to queue continuation: {e}")
|
|
1655
|
+
else:
|
|
1656
|
+
# Un-recreatable — drop the mapping and fall through to create.
|
|
1657
|
+
self._chat_sessions.pop(key, None)
|
|
1658
|
+
session_id = None
|
|
1659
|
+
|
|
1660
|
+
if not session_id:
|
|
1661
|
+
from agents.task.constants import CHAT_MAX_STEPS
|
|
1662
|
+
# Provider/model via the one shared resolver (Seam 2): CHAT_/DEFAULT_ env
|
|
1663
|
+
# pins win; else the historical openai/gpt-5 default if OpenAI is keyed;
|
|
1664
|
+
# else the first keyed provider (fixes only-one-key chat). Model always
|
|
1665
|
+
# matches the resolved provider.
|
|
1666
|
+
_provider, _model = _resolve_chat_runtime()
|
|
1667
|
+
# B3: a per-request model override for a BRAND-NEW session has no
|
|
1668
|
+
# live agent yet to swap_model() (that's created later, inside
|
|
1669
|
+
# run_session) — bake it into the SessionRequest instead, the
|
|
1670
|
+
# same knob CHAT_PROVIDER/CHAT_MODEL already use above.
|
|
1671
|
+
if model:
|
|
1672
|
+
_model = model
|
|
1673
|
+
_provider = provider or _provider
|
|
1674
|
+
req = SessionRequest(
|
|
1675
|
+
task=text,
|
|
1676
|
+
tools=self._chat_tool_ids(),
|
|
1677
|
+
max_steps=CHAT_MAX_STEPS,
|
|
1678
|
+
use_vision=False,
|
|
1679
|
+
provider=_provider,
|
|
1680
|
+
model=_model,
|
|
1681
|
+
)
|
|
1682
|
+
from agents.task.constants import CHAT_SKIP_CREDIT_CHECK
|
|
1683
|
+
info = await self.create_session(
|
|
1684
|
+
user_id, req, chat_session_key=key,
|
|
1685
|
+
skip_credit_check=CHAT_SKIP_CREDIT_CHECK,
|
|
1686
|
+
)
|
|
1687
|
+
session_id = info['id']
|
|
1688
|
+
self._chat_sessions[key] = session_id
|
|
1689
|
+
orch = self._registry.get(session_id)
|
|
1690
|
+
if orch and persona:
|
|
1691
|
+
orch._persona_block = persona
|
|
1692
|
+
|
|
1693
|
+
await self.run_session(user_id, session_id)
|
|
1694
|
+
return self._extract_chat_reply(session_id) or ""
|
|
1695
|
+
|
|
1696
|
+
async def deliver_self_wake(
|
|
1697
|
+
self,
|
|
1698
|
+
session_id: str,
|
|
1699
|
+
user_id: str,
|
|
1700
|
+
text: str,
|
|
1701
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1702
|
+
) -> bool:
|
|
1703
|
+
"""Forge a fresh internal turn for ``session_id`` (W1 self-wake rail).
|
|
1704
|
+
|
|
1705
|
+
The public producer seam for non-user re-entry: a finished goal run, cron job,
|
|
1706
|
+
or future background producer calls this to re-enter a session that has gone
|
|
1707
|
+
idle (its loop ended on done()/conversational-exit). Reuses UP-12's proven
|
|
1708
|
+
ingress — ``submit_user_message`` → HITL queue → run-loop drain — rather than a
|
|
1709
|
+
parallel queue, then kicks the loop via ``run_session`` (the forge-fresh-run
|
|
1710
|
+
UP-12 lacks: it only re-enters an already-running loop).
|
|
1711
|
+
|
|
1712
|
+
Safety (all enforced here):
|
|
1713
|
+
* gated ``SELF_WAKE_ENABLED`` (default OFF → returns False, no-op);
|
|
1714
|
+
* ``ReentryBudget`` depth + idle-backoff cap (prevents ping-pong storms);
|
|
1715
|
+
* resident-or-recreatable only (in-process; never claims a remote session —
|
|
1716
|
+
an unrecoverable session is dropped + audit-logged, the honest behaviour);
|
|
1717
|
+
* forged text framed as UP-06 untrusted DATA + tagged ``kind="self_wake"``.
|
|
1718
|
+
|
|
1719
|
+
Returns True iff a forged turn was dispatched.
|
|
1720
|
+
"""
|
|
1721
|
+
from agents.task.constants import AutonomyConfig
|
|
1722
|
+
if not AutonomyConfig.self_wake_enabled():
|
|
1723
|
+
return False
|
|
1724
|
+
|
|
1725
|
+
from agents.task.agent.core.self_wake import (
|
|
1726
|
+
get_reentry_budget, format_self_wake, SELF_WAKE_KIND,
|
|
1727
|
+
)
|
|
1728
|
+
def _wake_ev(outcome: str, reason: Optional[str] = None) -> None:
|
|
1729
|
+
# Self-wake was entirely invisible (no log/telemetry): fired vs
|
|
1730
|
+
# skipped vs dropped collapsed to a silent return. Emit each outcome
|
|
1731
|
+
# to the durable event log (fail-open).
|
|
1732
|
+
try:
|
|
1733
|
+
from agents.task.telemetry.event_log import get_event_log, event_log_enabled
|
|
1734
|
+
if event_log_enabled():
|
|
1735
|
+
get_event_log().record(
|
|
1736
|
+
"self_wake", user_id=user_id, session_id=session_id,
|
|
1737
|
+
source="self_wake", outcome=outcome, reason=reason)
|
|
1738
|
+
except Exception:
|
|
1739
|
+
pass
|
|
1740
|
+
|
|
1741
|
+
budget = get_reentry_budget()
|
|
1742
|
+
# Atomically consume the slot up-front (see ReentryBudget.try_consume) so two
|
|
1743
|
+
# concurrent producers can't both pass the check and exceed the cap. A slot
|
|
1744
|
+
# consumed on a subsequently-failed dispatch errs toward FEWER wakes — the
|
|
1745
|
+
# safe direction for a runaway guard.
|
|
1746
|
+
if not budget.try_consume(session_id):
|
|
1747
|
+
logger.info(f"🛌 self-wake budget exhausted for session {session_id} — dropping")
|
|
1748
|
+
_wake_ev("skipped", "budget_exhausted")
|
|
1749
|
+
return False
|
|
1750
|
+
|
|
1751
|
+
try:
|
|
1752
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
1753
|
+
if not session_info:
|
|
1754
|
+
logger.warning(f"self-wake: session {session_id} not found — dropping (audit)")
|
|
1755
|
+
_wake_ev("dropped", "session_not_found")
|
|
1756
|
+
return False
|
|
1757
|
+
|
|
1758
|
+
orchestrator = await self._resolve_or_recreate(session_id, session_info)
|
|
1759
|
+
if not orchestrator:
|
|
1760
|
+
logger.warning(
|
|
1761
|
+
f"self-wake: session {session_id} not resident and not recreatable "
|
|
1762
|
+
f"— dropping (cross-worker/expired reality, audit)"
|
|
1763
|
+
)
|
|
1764
|
+
_wake_ev("dropped", "not_resident")
|
|
1765
|
+
return False
|
|
1766
|
+
|
|
1767
|
+
meta = dict(metadata or {})
|
|
1768
|
+
meta.setdefault("source", "self_wake")
|
|
1769
|
+
await orchestrator.submit_user_message(
|
|
1770
|
+
agent_id=None,
|
|
1771
|
+
text=format_self_wake(text),
|
|
1772
|
+
kind=SELF_WAKE_KIND,
|
|
1773
|
+
metadata=meta,
|
|
1774
|
+
)
|
|
1775
|
+
# NOTE: the budget slot was already consumed atomically by try_consume()
|
|
1776
|
+
# above — do not record() again here (that would double-count).
|
|
1777
|
+
# AU-F3.1: hold a strong ref (asyncio.create_task alone only weakly
|
|
1778
|
+
# references the task — see _SELF_WAKE_TASKS docstring above).
|
|
1779
|
+
t = asyncio.create_task(self.run_session(user_id, session_id))
|
|
1780
|
+
_SELF_WAKE_TASKS.add(t)
|
|
1781
|
+
t.add_done_callback(_SELF_WAKE_TASKS.discard)
|
|
1782
|
+
logger.info(f"🛎️ self-wake dispatched to session {session_id} "
|
|
1783
|
+
f"(remaining budget {budget.remaining(session_id)})")
|
|
1784
|
+
_wake_ev("fired", None)
|
|
1785
|
+
return True
|
|
1786
|
+
except Exception as e:
|
|
1787
|
+
logger.error(f"self-wake delivery failed for {session_id}: {e}", exc_info=True)
|
|
1788
|
+
_wake_ev("error", str(e)[:200])
|
|
1789
|
+
return False
|
|
1790
|
+
|
|
1791
|
+
async def deliver_correspondent_data(
|
|
1792
|
+
self,
|
|
1793
|
+
session_id: str,
|
|
1794
|
+
source: str,
|
|
1795
|
+
text: str,
|
|
1796
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1797
|
+
) -> bool:
|
|
1798
|
+
"""WS-A: deliver a third-party correspondent reply as DATA into ``session_id``.
|
|
1799
|
+
|
|
1800
|
+
The reply is injected as a CORRESPONDENT-origin control message (untrusted-
|
|
1801
|
+
wrapped) into the session that INITIATED contact — never a steering user turn,
|
|
1802
|
+
never a new session. Resident-or-recreatable only: an unrecoverable session is
|
|
1803
|
+
dropped + audit-logged (a third party can't resurrect a dead session). The
|
|
1804
|
+
owner ``user_id`` for the re-run comes from the session's OWN metadata, never
|
|
1805
|
+
the correspondent's identity (tenant safety). Gated ``CORRESPONDENT_ACCESS_ENABLED``
|
|
1806
|
+
(default OFF → no-op). Returns True iff the data was delivered.
|
|
1807
|
+
"""
|
|
1808
|
+
from agents.task.surface_config import SurfaceConfig
|
|
1809
|
+
if not SurfaceConfig.correspondent_access_enabled():
|
|
1810
|
+
return False
|
|
1811
|
+
try:
|
|
1812
|
+
session_info = self.session_manager.get_session_info(session_id)
|
|
1813
|
+
if not session_info:
|
|
1814
|
+
logger.warning(
|
|
1815
|
+
f"correspondent delivery: session {session_id} not found — dropping (audit)")
|
|
1816
|
+
return False
|
|
1817
|
+
orchestrator = await self._resolve_or_recreate(session_id, session_info)
|
|
1818
|
+
if not orchestrator:
|
|
1819
|
+
logger.warning(
|
|
1820
|
+
f"correspondent delivery: session {session_id} not resident/recreatable "
|
|
1821
|
+
f"— dropping (audit)")
|
|
1822
|
+
return False
|
|
1823
|
+
delivered = orchestrator.inject_correspondent_message(text, source, metadata)
|
|
1824
|
+
if not delivered:
|
|
1825
|
+
logger.warning(
|
|
1826
|
+
f"correspondent delivery: no resident agent for {session_id} — dropping (audit)")
|
|
1827
|
+
return False
|
|
1828
|
+
owner_user_id = (session_info.get("user_id")
|
|
1829
|
+
if isinstance(session_info, dict)
|
|
1830
|
+
else getattr(session_info, "user_id", None))
|
|
1831
|
+
asyncio.create_task(self.run_session(owner_user_id, session_id))
|
|
1832
|
+
logger.info(f"📨 correspondent DATA from {source} delivered to session {session_id}")
|
|
1833
|
+
return True
|
|
1834
|
+
except Exception as e:
|
|
1835
|
+
logger.error(f"correspondent delivery failed for {session_id}: {e}", exc_info=True)
|
|
1836
|
+
return False
|
|
1837
|
+
|
|
1838
|
+
async def _recreate_orchestrator(
|
|
1839
|
+
self,
|
|
1840
|
+
session_id: str,
|
|
1841
|
+
session_info: Dict[str, Any]
|
|
1842
|
+
) -> Optional[Any]:
|
|
1843
|
+
"""Recreate orchestrator from saved session state (for suspended sessions).
|
|
1844
|
+
|
|
1845
|
+
Args:
|
|
1846
|
+
session_id: Session identifier
|
|
1847
|
+
session_info: Session metadata from SessionManager
|
|
1848
|
+
|
|
1849
|
+
Returns:
|
|
1850
|
+
Recreated orchestrator or None if recreation failed
|
|
1851
|
+
"""
|
|
1852
|
+
try:
|
|
1853
|
+
from agents.task.agent.orchestrator import SessionOrchestrator
|
|
1854
|
+
|
|
1855
|
+
# Get session request metadata - check multiple sources
|
|
1856
|
+
request = session_info.get('request', {})
|
|
1857
|
+
config = session_info.get('config', {})
|
|
1858
|
+
|
|
1859
|
+
# Log what we found for debugging
|
|
1860
|
+
if not request and not config:
|
|
1861
|
+
logger.error(f"No request or config metadata found for session {session_id}")
|
|
1862
|
+
logger.debug(f"Session info keys: {list(session_info.keys())}")
|
|
1863
|
+
return None
|
|
1864
|
+
|
|
1865
|
+
user_id = session_info.get('user_id')
|
|
1866
|
+
if not user_id:
|
|
1867
|
+
logger.error(f"No user_id found for session {session_id}")
|
|
1868
|
+
return None
|
|
1869
|
+
|
|
1870
|
+
# Webview streaming is optional in core mode (see create_session).
|
|
1871
|
+
stream_callback = None
|
|
1872
|
+
try:
|
|
1873
|
+
from agents.task.utils_webview import make_webview_stream_callback
|
|
1874
|
+
stream_callback = make_webview_stream_callback()
|
|
1875
|
+
except Exception as e:
|
|
1876
|
+
logger.debug(f"Webview streaming unavailable on recreate: {e}")
|
|
1877
|
+
|
|
1878
|
+
# Recreate orchestrator
|
|
1879
|
+
orchestrator = SessionOrchestrator(
|
|
1880
|
+
session_id=session_id,
|
|
1881
|
+
user_id=user_id,
|
|
1882
|
+
container=self.container,
|
|
1883
|
+
on_stream_chunk=stream_callback,
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
# #0 mute-on-resume: re-attach the outbound chat surface BEFORE initialize()
|
|
1887
|
+
# so a resumed chat's replies route back out (recreation otherwise leaves
|
|
1888
|
+
# _message_router/_chat_session_key unset → the agent answers into the void).
|
|
1889
|
+
self._rebind_recreated_chat(orchestrator, session_id, user_id)
|
|
1890
|
+
|
|
1891
|
+
# Initialize with same tools - check multiple sources for tools
|
|
1892
|
+
# Priority: request.tools > config.tools > session_info.tools > defaults
|
|
1893
|
+
tool_ids = (
|
|
1894
|
+
request.get('tools') or
|
|
1895
|
+
config.get('tools') or
|
|
1896
|
+
session_info.get('tools') or
|
|
1897
|
+
['browser', 'filesystem', 'task']
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
# Get tools_config from multiple sources
|
|
1901
|
+
tools_config = (
|
|
1902
|
+
(request.get('session_config') or {}).get('tools_config') or
|
|
1903
|
+
config.get('tools_config') or
|
|
1904
|
+
{}
|
|
1905
|
+
)
|
|
1906
|
+
|
|
1907
|
+
logger.info(f"Recreating orchestrator with tools: {tool_ids}")
|
|
1908
|
+
|
|
1909
|
+
await orchestrator.initialize(
|
|
1910
|
+
tool_ids=tool_ids,
|
|
1911
|
+
tools_config=tools_config
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
# Recreate agent - use both request and config for fallbacks
|
|
1915
|
+
# Merge request and config for _get_llm_for_request
|
|
1916
|
+
llm_request = {**config, **request} # request takes priority
|
|
1917
|
+
llm = await self._get_llm_for_request(llm_request)
|
|
1918
|
+
agent = await orchestrator.create_agent(
|
|
1919
|
+
task=request.get('task') or session_info.get('task', ''),
|
|
1920
|
+
llm=llm,
|
|
1921
|
+
agent_name="executor",
|
|
1922
|
+
use_vision=request.get('use_vision', config.get('use_vision', True)),
|
|
1923
|
+
max_actions_per_step=10,
|
|
1924
|
+
session_config=request.get('session_config') or config
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
# RESTORE MESSAGE HISTORY (FIX #4)
|
|
1928
|
+
if hasattr(agent, 'message_manager') and agent.message_manager:
|
|
1929
|
+
try:
|
|
1930
|
+
loaded = agent.message_manager.load_from_disk(
|
|
1931
|
+
session_id=session_id,
|
|
1932
|
+
user_id=user_id
|
|
1933
|
+
)
|
|
1934
|
+
if loaded:
|
|
1935
|
+
logger.info(f"📂 Restored message history for session {session_id}")
|
|
1936
|
+
else:
|
|
1937
|
+
logger.info(f"No message history to restore for session {session_id}")
|
|
1938
|
+
except Exception as e:
|
|
1939
|
+
logger.error(f"Failed to restore message history: {e}")
|
|
1940
|
+
|
|
1941
|
+
# RESTORE HITL STATE (queued messages from before eviction)
|
|
1942
|
+
if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
|
|
1943
|
+
try:
|
|
1944
|
+
from agents.task.path import pm
|
|
1945
|
+
import json
|
|
1946
|
+
hitl_path = pm().create_file_path(
|
|
1947
|
+
session_id=session_id,
|
|
1948
|
+
subdir_name="memory",
|
|
1949
|
+
filename="hitl_state.json",
|
|
1950
|
+
user_id=user_id
|
|
1951
|
+
)
|
|
1952
|
+
if hitl_path.exists():
|
|
1953
|
+
with open(hitl_path, 'r') as f:
|
|
1954
|
+
hitl_state = json.load(f)
|
|
1955
|
+
agent.hitl_manager.restore_state(hitl_state)
|
|
1956
|
+
queued_count = len(hitl_state.get('queued_messages', []))
|
|
1957
|
+
logger.info(f"📂 Restored HITL state for session {session_id} ({queued_count} queued messages)")
|
|
1958
|
+
|
|
1959
|
+
# Remove the file after restoration to avoid re-restoring stale state
|
|
1960
|
+
hitl_path.unlink()
|
|
1961
|
+
except Exception as e:
|
|
1962
|
+
logger.warning(f"Could not restore HITL state: {e}")
|
|
1963
|
+
|
|
1964
|
+
# PRE-LOAD hierarchical memory to validate session can be fully restored
|
|
1965
|
+
# Note: The actual loading happens in Agent.run(), but we validate here
|
|
1966
|
+
if hasattr(agent, 'task_context_manager') and agent.task_context_manager:
|
|
1967
|
+
try:
|
|
1968
|
+
# Try to load the H-MEM session to verify it exists
|
|
1969
|
+
memory = agent.task_context_manager.load_session(session_id, user_id)
|
|
1970
|
+
if memory:
|
|
1971
|
+
logger.info(f"📂 Validated hierarchical memory exists for session {session_id}")
|
|
1972
|
+
else:
|
|
1973
|
+
logger.info(f"No existing H-MEM for session {session_id} (will be created on run)")
|
|
1974
|
+
except Exception as e:
|
|
1975
|
+
logger.warning(f"Could not pre-load H-MEM for session {session_id}: {e}")
|
|
1976
|
+
|
|
1977
|
+
# Store orchestrator
|
|
1978
|
+
self.register_orchestrator(session_id, orchestrator)
|
|
1979
|
+
self._session_last_activity[session_id] = time.time()
|
|
1980
|
+
|
|
1981
|
+
logger.info(f"Successfully recreated orchestrator for suspended session {session_id}")
|
|
1982
|
+
return orchestrator
|
|
1983
|
+
|
|
1984
|
+
except Exception as e:
|
|
1985
|
+
logger.error(f"Failed to recreate orchestrator for {session_id}: {e}", exc_info=True)
|
|
1986
|
+
return None
|
|
1987
|
+
|
|
1988
|
+
async def aclose(self) -> None:
|
|
1989
|
+
"""Cancel the periodic background loops. Idempotent and fail-open.
|
|
1990
|
+
|
|
1991
|
+
Safe to call when the loops were never started (the ImportError path sets
|
|
1992
|
+
task_available=False and skips creation, leaving _bg_tasks empty).
|
|
1993
|
+
"""
|
|
1994
|
+
tasks = getattr(self, "_bg_tasks", None) or []
|
|
1995
|
+
for t in tasks:
|
|
1996
|
+
try:
|
|
1997
|
+
t.cancel()
|
|
1998
|
+
except Exception:
|
|
1999
|
+
pass
|
|
2000
|
+
for t in tasks:
|
|
2001
|
+
try:
|
|
2002
|
+
await t
|
|
2003
|
+
except (asyncio.CancelledError, Exception):
|
|
2004
|
+
pass
|
|
2005
|
+
self._bg_tasks = []
|
|
2006
|
+
|
|
2007
|
+
async def _periodic_cleanup(self):
|
|
2008
|
+
"""Periodically clean up old sessions (TTL and LRU eviction)."""
|
|
2009
|
+
while True:
|
|
2010
|
+
try:
|
|
2011
|
+
await asyncio.sleep(self.cleanup_interval)
|
|
2012
|
+
await self._cleanup_old_sessions()
|
|
2013
|
+
except Exception as e:
|
|
2014
|
+
logger.error(f"Error in periodic cleanup: {e}")
|
|
2015
|
+
|
|
2016
|
+
async def _periodic_workspace_cleanup(self):
|
|
2017
|
+
"""Periodically clean up old session workspaces."""
|
|
2018
|
+
while True:
|
|
2019
|
+
try:
|
|
2020
|
+
# Run cleanup daily
|
|
2021
|
+
await asyncio.sleep(86400) # 24 hours
|
|
2022
|
+
|
|
2023
|
+
if self.session_manager:
|
|
2024
|
+
cleaned = self.session_manager.cleanup_old_workspaces(max_age_days=7)
|
|
2025
|
+
logger.info(f"Workspace cleanup: removed {cleaned} old workspaces")
|
|
2026
|
+
|
|
2027
|
+
except Exception as e:
|
|
2028
|
+
logger.error(f"Error in workspace cleanup: {e}")
|
|
2029
|
+
|
|
2030
|
+
@staticmethod
|
|
2031
|
+
def _session_age_seconds(created_at, now: Optional[datetime] = None) -> Optional[float]:
|
|
2032
|
+
"""Age in seconds from an ISO 'created_at' string, or None if unparseable."""
|
|
2033
|
+
if not created_at:
|
|
2034
|
+
return None
|
|
2035
|
+
try:
|
|
2036
|
+
ts = datetime.fromisoformat(created_at)
|
|
2037
|
+
except (ValueError, TypeError):
|
|
2038
|
+
return None
|
|
2039
|
+
return ((now or datetime.now()) - ts).total_seconds()
|
|
2040
|
+
|
|
2041
|
+
async def _cleanup_stale_created_sessions(self) -> int:
|
|
2042
|
+
"""Retire sessions stuck in 'created' that never ran.
|
|
2043
|
+
|
|
2044
|
+
A 'created' session counts toward the per-user limit (get_active_sessions), but
|
|
2045
|
+
the TTL/LRU GC keys off _session_last_activity, which is only written on the run
|
|
2046
|
+
path. So a created-but-never-run session is invisible to that GC and would consume
|
|
2047
|
+
the per-user slot forever, eventually blocking all new runs. This sweep is keyed
|
|
2048
|
+
off 'created_at' (always set at create time) and deletes both the in-memory entry
|
|
2049
|
+
and the on-disk metadata.
|
|
2050
|
+
|
|
2051
|
+
Returns the number of sessions retired.
|
|
2052
|
+
"""
|
|
2053
|
+
if not self.session_manager:
|
|
2054
|
+
return 0
|
|
2055
|
+
|
|
2056
|
+
now = datetime.now()
|
|
2057
|
+
ttl = self.created_session_ttl_seconds
|
|
2058
|
+
stale = []
|
|
2059
|
+
# get_active_sessions() (no user filter) reads in-memory _sessions only — exactly
|
|
2060
|
+
# the entries that count toward the per-user limit. Disk-only sessions are excluded.
|
|
2061
|
+
for session_id in self.session_manager.get_active_sessions():
|
|
2062
|
+
info = self.session_manager.get_session_info(session_id) or {}
|
|
2063
|
+
if info.get('status') != 'created':
|
|
2064
|
+
continue
|
|
2065
|
+
# A run-path activity timestamp means this is a normal in-flight session.
|
|
2066
|
+
if session_id in self._session_last_activity:
|
|
2067
|
+
continue
|
|
2068
|
+
age = self._session_age_seconds(info.get('created_at'), now)
|
|
2069
|
+
if age is None or age <= ttl:
|
|
2070
|
+
continue
|
|
2071
|
+
stale.append(session_id)
|
|
2072
|
+
|
|
2073
|
+
for session_id in stale:
|
|
2074
|
+
logger.info(
|
|
2075
|
+
f"GC: retiring stale 'created' session {session_id} "
|
|
2076
|
+
f"(age > {ttl}s, never ran)"
|
|
2077
|
+
)
|
|
2078
|
+
self._registry.remove(session_id) # no-op if never registered
|
|
2079
|
+
self._session_execution_locks.pop(session_id, None)
|
|
2080
|
+
self.session_manager.cleanup_session(session_id, delete_files=True)
|
|
2081
|
+
|
|
2082
|
+
return len(stale)
|
|
2083
|
+
|
|
2084
|
+
async def _cleanup_old_sessions(self):
|
|
2085
|
+
"""Remove sessions that haven't been active recently."""
|
|
2086
|
+
# First retire never-run 'created' sessions that the activity-keyed GC below
|
|
2087
|
+
# can never see (they have no _session_last_activity entry).
|
|
2088
|
+
await self._cleanup_stale_created_sessions()
|
|
2089
|
+
|
|
2090
|
+
now = time.time()
|
|
2091
|
+
ttl_threshold = now - self.session_ttl_seconds
|
|
2092
|
+
|
|
2093
|
+
sessions_to_evict = []
|
|
2094
|
+
|
|
2095
|
+
# Find sessions past TTL
|
|
2096
|
+
for session_id, last_activity in list(self._session_last_activity.items()):
|
|
2097
|
+
if last_activity < ttl_threshold:
|
|
2098
|
+
sessions_to_evict.append((session_id, 'ttl'))
|
|
2099
|
+
|
|
2100
|
+
# Evict by TTL
|
|
2101
|
+
for session_id, reason in sessions_to_evict:
|
|
2102
|
+
logger.info(f"Evicting session {session_id} (TTL {self.session_ttl_seconds}s exceeded)")
|
|
2103
|
+
await self._evict_session(session_id, reason='ttl')
|
|
2104
|
+
|
|
2105
|
+
# Also enforce max sessions limit (LRU eviction)
|
|
2106
|
+
if self._registry.count() > self.max_sessions_in_memory:
|
|
2107
|
+
# Sort by last activity (oldest first)
|
|
2108
|
+
sorted_sessions = sorted(
|
|
2109
|
+
self._session_last_activity.items(),
|
|
2110
|
+
key=lambda x: x[1]
|
|
2111
|
+
)
|
|
2112
|
+
|
|
2113
|
+
# Calculate how many to remove
|
|
2114
|
+
excess_count = self._registry.count() - self.max_sessions_in_memory
|
|
2115
|
+
|
|
2116
|
+
# Remove oldest
|
|
2117
|
+
for session_id, _ in sorted_sessions[:excess_count]:
|
|
2118
|
+
logger.info(f"Evicting session {session_id} (max sessions limit: {self.max_sessions_in_memory})")
|
|
2119
|
+
await self._evict_session(session_id, reason='lru')
|
|
2120
|
+
|
|
2121
|
+
async def _evict_session(self, session_id: str, reason: str = 'unknown'):
|
|
2122
|
+
"""Fully evict a session from memory with state persistence.
|
|
2123
|
+
|
|
2124
|
+
Args:
|
|
2125
|
+
session_id: Session to evict
|
|
2126
|
+
reason: Eviction reason (ttl, lru, manual)
|
|
2127
|
+
"""
|
|
2128
|
+
try:
|
|
2129
|
+
# a3: never reap a session with a live run loop. A held execution lock means
|
|
2130
|
+
# a turn is in flight — tearing it down mid-run would corrupt state and drop
|
|
2131
|
+
# the user's reply. Leave it; the next cleanup pass collects it once idle.
|
|
2132
|
+
exec_lock = self._session_execution_locks.get(session_id)
|
|
2133
|
+
if exec_lock is not None and exec_lock.locked():
|
|
2134
|
+
logger.debug(
|
|
2135
|
+
f"Skipping eviction of {session_id} (reason: {reason}) — run in flight"
|
|
2136
|
+
)
|
|
2137
|
+
return
|
|
2138
|
+
|
|
2139
|
+
orchestrator = self._registry.get(session_id)
|
|
2140
|
+
if orchestrator:
|
|
2141
|
+
# Save HITL state BEFORE cleanup (queued messages not saved by cleanup)
|
|
2142
|
+
# NOTE: Message history is saved by cleanup(full_cleanup=True) - no need to duplicate
|
|
2143
|
+
try:
|
|
2144
|
+
for agent in orchestrator.agents.values():
|
|
2145
|
+
# Save HITL state (queued messages, callbacks info)
|
|
2146
|
+
# This is NOT handled by cleanup() so we must do it here
|
|
2147
|
+
if hasattr(agent, 'hitl_manager') and agent.hitl_manager:
|
|
2148
|
+
try:
|
|
2149
|
+
hitl_state = agent.hitl_manager.get_state()
|
|
2150
|
+
if hitl_state.get('queued_messages'):
|
|
2151
|
+
from agents.task.path import pm
|
|
2152
|
+
import json
|
|
2153
|
+
hitl_path = pm().create_file_path(
|
|
2154
|
+
session_id=session_id,
|
|
2155
|
+
subdir_name="memory",
|
|
2156
|
+
filename="hitl_state.json",
|
|
2157
|
+
user_id=orchestrator.user_id
|
|
2158
|
+
)
|
|
2159
|
+
hitl_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2160
|
+
with open(hitl_path, 'w') as f:
|
|
2161
|
+
json.dump(hitl_state, f, indent=2, default=str)
|
|
2162
|
+
logger.info(f"💾 Saved HITL state for agent {agent.agent_id} ({len(hitl_state.get('queued_messages', []))} queued messages)")
|
|
2163
|
+
except Exception as e:
|
|
2164
|
+
logger.error(f"Failed to save HITL state for agent {agent.agent_id}: {e}")
|
|
2165
|
+
except Exception as e:
|
|
2166
|
+
logger.error(f"Failed to save HITL state during eviction: {e}")
|
|
2167
|
+
|
|
2168
|
+
# Capture telemetry before eviction
|
|
2169
|
+
if self.telemetry:
|
|
2170
|
+
try:
|
|
2171
|
+
# Calculate session age
|
|
2172
|
+
last_activity = self._session_last_activity.get(session_id, time.time())
|
|
2173
|
+
session_age = time.time() - last_activity
|
|
2174
|
+
|
|
2175
|
+
self.telemetry.capture_event(
|
|
2176
|
+
event_type="session_eviction",
|
|
2177
|
+
data={
|
|
2178
|
+
"session_id": session_id,
|
|
2179
|
+
"reason": reason,
|
|
2180
|
+
"session_age_seconds": session_age,
|
|
2181
|
+
"total_sessions_before": self._registry.count(),
|
|
2182
|
+
"max_sessions_limit": self.max_sessions_in_memory
|
|
2183
|
+
}
|
|
2184
|
+
)
|
|
2185
|
+
except Exception as e:
|
|
2186
|
+
logger.debug(f"Failed to emit eviction telemetry: {e}")
|
|
2187
|
+
|
|
2188
|
+
# STEP 2: Full cleanup - release all resources
|
|
2189
|
+
await orchestrator.cleanup(
|
|
2190
|
+
preserve_workspace=True,
|
|
2191
|
+
status="suspended",
|
|
2192
|
+
full_cleanup=True # Release agents, LLMs, tools
|
|
2193
|
+
)
|
|
2194
|
+
|
|
2195
|
+
# Remove from all tracking
|
|
2196
|
+
self._registry.remove(session_id)
|
|
2197
|
+
self._session_last_activity.pop(session_id, None)
|
|
2198
|
+
self._session_execution_locks.pop(session_id, None) # Remove execution lock
|
|
2199
|
+
self._recreate_locks.pop(session_id, None) # a3: don't leak recreate locks
|
|
2200
|
+
|
|
2201
|
+
logger.info(f"Evicted session {session_id} from memory (reason: {reason}) with persistence")
|
|
2202
|
+
except Exception as e:
|
|
2203
|
+
logger.error(f"Error evicting session {session_id}: {e}")
|
|
2204
|
+
|
|
2205
|
+
async def _cleanup(self) -> None:
|
|
2206
|
+
"""Cleanup orchestrators and resources during TaskAgent shutdown."""
|
|
2207
|
+
# Cleanup active orchestrators - full cleanup since we're shutting down
|
|
2208
|
+
for session_id, orchestrator in self._registry.items():
|
|
2209
|
+
try:
|
|
2210
|
+
await orchestrator.cleanup(
|
|
2211
|
+
preserve_workspace=True,
|
|
2212
|
+
preserve_agents=False, # Clear agents during shutdown
|
|
2213
|
+
full_cleanup=True # Release all resources
|
|
2214
|
+
)
|
|
2215
|
+
except Exception as e:
|
|
2216
|
+
logger.error(f"Error cleaning up orchestrator for {session_id}: {e}")
|
|
2217
|
+
|
|
2218
|
+
self._registry.clear()
|
|
2219
|
+
self._session_last_activity.clear()
|
|
2220
|
+
self._session_execution_locks.clear()
|
|
2221
|
+
self._recreate_locks.clear() # a3: don't leak recreate locks across shutdown
|
|
2222
|
+
logger.info("TaskAgent cleanup complete")
|
|
2223
|
+
|
|
2224
|
+
# Required abstract methods from BaseAgent
|
|
2225
|
+
|
|
2226
|
+
async def cleanup(self) -> None:
|
|
2227
|
+
"""Clean up resources."""
|
|
2228
|
+
await self._cleanup()
|
|
2229
|
+
|
|
2230
|
+
async def process_input(
|
|
2231
|
+
self,
|
|
2232
|
+
user_input: str,
|
|
2233
|
+
user_id: str,
|
|
2234
|
+
chat_id: Optional[str] = None,
|
|
2235
|
+
**kwargs
|
|
2236
|
+
) -> str:
|
|
2237
|
+
"""Process user input - delegates to process_user_message."""
|
|
2238
|
+
return await self.process_user_message(user_id, user_input, kwargs)
|
|
2239
|
+
|
|
2240
|
+
async def start_conversation(
|
|
2241
|
+
self,
|
|
2242
|
+
user_id: str,
|
|
2243
|
+
chat_id: Optional[str] = None,
|
|
2244
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
2245
|
+
) -> bool:
|
|
2246
|
+
"""Start a conversation - task agent doesn't need this."""
|
|
2247
|
+
return True
|
|
2248
|
+
|
|
2249
|
+
# Additional convenience methods that delegate to SessionManager
|
|
2250
|
+
|
|
2251
|
+
def list_sessions(self, user_id: str) -> list:
|
|
2252
|
+
"""List user's sessions."""
|
|
2253
|
+
if not self.task_available:
|
|
2254
|
+
return []
|
|
2255
|
+
return self.session_manager.get_active_sessions(user_id)
|
|
2256
|
+
|
|
2257
|
+
def _count_user_sessions(self, user_id: str) -> int:
|
|
2258
|
+
"""Count active sessions for a user.
|
|
2259
|
+
|
|
2260
|
+
Args:
|
|
2261
|
+
user_id: User identifier
|
|
2262
|
+
|
|
2263
|
+
Returns:
|
|
2264
|
+
Number of active sessions for this user
|
|
2265
|
+
"""
|
|
2266
|
+
if not self.session_manager:
|
|
2267
|
+
return 0
|
|
2268
|
+
|
|
2269
|
+
sessions = self.session_manager.get_active_sessions(user_id)
|
|
2270
|
+
return len(sessions)
|
|
2271
|
+
|
|
2272
|
+
def _check_user_session_limit(self, user_id: str) -> bool:
|
|
2273
|
+
"""Check if user has reached session limit.
|
|
2274
|
+
|
|
2275
|
+
Args:
|
|
2276
|
+
user_id: User identifier
|
|
2277
|
+
|
|
2278
|
+
Returns:
|
|
2279
|
+
True if under limit, False if at/over limit
|
|
2280
|
+
"""
|
|
2281
|
+
current_count = self._count_user_sessions(user_id)
|
|
2282
|
+
max_limit = getattr(self.config, 'max_sessions_per_user', MAX_SESSIONS_PER_USER)
|
|
2283
|
+
|
|
2284
|
+
if current_count >= max_limit:
|
|
2285
|
+
logger.warning(
|
|
2286
|
+
f"User {user_id} at session limit: {current_count}/{max_limit}"
|
|
2287
|
+
)
|
|
2288
|
+
return False
|
|
2289
|
+
|
|
2290
|
+
return True
|
|
2291
|
+
|
|
2292
|
+
async def _validate_user_credits(self, user_id: str, min_credits: int = 1):
|
|
2293
|
+
"""Validate user has sufficient credits before session creation.
|
|
2294
|
+
|
|
2295
|
+
This is a pre-validation check to fail fast before creating resources.
|
|
2296
|
+
The actual billing happens during LLM calls via usage_tracker.
|
|
2297
|
+
|
|
2298
|
+
Args:
|
|
2299
|
+
user_id: User to validate
|
|
2300
|
+
min_credits: Minimum credits required (default: 1 for session creation)
|
|
2301
|
+
|
|
2302
|
+
Raises:
|
|
2303
|
+
AgentError: If insufficient credits
|
|
2304
|
+
"""
|
|
2305
|
+
# Skip for admin users and x402 users (they already paid via x402 middleware)
|
|
2306
|
+
try:
|
|
2307
|
+
tier_mgr = self.container.get_service('tier_manager')
|
|
2308
|
+
if tier_mgr:
|
|
2309
|
+
user_tier = await tier_mgr.get_user_tier(user_id)
|
|
2310
|
+
if user_tier == 'admin':
|
|
2311
|
+
logger.debug(f"Admin user {user_id} bypassed credit check")
|
|
2312
|
+
return
|
|
2313
|
+
if user_tier == 'x402':
|
|
2314
|
+
# x402 users pay per-request via middleware, not via credits
|
|
2315
|
+
logger.debug(f"x402 user {user_id} bypassed credit check (paid via x402)")
|
|
2316
|
+
return
|
|
2317
|
+
except Exception as e:
|
|
2318
|
+
logger.debug(f"Could not check tier (proceeding with credit check): {e}")
|
|
2319
|
+
|
|
2320
|
+
# Check balance
|
|
2321
|
+
try:
|
|
2322
|
+
balance_mgr = self.container.get_service('balance_manager')
|
|
2323
|
+
if not balance_mgr:
|
|
2324
|
+
logger.warning("Balance manager not available - skipping credit check")
|
|
2325
|
+
return
|
|
2326
|
+
|
|
2327
|
+
has_credits = await balance_mgr.has_sufficient_balance(user_id, min_credits)
|
|
2328
|
+
if not has_credits:
|
|
2329
|
+
balance = await balance_mgr.get_balance(user_id)
|
|
2330
|
+
available = balance.get('balance', 0) if balance else 0
|
|
2331
|
+
raise AgentError(
|
|
2332
|
+
f"Insufficient credits. Required: {min_credits}, Available: {available}. "
|
|
2333
|
+
f"Please add credits at /api/payments/deposit to continue."
|
|
2334
|
+
)
|
|
2335
|
+
except AgentError:
|
|
2336
|
+
raise # Re-raise our own error
|
|
2337
|
+
except Exception as e:
|
|
2338
|
+
logger.warning(f"Credit check failed (allowing session): {e}")
|
|
2339
|
+
|
|
2340
|
+
def get_all_sessions(self) -> Dict[str, Any]:
|
|
2341
|
+
"""Get all sessions (admin function)."""
|
|
2342
|
+
if not self.task_available or not self.session_manager:
|
|
2343
|
+
return {}
|
|
2344
|
+
# Access internal tracking for admin purposes
|
|
2345
|
+
return self.session_manager._sessions.copy()
|
|
2346
|
+
|
|
2347
|
+
# Methods required by API
|
|
2348
|
+
|
|
2349
|
+
def _get_user_sessions(self, user_id: str) -> Dict[str, Any]:
|
|
2350
|
+
"""Get all sessions for a user."""
|
|
2351
|
+
if not self.session_manager:
|
|
2352
|
+
return {}
|
|
2353
|
+
|
|
2354
|
+
sessions = {}
|
|
2355
|
+
for session_id in self.session_manager.get_active_sessions(user_id):
|
|
2356
|
+
info = self.session_manager.get_session_info(session_id)
|
|
2357
|
+
if info:
|
|
2358
|
+
sessions[session_id] = info
|
|
2359
|
+
return sessions
|
|
2360
|
+
|
|
2361
|
+
def _remove_session(self, user_id: str, session_id: str) -> None:
|
|
2362
|
+
"""Remove a session and cleanup."""
|
|
2363
|
+
orchestrator = self._registry.remove(session_id)
|
|
2364
|
+
if orchestrator:
|
|
2365
|
+
asyncio.create_task(orchestrator.cleanup())
|
|
2366
|
+
|
|
2367
|
+
# NOTE: Don't call cleanup_session - keep completed sessions in memory
|
|
2368
|
+
# for continuous chat feature. They remain accessible via get_session_by_id()
|
|
2369
|
+
# but the orchestrator is cleaned up to free resources.
|
|
2370
|
+
|
|
2371
|
+
# API compatibility methods
|
|
2372
|
+
|
|
2373
|
+
async def get_session_by_id(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
2374
|
+
"""Get session info by ID (API compatibility)."""
|
|
2375
|
+
if not self.session_manager:
|
|
2376
|
+
return None
|
|
2377
|
+
|
|
2378
|
+
info = self.session_manager.get_session_info(session_id)
|
|
2379
|
+
if info:
|
|
2380
|
+
# Update active_sessions for compatibility
|
|
2381
|
+
user_id = info.get('user_id')
|
|
2382
|
+
if user_id:
|
|
2383
|
+
if user_id not in self.active_sessions:
|
|
2384
|
+
self.active_sessions[user_id] = {}
|
|
2385
|
+
self.active_sessions[user_id][session_id] = info
|
|
2386
|
+
return info
|
|
2387
|
+
|
|
2388
|
+
async def cancel_session_by_id(self, session_id: str, force: bool = False) -> bool:
|
|
2389
|
+
"""Cancel a session by ID (API compatibility)."""
|
|
2390
|
+
try:
|
|
2391
|
+
info = self.session_manager.get_session_info(session_id)
|
|
2392
|
+
if info:
|
|
2393
|
+
user_id = info.get('user_id')
|
|
2394
|
+
return await self.cancel_session(user_id, force=force, session_id=session_id)
|
|
2395
|
+
return False
|
|
2396
|
+
except Exception as e:
|
|
2397
|
+
logger.error(f"Failed to cancel session {session_id}: {e}")
|
|
2398
|
+
return False
|