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
api/task_http_api.py
ADDED
|
@@ -0,0 +1,1856 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP API endpoints for Task multi-session support.
|
|
3
|
+
Provides RESTful endpoints for session management and user messaging.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Any, Optional, List
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Depends, Request, UploadFile, File
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import aiofiles
|
|
15
|
+
|
|
16
|
+
from api.session_routing import guard_remote
|
|
17
|
+
import asyncio
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# H2 FIX: hold strong references to fire-and-forget session tasks so the event loop
|
|
23
|
+
# can't garbage-collect a running agent session (it keeps only a weak ref to a bare
|
|
24
|
+
# Task). Tasks discard themselves on completion via the done callback.
|
|
25
|
+
_BACKGROUND_SESSION_TASKS: set = set()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _spawn_session_task(coro) -> "asyncio.Task":
|
|
29
|
+
"""Schedule a background coroutine while retaining a strong reference to it."""
|
|
30
|
+
task = asyncio.create_task(coro)
|
|
31
|
+
_BACKGROUND_SESSION_TASKS.add(task)
|
|
32
|
+
task.add_done_callback(_BACKGROUND_SESSION_TASKS.discard)
|
|
33
|
+
return task
|
|
34
|
+
|
|
35
|
+
# Create router for Task endpoints
|
|
36
|
+
router = APIRouter(prefix="/task", tags=["task"])
|
|
37
|
+
|
|
38
|
+
# Import models from api.models to avoid duplication
|
|
39
|
+
from api.models import (
|
|
40
|
+
SessionStatusResponse,
|
|
41
|
+
MessageResponse,
|
|
42
|
+
UserMessage as UserMessageRequest,
|
|
43
|
+
SessionResponse
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Helper to clean session IDs at API entry (single point of cleaning)
|
|
47
|
+
def clean_session_id_at_entry(session_id: str) -> str:
|
|
48
|
+
"""Clean session ID once at API entry point.
|
|
49
|
+
|
|
50
|
+
This is the ONLY place where session IDs should be cleaned in the API layer.
|
|
51
|
+
All downstream code can trust that session IDs are already sanitized.
|
|
52
|
+
"""
|
|
53
|
+
from agents.task.path import pm
|
|
54
|
+
return pm().clean_session_id(session_id)
|
|
55
|
+
|
|
56
|
+
# Helper to normalize status values
|
|
57
|
+
def normalize_status_value(value: Any) -> str:
|
|
58
|
+
"""Normalize status/state value to lowercase string.
|
|
59
|
+
|
|
60
|
+
Handles various input formats:
|
|
61
|
+
- SessionStatus enum objects
|
|
62
|
+
- Enum string representations ("SessionStatus.RUNNING")
|
|
63
|
+
- Plain strings ("running", "RUNNING")
|
|
64
|
+
- None/null values
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Lowercase status string (e.g., "running", "completed")
|
|
68
|
+
"""
|
|
69
|
+
# Import here to avoid circular dependency
|
|
70
|
+
from agents.task.agent.session import SessionStatus
|
|
71
|
+
|
|
72
|
+
# Handle enum objects
|
|
73
|
+
if isinstance(value, SessionStatus):
|
|
74
|
+
return value.value
|
|
75
|
+
|
|
76
|
+
# Handle strings
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
# Handle enum string like "SessionStatus.RUNNING" or "SessionState.RUNNING"
|
|
79
|
+
if '.' in value:
|
|
80
|
+
return value.split('.')[-1].lower()
|
|
81
|
+
return value.lower()
|
|
82
|
+
|
|
83
|
+
# Handle None or other types
|
|
84
|
+
return str(value).lower() if value else "unknown"
|
|
85
|
+
|
|
86
|
+
def _require_session_owner(req: Request, resource_owner: Optional[str]) -> None:
|
|
87
|
+
"""Raise 403 if the authenticated caller does not own `resource_owner`.
|
|
88
|
+
|
|
89
|
+
Mirrors the ownership-check pattern already correct at the
|
|
90
|
+
workspace/upload endpoint (api/task_http_api.py ~1554-1564) — applied
|
|
91
|
+
consistently across the session read/write endpoints that were missing it
|
|
92
|
+
(E8 / A6 gap 4). No-op when resource_owner is falsy (nothing recorded to
|
|
93
|
+
compare against — matches the pre-existing upload-endpoint convention).
|
|
94
|
+
"""
|
|
95
|
+
from utils.auth_utils import get_authenticated_user_id
|
|
96
|
+
caller_id = get_authenticated_user_id(req)
|
|
97
|
+
if resource_owner and caller_id != resource_owner:
|
|
98
|
+
raise HTTPException(status_code=403, detail="Access denied - session owned by a different user")
|
|
99
|
+
|
|
100
|
+
# Dependency to get TaskAgent instance
|
|
101
|
+
async def get_task_agent():
|
|
102
|
+
"""Get the TaskAgent instance from the container"""
|
|
103
|
+
from core.container import DependencyContainer
|
|
104
|
+
container = DependencyContainer.get_instance()
|
|
105
|
+
# Use get_agent for consistency
|
|
106
|
+
agent = container.get_agent("task_agent")
|
|
107
|
+
if not agent:
|
|
108
|
+
# Fallback to get_service for backward compatibility
|
|
109
|
+
agent = container.get_service("task_agent")
|
|
110
|
+
if not agent:
|
|
111
|
+
raise HTTPException(status_code=503, detail="Task agent not available")
|
|
112
|
+
return agent
|
|
113
|
+
|
|
114
|
+
@router.post("/sessions/{session_id}/messages", response_model=MessageResponse)
|
|
115
|
+
async def send_user_message(
|
|
116
|
+
session_id: str,
|
|
117
|
+
request: UserMessageRequest,
|
|
118
|
+
req: Request,
|
|
119
|
+
agent = Depends(get_task_agent)
|
|
120
|
+
):
|
|
121
|
+
"""
|
|
122
|
+
Send a user message to a running session.
|
|
123
|
+
|
|
124
|
+
This endpoint allows sending guidance, corrections, or other messages
|
|
125
|
+
to an active Task session for processing in the next step.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
# OPTIMIZATION: Clean session ID once at API entry
|
|
129
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
130
|
+
|
|
131
|
+
# Item 6: if this session is owned by another worker, return an honest 409
|
|
132
|
+
# (with owner_pid) instead of a false-404. No-op for the in-process registry.
|
|
133
|
+
guard_remote(agent, session_id)
|
|
134
|
+
|
|
135
|
+
# Get session from agent
|
|
136
|
+
session_info = await agent.get_session_by_id(session_id)
|
|
137
|
+
if not session_info:
|
|
138
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
139
|
+
|
|
140
|
+
# Check session status (unified field name)
|
|
141
|
+
status = session_info.get('status', 'unknown').lower()
|
|
142
|
+
|
|
143
|
+
# Allow messages in resumable states for continuous chat
|
|
144
|
+
# - "created": first message to start the agent with attached files
|
|
145
|
+
# - "running": active session
|
|
146
|
+
# - "completed/resumed/suspended": continuous chat resume
|
|
147
|
+
# - "failed/error": retry after failure (error is legacy, failed is current)
|
|
148
|
+
RESUMABLE_STATUSES = ["created", "running", "completed", "resumed", "suspended", "failed", "error"]
|
|
149
|
+
if status not in RESUMABLE_STATUSES:
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=400,
|
|
152
|
+
detail=f"Session is in {status} status, cannot send messages"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Get user_id early for use in closure
|
|
156
|
+
user_id = session_info.get('user_id')
|
|
157
|
+
|
|
158
|
+
# E8 (A6 gap 4): the caller must own this session — any authenticated
|
|
159
|
+
# caller could otherwise inject a message into another tenant's session.
|
|
160
|
+
_require_session_owner(req, user_id)
|
|
161
|
+
|
|
162
|
+
# ============================================================================
|
|
163
|
+
# CRITICAL FIX: Process attached files BEFORE starting session
|
|
164
|
+
# This eliminates race condition where agent starts before message is queued
|
|
165
|
+
# ============================================================================
|
|
166
|
+
|
|
167
|
+
# STEP 1: Verify all attached files are ready (explicit synchronization)
|
|
168
|
+
if request.attached_files:
|
|
169
|
+
logger.info(f"📁 Verifying {len(request.attached_files)} attached file(s) are ready...")
|
|
170
|
+
files_ready, missing_files = await verify_files_ready(
|
|
171
|
+
request.attached_files,
|
|
172
|
+
session_id,
|
|
173
|
+
user_id,
|
|
174
|
+
max_wait_seconds=10.0
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not files_ready:
|
|
178
|
+
logger.error(f"📁 File verification failed. Missing/unreadable: {missing_files}")
|
|
179
|
+
raise HTTPException(
|
|
180
|
+
status_code=425, # Too Early
|
|
181
|
+
detail=f"Files not ready: {', '.join(missing_files)}. Please retry in a few seconds.",
|
|
182
|
+
headers={"Retry-After": "5"}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
logger.info(f"📁 ✅ All {len(request.attached_files)} file(s) verified and ready")
|
|
186
|
+
|
|
187
|
+
# STEP 2: Process attached files to create metadata (BEFORE starting session)
|
|
188
|
+
message_text = request.text
|
|
189
|
+
image_attachments = [] # Track images for vision
|
|
190
|
+
|
|
191
|
+
if request.attached_files:
|
|
192
|
+
logger.info(f"📎 Processing {len(request.attached_files)} attached file(s)...")
|
|
193
|
+
for file_path in request.attached_files:
|
|
194
|
+
logger.info(f"📎 Processing file: {file_path}")
|
|
195
|
+
# Returns tuple (text, images)
|
|
196
|
+
message_text, images = await inject_file_content_to_message(
|
|
197
|
+
file_path,
|
|
198
|
+
message_text,
|
|
199
|
+
session_id,
|
|
200
|
+
user_id
|
|
201
|
+
)
|
|
202
|
+
logger.info(f"📎 File processed: {file_path}")
|
|
203
|
+
|
|
204
|
+
# Collect images for vision
|
|
205
|
+
if images:
|
|
206
|
+
image_attachments.extend(images)
|
|
207
|
+
logger.info(f"📷 Collected {len(images)} image(s) for vision from {file_path}")
|
|
208
|
+
|
|
209
|
+
# STEP 3: Create metadata with images (BEFORE starting session)
|
|
210
|
+
metadata = request.metadata or {}
|
|
211
|
+
if image_attachments:
|
|
212
|
+
metadata['image_attachments'] = image_attachments
|
|
213
|
+
logger.info(f"📷 Prepared message with {len(image_attachments)} image(s) for vision")
|
|
214
|
+
|
|
215
|
+
# STEP 4: Get orchestrator (or start session if needed)
|
|
216
|
+
orchestrator = session_info.get('orchestrator')
|
|
217
|
+
if not orchestrator:
|
|
218
|
+
# Look up the live orchestrator via the public accessor (for completed sessions)
|
|
219
|
+
if hasattr(agent, 'get_orchestrator'):
|
|
220
|
+
orchestrator = agent.get_orchestrator(session_id)
|
|
221
|
+
|
|
222
|
+
if not orchestrator:
|
|
223
|
+
# Try to get from active sessions (nested structure - for compatibility)
|
|
224
|
+
if user_id and hasattr(agent, 'active_sessions'):
|
|
225
|
+
user_sessions = agent.active_sessions.get(user_id, {})
|
|
226
|
+
session_data = user_sessions.get(session_id, {})
|
|
227
|
+
orchestrator = session_data.get('orchestrator')
|
|
228
|
+
|
|
229
|
+
# Check if orchestrator has agents
|
|
230
|
+
has_agents = orchestrator and hasattr(orchestrator, 'agents') and len(orchestrator.agents) > 0
|
|
231
|
+
|
|
232
|
+
# Track whether message has been queued to prevent double-queueing
|
|
233
|
+
message_queued = False
|
|
234
|
+
|
|
235
|
+
# STEP 5: Start session if needed (QUEUE MESSAGE FIRST to prevent race condition)
|
|
236
|
+
if not has_agents and status == "created":
|
|
237
|
+
logger.info(
|
|
238
|
+
f"Starting agent for session {session_id} with pre-queued message "
|
|
239
|
+
f"({len(image_attachments)} image(s) ready)"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Start session in background (don't wait for it to be ready)
|
|
243
|
+
import asyncio
|
|
244
|
+
|
|
245
|
+
# CRITICAL FIX (Nov 25, 2025): Queue message BEFORE starting agent
|
|
246
|
+
# This prevents race condition where agent starts and checks for messages
|
|
247
|
+
# before the message is queued, causing images to be lost.
|
|
248
|
+
|
|
249
|
+
# Get the live orchestrator via the public accessor (not from session_info)
|
|
250
|
+
# Note: get_session_by_id() doesn't include orchestrator in its return value
|
|
251
|
+
pre_orchestrator = None
|
|
252
|
+
if hasattr(agent, 'get_orchestrator'):
|
|
253
|
+
pre_orchestrator = agent.get_orchestrator(session_id)
|
|
254
|
+
|
|
255
|
+
if pre_orchestrator:
|
|
256
|
+
# Queue message FIRST
|
|
257
|
+
logger.info(f"📷 Pre-queuing message with {len(image_attachments)} image(s) BEFORE agent starts")
|
|
258
|
+
await pre_orchestrator.submit_user_message(
|
|
259
|
+
None,
|
|
260
|
+
message_text,
|
|
261
|
+
request.kind,
|
|
262
|
+
metadata
|
|
263
|
+
)
|
|
264
|
+
message_queued = True # Mark as queued
|
|
265
|
+
logger.info(f"✅ Message pre-queued successfully")
|
|
266
|
+
else:
|
|
267
|
+
logger.warning(f"Orchestrator not ready for pre-queuing, will queue after start")
|
|
268
|
+
|
|
269
|
+
# Capture user_id and session_id in closure
|
|
270
|
+
_user_id = user_id
|
|
271
|
+
_session_id = session_id
|
|
272
|
+
|
|
273
|
+
async def run_session_with_logging():
|
|
274
|
+
try:
|
|
275
|
+
logger.info(f"Starting session execution for {_session_id}")
|
|
276
|
+
result = await agent.run_session(_user_id, _session_id)
|
|
277
|
+
logger.info(f"Session {_session_id} completed with result: {result}")
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.error(f"Session {_session_id} failed with error: {e}", exc_info=True)
|
|
280
|
+
|
|
281
|
+
# Now start the agent (message is already queued; strong ref retained)
|
|
282
|
+
_spawn_session_task(run_session_with_logging())
|
|
283
|
+
|
|
284
|
+
# Give it a tiny moment to start up
|
|
285
|
+
await asyncio.sleep(0.1)
|
|
286
|
+
|
|
287
|
+
# Update orchestrator reference
|
|
288
|
+
orchestrator = pre_orchestrator
|
|
289
|
+
|
|
290
|
+
elif not orchestrator:
|
|
291
|
+
logger.warning(f"No orchestrator for session {session_id} in status {status}")
|
|
292
|
+
# Fall through to queue message below
|
|
293
|
+
|
|
294
|
+
# STEP 6: Queue message (only if not already queued above)
|
|
295
|
+
# Use explicit flag to prevent any double-queueing race conditions
|
|
296
|
+
if not message_queued and orchestrator:
|
|
297
|
+
await orchestrator.submit_user_message(
|
|
298
|
+
None, # Let orchestrator route to active agent
|
|
299
|
+
message_text,
|
|
300
|
+
request.kind,
|
|
301
|
+
metadata
|
|
302
|
+
)
|
|
303
|
+
message_queued = True
|
|
304
|
+
|
|
305
|
+
logger.info(f"Sent user message to session {session_id}: {request.text[:50]}...")
|
|
306
|
+
|
|
307
|
+
# NEW: Emit user message to feed for chat display
|
|
308
|
+
# CRITICAL FIX: Skip if running session with attached files - will be added later
|
|
309
|
+
# to prevent duplicate user_message events in the feed
|
|
310
|
+
should_add_to_feed = not (request.attached_files and status == "running" and not message_queued)
|
|
311
|
+
|
|
312
|
+
if should_add_to_feed and hasattr(agent, 'session_manager'):
|
|
313
|
+
try:
|
|
314
|
+
agent.session_manager.add_to_feed(
|
|
315
|
+
session_id,
|
|
316
|
+
'user_message',
|
|
317
|
+
{
|
|
318
|
+
'text': request.text,
|
|
319
|
+
'kind': request.kind,
|
|
320
|
+
'metadata': request.metadata or {},
|
|
321
|
+
'timestamp': time.time()
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
logger.debug(f"Added user message to feed for session {session_id}")
|
|
325
|
+
except Exception as e:
|
|
326
|
+
logger.warning(f"Failed to add user message to feed: {e}")
|
|
327
|
+
|
|
328
|
+
# Handle terminal states that need resume
|
|
329
|
+
# NOTE: Don't update status here - let TaskAgent handle status transitions
|
|
330
|
+
# to avoid race condition where API and TaskAgent both try to update status
|
|
331
|
+
if status in ["suspended", "failed", "error"]:
|
|
332
|
+
logger.info(f"Session {session_id} is {status}, will be resumed by TaskAgent")
|
|
333
|
+
|
|
334
|
+
# Crash-mid-turn recovery: a session interrupted when the process died is
|
|
335
|
+
# rewritten running->"suspended" at startup (SessionManager._load_sessions_from_disk),
|
|
336
|
+
# so it enters the resumable block below. In a FRESH process its live orchestrator
|
|
337
|
+
# is absent, and STEP 6 (which needs an orchestrator) never queued the inbound
|
|
338
|
+
# message — see the ensure_session_and_deliver block below, which recreates the
|
|
339
|
+
# session from disk and delivers the message so the resume actually processes it.
|
|
340
|
+
|
|
341
|
+
# If session is in a resumable state, restart execution to process the message
|
|
342
|
+
if status in ["completed", "resumed", "suspended", "failed", "error"]:
|
|
343
|
+
import asyncio
|
|
344
|
+
user_id_from_session = session_info.get('user_id')
|
|
345
|
+
|
|
346
|
+
# Deliver the message into the (possibly evicted) session BEFORE running it.
|
|
347
|
+
# STEP 6 only queues when a live orchestrator exists; in a FRESH process the
|
|
348
|
+
# orchestrator is absent, so without this the resume runs with the message
|
|
349
|
+
# never queued — `_run_session_impl` recreates from disk but a completed
|
|
350
|
+
# session with no pending input short-circuits ("No new input"), silently
|
|
351
|
+
# dropping the message. `ensure_session_and_deliver` recreates-then-queues
|
|
352
|
+
# (the self-wake rail), so the recreated session actually processes it.
|
|
353
|
+
# Fail-open: on any delivery error, fall back to the bare run below.
|
|
354
|
+
if not message_queued and not orchestrator and hasattr(agent, 'ensure_session_and_deliver'):
|
|
355
|
+
try:
|
|
356
|
+
_delivery = await agent.ensure_session_and_deliver(
|
|
357
|
+
user_id_from_session, session_id, message_text,
|
|
358
|
+
kind=request.kind, metadata=metadata)
|
|
359
|
+
if _delivery == "delivered":
|
|
360
|
+
message_queued = True
|
|
361
|
+
logger.info(f"Session {session_id} message delivered into recreated orchestrator")
|
|
362
|
+
elif _delivery == "busy":
|
|
363
|
+
logger.info(f"Session {session_id} busy — message queued on resident session")
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.warning(f"ensure_session_and_deliver failed for {session_id} "
|
|
366
|
+
f"(falling back to bare resume): {e}")
|
|
367
|
+
|
|
368
|
+
# NOTE: Status transition is handled by TaskAgent._run_session_impl
|
|
369
|
+
# API should NOT update status to avoid race condition:
|
|
370
|
+
# 1. API reads status='completed'
|
|
371
|
+
# 2. API updates to 'resumed'
|
|
372
|
+
# 3. TaskAgent tries try_transition_status(completed→resumed) - FAILS!
|
|
373
|
+
#
|
|
374
|
+
# Instead, let TaskAgent be the single owner of status transitions.
|
|
375
|
+
# API only increments task phase for multi-phase tracking.
|
|
376
|
+
if hasattr(agent, 'session_manager'):
|
|
377
|
+
# ✅ Increment task phase for multi-phase tracking
|
|
378
|
+
try:
|
|
379
|
+
new_phase = agent.session_manager.increment_task_phase(session_id)
|
|
380
|
+
logger.info(f"Session {session_id} entering Phase {new_phase} (status transition handled by TaskAgent)")
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.debug(f"Could not increment task_phase: {e}")
|
|
383
|
+
logger.info(f"Session {session_id} resuming (status transition handled by TaskAgent)")
|
|
384
|
+
|
|
385
|
+
# Create background task with error tracking. H2 FIX: retain a strong
|
|
386
|
+
# reference in _BACKGROUND_SESSION_TASKS so the event loop can't GC this
|
|
387
|
+
# session task after the handler returns (the asyncio.shield below only
|
|
388
|
+
# keeps it alive during the 2s wait, not afterward).
|
|
389
|
+
restart_task = asyncio.create_task(
|
|
390
|
+
agent.run_session(user_id_from_session, session_id)
|
|
391
|
+
)
|
|
392
|
+
_BACKGROUND_SESSION_TASKS.add(restart_task)
|
|
393
|
+
restart_task.add_done_callback(_BACKGROUND_SESSION_TASKS.discard)
|
|
394
|
+
|
|
395
|
+
# Wait briefly to catch immediate errors (2 seconds)
|
|
396
|
+
resume_status = "running"
|
|
397
|
+
resume_error = None
|
|
398
|
+
is_retryable = False
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
await asyncio.wait_for(
|
|
402
|
+
asyncio.shield(restart_task), # Shield so it continues in background
|
|
403
|
+
timeout=2.0
|
|
404
|
+
)
|
|
405
|
+
resume_status = "running"
|
|
406
|
+
resume_error = None
|
|
407
|
+
except asyncio.TimeoutError:
|
|
408
|
+
# Expected - task running in background
|
|
409
|
+
logger.debug(f"Session {session_id} auto-resume initiated (background)")
|
|
410
|
+
resume_status = "running"
|
|
411
|
+
resume_error = None
|
|
412
|
+
except ValueError as e:
|
|
413
|
+
# User/validation error - permanent failure
|
|
414
|
+
logger.error(f"Auto-resume validation error for {session_id}: {e}")
|
|
415
|
+
resume_status = "failed"
|
|
416
|
+
resume_error = str(e)
|
|
417
|
+
is_retryable = False
|
|
418
|
+
|
|
419
|
+
# Emit telemetry
|
|
420
|
+
if hasattr(agent, 'telemetry') and agent.telemetry:
|
|
421
|
+
try:
|
|
422
|
+
agent.telemetry.capture_event(
|
|
423
|
+
event_type="auto_resume_failure",
|
|
424
|
+
data={
|
|
425
|
+
"session_id": session_id,
|
|
426
|
+
"error": str(e),
|
|
427
|
+
"error_type": "validation",
|
|
428
|
+
"retryable": False
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
except RuntimeError as e:
|
|
434
|
+
# System error - may be retryable
|
|
435
|
+
logger.error(f"Auto-resume system error for {session_id}: {e}")
|
|
436
|
+
resume_status = "error"
|
|
437
|
+
resume_error = str(e)
|
|
438
|
+
is_retryable = True
|
|
439
|
+
|
|
440
|
+
# Emit telemetry
|
|
441
|
+
if hasattr(agent, 'telemetry') and agent.telemetry:
|
|
442
|
+
try:
|
|
443
|
+
agent.telemetry.capture_event(
|
|
444
|
+
event_type="auto_resume_failure",
|
|
445
|
+
data={
|
|
446
|
+
"session_id": session_id,
|
|
447
|
+
"error": str(e),
|
|
448
|
+
"error_type": "runtime",
|
|
449
|
+
"retryable": True
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
except Exception as e:
|
|
455
|
+
# Unknown error - log with traceback
|
|
456
|
+
logger.error(f"Auto-resume unexpected error for {session_id}: {e}", exc_info=True)
|
|
457
|
+
resume_status = "error"
|
|
458
|
+
resume_error = f"Unexpected error: {str(e)}"
|
|
459
|
+
is_retryable = True
|
|
460
|
+
|
|
461
|
+
# Emit telemetry
|
|
462
|
+
if hasattr(agent, 'telemetry') and agent.telemetry:
|
|
463
|
+
try:
|
|
464
|
+
agent.telemetry.capture_event(
|
|
465
|
+
event_type="auto_resume_failure",
|
|
466
|
+
data={
|
|
467
|
+
"session_id": session_id,
|
|
468
|
+
"error": str(e),
|
|
469
|
+
"error_type": type(e).__name__,
|
|
470
|
+
"retryable": True
|
|
471
|
+
}
|
|
472
|
+
)
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
return MessageResponse(
|
|
477
|
+
success=(resume_status == "running"),
|
|
478
|
+
message=f"Message queued, resume: {resume_status}",
|
|
479
|
+
metadata={
|
|
480
|
+
"session_id": session_id,
|
|
481
|
+
"resumed": (resume_status == "running"),
|
|
482
|
+
"resume_status": resume_status,
|
|
483
|
+
"resume_error": resume_error,
|
|
484
|
+
"retryable": is_retryable # Hint for client retry logic
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# CRITICAL FIX: Handle attached files for RUNNING sessions
|
|
489
|
+
# Our initial P0 fix only handled 'created' status, but if auto_start=true
|
|
490
|
+
# was used (incorrectly), the session is already 'running' when we try to
|
|
491
|
+
# send files. We need to handle this case too.
|
|
492
|
+
# Note: Only process if message wasn't already queued above
|
|
493
|
+
if request.attached_files and status == "running" and not message_queued:
|
|
494
|
+
logger.info(f"📁 Processing {len(request.attached_files)} attached file(s) for RUNNING session")
|
|
495
|
+
|
|
496
|
+
# STEP 1: Verify all attached files are ready
|
|
497
|
+
files_ready, missing_files = await verify_files_ready(
|
|
498
|
+
request.attached_files,
|
|
499
|
+
session_id,
|
|
500
|
+
user_id,
|
|
501
|
+
max_wait_seconds=10.0
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if not files_ready:
|
|
505
|
+
logger.error(f"📁 File verification failed for running session. Missing/unreadable: {missing_files}")
|
|
506
|
+
raise HTTPException(
|
|
507
|
+
status_code=425, # Too Early
|
|
508
|
+
detail=f"Files not ready: {', '.join(missing_files)}. Please retry in a few seconds.",
|
|
509
|
+
headers={"Retry-After": "5"}
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
logger.info(f"📁 ✅ All {len(request.attached_files)} file(s) verified and ready for running session")
|
|
513
|
+
|
|
514
|
+
# STEP 2: Process files to create metadata
|
|
515
|
+
image_attachments_running = []
|
|
516
|
+
message_text_running = request.text
|
|
517
|
+
|
|
518
|
+
for file_path in request.attached_files:
|
|
519
|
+
message_text_running, images = await inject_file_content_to_message(
|
|
520
|
+
file_path,
|
|
521
|
+
message_text_running,
|
|
522
|
+
session_id,
|
|
523
|
+
user_id
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if images:
|
|
527
|
+
image_attachments_running.extend(images)
|
|
528
|
+
logger.info(f"📷 Collected {len(images)} image(s) for vision from {file_path} (running session)")
|
|
529
|
+
|
|
530
|
+
# STEP 3: Add images to metadata and queue message
|
|
531
|
+
metadata_running = request.metadata or {}
|
|
532
|
+
if image_attachments_running:
|
|
533
|
+
metadata_running['image_attachments'] = image_attachments_running
|
|
534
|
+
logger.info(f"📷 Prepared message with {len(image_attachments_running)} image(s) for running session")
|
|
535
|
+
|
|
536
|
+
# STEP 4: Queue the message (agent will process on next queue check)
|
|
537
|
+
# Note: We already queued the message earlier at line 234-239, but that was
|
|
538
|
+
# BEFORE file processing. We need to queue again with the processed metadata.
|
|
539
|
+
# Actually, wait - looking at the code flow, we haven't queued yet for running
|
|
540
|
+
# sessions. The queue happens at line 234-239 but that's inside the "created"
|
|
541
|
+
# branch. So we need to queue here.
|
|
542
|
+
await orchestrator.submit_user_message(
|
|
543
|
+
None, # Let orchestrator route to active agent
|
|
544
|
+
message_text_running,
|
|
545
|
+
request.kind,
|
|
546
|
+
metadata_running
|
|
547
|
+
)
|
|
548
|
+
message_queued = True # Mark as queued to prevent any further queueing
|
|
549
|
+
|
|
550
|
+
logger.info(f"✅ Queued message with {len(image_attachments_running)} image(s) for running session {session_id}")
|
|
551
|
+
|
|
552
|
+
# Emit to feed
|
|
553
|
+
if hasattr(agent, 'session_manager'):
|
|
554
|
+
try:
|
|
555
|
+
agent.session_manager.add_to_feed(
|
|
556
|
+
session_id,
|
|
557
|
+
'user_message',
|
|
558
|
+
{
|
|
559
|
+
'text': request.text,
|
|
560
|
+
'kind': request.kind,
|
|
561
|
+
'metadata': request.metadata or {},
|
|
562
|
+
'timestamp': time.time()
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
logger.warning(f"Failed to add user message to feed: {e}")
|
|
567
|
+
|
|
568
|
+
return MessageResponse(
|
|
569
|
+
success=True,
|
|
570
|
+
message=f"Message with {len(image_attachments_running)} image(s) queued for running session",
|
|
571
|
+
metadata={
|
|
572
|
+
"session_id": session_id,
|
|
573
|
+
"resumed": False,
|
|
574
|
+
"images_attached": len(image_attachments_running)
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# For non-completed sessions WITHOUT attached files
|
|
579
|
+
return MessageResponse(
|
|
580
|
+
success=True,
|
|
581
|
+
message="Message sent successfully",
|
|
582
|
+
metadata={"session_id": session_id, "resumed": False}
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
except HTTPException:
|
|
586
|
+
raise
|
|
587
|
+
except Exception as e:
|
|
588
|
+
# Import here to avoid circular imports
|
|
589
|
+
from core.exceptions import MessageQueueFullError
|
|
590
|
+
|
|
591
|
+
# Handle specific exception types with proper HTTP status codes
|
|
592
|
+
if isinstance(e, MessageQueueFullError):
|
|
593
|
+
logger.warning(f"Message queue full for session {session_id}: {e}")
|
|
594
|
+
raise HTTPException(
|
|
595
|
+
status_code=429,
|
|
596
|
+
detail=str(e),
|
|
597
|
+
headers={"Retry-After": "30"}
|
|
598
|
+
)
|
|
599
|
+
elif isinstance(e, ValueError):
|
|
600
|
+
error_str = str(e)
|
|
601
|
+
# Handle rate limit errors from HITL manager
|
|
602
|
+
if "Rate limit exceeded" in error_str:
|
|
603
|
+
logger.warning(f"Rate limit exceeded for session {session_id}")
|
|
604
|
+
raise HTTPException(
|
|
605
|
+
status_code=429,
|
|
606
|
+
detail=error_str,
|
|
607
|
+
headers={"Retry-After": "60"}
|
|
608
|
+
)
|
|
609
|
+
raise HTTPException(status_code=400, detail=error_str)
|
|
610
|
+
else:
|
|
611
|
+
logger.error(f"Error sending user message: {e}")
|
|
612
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
613
|
+
|
|
614
|
+
@router.post("/sessions/{session_id}/cancel", response_model=MessageResponse)
|
|
615
|
+
async def cancel_session(
|
|
616
|
+
session_id: str,
|
|
617
|
+
req: Request,
|
|
618
|
+
agent = Depends(get_task_agent)
|
|
619
|
+
):
|
|
620
|
+
"""Cancel a running session"""
|
|
621
|
+
try:
|
|
622
|
+
# OPTIMIZATION: Clean session ID once at API entry
|
|
623
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
624
|
+
|
|
625
|
+
session_info = await agent.get_session_by_id(session_id)
|
|
626
|
+
if not session_info:
|
|
627
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
628
|
+
|
|
629
|
+
# IDOR: the caller must own this session. cancel_session_by_id self-authorizes
|
|
630
|
+
# against the session's OWN user_id, so without this gate any authenticated
|
|
631
|
+
# tenant could force-cancel another tenant's in-flight (paid) run.
|
|
632
|
+
_require_session_owner(req, session_info.get('user_id'))
|
|
633
|
+
|
|
634
|
+
# Call agent's cancel method
|
|
635
|
+
success = await agent.cancel_session_by_id(session_id, force=True)
|
|
636
|
+
|
|
637
|
+
if success:
|
|
638
|
+
return MessageResponse(
|
|
639
|
+
success=True,
|
|
640
|
+
message="Session cancelled successfully"
|
|
641
|
+
)
|
|
642
|
+
else:
|
|
643
|
+
raise HTTPException(status_code=400, detail="Failed to cancel session")
|
|
644
|
+
|
|
645
|
+
except HTTPException:
|
|
646
|
+
raise
|
|
647
|
+
except Exception as e:
|
|
648
|
+
logger.error(f"Error cancelling session: {e}")
|
|
649
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
650
|
+
|
|
651
|
+
@router.get("/sessions/{session_id}", response_model=SessionStatusResponse)
|
|
652
|
+
async def get_session_status(
|
|
653
|
+
session_id: str,
|
|
654
|
+
req: Request,
|
|
655
|
+
agent = Depends(get_task_agent)
|
|
656
|
+
):
|
|
657
|
+
"""Get the status of a session with user-friendly status"""
|
|
658
|
+
try:
|
|
659
|
+
# OPTIMIZATION: Clean session ID once at API entry
|
|
660
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
661
|
+
|
|
662
|
+
session_info = await agent.get_session_by_id(session_id)
|
|
663
|
+
if not session_info:
|
|
664
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
665
|
+
|
|
666
|
+
# E8 (A6 gap 4): the caller must own this session — any authenticated
|
|
667
|
+
# caller could otherwise read another tenant's session (task, config, metadata).
|
|
668
|
+
_require_session_owner(req, session_info.get('user_id'))
|
|
669
|
+
|
|
670
|
+
# Get internal status
|
|
671
|
+
internal_status = normalize_status_value(session_info.get("status", "unknown"))
|
|
672
|
+
|
|
673
|
+
# Convert to user-facing status
|
|
674
|
+
from agents.task.agent.session import get_user_status
|
|
675
|
+
user_status = get_user_status(internal_status)
|
|
676
|
+
|
|
677
|
+
# Determine user capabilities
|
|
678
|
+
can_cancel = (user_status == "active")
|
|
679
|
+
can_message = True # Always allow messages
|
|
680
|
+
|
|
681
|
+
# Build response with user-facing status
|
|
682
|
+
response_data = {
|
|
683
|
+
"id": session_info.get("id", session_id),
|
|
684
|
+
"session_id": session_info.get("id", session_id),
|
|
685
|
+
"user_id": session_info.get("user_id", "_anonymous_"),
|
|
686
|
+
"task": session_info.get("task", "Unknown task"),
|
|
687
|
+
"status": user_status, # User-facing: active/idle/stopped
|
|
688
|
+
"can_cancel": can_cancel,
|
|
689
|
+
"can_send_message": can_message,
|
|
690
|
+
"created_at": session_info.get("created_at", datetime.now().isoformat()),
|
|
691
|
+
"last_updated": session_info.get("last_updated"),
|
|
692
|
+
"internal_status": internal_status, # For debugging
|
|
693
|
+
"agents": {}, # Simplified for user
|
|
694
|
+
"config": session_info.get("config", {}),
|
|
695
|
+
"metadata": session_info.get("metadata", {}),
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
# Extract model and tools from config if present
|
|
699
|
+
config = session_info.get("config", {})
|
|
700
|
+
response_data["model"] = config.get("model")
|
|
701
|
+
response_data["tools"] = config.get("tools", [])
|
|
702
|
+
|
|
703
|
+
# Add webview URL if present in metadata
|
|
704
|
+
metadata = session_info.get("metadata", {})
|
|
705
|
+
response_data["webview_url"] = metadata.get("webview_url")
|
|
706
|
+
|
|
707
|
+
return SessionStatusResponse(**response_data)
|
|
708
|
+
|
|
709
|
+
except HTTPException:
|
|
710
|
+
raise
|
|
711
|
+
except Exception as e:
|
|
712
|
+
logger.error(f"Error getting session status: {e}")
|
|
713
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
714
|
+
|
|
715
|
+
@router.get("/sessions/{session_id}/queue-status", response_model=dict)
|
|
716
|
+
async def get_queue_status(
|
|
717
|
+
session_id: str,
|
|
718
|
+
req: Request,
|
|
719
|
+
agent = Depends(get_task_agent)
|
|
720
|
+
):
|
|
721
|
+
"""Get message queue status for a session.
|
|
722
|
+
|
|
723
|
+
Returns queue status for active sessions, or final status for completed/failed sessions.
|
|
724
|
+
This prevents 404 errors when WebView polls completed sessions.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
session_id: Session identifier
|
|
728
|
+
agent: TaskAgent dependency injection
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
{
|
|
732
|
+
"queued_messages": int,
|
|
733
|
+
"agent_status": str,
|
|
734
|
+
"session_completed": bool,
|
|
735
|
+
"streaming_callbacks": int,
|
|
736
|
+
"callback_failures": int
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
Raises:
|
|
740
|
+
HTTPException 404: Session not found
|
|
741
|
+
"""
|
|
742
|
+
try:
|
|
743
|
+
# Clean session ID
|
|
744
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
745
|
+
|
|
746
|
+
# Item 6: honest 409 (not false-404) if owned by another worker. No-op locally.
|
|
747
|
+
guard_remote(agent, session_id)
|
|
748
|
+
|
|
749
|
+
# IDOR: fetch metadata and confirm the caller owns this session BEFORE exposing
|
|
750
|
+
# runtime state (agent_status/queued/callbacks) or using 404-vs-200 as a
|
|
751
|
+
# session-existence enumeration oracle.
|
|
752
|
+
session_info = await agent.get_session_by_id(session_id)
|
|
753
|
+
if not session_info:
|
|
754
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
755
|
+
_require_session_owner(req, session_info.get('user_id'))
|
|
756
|
+
|
|
757
|
+
# Try to get orchestrator from active sessions
|
|
758
|
+
orchestrator = agent.get_orchestrator(session_id)
|
|
759
|
+
|
|
760
|
+
if not orchestrator:
|
|
761
|
+
# ✅ FIX #4: session exists but is not active - return final status
|
|
762
|
+
session_status = normalize_status_value(session_info.get('status', 'unknown'))
|
|
763
|
+
|
|
764
|
+
if session_status in ['completed', 'failed', 'suspended', 'error']:
|
|
765
|
+
logger.debug(f"Session {session_id} is {session_status}, returning final status")
|
|
766
|
+
return {
|
|
767
|
+
"queued_messages": 0,
|
|
768
|
+
"agent_status": session_status,
|
|
769
|
+
"session_completed": True,
|
|
770
|
+
"streaming_callbacks": 0,
|
|
771
|
+
"callback_failures": 0,
|
|
772
|
+
"message": f"Session {session_status}"
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
# Session exists but has no active orchestrator and a non-final status
|
|
776
|
+
raise HTTPException(
|
|
777
|
+
status_code=404,
|
|
778
|
+
detail=f"Session {session_id} not found"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# Session is active - return current status
|
|
782
|
+
agents = orchestrator.agents
|
|
783
|
+
if not agents:
|
|
784
|
+
logger.warning(f"Session {session_id} orchestrator has no agents")
|
|
785
|
+
return {
|
|
786
|
+
"queued_messages": 0,
|
|
787
|
+
"agent_status": "no_agent",
|
|
788
|
+
"session_completed": False,
|
|
789
|
+
"streaming_callbacks": 0,
|
|
790
|
+
"callback_failures": 0
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
# Get first agent (usually there's only one)
|
|
794
|
+
first_agent = next(iter(agents.values()))
|
|
795
|
+
|
|
796
|
+
# Check if agent has HITL manager
|
|
797
|
+
if not hasattr(first_agent, 'hitl_manager'):
|
|
798
|
+
logger.warning(f"Agent {first_agent.agent_id} has no hitl_manager")
|
|
799
|
+
return {
|
|
800
|
+
"queued_messages": 0,
|
|
801
|
+
"agent_status": "no_hitl",
|
|
802
|
+
"session_completed": False,
|
|
803
|
+
"streaming_callbacks": 0,
|
|
804
|
+
"callback_failures": 0
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
# Get stats from HITL manager
|
|
808
|
+
stats = first_agent.hitl_manager.get_stats()
|
|
809
|
+
|
|
810
|
+
# Determine agent status
|
|
811
|
+
agent_status = "running"
|
|
812
|
+
if hasattr(first_agent, 'is_done') and first_agent.is_done:
|
|
813
|
+
agent_status = "done"
|
|
814
|
+
elif hasattr(first_agent, 'is_running') and not first_agent.is_running:
|
|
815
|
+
agent_status = "idle"
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
"queued_messages": stats.get("queued_messages", 0),
|
|
819
|
+
"agent_status": agent_status,
|
|
820
|
+
"session_completed": False,
|
|
821
|
+
"streaming_callbacks": stats.get("streaming_callbacks", 0),
|
|
822
|
+
"callback_failures": stats.get("callback_failures", 0)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
except HTTPException:
|
|
826
|
+
raise
|
|
827
|
+
except Exception as e:
|
|
828
|
+
logger.error(f"Error getting queue status: {e}")
|
|
829
|
+
raise HTTPException(
|
|
830
|
+
status_code=500,
|
|
831
|
+
detail=f"Internal error: {str(e)}"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
@router.get("/users/{user_id}/sessions", response_model=Dict[str, Any])
|
|
835
|
+
async def get_user_sessions(
|
|
836
|
+
user_id: str,
|
|
837
|
+
req: Request,
|
|
838
|
+
agent = Depends(get_task_agent)
|
|
839
|
+
):
|
|
840
|
+
"""Get all sessions for a user"""
|
|
841
|
+
try:
|
|
842
|
+
# E8 (A6 gap 4): the caller must be the user whose sessions are requested —
|
|
843
|
+
# any authenticated caller could otherwise list another tenant's sessions.
|
|
844
|
+
_require_session_owner(req, user_id)
|
|
845
|
+
|
|
846
|
+
# Get all sessions for user from agent's active sessions (nested structure)
|
|
847
|
+
user_sessions = []
|
|
848
|
+
if hasattr(agent, 'active_sessions') and user_id in agent.active_sessions:
|
|
849
|
+
for sid, session_data in agent.active_sessions[user_id].items():
|
|
850
|
+
session_info = await agent.get_session_by_id(sid)
|
|
851
|
+
if session_info:
|
|
852
|
+
user_sessions.append(session_info)
|
|
853
|
+
|
|
854
|
+
# Get active session from agent state
|
|
855
|
+
active_session_id = None
|
|
856
|
+
if hasattr(agent, 'user_sessions') and user_id in agent.user_sessions:
|
|
857
|
+
active_session_id = agent.user_sessions.get(user_id)
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
"ok": True,
|
|
861
|
+
"sessions": user_sessions,
|
|
862
|
+
"active_session_id": active_session_id
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
except HTTPException:
|
|
866
|
+
raise
|
|
867
|
+
except Exception as e:
|
|
868
|
+
logger.error(f"Error getting user sessions: {e}")
|
|
869
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
870
|
+
|
|
871
|
+
@router.post("/users/{user_id}/active_session", response_model=MessageResponse)
|
|
872
|
+
async def switch_active_session(
|
|
873
|
+
user_id: str,
|
|
874
|
+
request: Dict[str, str],
|
|
875
|
+
req: Request,
|
|
876
|
+
agent = Depends(get_task_agent)
|
|
877
|
+
):
|
|
878
|
+
"""Switch the active session for a user"""
|
|
879
|
+
try:
|
|
880
|
+
# E8 (A6 gap 4): the caller must be the user whose active-session pointer
|
|
881
|
+
# is being changed — any authenticated caller could otherwise hijack it.
|
|
882
|
+
_require_session_owner(req, user_id)
|
|
883
|
+
|
|
884
|
+
session_id = request.get("session_id")
|
|
885
|
+
if not session_id:
|
|
886
|
+
raise HTTPException(status_code=400, detail="session_id is required")
|
|
887
|
+
|
|
888
|
+
# Set active session through agent
|
|
889
|
+
if hasattr(agent, 'user_sessions'):
|
|
890
|
+
agent.user_sessions[user_id] = session_id
|
|
891
|
+
success = True
|
|
892
|
+
else:
|
|
893
|
+
success = False
|
|
894
|
+
|
|
895
|
+
if success:
|
|
896
|
+
return MessageResponse(
|
|
897
|
+
success=True,
|
|
898
|
+
message=f"Switched to session {session_id}",
|
|
899
|
+
metadata={"session_id": session_id}
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
raise HTTPException(status_code=400, detail="Failed to switch session")
|
|
903
|
+
|
|
904
|
+
except HTTPException:
|
|
905
|
+
raise
|
|
906
|
+
except Exception as e:
|
|
907
|
+
logger.error(f"Error switching session: {e}")
|
|
908
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
909
|
+
|
|
910
|
+
@router.get("/capabilities")
|
|
911
|
+
async def get_capabilities(request: Request):
|
|
912
|
+
"""Get system capabilities including available models and tools."""
|
|
913
|
+
try:
|
|
914
|
+
from core.container import DependencyContainer
|
|
915
|
+
container = DependencyContainer.get_instance()
|
|
916
|
+
|
|
917
|
+
# FIXED: LLMManager is registered as 'llm', not 'llm_manager'
|
|
918
|
+
llm_manager = container.get_service("llm")
|
|
919
|
+
|
|
920
|
+
# Get available models (only from initialized providers)
|
|
921
|
+
# Include pricing and context info from registry
|
|
922
|
+
from modules.llm.model_registry import get_model_config
|
|
923
|
+
|
|
924
|
+
available_models = []
|
|
925
|
+
if llm_manager and hasattr(llm_manager, 'get_available_models'):
|
|
926
|
+
model_tuples = await llm_manager.get_available_models()
|
|
927
|
+
for provider, model in model_tuples:
|
|
928
|
+
model_info = {"provider": provider, "model": model}
|
|
929
|
+
# Enrich with registry data
|
|
930
|
+
config = get_model_config(model)
|
|
931
|
+
if config:
|
|
932
|
+
model_info["context_window"] = config.context_window
|
|
933
|
+
model_info["max_output"] = config.max_completion_tokens
|
|
934
|
+
if config.pricing:
|
|
935
|
+
model_info["price_input"] = config.pricing.input_price
|
|
936
|
+
model_info["price_output"] = config.pricing.output_price
|
|
937
|
+
available_models.append(model_info)
|
|
938
|
+
else:
|
|
939
|
+
# P0.6: fallback (no llm_manager) now rides the ONE catalog too. Import the
|
|
940
|
+
# builder under an alias to avoid shadowing the local `available_models` list.
|
|
941
|
+
from modules.llm.available_models import available_models as _build_models
|
|
942
|
+
for c in _build_models():
|
|
943
|
+
model_info = {"provider": c.provider, "model": c.model}
|
|
944
|
+
config = get_model_config(c.model)
|
|
945
|
+
if config:
|
|
946
|
+
model_info["context_window"] = config.context_window
|
|
947
|
+
model_info["max_output"] = config.max_completion_tokens
|
|
948
|
+
if config.pricing:
|
|
949
|
+
model_info["price_input"] = config.pricing.input_price
|
|
950
|
+
model_info["price_output"] = config.pricing.output_price
|
|
951
|
+
available_models.append(model_info)
|
|
952
|
+
|
|
953
|
+
# Get available tools dynamically from descriptors
|
|
954
|
+
# Uses centralized functions to ensure consistency across codebase
|
|
955
|
+
from tools.descriptors import get_agent_usable_tools, get_tool_display_name, get_default_tools
|
|
956
|
+
|
|
957
|
+
agent_tools = get_agent_usable_tools()
|
|
958
|
+
available_tools = {}
|
|
959
|
+
|
|
960
|
+
for tool_name, descriptor in agent_tools.items():
|
|
961
|
+
# Get display name (e.g., browser_manager -> browser)
|
|
962
|
+
display_name = get_tool_display_name(tool_name)
|
|
963
|
+
|
|
964
|
+
# Check if tool is initialized in container
|
|
965
|
+
tool = container.get_service(tool_name)
|
|
966
|
+
initialized = tool is not None
|
|
967
|
+
|
|
968
|
+
# Special case: browser_manager is registered as 'browser_manager' but displayed as 'browser'
|
|
969
|
+
if tool_name == 'browser_manager' and not initialized:
|
|
970
|
+
# Check if already checked
|
|
971
|
+
pass # Already using tool_name for lookup
|
|
972
|
+
|
|
973
|
+
available_tools[display_name] = {
|
|
974
|
+
"name": display_name,
|
|
975
|
+
"initialized": initialized,
|
|
976
|
+
"description": descriptor.description,
|
|
977
|
+
"category": descriptor.category.value if descriptor.category else "other"
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
# Get default tools from centralized source
|
|
981
|
+
default_tools_list = get_default_tools()
|
|
982
|
+
|
|
983
|
+
# Get default configuration
|
|
984
|
+
config = container.config
|
|
985
|
+
# Default to Grok 4.1 Fast for task agent (best agentic model)
|
|
986
|
+
default_model = getattr(config, 'model', 'x-ai/grok-4.1-fast')
|
|
987
|
+
default_provider = getattr(config, 'provider', 'openrouter')
|
|
988
|
+
|
|
989
|
+
# Get MCP server information if available, including connection status
|
|
990
|
+
mcp_servers = {"global": [], "user": [], "include_global": True}
|
|
991
|
+
mcp_tool = container.get_service('mcp')
|
|
992
|
+
if mcp_tool and hasattr(mcp_tool, 'server_manager') and mcp_tool.server_manager:
|
|
993
|
+
try:
|
|
994
|
+
# Get detailed server info including status
|
|
995
|
+
servers_info = await mcp_tool.server_manager.list_servers()
|
|
996
|
+
for server_info in servers_info:
|
|
997
|
+
name = server_info.get('name', '')
|
|
998
|
+
status = server_info.get('status', 'unknown')
|
|
999
|
+
last_error = server_info.get('last_error', None)
|
|
1000
|
+
tools_count = server_info.get('tools_count', 0)
|
|
1001
|
+
|
|
1002
|
+
server_entry = {
|
|
1003
|
+
"name": name,
|
|
1004
|
+
"tool_id": f"mcp:{name}",
|
|
1005
|
+
"type": "global",
|
|
1006
|
+
"status": status, # connected, error, disconnected
|
|
1007
|
+
"tools_count": tools_count
|
|
1008
|
+
}
|
|
1009
|
+
# Include error message if server failed to connect
|
|
1010
|
+
if status == "error" and last_error:
|
|
1011
|
+
server_entry["error"] = last_error
|
|
1012
|
+
|
|
1013
|
+
mcp_servers["global"].append(server_entry)
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
logger.warning(f"Failed to get MCP server details: {e}")
|
|
1016
|
+
# Fallback to basic listing
|
|
1017
|
+
if hasattr(mcp_tool, 'get_global_server_names'):
|
|
1018
|
+
global_server_names = mcp_tool.get_global_server_names()
|
|
1019
|
+
for full_name in global_server_names:
|
|
1020
|
+
name = full_name.replace("global::", "")
|
|
1021
|
+
mcp_servers["global"].append({
|
|
1022
|
+
"name": name,
|
|
1023
|
+
"tool_id": f"mcp:{name}",
|
|
1024
|
+
"type": "global",
|
|
1025
|
+
"status": "unknown"
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
# Get user MCP servers if authenticated
|
|
1029
|
+
user_id = getattr(request.state, 'user_id', None)
|
|
1030
|
+
if user_id:
|
|
1031
|
+
user_mcp_service = container.get_service('user_mcp_service')
|
|
1032
|
+
if user_mcp_service:
|
|
1033
|
+
try:
|
|
1034
|
+
result = await user_mcp_service.get_available_servers_for_session(user_id)
|
|
1035
|
+
mcp_servers["user"] = result.get("user", [])
|
|
1036
|
+
mcp_servers["include_global"] = result.get("include_global", True)
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
logger.warning(f"Failed to get user MCP servers: {e}")
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
"models": available_models,
|
|
1042
|
+
"tools": available_tools,
|
|
1043
|
+
"mcp_servers": mcp_servers,
|
|
1044
|
+
"default_model": default_model,
|
|
1045
|
+
"default_provider": default_provider,
|
|
1046
|
+
"default_tools": default_tools_list # From descriptors.get_default_tools()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
except Exception as e:
|
|
1050
|
+
logger.error(f"Error getting capabilities: {e}")
|
|
1051
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1052
|
+
|
|
1053
|
+
@router.post("/sessions")
|
|
1054
|
+
async def create_session(
|
|
1055
|
+
request_body: Dict[str, Any],
|
|
1056
|
+
req: Request,
|
|
1057
|
+
agent = Depends(get_task_agent)
|
|
1058
|
+
):
|
|
1059
|
+
"""Create and start a new Task session.
|
|
1060
|
+
|
|
1061
|
+
BETA: Requires DEN token ownership for session creation.
|
|
1062
|
+
Supports both credits (for authenticated users) and x402 payment.
|
|
1063
|
+
"""
|
|
1064
|
+
try:
|
|
1065
|
+
from agents.task_agent_lite import SessionRequest
|
|
1066
|
+
from api.payment_verification import verify_payment_for_request, payment_required_response
|
|
1067
|
+
from fastapi.responses import JSONResponse
|
|
1068
|
+
from core.exceptions import SessionOwnershipError
|
|
1069
|
+
|
|
1070
|
+
# Extract parameters with defaults
|
|
1071
|
+
task = request_body.get("task")
|
|
1072
|
+
if not task:
|
|
1073
|
+
raise HTTPException(status_code=400, detail="Task is required")
|
|
1074
|
+
|
|
1075
|
+
# PAYMENT VERIFICATION (NEW)
|
|
1076
|
+
# This fixes the critical gap: endpoints now verify payment before execution
|
|
1077
|
+
try:
|
|
1078
|
+
payment_method, payment_details = await verify_payment_for_request(
|
|
1079
|
+
request=req,
|
|
1080
|
+
cost_credits=1 # Session creation = 1 credit ($0.01) - LLM costs metered separately
|
|
1081
|
+
)
|
|
1082
|
+
logger.info(f"Payment verified via {payment_method}: {payment_details}")
|
|
1083
|
+
except HTTPException as e:
|
|
1084
|
+
if e.status_code == 402:
|
|
1085
|
+
# Return payment options (credits + x402)
|
|
1086
|
+
response_data = payment_required_response(req, cost_credits=1)
|
|
1087
|
+
return JSONResponse(status_code=402, content=response_data)
|
|
1088
|
+
raise
|
|
1089
|
+
|
|
1090
|
+
# SECURITY: Get user_id from authenticated JWT token or x402 payment
|
|
1091
|
+
# Request body user_id is ignored to prevent session hijacking
|
|
1092
|
+
from utils.auth_utils import get_authenticated_user_id, get_user_tier
|
|
1093
|
+
import os
|
|
1094
|
+
|
|
1095
|
+
# If paid via x402, get user_id from request state (set by middleware)
|
|
1096
|
+
if payment_method == "x402":
|
|
1097
|
+
# Middleware sets proper user_id (usr_xxx format) from wallet
|
|
1098
|
+
user_id = getattr(req.state, 'user_id', None)
|
|
1099
|
+
if not user_id:
|
|
1100
|
+
# Fallback: generate from payer_address
|
|
1101
|
+
from core.identity import generate_user_id_from_wallet
|
|
1102
|
+
payer_address = payment_details.get("payer_address")
|
|
1103
|
+
if payer_address:
|
|
1104
|
+
user_id = generate_user_id_from_wallet(payer_address)
|
|
1105
|
+
else:
|
|
1106
|
+
user_id = "x402_user"
|
|
1107
|
+
tier = "x402" # x402 users have special tier
|
|
1108
|
+
else:
|
|
1109
|
+
user_id = get_authenticated_user_id(req)
|
|
1110
|
+
tier = get_user_tier(req)
|
|
1111
|
+
|
|
1112
|
+
logger.info(f"Creating session for user: {user_id} (tier: {tier}, payment: {payment_method})")
|
|
1113
|
+
|
|
1114
|
+
# BETA ACCESS: Only allow tiers with full access
|
|
1115
|
+
# - free: BLOCKED (no DEN token, no admin grant)
|
|
1116
|
+
# - free_access: ALLOWED (admin-granted access, uses credits)
|
|
1117
|
+
# - holder: ALLOWED (has DEN token)
|
|
1118
|
+
# - x402: ALLOWED (pay-per-request)
|
|
1119
|
+
# - admin: ALLOWED (via admin_bypass payment method)
|
|
1120
|
+
from api.auth_constants import has_full_access
|
|
1121
|
+
is_development = os.getenv('ENVIRONMENT', 'production') == 'development'
|
|
1122
|
+
is_admin_user = payment_method == "admin_bypass"
|
|
1123
|
+
|
|
1124
|
+
if not is_development and not is_admin_user and not has_full_access(tier):
|
|
1125
|
+
raise HTTPException(
|
|
1126
|
+
status_code=403,
|
|
1127
|
+
detail="Beta access: Task automation requires DEN token ownership or admin-granted access. Join https://t.me/tmachinrobot for access."
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
# Build session config with tools_config if provided
|
|
1131
|
+
from agents.task.config import TaskSessionConfig
|
|
1132
|
+
from agents.task.utils import detect_llm_provider
|
|
1133
|
+
|
|
1134
|
+
session_config = TaskSessionConfig.defaults()
|
|
1135
|
+
session_config.llm.model = request_body.get("model", "gpt-5")
|
|
1136
|
+
|
|
1137
|
+
# Auto-detect provider from model name if not specified
|
|
1138
|
+
if "provider" in request_body:
|
|
1139
|
+
session_config.llm.provider = request_body["provider"]
|
|
1140
|
+
else:
|
|
1141
|
+
session_config.llm.provider = detect_llm_provider(None, session_config.llm.model)
|
|
1142
|
+
logger.info(f"Auto-detected provider '{session_config.llm.provider}' for model '{session_config.llm.model}'")
|
|
1143
|
+
|
|
1144
|
+
session_config.llm.temperature = request_body.get("temperature", 0.0)
|
|
1145
|
+
|
|
1146
|
+
# Auto-detect vision support from model registry if not explicitly set
|
|
1147
|
+
if "use_vision" in request_body:
|
|
1148
|
+
session_config.llm.use_vision = request_body["use_vision"]
|
|
1149
|
+
else:
|
|
1150
|
+
# Check model registry for vision support
|
|
1151
|
+
from modules.llm.model_registry import get_model_config
|
|
1152
|
+
model_config = get_model_config(session_config.llm.model)
|
|
1153
|
+
if model_config and model_config.capabilities:
|
|
1154
|
+
session_config.llm.use_vision = model_config.capabilities.supports_vision
|
|
1155
|
+
logger.info(f"Auto-detected vision support for '{session_config.llm.model}': {session_config.llm.use_vision}")
|
|
1156
|
+
else:
|
|
1157
|
+
session_config.llm.use_vision = False # Conservative default for unknown models
|
|
1158
|
+
logger.warning(f"Model '{session_config.llm.model}' not in registry - defaulting to no vision")
|
|
1159
|
+
|
|
1160
|
+
session_config.limits.max_steps = request_body.get("max_steps", 50)
|
|
1161
|
+
session_config.tools = request_body.get("tools", ["browser", "filesystem"])
|
|
1162
|
+
|
|
1163
|
+
# Handle MCP server selection
|
|
1164
|
+
# mcp_servers is a list of tool_ids like ["mcp:anysite", "mcp:user:myserver"]
|
|
1165
|
+
mcp_servers = request_body.get("mcp_servers", [])
|
|
1166
|
+
if mcp_servers:
|
|
1167
|
+
# Add 'mcp' to tools if not present and we have MCP servers selected
|
|
1168
|
+
if "mcp" not in session_config.tools:
|
|
1169
|
+
session_config.tools.append("mcp")
|
|
1170
|
+
# Store requested MCP servers in tools_config for the orchestrator
|
|
1171
|
+
if not hasattr(session_config, 'tools_config') or session_config.tools_config is None:
|
|
1172
|
+
session_config.tools_config = {}
|
|
1173
|
+
session_config.tools_config["mcp_servers"] = mcp_servers
|
|
1174
|
+
logger.info(f"Session configured with MCP servers: {mcp_servers}")
|
|
1175
|
+
|
|
1176
|
+
# Add tools_config if provided
|
|
1177
|
+
if "tools_config" in request_body:
|
|
1178
|
+
if not hasattr(session_config, 'tools_config') or session_config.tools_config is None:
|
|
1179
|
+
session_config.tools_config = {}
|
|
1180
|
+
session_config.tools_config.update(request_body.get("tools_config", {}))
|
|
1181
|
+
|
|
1182
|
+
# Create session request with session_config
|
|
1183
|
+
session_request = SessionRequest(
|
|
1184
|
+
task=task,
|
|
1185
|
+
model=session_config.llm.model,
|
|
1186
|
+
provider=session_config.llm.provider,
|
|
1187
|
+
tools=session_config.tools,
|
|
1188
|
+
max_steps=session_config.limits.max_steps,
|
|
1189
|
+
temperature=session_config.llm.temperature,
|
|
1190
|
+
use_vision=session_config.llm.use_vision,
|
|
1191
|
+
session_config=session_config.to_dict()
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
# user_id already extracted above (line 732)
|
|
1195
|
+
session_id = request_body.get("session_id") # Allow CLI to specify
|
|
1196
|
+
|
|
1197
|
+
# Create session
|
|
1198
|
+
try:
|
|
1199
|
+
session_info = await agent.create_session(
|
|
1200
|
+
user_id,
|
|
1201
|
+
session_request,
|
|
1202
|
+
session_id=session_id
|
|
1203
|
+
)
|
|
1204
|
+
except SessionOwnershipError as owner_error:
|
|
1205
|
+
# C4: client-supplied session_id belongs to another user.
|
|
1206
|
+
raise HTTPException(status_code=403, detail=str(owner_error))
|
|
1207
|
+
except Exception as create_error:
|
|
1208
|
+
logger.error(f"Session creation failed: {create_error}", exc_info=True)
|
|
1209
|
+
raise HTTPException(status_code=500, detail=f"Failed to create session: {str(create_error)}")
|
|
1210
|
+
|
|
1211
|
+
if not session_info:
|
|
1212
|
+
raise HTTPException(status_code=500, detail="Failed to create session - no session info returned")
|
|
1213
|
+
|
|
1214
|
+
session_id = session_info.get("id")
|
|
1215
|
+
|
|
1216
|
+
# Create workspace directory immediately to prevent race condition with file uploads
|
|
1217
|
+
from agents.task.path import pm
|
|
1218
|
+
workspace_dir = pm().get_workspace_dir(session_id, user_id)
|
|
1219
|
+
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
1220
|
+
logger.info(f"Created workspace directory for session {session_id}: {workspace_dir}")
|
|
1221
|
+
|
|
1222
|
+
# Generate WebView URL using the utility function (single source of truth)
|
|
1223
|
+
from agents.task.utils_webview import get_webview_url
|
|
1224
|
+
webview_url = get_webview_url(session_id)
|
|
1225
|
+
|
|
1226
|
+
# Store in session metadata
|
|
1227
|
+
if hasattr(agent, 'session_manager'):
|
|
1228
|
+
try:
|
|
1229
|
+
agent.session_manager.update_session_metadata(
|
|
1230
|
+
session_id, {"webview_url": webview_url}
|
|
1231
|
+
)
|
|
1232
|
+
except Exception as e:
|
|
1233
|
+
logger.debug(f"Failed to store webview URL in metadata: {e}")
|
|
1234
|
+
|
|
1235
|
+
# AUTO-START: Check if client wants to delay start (for file uploads)
|
|
1236
|
+
# CRITICAL: Log the received value for debugging
|
|
1237
|
+
auto_start_requested = request_body.get("auto_start", True)
|
|
1238
|
+
logger.info(f"📋 Session creation auto_start parameter: {auto_start_requested} (type: {type(auto_start_requested).__name__})")
|
|
1239
|
+
|
|
1240
|
+
# CRITICAL FIX: Force auto_start=false if files will be attached
|
|
1241
|
+
# Don't trust frontend - enforce on backend to prevent race conditions
|
|
1242
|
+
wait_for_uploads = request_body.get("wait_for_uploads", False)
|
|
1243
|
+
has_attached_files = request_body.get("attached_files") and len(request_body.get("attached_files", [])) > 0
|
|
1244
|
+
|
|
1245
|
+
if wait_for_uploads or has_attached_files:
|
|
1246
|
+
auto_start = False
|
|
1247
|
+
logger.info(f"🔒 FORCING auto_start=false (wait_for_uploads={wait_for_uploads}, has_attached_files={has_attached_files})")
|
|
1248
|
+
else:
|
|
1249
|
+
auto_start = auto_start_requested
|
|
1250
|
+
logger.info(f"✓ Using requested auto_start={auto_start}")
|
|
1251
|
+
|
|
1252
|
+
if auto_start:
|
|
1253
|
+
# Start agent execution in background with the task
|
|
1254
|
+
logger.info(f"Auto-starting session {session_id} with task: {task[:100]}...")
|
|
1255
|
+
|
|
1256
|
+
import asyncio
|
|
1257
|
+
|
|
1258
|
+
async def start_session_with_task():
|
|
1259
|
+
try:
|
|
1260
|
+
logger.info(f"Starting session execution for {session_id}")
|
|
1261
|
+
result = await agent.run_session(user_id, session_id)
|
|
1262
|
+
logger.info(f"Session {session_id} completed with result: {result}")
|
|
1263
|
+
except Exception as e:
|
|
1264
|
+
logger.error(f"Session {session_id} failed with error: {e}", exc_info=True)
|
|
1265
|
+
|
|
1266
|
+
_spawn_session_task(start_session_with_task())
|
|
1267
|
+
|
|
1268
|
+
# Return immediately - agent runs in background
|
|
1269
|
+
return SessionResponse(
|
|
1270
|
+
ok=True,
|
|
1271
|
+
session_id=session_id,
|
|
1272
|
+
task=task,
|
|
1273
|
+
status="running",
|
|
1274
|
+
model=session_request.model,
|
|
1275
|
+
tools=session_request.tools,
|
|
1276
|
+
webview_url=webview_url,
|
|
1277
|
+
message="Session created and started successfully"
|
|
1278
|
+
)
|
|
1279
|
+
else:
|
|
1280
|
+
# Client will upload files and send first message manually
|
|
1281
|
+
logger.info(f"Created session {session_id} for user {user_id} (waiting for first message)")
|
|
1282
|
+
return SessionResponse(
|
|
1283
|
+
ok=True,
|
|
1284
|
+
session_id=session_id,
|
|
1285
|
+
task=task,
|
|
1286
|
+
status="created",
|
|
1287
|
+
model=session_request.model,
|
|
1288
|
+
tools=session_request.tools,
|
|
1289
|
+
webview_url=webview_url,
|
|
1290
|
+
message="Session created - send a message to start"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
except HTTPException:
|
|
1294
|
+
raise
|
|
1295
|
+
except Exception as e:
|
|
1296
|
+
logger.error(f"Error creating session: {e}")
|
|
1297
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1298
|
+
|
|
1299
|
+
@router.get("/metrics", response_model=dict)
|
|
1300
|
+
async def get_resource_metrics(agent = Depends(get_task_agent)):
|
|
1301
|
+
"""Get current resource usage metrics.
|
|
1302
|
+
|
|
1303
|
+
Returns:
|
|
1304
|
+
Resource usage statistics for monitoring
|
|
1305
|
+
"""
|
|
1306
|
+
try:
|
|
1307
|
+
import psutil
|
|
1308
|
+
import os
|
|
1309
|
+
|
|
1310
|
+
# Get process memory
|
|
1311
|
+
process = psutil.Process(os.getpid())
|
|
1312
|
+
memory_info = process.memory_info()
|
|
1313
|
+
|
|
1314
|
+
# Session metrics
|
|
1315
|
+
sessions_in_memory = agent.active_session_count()
|
|
1316
|
+
max_sessions = agent.max_sessions_in_memory
|
|
1317
|
+
|
|
1318
|
+
# Browser metrics
|
|
1319
|
+
browser_manager = None
|
|
1320
|
+
if agent.container and agent.container.has_service('browser_manager'):
|
|
1321
|
+
browser_manager = agent.container.get_service('browser_manager')
|
|
1322
|
+
|
|
1323
|
+
browser_metrics = {}
|
|
1324
|
+
if browser_manager:
|
|
1325
|
+
browser_metrics = {
|
|
1326
|
+
"contexts_in_use": len(browser_manager.contexts_in_use),
|
|
1327
|
+
"max_contexts": browser_manager.browser_config.max_contexts,
|
|
1328
|
+
"contexts_in_pool": len(browser_manager.context_pool),
|
|
1329
|
+
"wait_queue_depth": len(browser_manager._wait_queue),
|
|
1330
|
+
"utilization_pct": (len(browser_manager.contexts_in_use) / browser_manager.browser_config.max_contexts * 100) if browser_manager.browser_config.max_contexts > 0 else 0
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
# Session breakdown by status
|
|
1334
|
+
all_sessions = agent.get_all_sessions()
|
|
1335
|
+
status_counts = {}
|
|
1336
|
+
for session_id, session_info in all_sessions.items():
|
|
1337
|
+
status = session_info.get('status', 'unknown')
|
|
1338
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
1339
|
+
|
|
1340
|
+
# User distribution
|
|
1341
|
+
user_session_counts = {}
|
|
1342
|
+
for session_id, session_info in all_sessions.items():
|
|
1343
|
+
user_id = session_info.get('user_id', '_anonymous_')
|
|
1344
|
+
user_session_counts[user_id] = user_session_counts.get(user_id, 0) + 1
|
|
1345
|
+
|
|
1346
|
+
# Message queue metrics
|
|
1347
|
+
total_queued_messages = 0
|
|
1348
|
+
for orchestrator in agent.active_orchestrators():
|
|
1349
|
+
for agent_obj in orchestrator.agents.values():
|
|
1350
|
+
if hasattr(agent_obj, 'hitl_manager') and agent_obj.hitl_manager:
|
|
1351
|
+
total_queued_messages += agent_obj.hitl_manager.get_queue_size()
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
"timestamp": datetime.now().isoformat(),
|
|
1355
|
+
"sessions": {
|
|
1356
|
+
"in_memory": sessions_in_memory,
|
|
1357
|
+
"max_allowed": max_sessions,
|
|
1358
|
+
"utilization_pct": (sessions_in_memory / max_sessions * 100) if max_sessions > 0 else 0,
|
|
1359
|
+
"by_status": status_counts,
|
|
1360
|
+
"total_across_all_users": len(all_sessions)
|
|
1361
|
+
},
|
|
1362
|
+
"browser": browser_metrics,
|
|
1363
|
+
"memory": {
|
|
1364
|
+
"process_mb": memory_info.rss / 1024 / 1024,
|
|
1365
|
+
"process_mb_peak": memory_info.vms / 1024 / 1024,
|
|
1366
|
+
"system_available_mb": psutil.virtual_memory().available / 1024 / 1024,
|
|
1367
|
+
"system_total_mb": psutil.virtual_memory().total / 1024 / 1024,
|
|
1368
|
+
"system_used_pct": psutil.virtual_memory().percent
|
|
1369
|
+
},
|
|
1370
|
+
"messages": {
|
|
1371
|
+
"total_queued": total_queued_messages,
|
|
1372
|
+
"queues_active": sum(1 for o in agent.active_orchestrators() for a in o.agents.values() if hasattr(a, 'hitl_manager'))
|
|
1373
|
+
},
|
|
1374
|
+
"users": {
|
|
1375
|
+
"total_users": len(user_session_counts),
|
|
1376
|
+
"sessions_per_user": user_session_counts,
|
|
1377
|
+
"top_users": sorted(user_session_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
except Exception as e:
|
|
1382
|
+
logger.error(f"Error getting metrics: {e}")
|
|
1383
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1384
|
+
|
|
1385
|
+
async def verify_files_ready(
|
|
1386
|
+
file_paths: List[str],
|
|
1387
|
+
session_id: str,
|
|
1388
|
+
user_id: str,
|
|
1389
|
+
max_wait_seconds: float = 10.0
|
|
1390
|
+
) -> tuple[bool, List[str]]:
|
|
1391
|
+
"""Verify that all uploaded files exist and are readable.
|
|
1392
|
+
|
|
1393
|
+
Implements explicit synchronization instead of relying on timing.
|
|
1394
|
+
Retries with exponential backoff to handle filesystem latency.
|
|
1395
|
+
|
|
1396
|
+
Args:
|
|
1397
|
+
file_paths: List of relative file paths (e.g., ['image.png'])
|
|
1398
|
+
session_id: Session ID for workspace resolution
|
|
1399
|
+
user_id: User ID for workspace resolution
|
|
1400
|
+
max_wait_seconds: Maximum time to wait for files (default 10s)
|
|
1401
|
+
|
|
1402
|
+
Returns:
|
|
1403
|
+
tuple: (all_ready: bool, missing_files: List[str])
|
|
1404
|
+
"""
|
|
1405
|
+
from pathlib import Path
|
|
1406
|
+
from agents.task.path import pm
|
|
1407
|
+
import asyncio
|
|
1408
|
+
|
|
1409
|
+
workspace_dir = pm().get_workspace_dir(session_id, user_id)
|
|
1410
|
+
|
|
1411
|
+
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, 3.2s, ...
|
|
1412
|
+
wait_time = 0.1
|
|
1413
|
+
total_waited = 0.0
|
|
1414
|
+
attempt = 0
|
|
1415
|
+
|
|
1416
|
+
while total_waited < max_wait_seconds:
|
|
1417
|
+
attempt += 1
|
|
1418
|
+
missing_files = []
|
|
1419
|
+
unreadable_files = []
|
|
1420
|
+
|
|
1421
|
+
for file_path in file_paths:
|
|
1422
|
+
full_path = workspace_dir / file_path
|
|
1423
|
+
|
|
1424
|
+
# Check existence
|
|
1425
|
+
if not full_path.exists():
|
|
1426
|
+
missing_files.append(file_path)
|
|
1427
|
+
continue
|
|
1428
|
+
|
|
1429
|
+
# Check readability and size
|
|
1430
|
+
try:
|
|
1431
|
+
stat = full_path.stat()
|
|
1432
|
+
if stat.st_size == 0:
|
|
1433
|
+
unreadable_files.append(f"{file_path} (0 bytes)")
|
|
1434
|
+
continue
|
|
1435
|
+
|
|
1436
|
+
# Try to open for reading (verifies no write lock)
|
|
1437
|
+
with open(full_path, 'rb') as f:
|
|
1438
|
+
# Read first byte to ensure file is actually accessible
|
|
1439
|
+
f.read(1)
|
|
1440
|
+
|
|
1441
|
+
except (IOError, PermissionError) as e:
|
|
1442
|
+
unreadable_files.append(f"{file_path} ({e})")
|
|
1443
|
+
|
|
1444
|
+
# All files ready?
|
|
1445
|
+
if not missing_files and not unreadable_files:
|
|
1446
|
+
if attempt > 1:
|
|
1447
|
+
logger.info(
|
|
1448
|
+
f"📁 All {len(file_paths)} file(s) verified ready after {attempt} attempts "
|
|
1449
|
+
f"({total_waited:.2f}s total wait)"
|
|
1450
|
+
)
|
|
1451
|
+
return (True, [])
|
|
1452
|
+
|
|
1453
|
+
# Log status on first attempt and every 5th attempt
|
|
1454
|
+
if attempt == 1 or attempt % 5 == 0:
|
|
1455
|
+
logger.info(
|
|
1456
|
+
f"📁 Waiting for files (attempt {attempt}, {total_waited:.2f}s waited): "
|
|
1457
|
+
f"missing={len(missing_files)}, unreadable={len(unreadable_files)}"
|
|
1458
|
+
)
|
|
1459
|
+
if missing_files:
|
|
1460
|
+
logger.debug(f" Missing: {missing_files}")
|
|
1461
|
+
if unreadable_files:
|
|
1462
|
+
logger.debug(f" Unreadable: {unreadable_files}")
|
|
1463
|
+
|
|
1464
|
+
# Wait before retry
|
|
1465
|
+
await asyncio.sleep(wait_time)
|
|
1466
|
+
total_waited += wait_time
|
|
1467
|
+
wait_time = min(wait_time * 2, 2.0) # Cap at 2s
|
|
1468
|
+
|
|
1469
|
+
# Timeout - return what's missing
|
|
1470
|
+
all_problems = missing_files + unreadable_files
|
|
1471
|
+
logger.warning(
|
|
1472
|
+
f"📁 File verification timeout after {total_waited:.2f}s. "
|
|
1473
|
+
f"Problems with {len(all_problems)} file(s): {all_problems}"
|
|
1474
|
+
)
|
|
1475
|
+
return (False, all_problems)
|
|
1476
|
+
|
|
1477
|
+
async def inject_file_content_to_message(
|
|
1478
|
+
file_path,
|
|
1479
|
+
message_text: str,
|
|
1480
|
+
session_id: str,
|
|
1481
|
+
user_id: str
|
|
1482
|
+
) -> tuple[str, Optional[List[Dict[str, Any]]]]:
|
|
1483
|
+
"""Smart content injection with IMAGE support for vision.
|
|
1484
|
+
|
|
1485
|
+
Returns both updated text and optional image attachments for vision models.
|
|
1486
|
+
|
|
1487
|
+
Small text files (< 30KB): Inject full content inline
|
|
1488
|
+
Large text files (>= 30KB): Just mention with metadata
|
|
1489
|
+
Images: Convert to base64 for vision models
|
|
1490
|
+
|
|
1491
|
+
Args:
|
|
1492
|
+
file_path: Path to file in workspace
|
|
1493
|
+
message_text: User's message text
|
|
1494
|
+
session_id: Session ID for path resolution
|
|
1495
|
+
user_id: User ID for path resolution
|
|
1496
|
+
|
|
1497
|
+
Returns:
|
|
1498
|
+
tuple: (combined_message_text, image_attachments)
|
|
1499
|
+
- combined_message_text: Text with file references
|
|
1500
|
+
- image_attachments: List of image data dicts for vision (None if no images)
|
|
1501
|
+
"""
|
|
1502
|
+
from pathlib import Path
|
|
1503
|
+
from agents.task.path import pm
|
|
1504
|
+
import aiofiles
|
|
1505
|
+
import base64
|
|
1506
|
+
|
|
1507
|
+
# Get full file path
|
|
1508
|
+
workspace_dir = pm().get_workspace_dir(session_id, user_id)
|
|
1509
|
+
full_path = workspace_dir / file_path
|
|
1510
|
+
|
|
1511
|
+
if not full_path.exists():
|
|
1512
|
+
return (f"{message_text}\n\n[Error: Attached file not found: {file_path}]", None)
|
|
1513
|
+
|
|
1514
|
+
file_size = full_path.stat().st_size
|
|
1515
|
+
file_ext = full_path.suffix.lower()
|
|
1516
|
+
|
|
1517
|
+
# IMAGE HANDLING (NEW): Convert to base64 for vision
|
|
1518
|
+
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
1519
|
+
if file_ext in IMAGE_EXTENSIONS:
|
|
1520
|
+
try:
|
|
1521
|
+
# Validate image size (10MB max, same as upload limit)
|
|
1522
|
+
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
1523
|
+
if file_size > MAX_IMAGE_SIZE:
|
|
1524
|
+
size_mb = file_size / 1024 / 1024
|
|
1525
|
+
return (
|
|
1526
|
+
f"{message_text}\n\n[Error: Image too large ({size_mb:.1f}MB). Max: 10MB for vision processing]",
|
|
1527
|
+
None
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
# Read image as binary and convert to base64
|
|
1531
|
+
# Using synchronous I/O (FastAPI handles blocking ops via thread pool)
|
|
1532
|
+
with open(full_path, 'rb') as f:
|
|
1533
|
+
image_bytes = f.read()
|
|
1534
|
+
|
|
1535
|
+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
|
1536
|
+
|
|
1537
|
+
# Determine MIME type
|
|
1538
|
+
mime_map = {
|
|
1539
|
+
'.png': 'image/png',
|
|
1540
|
+
'.jpg': 'image/jpeg',
|
|
1541
|
+
'.jpeg': 'image/jpeg',
|
|
1542
|
+
'.gif': 'image/gif',
|
|
1543
|
+
'.webp': 'image/webp'
|
|
1544
|
+
}
|
|
1545
|
+
mime_type = mime_map.get(file_ext, 'image/png')
|
|
1546
|
+
|
|
1547
|
+
# Return text + image data for multimodal message
|
|
1548
|
+
size_kb = file_size / 1024
|
|
1549
|
+
size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
|
|
1550
|
+
|
|
1551
|
+
updated_text = f"{message_text}\n\n[Attached image: {full_path.name} ({size_str})]\nPath in workspace: {file_path}"
|
|
1552
|
+
image_data = {
|
|
1553
|
+
'type': 'image_url',
|
|
1554
|
+
'image_url': {
|
|
1555
|
+
'url': f'data:{mime_type};base64,{image_base64}'
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
logger.info(f"📷 Image attached for vision: {full_path.name} ({size_str})")
|
|
1560
|
+
return (updated_text, [image_data])
|
|
1561
|
+
|
|
1562
|
+
except Exception as e:
|
|
1563
|
+
logger.error(f"Failed to process image {full_path.name}: {e}")
|
|
1564
|
+
return (
|
|
1565
|
+
f"{message_text}\n\n[Error: Failed to process image {full_path.name}: {str(e)}]",
|
|
1566
|
+
None
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
# TEXT FILE HANDLING (existing logic)
|
|
1570
|
+
INJECT_SIZE_THRESHOLD = 30 * 1024 # 30KB
|
|
1571
|
+
|
|
1572
|
+
if file_size < INJECT_SIZE_THRESHOLD:
|
|
1573
|
+
# Small file - inject full content
|
|
1574
|
+
if file_ext in ['.txt', '.md', '.csv', '.json', '.xml', '.py', '.js', '.html', '.css']:
|
|
1575
|
+
try:
|
|
1576
|
+
# Using synchronous I/O (FastAPI handles blocking ops via thread pool)
|
|
1577
|
+
with open(full_path, 'r', encoding='utf-8') as f:
|
|
1578
|
+
content = f.read()
|
|
1579
|
+
|
|
1580
|
+
combined_text = f"""{message_text}
|
|
1581
|
+
|
|
1582
|
+
[Attached file: {full_path.name} ({file_size/1024:.1f}KB)]
|
|
1583
|
+
--- File Content Start ---
|
|
1584
|
+
{content}
|
|
1585
|
+
--- File Content End ---"""
|
|
1586
|
+
return (combined_text, None)
|
|
1587
|
+
except UnicodeDecodeError:
|
|
1588
|
+
# Fall through to large file handling
|
|
1589
|
+
pass
|
|
1590
|
+
|
|
1591
|
+
# Large file or binary - just reference
|
|
1592
|
+
size_kb = file_size / 1024
|
|
1593
|
+
size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
|
|
1594
|
+
|
|
1595
|
+
reference_text = f"""{message_text}
|
|
1596
|
+
|
|
1597
|
+
[Attached large file: {full_path.name} ({size_str})]
|
|
1598
|
+
File type: {file_ext}
|
|
1599
|
+
Path in workspace: {file_path}
|
|
1600
|
+
|
|
1601
|
+
To read this file, use: filesystem_read_file(path="{file_path}")"""
|
|
1602
|
+
|
|
1603
|
+
return (reference_text, None)
|
|
1604
|
+
|
|
1605
|
+
@router.post("/sessions/{session_id}/workspace/upload")
|
|
1606
|
+
async def upload_document(
|
|
1607
|
+
session_id: str,
|
|
1608
|
+
file: UploadFile = File(...),
|
|
1609
|
+
req: Request = None,
|
|
1610
|
+
agent = Depends(get_task_agent)
|
|
1611
|
+
):
|
|
1612
|
+
"""Upload a file to session workspace.
|
|
1613
|
+
|
|
1614
|
+
Supports:
|
|
1615
|
+
- Documents: PDF, DOCX, DOC, TXT, MD, CSV, JSON, XML
|
|
1616
|
+
- Images: PNG, JPG, JPEG, GIF, WEBP (for vision)
|
|
1617
|
+
|
|
1618
|
+
Max size: 10MB per file
|
|
1619
|
+
"""
|
|
1620
|
+
from pathlib import Path
|
|
1621
|
+
from agents.task.path import pm
|
|
1622
|
+
import aiofiles
|
|
1623
|
+
|
|
1624
|
+
try:
|
|
1625
|
+
# 1. Clean session ID
|
|
1626
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
1627
|
+
|
|
1628
|
+
# 2. Validate session exists
|
|
1629
|
+
session_info = agent.session_manager.get_session_info(session_id)
|
|
1630
|
+
if not session_info:
|
|
1631
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1632
|
+
|
|
1633
|
+
# 3. Security: Get authenticated user_id
|
|
1634
|
+
from utils.auth_utils import get_authenticated_user_id
|
|
1635
|
+
user_id = get_authenticated_user_id(req)
|
|
1636
|
+
|
|
1637
|
+
# Verify user owns this session
|
|
1638
|
+
session_owner = session_info.get('user_id')
|
|
1639
|
+
logger.info(f"[Upload Auth Check] Token user_id: {user_id}, Session owner: {session_owner}, Match: {user_id == session_owner}")
|
|
1640
|
+
|
|
1641
|
+
if session_owner != user_id:
|
|
1642
|
+
logger.warning(f"[Upload Auth FAILED] Access denied - user {user_id} tried to upload to session owned by {session_owner}")
|
|
1643
|
+
raise HTTPException(status_code=403, detail=f"Access denied - session owned by different user")
|
|
1644
|
+
|
|
1645
|
+
# 4. Validate file type (documents + images for vision)
|
|
1646
|
+
ALLOWED_EXTENSIONS = {'.pdf', '.docx', '.doc', '.txt', '.md', '.csv', '.json', '.xml', '.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
1647
|
+
file_ext = Path(file.filename).suffix.lower()
|
|
1648
|
+
|
|
1649
|
+
if file_ext not in ALLOWED_EXTENSIONS:
|
|
1650
|
+
raise HTTPException(
|
|
1651
|
+
status_code=400,
|
|
1652
|
+
detail=f"File type {file_ext} not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
# 5. Validate file size (10MB limit)
|
|
1656
|
+
MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
|
1657
|
+
file_content = await file.read()
|
|
1658
|
+
if len(file_content) > MAX_SIZE:
|
|
1659
|
+
raise HTTPException(
|
|
1660
|
+
status_code=400,
|
|
1661
|
+
detail=f"File too large. Max size: {MAX_SIZE/1024/1024}MB"
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
# 6. Validate MIME type (security: prevent file type spoofing)
|
|
1665
|
+
ALLOWED_MIME_TYPES = {
|
|
1666
|
+
'application/pdf',
|
|
1667
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .docx
|
|
1668
|
+
'application/msword', # .doc
|
|
1669
|
+
'text/plain',
|
|
1670
|
+
'text/markdown',
|
|
1671
|
+
'text/csv',
|
|
1672
|
+
'application/json',
|
|
1673
|
+
'application/xml',
|
|
1674
|
+
'text/xml',
|
|
1675
|
+
# Image types for vision
|
|
1676
|
+
'image/png',
|
|
1677
|
+
'image/jpeg',
|
|
1678
|
+
'image/gif',
|
|
1679
|
+
'image/webp'
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
try:
|
|
1683
|
+
import magic
|
|
1684
|
+
# Detect actual MIME type from file content (not just extension)
|
|
1685
|
+
detected_mime = magic.from_buffer(file_content, mime=True)
|
|
1686
|
+
|
|
1687
|
+
if detected_mime not in ALLOWED_MIME_TYPES:
|
|
1688
|
+
logger.warning(f"Rejected file with MIME type {detected_mime}: {file.filename}")
|
|
1689
|
+
raise HTTPException(
|
|
1690
|
+
status_code=400,
|
|
1691
|
+
detail=f"Invalid file type detected: {detected_mime}. Allowed types: PDF, DOCX, DOC, TXT, MD, CSV, JSON, XML, PNG, JPG, JPEG, GIF, WEBP"
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
logger.info(f"File MIME type validated: {detected_mime} for {file.filename}")
|
|
1695
|
+
except ImportError:
|
|
1696
|
+
logger.warning("python-magic not installed - skipping MIME type validation (security risk!)")
|
|
1697
|
+
except Exception as e:
|
|
1698
|
+
logger.warning(f"MIME type detection failed: {e}, using extension check only")
|
|
1699
|
+
|
|
1700
|
+
# 7. Sanitize filename (prevent path traversal)
|
|
1701
|
+
logger.info(f"[Upload] Step 7: Sanitizing filename '{file.filename}'")
|
|
1702
|
+
from utils.path_validator import sanitize_filename
|
|
1703
|
+
safe_filename = sanitize_filename(file.filename)
|
|
1704
|
+
logger.info(f"[Upload] Sanitized filename: '{safe_filename}'")
|
|
1705
|
+
|
|
1706
|
+
# 8. Get workspace directory
|
|
1707
|
+
logger.info(f"[Upload] Step 8: Getting workspace directory for session {session_id}, user {user_id}")
|
|
1708
|
+
workspace_dir = pm().get_workspace_dir(session_id, user_id)
|
|
1709
|
+
logger.info(f"[Upload] Workspace directory: {workspace_dir}")
|
|
1710
|
+
file_path = workspace_dir / safe_filename
|
|
1711
|
+
logger.info(f"[Upload] Full file path: {file_path}")
|
|
1712
|
+
|
|
1713
|
+
# Handle duplicate filenames
|
|
1714
|
+
if file_path.exists():
|
|
1715
|
+
# Add timestamp suffix
|
|
1716
|
+
from datetime import datetime
|
|
1717
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1718
|
+
stem = file_path.stem
|
|
1719
|
+
suffix = file_path.suffix
|
|
1720
|
+
safe_filename = f"{stem}_{timestamp}{suffix}"
|
|
1721
|
+
file_path = workspace_dir / safe_filename
|
|
1722
|
+
|
|
1723
|
+
# 9. Write file to workspace
|
|
1724
|
+
# Using synchronous I/O (FastAPI handles blocking ops via thread pool)
|
|
1725
|
+
# Previous async implementation was hanging indefinitely
|
|
1726
|
+
logger.info(f"[Upload] Step 9: Writing {len(file_content)} bytes to {file_path}")
|
|
1727
|
+
try:
|
|
1728
|
+
with open(file_path, 'wb') as f:
|
|
1729
|
+
f.write(file_content)
|
|
1730
|
+
logger.info(f"[Upload] File write completed successfully")
|
|
1731
|
+
except Exception as write_error:
|
|
1732
|
+
logger.error(f"[Upload] File write failed: {write_error}", exc_info=True)
|
|
1733
|
+
raise
|
|
1734
|
+
|
|
1735
|
+
logger.info(f"Uploaded document to session {session_id}: {safe_filename}")
|
|
1736
|
+
|
|
1737
|
+
# 10. Generate file ID for client tracking
|
|
1738
|
+
file_id = str(uuid.uuid4())
|
|
1739
|
+
|
|
1740
|
+
# 11. Determine if file can be auto-injected (30KB threshold)
|
|
1741
|
+
INJECT_SIZE_THRESHOLD = 30 * 1024 # 30KB
|
|
1742
|
+
can_inject = len(file_content) < INJECT_SIZE_THRESHOLD
|
|
1743
|
+
|
|
1744
|
+
# 12. Add to feed for UI notification
|
|
1745
|
+
logger.info(f"[Upload] Step 12: Adding to feed for session {session_id}")
|
|
1746
|
+
agent.session_manager.add_to_feed(
|
|
1747
|
+
session_id,
|
|
1748
|
+
'document_uploaded',
|
|
1749
|
+
{
|
|
1750
|
+
'file_id': file_id,
|
|
1751
|
+
'filename': safe_filename,
|
|
1752
|
+
'size': len(file_content),
|
|
1753
|
+
'path': str(file_path.relative_to(workspace_dir)),
|
|
1754
|
+
'can_inject': can_inject,
|
|
1755
|
+
'timestamp': time.time()
|
|
1756
|
+
}
|
|
1757
|
+
)
|
|
1758
|
+
logger.info(f"[Upload] Feed notification added successfully")
|
|
1759
|
+
|
|
1760
|
+
# 13. Notify workspace context of upload (for agent awareness)
|
|
1761
|
+
logger.info(f"[Upload] Step 13: Notifying workspace context")
|
|
1762
|
+
try:
|
|
1763
|
+
from agents.task.workspace_context import get_workspace_context
|
|
1764
|
+
workspace_ctx = get_workspace_context()
|
|
1765
|
+
workspace_ctx.notify_upload(
|
|
1766
|
+
session_id=session_id,
|
|
1767
|
+
user_id=user_id,
|
|
1768
|
+
filename=safe_filename,
|
|
1769
|
+
size=len(file_content)
|
|
1770
|
+
)
|
|
1771
|
+
logger.debug(f"Notified workspace context of upload: {safe_filename}")
|
|
1772
|
+
except Exception as e:
|
|
1773
|
+
logger.warning(f"Failed to notify workspace context: {e}")
|
|
1774
|
+
|
|
1775
|
+
# 14. Return success response with file metadata
|
|
1776
|
+
# Determine file type for appropriate message
|
|
1777
|
+
is_image = file_ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
1778
|
+
file_type = "Image" if is_image else "Document"
|
|
1779
|
+
|
|
1780
|
+
response_data = {
|
|
1781
|
+
'success': True,
|
|
1782
|
+
'file_id': file_id,
|
|
1783
|
+
'filename': safe_filename,
|
|
1784
|
+
'path': str(file_path.relative_to(workspace_dir)),
|
|
1785
|
+
'size': len(file_content),
|
|
1786
|
+
'can_inject': can_inject,
|
|
1787
|
+
'is_image': is_image, # NEW: Flag for frontend to handle images differently
|
|
1788
|
+
'message': f'{file_type} uploaded successfully'
|
|
1789
|
+
}
|
|
1790
|
+
logger.info(f"[Upload] Step 14: Returning success response for {safe_filename}")
|
|
1791
|
+
return response_data
|
|
1792
|
+
|
|
1793
|
+
except HTTPException:
|
|
1794
|
+
raise
|
|
1795
|
+
except Exception as e:
|
|
1796
|
+
logger.error(f"Error uploading document: {e}", exc_info=True)
|
|
1797
|
+
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
1798
|
+
|
|
1799
|
+
@router.get("/sessions/{session_id}/documents")
|
|
1800
|
+
async def list_session_documents(
|
|
1801
|
+
session_id: str,
|
|
1802
|
+
req: Request = None,
|
|
1803
|
+
agent = Depends(get_task_agent)
|
|
1804
|
+
):
|
|
1805
|
+
"""List all documents in session workspace with metadata."""
|
|
1806
|
+
from pathlib import Path
|
|
1807
|
+
from agents.task.path import pm
|
|
1808
|
+
|
|
1809
|
+
try:
|
|
1810
|
+
# 1. Clean session ID
|
|
1811
|
+
session_id = clean_session_id_at_entry(session_id)
|
|
1812
|
+
|
|
1813
|
+
# 2. Validate session and access
|
|
1814
|
+
session_info = agent.session_manager.get_session_info(session_id)
|
|
1815
|
+
if not session_info:
|
|
1816
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1817
|
+
|
|
1818
|
+
from utils.auth_utils import get_authenticated_user_id
|
|
1819
|
+
user_id = get_authenticated_user_id(req)
|
|
1820
|
+
|
|
1821
|
+
if session_info.get('user_id') != user_id:
|
|
1822
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
1823
|
+
|
|
1824
|
+
# 3. Get workspace directory
|
|
1825
|
+
workspace_dir = pm().get_workspace_dir(session_id, user_id)
|
|
1826
|
+
|
|
1827
|
+
# 4. Scan for documents
|
|
1828
|
+
documents = []
|
|
1829
|
+
DOCUMENT_EXTENSIONS = {'.pdf', '.docx', '.doc', '.txt', '.md', '.csv', '.json', '.xml'}
|
|
1830
|
+
|
|
1831
|
+
for file_path in workspace_dir.rglob('*'):
|
|
1832
|
+
if file_path.is_file() and file_path.suffix.lower() in DOCUMENT_EXTENSIONS:
|
|
1833
|
+
stat = file_path.stat()
|
|
1834
|
+
documents.append({
|
|
1835
|
+
'filename': file_path.name,
|
|
1836
|
+
'path': str(file_path.relative_to(workspace_dir)),
|
|
1837
|
+
'size': stat.st_size,
|
|
1838
|
+
'modified': stat.st_mtime,
|
|
1839
|
+
'extension': file_path.suffix.lower()
|
|
1840
|
+
})
|
|
1841
|
+
|
|
1842
|
+
# Sort by modification time (newest first)
|
|
1843
|
+
documents.sort(key=lambda x: x['modified'], reverse=True)
|
|
1844
|
+
|
|
1845
|
+
return {
|
|
1846
|
+
'success': True,
|
|
1847
|
+
'session_id': session_id,
|
|
1848
|
+
'documents': documents,
|
|
1849
|
+
'count': len(documents)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
except HTTPException:
|
|
1853
|
+
raise
|
|
1854
|
+
except Exception as e:
|
|
1855
|
+
logger.error(f"Error listing documents: {e}", exc_info=True)
|
|
1856
|
+
raise HTTPException(status_code=500, detail=str(e))
|