calvyn-code 0.14.0
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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/README.zh-CN.md +180 -0
- package/acp_adapter/__init__.py +1 -0
- package/acp_adapter/__main__.py +5 -0
- package/acp_adapter/auth.py +68 -0
- package/acp_adapter/bootstrap/__init__.py +0 -0
- package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
- package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
- package/acp_adapter/entry.py +292 -0
- package/acp_adapter/events.py +265 -0
- package/acp_adapter/permissions.py +148 -0
- package/acp_adapter/server.py +1713 -0
- package/acp_adapter/session.py +629 -0
- package/acp_adapter/tools.py +1180 -0
- package/agent/__init__.py +6 -0
- package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
- package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
- package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
- package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
- package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
- package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
- package/agent/__pycache__/curator.cpython-312.pyc +0 -0
- package/agent/__pycache__/display.cpython-312.pyc +0 -0
- package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
- package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
- package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
- package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
- package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
- package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
- package/agent/__pycache__/insights.cpython-312.pyc +0 -0
- package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
- package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
- package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
- package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
- package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
- package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
- package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
- package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
- package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
- package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
- package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
- package/agent/__pycache__/redact.cpython-312.pyc +0 -0
- package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
- package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
- package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
- package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
- package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
- package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
- package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
- package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
- package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
- package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
- package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
- package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
- package/agent/account_usage.py +326 -0
- package/agent/anthropic_adapter.py +2087 -0
- package/agent/async_utils.py +68 -0
- package/agent/auxiliary_client.py +4893 -0
- package/agent/bedrock_adapter.py +1276 -0
- package/agent/codex_responses_adapter.py +1084 -0
- package/agent/context_compressor.py +1583 -0
- package/agent/context_engine.py +211 -0
- package/agent/context_references.py +519 -0
- package/agent/copilot_acp_client.py +684 -0
- package/agent/credential_pool.py +1780 -0
- package/agent/credential_sources.py +449 -0
- package/agent/curator.py +1782 -0
- package/agent/curator_backup.py +694 -0
- package/agent/display.py +987 -0
- package/agent/error_classifier.py +1058 -0
- package/agent/file_safety.py +112 -0
- package/agent/gemini_cloudcode_adapter.py +909 -0
- package/agent/gemini_native_adapter.py +971 -0
- package/agent/gemini_schema.py +99 -0
- package/agent/google_code_assist.py +452 -0
- package/agent/google_oauth.py +1062 -0
- package/agent/i18n.py +258 -0
- package/agent/image_gen_provider.py +243 -0
- package/agent/image_gen_registry.py +145 -0
- package/agent/image_routing.py +301 -0
- package/agent/insights.py +931 -0
- package/agent/lmstudio_reasoning.py +48 -0
- package/agent/lsp/__init__.py +106 -0
- package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
- package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
- package/agent/lsp/cli.py +308 -0
- package/agent/lsp/client.py +930 -0
- package/agent/lsp/eventlog.py +213 -0
- package/agent/lsp/install.py +376 -0
- package/agent/lsp/manager.py +644 -0
- package/agent/lsp/protocol.py +196 -0
- package/agent/lsp/range_shift.py +149 -0
- package/agent/lsp/reporter.py +78 -0
- package/agent/lsp/servers.py +1040 -0
- package/agent/lsp/workspace.py +223 -0
- package/agent/manual_compression_feedback.py +49 -0
- package/agent/markdown_tables.py +309 -0
- package/agent/memory_manager.py +556 -0
- package/agent/memory_provider.py +279 -0
- package/agent/model_metadata.py +1827 -0
- package/agent/models_dev.py +724 -0
- package/agent/moonshot_schema.py +231 -0
- package/agent/nous_rate_guard.py +326 -0
- package/agent/onboarding.py +193 -0
- package/agent/plugin_llm.py +1046 -0
- package/agent/portal_tags.py +64 -0
- package/agent/prompt_builder.py +1457 -0
- package/agent/prompt_caching.py +79 -0
- package/agent/rate_limit_tracker.py +246 -0
- package/agent/redact.py +403 -0
- package/agent/retry_utils.py +57 -0
- package/agent/shell_hooks.py +837 -0
- package/agent/skill_commands.py +502 -0
- package/agent/skill_preprocessing.py +131 -0
- package/agent/skill_utils.py +512 -0
- package/agent/subdirectory_hints.py +224 -0
- package/agent/think_scrubber.py +386 -0
- package/agent/title_generator.py +171 -0
- package/agent/tool_guardrails.py +458 -0
- package/agent/tool_result_classification.py +26 -0
- package/agent/trajectory.py +56 -0
- package/agent/transports/__init__.py +68 -0
- package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
- package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
- package/agent/transports/anthropic.py +179 -0
- package/agent/transports/base.py +89 -0
- package/agent/transports/bedrock.py +154 -0
- package/agent/transports/chat_completions.py +614 -0
- package/agent/transports/codex.py +283 -0
- package/agent/transports/codex_app_server.py +368 -0
- package/agent/transports/codex_app_server_session.py +810 -0
- package/agent/transports/codex_event_projector.py +312 -0
- package/agent/transports/hermes_tools_mcp_server.py +233 -0
- package/agent/transports/types.py +162 -0
- package/agent/usage_pricing.py +877 -0
- package/agent/video_gen_provider.py +300 -0
- package/agent/video_gen_registry.py +117 -0
- package/agent/web_search_provider.py +221 -0
- package/agent/web_search_registry.py +262 -0
- package/assets/banner.png +0 -0
- package/batch_runner.py +1303 -0
- package/bin/calvyn.js +67 -0
- package/calvyn_bootstrap.py +130 -0
- package/calvyn_constants.py +346 -0
- package/calvyn_logging.py +390 -0
- package/calvyn_state.py +2967 -0
- package/calvyn_time.py +105 -0
- package/cli.py +14160 -0
- package/cron/__init__.py +42 -0
- package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
- package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
- package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
- package/cron/jobs.py +1160 -0
- package/cron/scheduler.py +1832 -0
- package/gateway/__init__.py +35 -0
- package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
- package/gateway/__pycache__/config.cpython-312.pyc +0 -0
- package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
- package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
- package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
- package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
- package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
- package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
- package/gateway/__pycache__/run.cpython-312.pyc +0 -0
- package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
- package/gateway/__pycache__/session.cpython-312.pyc +0 -0
- package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
- package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
- package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
- package/gateway/__pycache__/status.cpython-312.pyc +0 -0
- package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
- package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
- package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
- package/gateway/builtin_hooks/__init__.py +1 -0
- package/gateway/channel_directory.py +357 -0
- package/gateway/config.py +1873 -0
- package/gateway/delivery.py +258 -0
- package/gateway/display_config.py +206 -0
- package/gateway/hooks.py +210 -0
- package/gateway/mirror.py +179 -0
- package/gateway/pairing.py +322 -0
- package/gateway/platform_registry.py +260 -0
- package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
- package/gateway/platforms/__init__.py +45 -0
- package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
- package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
- package/gateway/platforms/_http_client_limits.py +84 -0
- package/gateway/platforms/api_server.py +3488 -0
- package/gateway/platforms/base.py +3747 -0
- package/gateway/platforms/bluebubbles.py +937 -0
- package/gateway/platforms/dingtalk.py +1473 -0
- package/gateway/platforms/discord.py +5584 -0
- package/gateway/platforms/email.py +773 -0
- package/gateway/platforms/feishu.py +5059 -0
- package/gateway/platforms/feishu_comment.py +1382 -0
- package/gateway/platforms/feishu_comment_rules.py +430 -0
- package/gateway/platforms/helpers.py +279 -0
- package/gateway/platforms/homeassistant.py +449 -0
- package/gateway/platforms/matrix.py +2777 -0
- package/gateway/platforms/mattermost.py +852 -0
- package/gateway/platforms/msgraph_webhook.py +397 -0
- package/gateway/platforms/qqbot/__init__.py +91 -0
- package/gateway/platforms/qqbot/adapter.py +3072 -0
- package/gateway/platforms/qqbot/chunked_upload.py +602 -0
- package/gateway/platforms/qqbot/constants.py +74 -0
- package/gateway/platforms/qqbot/crypto.py +45 -0
- package/gateway/platforms/qqbot/keyboards.py +473 -0
- package/gateway/platforms/qqbot/onboard.py +220 -0
- package/gateway/platforms/qqbot/utils.py +71 -0
- package/gateway/platforms/signal.py +1518 -0
- package/gateway/platforms/signal_rate_limit.py +369 -0
- package/gateway/platforms/slack.py +3028 -0
- package/gateway/platforms/sms.py +377 -0
- package/gateway/platforms/telegram.py +4836 -0
- package/gateway/platforms/telegram_network.py +249 -0
- package/gateway/platforms/webhook.py +806 -0
- package/gateway/platforms/wecom.py +1610 -0
- package/gateway/platforms/wecom_callback.py +403 -0
- package/gateway/platforms/wecom_crypto.py +142 -0
- package/gateway/platforms/weixin.py +2170 -0
- package/gateway/platforms/whatsapp.py +1283 -0
- package/gateway/platforms/yuanbao.py +4873 -0
- package/gateway/platforms/yuanbao_media.py +645 -0
- package/gateway/platforms/yuanbao_proto.py +1209 -0
- package/gateway/platforms/yuanbao_sticker.py +558 -0
- package/gateway/restart.py +20 -0
- package/gateway/run.py +17074 -0
- package/gateway/runtime_footer.py +150 -0
- package/gateway/session.py +1399 -0
- package/gateway/session_context.py +156 -0
- package/gateway/shutdown_forensics.py +462 -0
- package/gateway/slash_access.py +229 -0
- package/gateway/status.py +972 -0
- package/gateway/sticker_cache.py +111 -0
- package/gateway/stream_consumer.py +1286 -0
- package/gateway/whatsapp_identity.py +156 -0
- package/hermes_cli/__init__.py +47 -0
- package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
- package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
- package/hermes_cli/_parser.py +365 -0
- package/hermes_cli/_subprocess_compat.py +175 -0
- package/hermes_cli/auth.py +6299 -0
- package/hermes_cli/auth_commands.py +749 -0
- package/hermes_cli/azure_detect.py +300 -0
- package/hermes_cli/backup.py +938 -0
- package/hermes_cli/banner.py +703 -0
- package/hermes_cli/browser_connect.py +139 -0
- package/hermes_cli/callbacks.py +243 -0
- package/hermes_cli/checkpoints.py +244 -0
- package/hermes_cli/claw.py +810 -0
- package/hermes_cli/cli_output.py +78 -0
- package/hermes_cli/clipboard.py +495 -0
- package/hermes_cli/codex_models.py +198 -0
- package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
- package/hermes_cli/codex_runtime_switch.py +266 -0
- package/hermes_cli/colors.py +38 -0
- package/hermes_cli/commands.py +1728 -0
- package/hermes_cli/completion.py +315 -0
- package/hermes_cli/config.py +5382 -0
- package/hermes_cli/copilot_auth.py +392 -0
- package/hermes_cli/cron.py +313 -0
- package/hermes_cli/curator.py +598 -0
- package/hermes_cli/curses_ui.py +472 -0
- package/hermes_cli/debug.py +747 -0
- package/hermes_cli/default_soul.py +11 -0
- package/hermes_cli/dep_ensure.py +107 -0
- package/hermes_cli/dingtalk_auth.py +293 -0
- package/hermes_cli/doctor.py +1863 -0
- package/hermes_cli/dump.py +326 -0
- package/hermes_cli/env_loader.py +175 -0
- package/hermes_cli/fallback_cmd.py +361 -0
- package/hermes_cli/gateway.py +5422 -0
- package/hermes_cli/gateway_windows.py +692 -0
- package/hermes_cli/goals.py +757 -0
- package/hermes_cli/hooks.py +385 -0
- package/hermes_cli/inventory.py +240 -0
- package/hermes_cli/kanban.py +2252 -0
- package/hermes_cli/kanban_db.py +4840 -0
- package/hermes_cli/kanban_diagnostics.py +776 -0
- package/hermes_cli/kanban_specify.py +266 -0
- package/hermes_cli/logs.py +391 -0
- package/hermes_cli/main.py +12396 -0
- package/hermes_cli/mcp_config.py +781 -0
- package/hermes_cli/memory_setup.py +465 -0
- package/hermes_cli/model_catalog.py +330 -0
- package/hermes_cli/model_normalize.py +473 -0
- package/hermes_cli/model_switch.py +1777 -0
- package/hermes_cli/models.py +3789 -0
- package/hermes_cli/nous_subscription.py +799 -0
- package/hermes_cli/oneshot.py +351 -0
- package/hermes_cli/pairing.py +115 -0
- package/hermes_cli/platforms.py +83 -0
- package/hermes_cli/plugins.py +1562 -0
- package/hermes_cli/plugins_cmd.py +1587 -0
- package/hermes_cli/profile_distribution.py +703 -0
- package/hermes_cli/profiles.py +1319 -0
- package/hermes_cli/providers.py +720 -0
- package/hermes_cli/proxy/__init__.py +20 -0
- package/hermes_cli/proxy/adapters/__init__.py +35 -0
- package/hermes_cli/proxy/adapters/base.py +94 -0
- package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
- package/hermes_cli/proxy/cli.py +141 -0
- package/hermes_cli/proxy/server.py +265 -0
- package/hermes_cli/pt_input_extras.py +83 -0
- package/hermes_cli/pty_bridge.py +237 -0
- package/hermes_cli/relaunch.py +205 -0
- package/hermes_cli/runtime_provider.py +1428 -0
- package/hermes_cli/security_advisories.py +452 -0
- package/hermes_cli/setup.py +3559 -0
- package/hermes_cli/skills_config.py +177 -0
- package/hermes_cli/skills_hub.py +1595 -0
- package/hermes_cli/skin_engine.py +929 -0
- package/hermes_cli/slack_cli.py +160 -0
- package/hermes_cli/status.py +550 -0
- package/hermes_cli/stdio.py +252 -0
- package/hermes_cli/timeouts.py +82 -0
- package/hermes_cli/tips.py +487 -0
- package/hermes_cli/tools_config.py +3151 -0
- package/hermes_cli/uninstall.py +681 -0
- package/hermes_cli/vercel_auth.py +70 -0
- package/hermes_cli/voice.py +846 -0
- package/hermes_cli/web_server.py +4438 -0
- package/hermes_cli/webhook.py +275 -0
- package/locales/af.yaml +350 -0
- package/locales/de.yaml +350 -0
- package/locales/en.yaml +365 -0
- package/locales/es.yaml +350 -0
- package/locales/fr.yaml +350 -0
- package/locales/ga.yaml +354 -0
- package/locales/hu.yaml +350 -0
- package/locales/it.yaml +350 -0
- package/locales/ja.yaml +350 -0
- package/locales/ko.yaml +350 -0
- package/locales/pt.yaml +350 -0
- package/locales/ru.yaml +350 -0
- package/locales/tr.yaml +350 -0
- package/locales/uk.yaml +350 -0
- package/locales/zh-hant.yaml +350 -0
- package/locales/zh.yaml +350 -0
- package/mcp_serve.py +898 -0
- package/model_tools.py +899 -0
- package/optional-skills/DESCRIPTION.md +24 -0
- package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
- package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
- package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
- package/optional-skills/blockchain/evm/SKILL.md +211 -0
- package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
- package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
- package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
- package/optional-skills/blockchain/solana/SKILL.md +208 -0
- package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
- package/optional-skills/communication/DESCRIPTION.md +1 -0
- package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
- package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
- package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
- package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
- package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
- package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
- package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
- package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
- package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
- package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
- package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
- package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
- package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
- package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
- package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
- package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
- package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
- package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
- package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
- package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
- package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
- package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
- package/optional-skills/creative/hyperframes/SKILL.md +191 -0
- package/optional-skills/creative/hyperframes/references/cli.md +185 -0
- package/optional-skills/creative/hyperframes/references/composition.md +129 -0
- package/optional-skills/creative/hyperframes/references/features.md +289 -0
- package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
- package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
- package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
- package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
- package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
- package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
- package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
- package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
- package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
- package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
- package/optional-skills/creative/meme-generation/SKILL.md +130 -0
- package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
- package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
- package/optional-skills/devops/cli/SKILL.md +156 -0
- package/optional-skills/devops/cli/references/app-discovery.md +112 -0
- package/optional-skills/devops/cli/references/authentication.md +59 -0
- package/optional-skills/devops/cli/references/cli-reference.md +104 -0
- package/optional-skills/devops/cli/references/running-apps.md +171 -0
- package/optional-skills/devops/docker-management/SKILL.md +281 -0
- package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
- package/optional-skills/devops/watchers/SKILL.md +112 -0
- package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
- package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
- package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
- package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
- package/optional-skills/dogfood/DESCRIPTION.md +3 -0
- package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
- package/optional-skills/email/agentmail/SKILL.md +126 -0
- package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
- package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
- package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
- package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
- package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
- package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
- package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
- package/optional-skills/finance/dcf-model/requirements.txt +7 -0
- package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
- package/optional-skills/finance/excel-author/SKILL.md +244 -0
- package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
- package/optional-skills/finance/lbo-model/SKILL.md +291 -0
- package/optional-skills/finance/merger-model/SKILL.md +144 -0
- package/optional-skills/finance/pptx-author/SKILL.md +173 -0
- package/optional-skills/finance/stocks/SKILL.md +95 -0
- package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
- package/optional-skills/health/DESCRIPTION.md +1 -0
- package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
- package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
- package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
- package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
- package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
- package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
- package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
- package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
- package/optional-skills/mcp/DESCRIPTION.md +3 -0
- package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
- package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
- package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
- package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
- package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
- package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
- package/optional-skills/mcp/mcporter/SKILL.md +123 -0
- package/optional-skills/migration/DESCRIPTION.md +2 -0
- package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
- package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
- package/optional-skills/mlops/accelerate/SKILL.md +336 -0
- package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
- package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
- package/optional-skills/mlops/accelerate/references/performance.md +525 -0
- package/optional-skills/mlops/chroma/SKILL.md +410 -0
- package/optional-skills/mlops/chroma/references/integration.md +38 -0
- package/optional-skills/mlops/clip/SKILL.md +257 -0
- package/optional-skills/mlops/clip/references/applications.md +207 -0
- package/optional-skills/mlops/faiss/SKILL.md +225 -0
- package/optional-skills/mlops/faiss/references/index_types.md +280 -0
- package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
- package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
- package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
- package/optional-skills/mlops/guidance/SKILL.md +576 -0
- package/optional-skills/mlops/guidance/references/backends.md +554 -0
- package/optional-skills/mlops/guidance/references/constraints.md +674 -0
- package/optional-skills/mlops/guidance/references/examples.md +767 -0
- package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
- package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
- package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
- package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
- package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
- package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
- package/optional-skills/mlops/instructor/SKILL.md +744 -0
- package/optional-skills/mlops/instructor/references/examples.md +107 -0
- package/optional-skills/mlops/instructor/references/providers.md +70 -0
- package/optional-skills/mlops/instructor/references/validation.md +606 -0
- package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
- package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
- package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
- package/optional-skills/mlops/llava/SKILL.md +308 -0
- package/optional-skills/mlops/llava/references/training.md +197 -0
- package/optional-skills/mlops/modal/SKILL.md +345 -0
- package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
- package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
- package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
- package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
- package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
- package/optional-skills/mlops/peft/SKILL.md +435 -0
- package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
- package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
- package/optional-skills/mlops/pinecone/SKILL.md +362 -0
- package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
- package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
- package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
- package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
- package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
- package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
- package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
- package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
- package/optional-skills/mlops/qdrant/SKILL.md +497 -0
- package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
- package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
- package/optional-skills/mlops/saelens/SKILL.md +390 -0
- package/optional-skills/mlops/saelens/references/README.md +69 -0
- package/optional-skills/mlops/saelens/references/api.md +333 -0
- package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
- package/optional-skills/mlops/simpo/SKILL.md +223 -0
- package/optional-skills/mlops/simpo/references/datasets.md +478 -0
- package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
- package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
- package/optional-skills/mlops/slime/SKILL.md +468 -0
- package/optional-skills/mlops/slime/references/api-reference.md +392 -0
- package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
- package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
- package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
- package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
- package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
- package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
- package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
- package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
- package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
- package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
- package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
- package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
- package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
- package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
- package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
- package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
- package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
- package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
- package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
- package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
- package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
- package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
- package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
- package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
- package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
- package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
- package/optional-skills/mlops/whisper/SKILL.md +321 -0
- package/optional-skills/mlops/whisper/references/languages.md +189 -0
- package/optional-skills/productivity/canvas/SKILL.md +98 -0
- package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
- package/optional-skills/productivity/here-now/SKILL.md +217 -0
- package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
- package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
- package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
- package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
- package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
- package/optional-skills/productivity/shop-app/SKILL.md +340 -0
- package/optional-skills/productivity/shopify/SKILL.md +373 -0
- package/optional-skills/productivity/siyuan/SKILL.md +298 -0
- package/optional-skills/productivity/telephony/SKILL.md +418 -0
- package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
- package/optional-skills/research/bioinformatics/SKILL.md +235 -0
- package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
- package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
- package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
- package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
- package/optional-skills/research/domain-intel/SKILL.md +97 -0
- package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
- package/optional-skills/research/drug-discovery/SKILL.md +227 -0
- package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
- package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
- package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
- package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
- package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
- package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
- package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
- package/optional-skills/research/osint-investigation/SKILL.md +277 -0
- package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
- package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
- package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
- package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
- package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
- package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
- package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
- package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
- package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
- package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
- package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
- package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
- package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
- package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
- package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
- package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
- package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
- package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
- package/optional-skills/research/parallel-cli/SKILL.md +391 -0
- package/optional-skills/research/qmd/SKILL.md +441 -0
- package/optional-skills/research/scrapling/SKILL.md +336 -0
- package/optional-skills/research/searxng-search/SKILL.md +212 -0
- package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
- package/optional-skills/security/1password/SKILL.md +163 -0
- package/optional-skills/security/1password/references/cli-examples.md +31 -0
- package/optional-skills/security/1password/references/get-started.md +21 -0
- package/optional-skills/security/DESCRIPTION.md +3 -0
- package/optional-skills/security/oss-forensics/SKILL.md +423 -0
- package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
- package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
- package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
- package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
- package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
- package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
- package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
- package/optional-skills/security/sherlock/SKILL.md +193 -0
- package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
- package/optional-skills/web-development/DESCRIPTION.md +5 -0
- package/optional-skills/web-development/page-agent/SKILL.md +190 -0
- package/package.json +78 -0
- package/plugins/__init__.py +1 -0
- package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/context_engine/__init__.py +219 -0
- package/plugins/disk-cleanup/README.md +51 -0
- package/plugins/disk-cleanup/__init__.py +316 -0
- package/plugins/disk-cleanup/disk_cleanup.py +497 -0
- package/plugins/disk-cleanup/plugin.yaml +7 -0
- package/plugins/example-dashboard/dashboard/manifest.json +14 -0
- package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
- package/plugins/google_meet/README.md +131 -0
- package/plugins/google_meet/SKILL.md +148 -0
- package/plugins/google_meet/__init__.py +103 -0
- package/plugins/google_meet/audio_bridge.py +244 -0
- package/plugins/google_meet/cli.py +479 -0
- package/plugins/google_meet/meet_bot.py +852 -0
- package/plugins/google_meet/node/__init__.py +54 -0
- package/plugins/google_meet/node/cli.py +125 -0
- package/plugins/google_meet/node/client.py +107 -0
- package/plugins/google_meet/node/protocol.py +124 -0
- package/plugins/google_meet/node/registry.py +113 -0
- package/plugins/google_meet/node/server.py +201 -0
- package/plugins/google_meet/plugin.yaml +16 -0
- package/plugins/google_meet/process_manager.py +324 -0
- package/plugins/google_meet/realtime/__init__.py +10 -0
- package/plugins/google_meet/realtime/openai_client.py +332 -0
- package/plugins/google_meet/tools.py +348 -0
- package/plugins/hermes-achievements/LICENSE +21 -0
- package/plugins/hermes-achievements/README.md +150 -0
- package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
- package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
- package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
- package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
- package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
- package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
- package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
- package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
- package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
- package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
- package/plugins/image_gen/openai/__init__.py +303 -0
- package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/openai/plugin.yaml +7 -0
- package/plugins/image_gen/openai-codex/__init__.py +378 -0
- package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
- package/plugins/image_gen/xai/__init__.py +316 -0
- package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/image_gen/xai/plugin.yaml +7 -0
- package/plugins/kanban/dashboard/dist/index.js +3143 -0
- package/plugins/kanban/dashboard/dist/style.css +1500 -0
- package/plugins/kanban/dashboard/manifest.json +14 -0
- package/plugins/kanban/dashboard/plugin_api.py +1612 -0
- package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
- package/plugins/memory/__init__.py +408 -0
- package/plugins/memory/byterover/README.md +41 -0
- package/plugins/memory/byterover/__init__.py +384 -0
- package/plugins/memory/byterover/plugin.yaml +9 -0
- package/plugins/memory/hindsight/README.md +138 -0
- package/plugins/memory/hindsight/__init__.py +1758 -0
- package/plugins/memory/hindsight/plugin.yaml +8 -0
- package/plugins/memory/holographic/README.md +36 -0
- package/plugins/memory/holographic/__init__.py +409 -0
- package/plugins/memory/holographic/holographic.py +203 -0
- package/plugins/memory/holographic/plugin.yaml +5 -0
- package/plugins/memory/holographic/retrieval.py +593 -0
- package/plugins/memory/holographic/store.py +579 -0
- package/plugins/memory/honcho/README.md +328 -0
- package/plugins/memory/honcho/__init__.py +1329 -0
- package/plugins/memory/honcho/cli.py +1452 -0
- package/plugins/memory/honcho/client.py +784 -0
- package/plugins/memory/honcho/plugin.yaml +7 -0
- package/plugins/memory/honcho/session.py +1255 -0
- package/plugins/memory/mem0/README.md +38 -0
- package/plugins/memory/mem0/__init__.py +374 -0
- package/plugins/memory/mem0/plugin.yaml +5 -0
- package/plugins/memory/openviking/README.md +40 -0
- package/plugins/memory/openviking/__init__.py +945 -0
- package/plugins/memory/openviking/plugin.yaml +9 -0
- package/plugins/memory/retaindb/README.md +40 -0
- package/plugins/memory/retaindb/__init__.py +767 -0
- package/plugins/memory/retaindb/plugin.yaml +7 -0
- package/plugins/memory/supermemory/README.md +99 -0
- package/plugins/memory/supermemory/__init__.py +792 -0
- package/plugins/memory/supermemory/plugin.yaml +5 -0
- package/plugins/model-providers/README.md +70 -0
- package/plugins/model-providers/ai-gateway/__init__.py +43 -0
- package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
- package/plugins/model-providers/alibaba/__init__.py +13 -0
- package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/alibaba/plugin.yaml +5 -0
- package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
- package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
- package/plugins/model-providers/anthropic/__init__.py +52 -0
- package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/anthropic/plugin.yaml +5 -0
- package/plugins/model-providers/arcee/__init__.py +13 -0
- package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/arcee/plugin.yaml +5 -0
- package/plugins/model-providers/azure-foundry/__init__.py +21 -0
- package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
- package/plugins/model-providers/bedrock/__init__.py +29 -0
- package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/bedrock/plugin.yaml +5 -0
- package/plugins/model-providers/copilot/__init__.py +58 -0
- package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/copilot/plugin.yaml +5 -0
- package/plugins/model-providers/copilot-acp/__init__.py +34 -0
- package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
- package/plugins/model-providers/custom/__init__.py +68 -0
- package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/custom/plugin.yaml +5 -0
- package/plugins/model-providers/deepseek/__init__.py +99 -0
- package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/deepseek/plugin.yaml +5 -0
- package/plugins/model-providers/gemini/__init__.py +72 -0
- package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/gemini/plugin.yaml +5 -0
- package/plugins/model-providers/gmi/__init__.py +31 -0
- package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/gmi/plugin.yaml +5 -0
- package/plugins/model-providers/huggingface/__init__.py +20 -0
- package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/huggingface/plugin.yaml +5 -0
- package/plugins/model-providers/kilocode/__init__.py +14 -0
- package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/kilocode/plugin.yaml +5 -0
- package/plugins/model-providers/kimi-coding/__init__.py +71 -0
- package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
- package/plugins/model-providers/minimax/__init__.py +45 -0
- package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/minimax/plugin.yaml +5 -0
- package/plugins/model-providers/nous/__init__.py +54 -0
- package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/nous/plugin.yaml +5 -0
- package/plugins/model-providers/novita/__init__.py +27 -0
- package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/novita/plugin.yaml +5 -0
- package/plugins/model-providers/nvidia/__init__.py +21 -0
- package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/nvidia/plugin.yaml +5 -0
- package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
- package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
- package/plugins/model-providers/openai-codex/__init__.py +15 -0
- package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
- package/plugins/model-providers/opencode-zen/__init__.py +30 -0
- package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
- package/plugins/model-providers/openrouter/__init__.py +115 -0
- package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/openrouter/plugin.yaml +5 -0
- package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
- package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
- package/plugins/model-providers/stepfun/__init__.py +14 -0
- package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/stepfun/plugin.yaml +5 -0
- package/plugins/model-providers/xai/__init__.py +15 -0
- package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/xai/plugin.yaml +5 -0
- package/plugins/model-providers/xiaomi/__init__.py +14 -0
- package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
- package/plugins/model-providers/zai/__init__.py +21 -0
- package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/model-providers/zai/plugin.yaml +5 -0
- package/plugins/observability/langfuse/README.md +53 -0
- package/plugins/observability/langfuse/__init__.py +1004 -0
- package/plugins/observability/langfuse/plugin.yaml +14 -0
- package/plugins/platforms/google_chat/__init__.py +3 -0
- package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/google_chat/adapter.py +3343 -0
- package/plugins/platforms/google_chat/oauth.py +639 -0
- package/plugins/platforms/google_chat/plugin.yaml +39 -0
- package/plugins/platforms/irc/__init__.py +3 -0
- package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/irc/adapter.py +969 -0
- package/plugins/platforms/irc/plugin.yaml +54 -0
- package/plugins/platforms/line/__init__.py +3 -0
- package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/line/adapter.py +1639 -0
- package/plugins/platforms/line/plugin.yaml +65 -0
- package/plugins/platforms/simplex/__init__.py +3 -0
- package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/simplex/adapter.py +746 -0
- package/plugins/platforms/simplex/plugin.yaml +37 -0
- package/plugins/platforms/teams/__init__.py +3 -0
- package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
- package/plugins/platforms/teams/adapter.py +1188 -0
- package/plugins/platforms/teams/plugin.yaml +48 -0
- package/plugins/spotify/__init__.py +66 -0
- package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
- package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
- package/plugins/spotify/client.py +435 -0
- package/plugins/spotify/plugin.yaml +13 -0
- package/plugins/spotify/tools.py +454 -0
- package/plugins/teams_pipeline/__init__.py +23 -0
- package/plugins/teams_pipeline/cli.py +463 -0
- package/plugins/teams_pipeline/meetings.py +333 -0
- package/plugins/teams_pipeline/models.py +350 -0
- package/plugins/teams_pipeline/pipeline.py +692 -0
- package/plugins/teams_pipeline/plugin.yaml +9 -0
- package/plugins/teams_pipeline/runtime.py +135 -0
- package/plugins/teams_pipeline/store.py +194 -0
- package/plugins/teams_pipeline/subscriptions.py +249 -0
- package/plugins/video_gen/fal/__init__.py +523 -0
- package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/video_gen/fal/plugin.yaml +7 -0
- package/plugins/video_gen/xai/__init__.py +441 -0
- package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/video_gen/xai/plugin.yaml +7 -0
- package/plugins/web/__init__.py +7 -0
- package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/__init__.py +14 -0
- package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/brave_free/plugin.yaml +7 -0
- package/plugins/web/brave_free/provider.py +137 -0
- package/plugins/web/ddgs/__init__.py +15 -0
- package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/ddgs/plugin.yaml +7 -0
- package/plugins/web/ddgs/provider.py +104 -0
- package/plugins/web/exa/__init__.py +15 -0
- package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/exa/plugin.yaml +7 -0
- package/plugins/web/exa/provider.py +212 -0
- package/plugins/web/firecrawl/__init__.py +28 -0
- package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/firecrawl/plugin.yaml +7 -0
- package/plugins/web/firecrawl/provider.py +773 -0
- package/plugins/web/parallel/__init__.py +16 -0
- package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/parallel/plugin.yaml +7 -0
- package/plugins/web/parallel/provider.py +291 -0
- package/plugins/web/searxng/__init__.py +15 -0
- package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/searxng/plugin.yaml +7 -0
- package/plugins/web/searxng/provider.py +140 -0
- package/plugins/web/tavily/__init__.py +15 -0
- package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
- package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
- package/plugins/web/tavily/plugin.yaml +7 -0
- package/plugins/web/tavily/provider.py +285 -0
- package/providers/README.md +78 -0
- package/providers/__init__.py +192 -0
- package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/providers/__pycache__/base.cpython-312.pyc +0 -0
- package/providers/base.py +184 -0
- package/pyproject.toml +255 -0
- package/run_agent.py +16409 -0
- package/scripts/benchmark_browser_eval.py +138 -0
- package/scripts/build_model_catalog.py +95 -0
- package/scripts/build_skills_index.py +325 -0
- package/scripts/check-windows-footguns.py +624 -0
- package/scripts/contributor_audit.py +473 -0
- package/scripts/discord-voice-doctor.py +396 -0
- package/scripts/hermes-gateway +416 -0
- package/scripts/install.cmd +28 -0
- package/scripts/install.ps1 +1611 -0
- package/scripts/install.sh +2007 -0
- package/scripts/install_psutil_android.py +117 -0
- package/scripts/keystroke_diagnostic.py +81 -0
- package/scripts/kill_modal.sh +34 -0
- package/scripts/lib/node-bootstrap.sh +238 -0
- package/scripts/lint_diff.py +207 -0
- package/scripts/postinstall.js +150 -0
- package/scripts/profile-tui.py +626 -0
- package/scripts/release.py +1680 -0
- package/scripts/run_tests.sh +129 -0
- package/scripts/sample_and_compress.py +409 -0
- package/scripts/setup_open_webui.sh +349 -0
- package/scripts/whatsapp-bridge/allowlist.js +88 -0
- package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
- package/scripts/whatsapp-bridge/bridge.js +729 -0
- package/scripts/whatsapp-bridge/package-lock.json +2141 -0
- package/scripts/whatsapp-bridge/package.json +19 -0
- package/skills/apple/DESCRIPTION.md +2 -0
- package/skills/apple/apple-notes/SKILL.md +90 -0
- package/skills/apple/apple-reminders/SKILL.md +98 -0
- package/skills/apple/findmy/SKILL.md +131 -0
- package/skills/apple/imessage/SKILL.md +102 -0
- package/skills/apple/macos-computer-use/SKILL.md +201 -0
- package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
- package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
- package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
- package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
- package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
- package/skills/creative/DESCRIPTION.md +3 -0
- package/skills/creative/architecture-diagram/SKILL.md +148 -0
- package/skills/creative/architecture-diagram/templates/template.html +319 -0
- package/skills/creative/ascii-art/SKILL.md +322 -0
- package/skills/creative/ascii-video/README.md +290 -0
- package/skills/creative/ascii-video/SKILL.md +241 -0
- package/skills/creative/ascii-video/references/architecture.md +802 -0
- package/skills/creative/ascii-video/references/composition.md +892 -0
- package/skills/creative/ascii-video/references/effects.md +1865 -0
- package/skills/creative/ascii-video/references/inputs.md +685 -0
- package/skills/creative/ascii-video/references/optimization.md +688 -0
- package/skills/creative/ascii-video/references/scenes.md +1011 -0
- package/skills/creative/ascii-video/references/shaders.md +1385 -0
- package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
- package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
- package/skills/creative/baoyu-comic/SKILL.md +247 -0
- package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
- package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
- package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
- package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
- package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
- package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
- package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
- package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
- package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
- package/skills/creative/baoyu-comic/references/character-template.md +180 -0
- package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
- package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
- package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
- package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
- package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
- package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
- package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
- package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
- package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
- package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
- package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
- package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
- package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
- package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
- package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
- package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
- package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
- package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
- package/skills/creative/baoyu-comic/references/workflow.md +401 -0
- package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
- package/skills/creative/baoyu-infographic/SKILL.md +237 -0
- package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
- package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
- package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
- package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
- package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
- package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
- package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
- package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
- package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
- package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
- package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
- package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
- package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
- package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
- package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
- package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
- package/skills/creative/claude-design/SKILL.md +591 -0
- package/skills/creative/comfyui/SKILL.md +612 -0
- package/skills/creative/comfyui/references/official-cli.md +255 -0
- package/skills/creative/comfyui/references/rest-api.md +312 -0
- package/skills/creative/comfyui/references/template-integrity.md +243 -0
- package/skills/creative/comfyui/references/workflow-format.md +226 -0
- package/skills/creative/comfyui/scripts/_common.py +835 -0
- package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
- package/skills/creative/comfyui/scripts/check_deps.py +437 -0
- package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
- package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
- package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
- package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
- package/skills/creative/comfyui/scripts/health_check.py +223 -0
- package/skills/creative/comfyui/scripts/run_batch.py +243 -0
- package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
- package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
- package/skills/creative/comfyui/tests/README.md +50 -0
- package/skills/creative/comfyui/tests/conftest.py +64 -0
- package/skills/creative/comfyui/tests/pytest.ini +5 -0
- package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
- package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
- package/skills/creative/comfyui/tests/test_common.py +447 -0
- package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
- package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
- package/skills/creative/comfyui/workflows/README.md +86 -0
- package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
- package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
- package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
- package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
- package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
- package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
- package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
- package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
- package/skills/creative/creative-ideation/SKILL.md +152 -0
- package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
- package/skills/creative/design-md/SKILL.md +199 -0
- package/skills/creative/design-md/templates/starter.md +99 -0
- package/skills/creative/excalidraw/SKILL.md +199 -0
- package/skills/creative/excalidraw/references/colors.md +44 -0
- package/skills/creative/excalidraw/references/dark-mode.md +68 -0
- package/skills/creative/excalidraw/references/examples.md +141 -0
- package/skills/creative/excalidraw/scripts/upload.py +133 -0
- package/skills/creative/humanizer/LICENSE +21 -0
- package/skills/creative/humanizer/SKILL.md +578 -0
- package/skills/creative/manim-video/README.md +23 -0
- package/skills/creative/manim-video/SKILL.md +269 -0
- package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
- package/skills/creative/manim-video/references/animations.md +282 -0
- package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
- package/skills/creative/manim-video/references/decorations.md +202 -0
- package/skills/creative/manim-video/references/equations.md +216 -0
- package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
- package/skills/creative/manim-video/references/mobjects.md +333 -0
- package/skills/creative/manim-video/references/paper-explainer.md +255 -0
- package/skills/creative/manim-video/references/production-quality.md +190 -0
- package/skills/creative/manim-video/references/rendering.md +185 -0
- package/skills/creative/manim-video/references/scene-planning.md +118 -0
- package/skills/creative/manim-video/references/troubleshooting.md +135 -0
- package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
- package/skills/creative/manim-video/references/visual-design.md +124 -0
- package/skills/creative/manim-video/scripts/setup.sh +14 -0
- package/skills/creative/p5js/README.md +64 -0
- package/skills/creative/p5js/SKILL.md +556 -0
- package/skills/creative/p5js/references/animation.md +439 -0
- package/skills/creative/p5js/references/color-systems.md +352 -0
- package/skills/creative/p5js/references/core-api.md +410 -0
- package/skills/creative/p5js/references/export-pipeline.md +566 -0
- package/skills/creative/p5js/references/interaction.md +398 -0
- package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
- package/skills/creative/p5js/references/troubleshooting.md +532 -0
- package/skills/creative/p5js/references/typography.md +302 -0
- package/skills/creative/p5js/references/visual-effects.md +895 -0
- package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
- package/skills/creative/p5js/scripts/export-frames.js +179 -0
- package/skills/creative/p5js/scripts/render.sh +108 -0
- package/skills/creative/p5js/scripts/serve.sh +28 -0
- package/skills/creative/p5js/scripts/setup.sh +87 -0
- package/skills/creative/p5js/templates/viewer.html +395 -0
- package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
- package/skills/creative/pixel-art/SKILL.md +218 -0
- package/skills/creative/pixel-art/references/palettes.md +49 -0
- package/skills/creative/pixel-art/scripts/__init__.py +0 -0
- package/skills/creative/pixel-art/scripts/palettes.py +167 -0
- package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
- package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
- package/skills/creative/popular-web-designs/SKILL.md +214 -0
- package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
- package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
- package/skills/creative/popular-web-designs/templates/apple.md +326 -0
- package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
- package/skills/creative/popular-web-designs/templates/cal.md +272 -0
- package/skills/creative/popular-web-designs/templates/claude.md +325 -0
- package/skills/creative/popular-web-designs/templates/clay.md +317 -0
- package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
- package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
- package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
- package/skills/creative/popular-web-designs/templates/composio.md +320 -0
- package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
- package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
- package/skills/creative/popular-web-designs/templates/expo.md +294 -0
- package/skills/creative/popular-web-designs/templates/figma.md +233 -0
- package/skills/creative/popular-web-designs/templates/framer.md +259 -0
- package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
- package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
- package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
- package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
- package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
- package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
- package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
- package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
- package/skills/creative/popular-web-designs/templates/miro.md +121 -0
- package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
- package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
- package/skills/creative/popular-web-designs/templates/notion.md +322 -0
- package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
- package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
- package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
- package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
- package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
- package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
- package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
- package/skills/creative/popular-web-designs/templates/resend.md +316 -0
- package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
- package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
- package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
- package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
- package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
- package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
- package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
- package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
- package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
- package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
- package/skills/creative/popular-web-designs/templates/uber.md +308 -0
- package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
- package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
- package/skills/creative/popular-web-designs/templates/warp.md +266 -0
- package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
- package/skills/creative/popular-web-designs/templates/wise.md +186 -0
- package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
- package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
- package/skills/creative/pretext/SKILL.md +220 -0
- package/skills/creative/pretext/references/patterns.md +258 -0
- package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
- package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
- package/skills/creative/sketch/SKILL.md +218 -0
- package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
- package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
- package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
- package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
- package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
- package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
- package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
- package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
- package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
- package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
- package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
- package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
- package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
- package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
- package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
- package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
- package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
- package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
- package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
- package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
- package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
- package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
- package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
- package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
- package/skills/data-science/DESCRIPTION.md +3 -0
- package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
- package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
- package/skills/devops/kanban-worker/SKILL.md +184 -0
- package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
- package/skills/diagramming/DESCRIPTION.md +3 -0
- package/skills/dogfood/SKILL.md +162 -0
- package/skills/dogfood/references/issue-taxonomy.md +109 -0
- package/skills/dogfood/templates/dogfood-report-template.md +86 -0
- package/skills/domain/DESCRIPTION.md +24 -0
- package/skills/email/DESCRIPTION.md +3 -0
- package/skills/email/himalaya/SKILL.md +299 -0
- package/skills/email/himalaya/references/configuration.md +227 -0
- package/skills/email/himalaya/references/message-composition.md +199 -0
- package/skills/gaming/DESCRIPTION.md +3 -0
- package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
- package/skills/gaming/pokemon-player/SKILL.md +216 -0
- package/skills/gifs/DESCRIPTION.md +3 -0
- package/skills/github/DESCRIPTION.md +3 -0
- package/skills/github/codebase-inspection/SKILL.md +116 -0
- package/skills/github/github-auth/SKILL.md +247 -0
- package/skills/github/github-auth/scripts/gh-env.sh +66 -0
- package/skills/github/github-code-review/SKILL.md +481 -0
- package/skills/github/github-code-review/references/review-output-template.md +74 -0
- package/skills/github/github-issues/SKILL.md +370 -0
- package/skills/github/github-issues/templates/bug-report.md +35 -0
- package/skills/github/github-issues/templates/feature-request.md +31 -0
- package/skills/github/github-pr-workflow/SKILL.md +367 -0
- package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
- package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
- package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
- package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
- package/skills/github/github-repo-management/SKILL.md +516 -0
- package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
- package/skills/index-cache/anthropics_skills_skills_.json +1 -0
- package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
- package/skills/index-cache/lobehub_index.json +1 -0
- package/skills/index-cache/openai_skills_skills_.json +1 -0
- package/skills/inference-sh/DESCRIPTION.md +19 -0
- package/skills/mcp/DESCRIPTION.md +3 -0
- package/skills/mcp/native-mcp/SKILL.md +357 -0
- package/skills/media/DESCRIPTION.md +3 -0
- package/skills/media/gif-search/SKILL.md +91 -0
- package/skills/media/heartmula/SKILL.md +171 -0
- package/skills/media/songsee/SKILL.md +83 -0
- package/skills/media/spotify/SKILL.md +135 -0
- package/skills/media/youtube-content/SKILL.md +73 -0
- package/skills/media/youtube-content/references/output-formats.md +56 -0
- package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
- package/skills/mlops/DESCRIPTION.md +3 -0
- package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
- package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
- package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
- package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
- package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
- package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
- package/skills/mlops/huggingface-hub/SKILL.md +81 -0
- package/skills/mlops/inference/DESCRIPTION.md +3 -0
- package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
- package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
- package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
- package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
- package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
- package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
- package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
- package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
- package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
- package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
- package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
- package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
- package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
- package/skills/mlops/inference/vllm/SKILL.md +372 -0
- package/skills/mlops/inference/vllm/references/optimization.md +226 -0
- package/skills/mlops/inference/vllm/references/quantization.md +284 -0
- package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
- package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
- package/skills/mlops/models/DESCRIPTION.md +3 -0
- package/skills/mlops/models/audiocraft/SKILL.md +568 -0
- package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
- package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
- package/skills/mlops/models/segment-anything/SKILL.md +506 -0
- package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
- package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
- package/skills/mlops/research/DESCRIPTION.md +3 -0
- package/skills/mlops/research/dspy/SKILL.md +594 -0
- package/skills/mlops/research/dspy/references/examples.md +663 -0
- package/skills/mlops/research/dspy/references/modules.md +475 -0
- package/skills/mlops/research/dspy/references/optimizers.md +566 -0
- package/skills/mlops/training/DESCRIPTION.md +3 -0
- package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
- package/skills/note-taking/DESCRIPTION.md +3 -0
- package/skills/note-taking/obsidian/SKILL.md +61 -0
- package/skills/productivity/DESCRIPTION.md +3 -0
- package/skills/productivity/airtable/SKILL.md +229 -0
- package/skills/productivity/google-workspace/SKILL.md +335 -0
- package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
- package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
- package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
- package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
- package/skills/productivity/google-workspace/scripts/setup.py +454 -0
- package/skills/productivity/linear/SKILL.md +380 -0
- package/skills/productivity/linear/scripts/linear_api.py +445 -0
- package/skills/productivity/maps/SKILL.md +195 -0
- package/skills/productivity/maps/scripts/maps_client.py +1298 -0
- package/skills/productivity/nano-pdf/SKILL.md +52 -0
- package/skills/productivity/notion/SKILL.md +448 -0
- package/skills/productivity/notion/references/block-types.md +112 -0
- package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
- package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
- package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
- package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
- package/skills/productivity/powerpoint/LICENSE.txt +30 -0
- package/skills/productivity/powerpoint/SKILL.md +237 -0
- package/skills/productivity/powerpoint/editing.md +205 -0
- package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
- package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
- package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
- package/skills/productivity/powerpoint/scripts/clean.py +286 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
- package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
- package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
- package/skills/red-teaming/godmode/SKILL.md +404 -0
- package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
- package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
- package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
- package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
- package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
- package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
- package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
- package/skills/red-teaming/godmode/templates/prefill.json +18 -0
- package/skills/research/DESCRIPTION.md +3 -0
- package/skills/research/arxiv/SKILL.md +282 -0
- package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
- package/skills/research/blogwatcher/SKILL.md +137 -0
- package/skills/research/llm-wiki/SKILL.md +507 -0
- package/skills/research/polymarket/SKILL.md +77 -0
- package/skills/research/polymarket/references/api-endpoints.md +220 -0
- package/skills/research/polymarket/scripts/polymarket.py +284 -0
- package/skills/research/research-paper-writing/SKILL.md +2377 -0
- package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
- package/skills/research/research-paper-writing/references/checklists.md +434 -0
- package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
- package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
- package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
- package/skills/research/research-paper-writing/references/paper-types.md +481 -0
- package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
- package/skills/research/research-paper-writing/references/sources.md +191 -0
- package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
- package/skills/research/research-paper-writing/templates/README.md +251 -0
- package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
- package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
- package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
- package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
- package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
- package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
- package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
- package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
- package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
- package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
- package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
- package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
- package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
- package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
- package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
- package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
- package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
- package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
- package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
- package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
- package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
- package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
- package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
- package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
- package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
- package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
- package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
- package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
- package/skills/smart-home/DESCRIPTION.md +3 -0
- package/skills/smart-home/openhue/SKILL.md +109 -0
- package/skills/social-media/DESCRIPTION.md +3 -0
- package/skills/social-media/xurl/SKILL.md +414 -0
- package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
- package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
- package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
- package/skills/software-development/plan/SKILL.md +58 -0
- package/skills/software-development/python-debugpy/SKILL.md +375 -0
- package/skills/software-development/requesting-code-review/SKILL.md +280 -0
- package/skills/software-development/spike/SKILL.md +197 -0
- package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
- package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
- package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
- package/skills/software-development/systematic-debugging/SKILL.md +367 -0
- package/skills/software-development/test-driven-development/SKILL.md +343 -0
- package/skills/software-development/writing-plans/SKILL.md +297 -0
- package/skills/yuanbao/SKILL.md +108 -0
- package/tools/__init__.py +25 -0
- package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/__pycache__/approval.cpython-312.pyc +0 -0
- package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
- package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
- package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
- package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
- package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
- package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
- package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
- package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
- package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
- package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
- package/tools/__pycache__/registry.cpython-312.pyc +0 -0
- package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
- package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
- package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
- package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
- package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
- package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
- package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
- package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
- package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
- package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
- package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
- package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
- package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
- package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
- package/tools/ansi_strip.py +44 -0
- package/tools/approval.py +1392 -0
- package/tools/binary_extensions.py +42 -0
- package/tools/browser_camofox.py +700 -0
- package/tools/browser_camofox_state.py +48 -0
- package/tools/browser_cdp_tool.py +569 -0
- package/tools/browser_dialog_tool.py +148 -0
- package/tools/browser_providers/__init__.py +10 -0
- package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
- package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
- package/tools/browser_providers/base.py +59 -0
- package/tools/browser_providers/browser_use.py +225 -0
- package/tools/browser_providers/browserbase.py +222 -0
- package/tools/browser_providers/firecrawl.py +112 -0
- package/tools/browser_supervisor.py +1457 -0
- package/tools/browser_tool.py +3676 -0
- package/tools/budget_config.py +51 -0
- package/tools/checkpoint_manager.py +1639 -0
- package/tools/clarify_gateway.py +278 -0
- package/tools/clarify_tool.py +141 -0
- package/tools/code_execution_tool.py +1782 -0
- package/tools/computer_use/__init__.py +43 -0
- package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
- package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
- package/tools/computer_use/backend.py +150 -0
- package/tools/computer_use/cua_backend.py +682 -0
- package/tools/computer_use/schema.py +191 -0
- package/tools/computer_use/tool.py +521 -0
- package/tools/computer_use_tool.py +39 -0
- package/tools/credential_files.py +437 -0
- package/tools/cronjob_tools.py +719 -0
- package/tools/debug_helpers.py +106 -0
- package/tools/delegate_tool.py +2797 -0
- package/tools/discord_tool.py +959 -0
- package/tools/env_passthrough.py +145 -0
- package/tools/environments/__init__.py +14 -0
- package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
- package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
- package/tools/environments/base.py +844 -0
- package/tools/environments/daytona.py +270 -0
- package/tools/environments/docker.py +656 -0
- package/tools/environments/file_sync.py +400 -0
- package/tools/environments/local.py +658 -0
- package/tools/environments/managed_modal.py +282 -0
- package/tools/environments/modal.py +479 -0
- package/tools/environments/modal_utils.py +199 -0
- package/tools/environments/singularity.py +263 -0
- package/tools/environments/ssh.py +295 -0
- package/tools/environments/vercel_sandbox.py +655 -0
- package/tools/feishu_doc_tool.py +138 -0
- package/tools/feishu_drive_tool.py +431 -0
- package/tools/file_operations.py +1825 -0
- package/tools/file_state.py +332 -0
- package/tools/file_tools.py +1172 -0
- package/tools/fuzzy_match.py +703 -0
- package/tools/homeassistant_tool.py +513 -0
- package/tools/image_generation_tool.py +1098 -0
- package/tools/interrupt.py +98 -0
- package/tools/kanban_tools.py +1139 -0
- package/tools/lazy_deps.py +608 -0
- package/tools/managed_tool_gateway.py +168 -0
- package/tools/mcp_oauth.py +633 -0
- package/tools/mcp_oauth_manager.py +607 -0
- package/tools/mcp_tool.py +3483 -0
- package/tools/memory_tool.py +584 -0
- package/tools/microsoft_graph_auth.py +245 -0
- package/tools/microsoft_graph_client.py +408 -0
- package/tools/mixture_of_agents_tool.py +542 -0
- package/tools/neutts_samples/jo.txt +1 -0
- package/tools/neutts_samples/jo.wav +0 -0
- package/tools/neutts_synth.py +104 -0
- package/tools/openrouter_client.py +33 -0
- package/tools/osv_check.py +155 -0
- package/tools/patch_parser.py +592 -0
- package/tools/path_security.py +43 -0
- package/tools/process_registry.py +1534 -0
- package/tools/registry.py +589 -0
- package/tools/schema_sanitizer.py +370 -0
- package/tools/send_message_tool.py +1900 -0
- package/tools/session_search_tool.py +613 -0
- package/tools/skill_manager_tool.py +932 -0
- package/tools/skill_provenance.py +78 -0
- package/tools/skill_usage.py +610 -0
- package/tools/skills_guard.py +932 -0
- package/tools/skills_hub.py +3263 -0
- package/tools/skills_sync.py +432 -0
- package/tools/skills_tool.py +1569 -0
- package/tools/slash_confirm.py +167 -0
- package/tools/terminal_tool.py +2376 -0
- package/tools/tirith_security.py +775 -0
- package/tools/todo_tool.py +277 -0
- package/tools/tool_backend_helpers.py +144 -0
- package/tools/tool_output_limits.py +92 -0
- package/tools/tool_result_storage.py +232 -0
- package/tools/transcription_tools.py +936 -0
- package/tools/tts_tool.py +2285 -0
- package/tools/url_safety.py +330 -0
- package/tools/video_generation_tool.py +561 -0
- package/tools/vision_tools.py +1422 -0
- package/tools/voice_mode.py +1019 -0
- package/tools/web_tools.py +1551 -0
- package/tools/website_policy.py +283 -0
- package/tools/x_search_tool.py +424 -0
- package/tools/xai_http.py +83 -0
- package/tools/yuanbao_tools.py +736 -0
- package/toolset_distributions.py +364 -0
- package/toolsets.py +866 -0
- package/trajectory_compressor.py +1509 -0
- package/tui_gateway/__init__.py +0 -0
- package/tui_gateway/entry.py +251 -0
- package/tui_gateway/event_publisher.py +126 -0
- package/tui_gateway/render.py +49 -0
- package/tui_gateway/server.py +6623 -0
- package/tui_gateway/slash_worker.py +76 -0
- package/tui_gateway/transport.py +219 -0
- package/tui_gateway/ws.py +178 -0
- package/utils.py +361 -0
|
@@ -0,0 +1,3488 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI-compatible API server platform adapter.
|
|
3
|
+
|
|
4
|
+
Exposes an HTTP server with endpoints:
|
|
5
|
+
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header; opt-in long-term memory scoping via X-Hermes-Session-Key header)
|
|
6
|
+
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported)
|
|
7
|
+
- GET /v1/responses/{response_id} — Retrieve a stored response
|
|
8
|
+
- DELETE /v1/responses/{response_id} — Delete a stored response
|
|
9
|
+
- GET /v1/models — lists hermes-agent as an available model
|
|
10
|
+
- GET /v1/capabilities — machine-readable API capabilities for external UIs
|
|
11
|
+
- POST /v1/runs — start a run, returns run_id immediately (202)
|
|
12
|
+
- GET /v1/runs/{run_id} — retrieve current run status
|
|
13
|
+
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
|
|
14
|
+
- POST /v1/runs/{run_id}/approval — resolve a pending run approval
|
|
15
|
+
- POST /v1/runs/{run_id}/stop — interrupt a running agent
|
|
16
|
+
- GET /health — health check
|
|
17
|
+
- GET /health/detailed — rich status for cross-container dashboard probing
|
|
18
|
+
|
|
19
|
+
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
|
|
20
|
+
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
|
|
21
|
+
through this adapter by pointing at http://localhost:8642/v1.
|
|
22
|
+
|
|
23
|
+
Requires:
|
|
24
|
+
- aiohttp (already available in the gateway)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import hashlib
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import socket as _socket
|
|
34
|
+
import re
|
|
35
|
+
import sqlite3
|
|
36
|
+
import time
|
|
37
|
+
import uuid
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from aiohttp import web
|
|
42
|
+
AIOHTTP_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
AIOHTTP_AVAILABLE = False
|
|
45
|
+
web = None # type: ignore[assignment]
|
|
46
|
+
|
|
47
|
+
from gateway.config import Platform, PlatformConfig
|
|
48
|
+
from gateway.platforms.base import (
|
|
49
|
+
BasePlatformAdapter,
|
|
50
|
+
SendResult,
|
|
51
|
+
is_network_accessible,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Default settings
|
|
57
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
58
|
+
DEFAULT_PORT = 8642
|
|
59
|
+
MAX_STORED_RESPONSES = 100
|
|
60
|
+
MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversations with tool calls
|
|
61
|
+
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
|
|
62
|
+
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
|
|
63
|
+
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int:
|
|
67
|
+
"""Parse a listen port without letting malformed env/config values crash startup."""
|
|
68
|
+
try:
|
|
69
|
+
return int(value)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
return default
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_chat_content(
|
|
75
|
+
content: Any, *, _max_depth: int = 10, _depth: int = 0,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Normalize OpenAI chat message content into a plain text string.
|
|
78
|
+
|
|
79
|
+
Some clients (Open WebUI, LobeChat, etc.) send content as an array of
|
|
80
|
+
typed parts instead of a plain string::
|
|
81
|
+
|
|
82
|
+
[{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}]
|
|
83
|
+
|
|
84
|
+
This function flattens those into a single string so the agent pipeline
|
|
85
|
+
(which expects strings) doesn't choke.
|
|
86
|
+
|
|
87
|
+
Defensive limits prevent abuse: recursion depth, list size, and output
|
|
88
|
+
length are all bounded.
|
|
89
|
+
"""
|
|
90
|
+
if _depth > _max_depth:
|
|
91
|
+
return ""
|
|
92
|
+
if content is None:
|
|
93
|
+
return ""
|
|
94
|
+
if isinstance(content, str):
|
|
95
|
+
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
|
|
96
|
+
|
|
97
|
+
if isinstance(content, list):
|
|
98
|
+
parts: List[str] = []
|
|
99
|
+
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
|
|
100
|
+
for item in items:
|
|
101
|
+
if isinstance(item, str):
|
|
102
|
+
if item:
|
|
103
|
+
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
|
|
104
|
+
elif isinstance(item, dict):
|
|
105
|
+
item_type = str(item.get("type") or "").strip().lower()
|
|
106
|
+
if item_type in {"text", "input_text", "output_text"}:
|
|
107
|
+
text = item.get("text", "")
|
|
108
|
+
if text:
|
|
109
|
+
try:
|
|
110
|
+
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
# Silently skip image_url / other non-text parts
|
|
114
|
+
elif isinstance(item, list):
|
|
115
|
+
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
|
|
116
|
+
if nested:
|
|
117
|
+
parts.append(nested)
|
|
118
|
+
# Check accumulated size
|
|
119
|
+
if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
|
|
120
|
+
break
|
|
121
|
+
result = "\n".join(parts)
|
|
122
|
+
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
|
123
|
+
|
|
124
|
+
# Fallback for unexpected types (int, float, bool, etc.)
|
|
125
|
+
try:
|
|
126
|
+
result = str(content)
|
|
127
|
+
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
|
128
|
+
except Exception:
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Content part type aliases used by the OpenAI Chat Completions and Responses
|
|
133
|
+
# APIs. We accept both spellings on input and emit a single canonical internal
|
|
134
|
+
# shape (``{"type": "text", ...}`` / ``{"type": "image_url", ...}``) that the
|
|
135
|
+
# rest of the agent pipeline already understands.
|
|
136
|
+
_TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text"})
|
|
137
|
+
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image"})
|
|
138
|
+
_FILE_PART_TYPES = frozenset({"file", "input_file"})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize_multimodal_content(content: Any) -> Any:
|
|
142
|
+
"""Validate and normalize multimodal content for the API server.
|
|
143
|
+
|
|
144
|
+
Returns a plain string when the content is text-only, or a list of
|
|
145
|
+
``{"type": "text"|"image_url", ...}`` parts when images are present.
|
|
146
|
+
The output shape is the native OpenAI Chat Completions vision format,
|
|
147
|
+
which the agent pipeline accepts verbatim (OpenAI-wire providers) or
|
|
148
|
+
converts (``_preprocess_anthropic_content`` for Anthropic).
|
|
149
|
+
|
|
150
|
+
Raises ``ValueError`` with an OpenAI-style code on invalid input:
|
|
151
|
+
* ``unsupported_content_type`` — file/input_file/file_id parts, or
|
|
152
|
+
non-image ``data:`` URLs.
|
|
153
|
+
* ``invalid_image_url`` — missing URL or unsupported scheme.
|
|
154
|
+
* ``invalid_content_part`` — malformed text/image objects.
|
|
155
|
+
|
|
156
|
+
Callers translate the ValueError into a 400 response.
|
|
157
|
+
"""
|
|
158
|
+
# Scalar passthrough mirrors ``_normalize_chat_content``.
|
|
159
|
+
if content is None:
|
|
160
|
+
return ""
|
|
161
|
+
if isinstance(content, str):
|
|
162
|
+
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
|
|
163
|
+
if not isinstance(content, list):
|
|
164
|
+
# Mirror the legacy text-normalizer's fallback so callers that
|
|
165
|
+
# pre-existed image support still get a string back.
|
|
166
|
+
return _normalize_chat_content(content)
|
|
167
|
+
|
|
168
|
+
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
|
|
169
|
+
normalized_parts: List[Dict[str, Any]] = []
|
|
170
|
+
text_accum_len = 0
|
|
171
|
+
|
|
172
|
+
for part in items:
|
|
173
|
+
if isinstance(part, str):
|
|
174
|
+
if part:
|
|
175
|
+
trimmed = part[:MAX_NORMALIZED_TEXT_LENGTH]
|
|
176
|
+
normalized_parts.append({"type": "text", "text": trimmed})
|
|
177
|
+
text_accum_len += len(trimmed)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if not isinstance(part, dict):
|
|
181
|
+
# Ignore unknown scalars for forward compatibility with future
|
|
182
|
+
# Responses API additions (e.g. ``refusal``). The same policy
|
|
183
|
+
# the text normalizer applies.
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
raw_type = part.get("type")
|
|
187
|
+
part_type = str(raw_type or "").strip().lower()
|
|
188
|
+
|
|
189
|
+
if part_type in _TEXT_PART_TYPES:
|
|
190
|
+
text = part.get("text")
|
|
191
|
+
if text is None:
|
|
192
|
+
continue
|
|
193
|
+
if not isinstance(text, str):
|
|
194
|
+
text = str(text)
|
|
195
|
+
if text:
|
|
196
|
+
trimmed = text[:MAX_NORMALIZED_TEXT_LENGTH]
|
|
197
|
+
normalized_parts.append({"type": "text", "text": trimmed})
|
|
198
|
+
text_accum_len += len(trimmed)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
if part_type in _IMAGE_PART_TYPES:
|
|
202
|
+
detail = part.get("detail")
|
|
203
|
+
image_ref = part.get("image_url")
|
|
204
|
+
# OpenAI Responses sends ``input_image`` with a top-level
|
|
205
|
+
# ``image_url`` string; Chat Completions sends ``image_url`` as
|
|
206
|
+
# ``{"url": "...", "detail": "..."}``. Support both.
|
|
207
|
+
if isinstance(image_ref, dict):
|
|
208
|
+
url_value = image_ref.get("url")
|
|
209
|
+
detail = image_ref.get("detail", detail)
|
|
210
|
+
else:
|
|
211
|
+
url_value = image_ref
|
|
212
|
+
if not isinstance(url_value, str) or not url_value.strip():
|
|
213
|
+
raise ValueError("invalid_image_url:Image parts must include a non-empty image URL.")
|
|
214
|
+
url_value = url_value.strip()
|
|
215
|
+
lowered = url_value.lower()
|
|
216
|
+
if lowered.startswith("data:"):
|
|
217
|
+
if not lowered.startswith("data:image/") or "," not in url_value:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"unsupported_content_type:Only image data URLs are supported. "
|
|
220
|
+
"Non-image data payloads are not supported."
|
|
221
|
+
)
|
|
222
|
+
elif not (lowered.startswith("http://") or lowered.startswith("https://")):
|
|
223
|
+
raise ValueError(
|
|
224
|
+
"invalid_image_url:Image inputs must use http(s) URLs or data:image/... URLs."
|
|
225
|
+
)
|
|
226
|
+
image_part: Dict[str, Any] = {"type": "image_url", "image_url": {"url": url_value}}
|
|
227
|
+
if detail is not None:
|
|
228
|
+
if not isinstance(detail, str) or not detail.strip():
|
|
229
|
+
raise ValueError("invalid_content_part:Image detail must be a non-empty string when provided.")
|
|
230
|
+
image_part["image_url"]["detail"] = detail.strip()
|
|
231
|
+
normalized_parts.append(image_part)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
if part_type in _FILE_PART_TYPES:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
"unsupported_content_type:Inline image inputs are supported, "
|
|
237
|
+
"but uploaded files and document inputs are not supported on this endpoint."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Unknown part type — reject explicitly so clients get a clear error
|
|
241
|
+
# instead of a silently dropped turn.
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"unsupported_content_type:Unsupported content part type {raw_type!r}. "
|
|
244
|
+
"Only text and image_url/input_image parts are supported."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if not normalized_parts:
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
# Text-only: collapse to a plain string so downstream logging/trajectory
|
|
251
|
+
# code sees the native shape and prompt caching on text-only turns is
|
|
252
|
+
# unaffected.
|
|
253
|
+
if all(p.get("type") == "text" for p in normalized_parts):
|
|
254
|
+
return "\n".join(p["text"] for p in normalized_parts if p.get("text"))
|
|
255
|
+
|
|
256
|
+
return normalized_parts
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _content_has_visible_payload(content: Any) -> bool:
|
|
260
|
+
"""True when content has any text or image attachment. Used to reject empty turns."""
|
|
261
|
+
if isinstance(content, str):
|
|
262
|
+
return bool(content.strip())
|
|
263
|
+
if isinstance(content, list):
|
|
264
|
+
for part in content:
|
|
265
|
+
if isinstance(part, dict):
|
|
266
|
+
ptype = str(part.get("type") or "").strip().lower()
|
|
267
|
+
if ptype in _TEXT_PART_TYPES and str(part.get("text") or "").strip():
|
|
268
|
+
return True
|
|
269
|
+
if ptype in _IMAGE_PART_TYPES:
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Response":
|
|
275
|
+
"""Translate a ``_normalize_multimodal_content`` ValueError into a 400 response."""
|
|
276
|
+
raw = str(exc)
|
|
277
|
+
code, _, message = raw.partition(":")
|
|
278
|
+
if not message:
|
|
279
|
+
code, message = "invalid_content_part", raw
|
|
280
|
+
return web.json_response(
|
|
281
|
+
_openai_error(message, code=code, param=param),
|
|
282
|
+
status=400,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_api_server_requirements() -> bool:
|
|
287
|
+
"""Check if API server dependencies are available."""
|
|
288
|
+
return AIOHTTP_AVAILABLE
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class ResponseStore:
|
|
292
|
+
"""
|
|
293
|
+
SQLite-backed LRU store for Responses API state.
|
|
294
|
+
|
|
295
|
+
Each stored response includes the full internal conversation history
|
|
296
|
+
(with tool calls and results) so it can be reconstructed on subsequent
|
|
297
|
+
requests via previous_response_id.
|
|
298
|
+
|
|
299
|
+
Persists across gateway restarts. Falls back to in-memory SQLite
|
|
300
|
+
if the on-disk path is unavailable.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def __init__(self, max_size: int = MAX_STORED_RESPONSES, db_path: str = None):
|
|
304
|
+
self._max_size = max_size
|
|
305
|
+
if db_path is None:
|
|
306
|
+
try:
|
|
307
|
+
from hermes_cli.config import get_hermes_home
|
|
308
|
+
db_path = str(get_hermes_home() / "response_store.db")
|
|
309
|
+
except Exception:
|
|
310
|
+
db_path = ":memory:"
|
|
311
|
+
try:
|
|
312
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
313
|
+
except Exception:
|
|
314
|
+
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
315
|
+
# Use shared WAL-fallback helper so response_store.db degrades
|
|
316
|
+
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same filesystem
|
|
317
|
+
# issue addressed for state.db/kanban.db — see
|
|
318
|
+
# calvyn_state._WAL_INCOMPAT_MARKERS).
|
|
319
|
+
from calvyn_state import apply_wal_with_fallback
|
|
320
|
+
apply_wal_with_fallback(self._conn, db_label="response_store.db")
|
|
321
|
+
self._conn.execute(
|
|
322
|
+
"""CREATE TABLE IF NOT EXISTS responses (
|
|
323
|
+
response_id TEXT PRIMARY KEY,
|
|
324
|
+
data TEXT NOT NULL,
|
|
325
|
+
accessed_at REAL NOT NULL
|
|
326
|
+
)"""
|
|
327
|
+
)
|
|
328
|
+
self._conn.execute(
|
|
329
|
+
"""CREATE TABLE IF NOT EXISTS conversations (
|
|
330
|
+
name TEXT PRIMARY KEY,
|
|
331
|
+
response_id TEXT NOT NULL
|
|
332
|
+
)"""
|
|
333
|
+
)
|
|
334
|
+
self._conn.commit()
|
|
335
|
+
|
|
336
|
+
def get(self, response_id: str) -> Optional[Dict[str, Any]]:
|
|
337
|
+
"""Retrieve a stored response by ID (updates access time for LRU)."""
|
|
338
|
+
row = self._conn.execute(
|
|
339
|
+
"SELECT data FROM responses WHERE response_id = ?", (response_id,)
|
|
340
|
+
).fetchone()
|
|
341
|
+
if row is None:
|
|
342
|
+
return None
|
|
343
|
+
self._conn.execute(
|
|
344
|
+
"UPDATE responses SET accessed_at = ? WHERE response_id = ?",
|
|
345
|
+
(time.time(), response_id),
|
|
346
|
+
)
|
|
347
|
+
self._conn.commit()
|
|
348
|
+
return json.loads(row[0])
|
|
349
|
+
|
|
350
|
+
def put(self, response_id: str, data: Dict[str, Any]) -> None:
|
|
351
|
+
"""Store a response, evicting the oldest if at capacity."""
|
|
352
|
+
self._conn.execute(
|
|
353
|
+
"INSERT OR REPLACE INTO responses (response_id, data, accessed_at) VALUES (?, ?, ?)",
|
|
354
|
+
(response_id, json.dumps(data, default=str), time.time()),
|
|
355
|
+
)
|
|
356
|
+
# Evict oldest entries beyond max_size
|
|
357
|
+
count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
|
|
358
|
+
if count > self._max_size:
|
|
359
|
+
# Collect IDs that will be evicted
|
|
360
|
+
evict_ids = [
|
|
361
|
+
row[0]
|
|
362
|
+
for row in self._conn.execute(
|
|
363
|
+
"SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?",
|
|
364
|
+
(count - self._max_size,),
|
|
365
|
+
).fetchall()
|
|
366
|
+
]
|
|
367
|
+
if evict_ids:
|
|
368
|
+
placeholders = ",".join("?" for _ in evict_ids)
|
|
369
|
+
# Clear conversation mappings pointing to evicted responses
|
|
370
|
+
self._conn.execute(
|
|
371
|
+
f"DELETE FROM conversations WHERE response_id IN ({placeholders})",
|
|
372
|
+
evict_ids,
|
|
373
|
+
)
|
|
374
|
+
# Delete evicted responses
|
|
375
|
+
self._conn.execute(
|
|
376
|
+
f"DELETE FROM responses WHERE response_id IN ({placeholders})",
|
|
377
|
+
evict_ids,
|
|
378
|
+
)
|
|
379
|
+
self._conn.commit()
|
|
380
|
+
|
|
381
|
+
def delete(self, response_id: str) -> bool:
|
|
382
|
+
"""Remove a response from the store. Returns True if found and deleted."""
|
|
383
|
+
# Clear conversation mappings pointing to this response
|
|
384
|
+
self._conn.execute(
|
|
385
|
+
"DELETE FROM conversations WHERE response_id = ?", (response_id,)
|
|
386
|
+
)
|
|
387
|
+
cursor = self._conn.execute(
|
|
388
|
+
"DELETE FROM responses WHERE response_id = ?", (response_id,)
|
|
389
|
+
)
|
|
390
|
+
self._conn.commit()
|
|
391
|
+
return cursor.rowcount > 0
|
|
392
|
+
|
|
393
|
+
def get_conversation(self, name: str) -> Optional[str]:
|
|
394
|
+
"""Get the latest response_id for a conversation name."""
|
|
395
|
+
row = self._conn.execute(
|
|
396
|
+
"SELECT response_id FROM conversations WHERE name = ?", (name,)
|
|
397
|
+
).fetchone()
|
|
398
|
+
return row[0] if row else None
|
|
399
|
+
|
|
400
|
+
def set_conversation(self, name: str, response_id: str) -> None:
|
|
401
|
+
"""Map a conversation name to its latest response_id."""
|
|
402
|
+
self._conn.execute(
|
|
403
|
+
"INSERT OR REPLACE INTO conversations (name, response_id) VALUES (?, ?)",
|
|
404
|
+
(name, response_id),
|
|
405
|
+
)
|
|
406
|
+
self._conn.commit()
|
|
407
|
+
|
|
408
|
+
def close(self) -> None:
|
|
409
|
+
"""Close the database connection."""
|
|
410
|
+
try:
|
|
411
|
+
self._conn.close()
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
def __len__(self) -> int:
|
|
416
|
+
row = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()
|
|
417
|
+
return row[0] if row else 0
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# CORS middleware
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
_CORS_HEADERS = {
|
|
425
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
426
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if AIOHTTP_AVAILABLE:
|
|
431
|
+
@web.middleware
|
|
432
|
+
async def cors_middleware(request, handler):
|
|
433
|
+
"""Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
|
|
434
|
+
adapter = request.app.get("api_server_adapter")
|
|
435
|
+
origin = request.headers.get("Origin", "")
|
|
436
|
+
cors_headers = None
|
|
437
|
+
if adapter is not None:
|
|
438
|
+
if not adapter._origin_allowed(origin):
|
|
439
|
+
return web.Response(status=403)
|
|
440
|
+
cors_headers = adapter._cors_headers_for_origin(origin)
|
|
441
|
+
|
|
442
|
+
if request.method == "OPTIONS":
|
|
443
|
+
if cors_headers is None:
|
|
444
|
+
return web.Response(status=403)
|
|
445
|
+
return web.Response(status=200, headers=cors_headers)
|
|
446
|
+
|
|
447
|
+
response = await handler(request)
|
|
448
|
+
if cors_headers is not None:
|
|
449
|
+
response.headers.update(cors_headers)
|
|
450
|
+
return response
|
|
451
|
+
else:
|
|
452
|
+
cors_middleware = None # type: ignore[assignment]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
|
|
456
|
+
"""OpenAI-style error envelope."""
|
|
457
|
+
return {
|
|
458
|
+
"error": {
|
|
459
|
+
"message": message,
|
|
460
|
+
"type": err_type,
|
|
461
|
+
"param": param,
|
|
462
|
+
"code": code,
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if AIOHTTP_AVAILABLE:
|
|
468
|
+
@web.middleware
|
|
469
|
+
async def body_limit_middleware(request, handler):
|
|
470
|
+
"""Reject overly large request bodies early based on Content-Length."""
|
|
471
|
+
if request.method in {"POST", "PUT", "PATCH"}:
|
|
472
|
+
cl = request.headers.get("Content-Length")
|
|
473
|
+
if cl is not None:
|
|
474
|
+
try:
|
|
475
|
+
if int(cl) > MAX_REQUEST_BYTES:
|
|
476
|
+
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
|
|
477
|
+
except ValueError:
|
|
478
|
+
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
|
|
479
|
+
return await handler(request)
|
|
480
|
+
else:
|
|
481
|
+
body_limit_middleware = None # type: ignore[assignment]
|
|
482
|
+
|
|
483
|
+
_SECURITY_HEADERS = {
|
|
484
|
+
"X-Content-Type-Options": "nosniff",
|
|
485
|
+
"Referrer-Policy": "no-referrer",
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if AIOHTTP_AVAILABLE:
|
|
490
|
+
@web.middleware
|
|
491
|
+
async def security_headers_middleware(request, handler):
|
|
492
|
+
"""Add security headers to all responses (including errors)."""
|
|
493
|
+
response = await handler(request)
|
|
494
|
+
for k, v in _SECURITY_HEADERS.items():
|
|
495
|
+
response.headers.setdefault(k, v)
|
|
496
|
+
return response
|
|
497
|
+
else:
|
|
498
|
+
security_headers_middleware = None # type: ignore[assignment]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class _IdempotencyCache:
|
|
502
|
+
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
|
503
|
+
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
|
|
504
|
+
from collections import OrderedDict
|
|
505
|
+
self._store = OrderedDict()
|
|
506
|
+
self._inflight: Dict[tuple[str, str], "asyncio.Task[Any]"] = {}
|
|
507
|
+
self._ttl = ttl_seconds
|
|
508
|
+
self._max = max_items
|
|
509
|
+
|
|
510
|
+
def _purge(self):
|
|
511
|
+
now = time.time()
|
|
512
|
+
expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl]
|
|
513
|
+
for k in expired:
|
|
514
|
+
self._store.pop(k, None)
|
|
515
|
+
while len(self._store) > self._max:
|
|
516
|
+
self._store.popitem(last=False)
|
|
517
|
+
|
|
518
|
+
async def get_or_set(self, key: str, fingerprint: str, compute_coro):
|
|
519
|
+
self._purge()
|
|
520
|
+
item = self._store.get(key)
|
|
521
|
+
if item and item["fp"] == fingerprint:
|
|
522
|
+
return item["resp"]
|
|
523
|
+
|
|
524
|
+
inflight_key = (key, fingerprint)
|
|
525
|
+
task = self._inflight.get(inflight_key)
|
|
526
|
+
if task is None:
|
|
527
|
+
async def _compute_and_store():
|
|
528
|
+
resp = await compute_coro()
|
|
529
|
+
import time as _t
|
|
530
|
+
self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()}
|
|
531
|
+
self._purge()
|
|
532
|
+
return resp
|
|
533
|
+
|
|
534
|
+
task = asyncio.create_task(_compute_and_store())
|
|
535
|
+
self._inflight[inflight_key] = task
|
|
536
|
+
|
|
537
|
+
def _clear_inflight(done_task: "asyncio.Task[Any]") -> None:
|
|
538
|
+
if self._inflight.get(inflight_key) is done_task:
|
|
539
|
+
self._inflight.pop(inflight_key, None)
|
|
540
|
+
|
|
541
|
+
task.add_done_callback(_clear_inflight)
|
|
542
|
+
|
|
543
|
+
return await asyncio.shield(task)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
_idem_cache = _IdempotencyCache()
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
|
|
550
|
+
from hashlib import sha256
|
|
551
|
+
subset = {k: body.get(k) for k in keys}
|
|
552
|
+
return sha256(repr(subset).encode("utf-8")).hexdigest()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _derive_chat_session_id(
|
|
556
|
+
system_prompt: Optional[str],
|
|
557
|
+
first_user_message: str,
|
|
558
|
+
) -> str:
|
|
559
|
+
"""Derive a stable session ID from the conversation's first user message.
|
|
560
|
+
|
|
561
|
+
OpenAI-compatible frontends (Open WebUI, LibreChat, etc.) send the full
|
|
562
|
+
conversation history with every request. The system prompt and first user
|
|
563
|
+
message are constant across all turns of the same conversation, so hashing
|
|
564
|
+
them produces a deterministic session ID that lets the API server reuse
|
|
565
|
+
the same Hermes session (and therefore the same Docker container sandbox
|
|
566
|
+
directory) across turns.
|
|
567
|
+
"""
|
|
568
|
+
seed = f"{system_prompt or ''}\n{first_user_message}"
|
|
569
|
+
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
570
|
+
return f"api-{digest}"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
_CRON_AVAILABLE = False
|
|
574
|
+
try:
|
|
575
|
+
from cron.jobs import (
|
|
576
|
+
list_jobs as _cron_list,
|
|
577
|
+
get_job as _cron_get,
|
|
578
|
+
create_job as _cron_create,
|
|
579
|
+
update_job as _cron_update,
|
|
580
|
+
remove_job as _cron_remove,
|
|
581
|
+
pause_job as _cron_pause,
|
|
582
|
+
resume_job as _cron_resume,
|
|
583
|
+
trigger_job as _cron_trigger,
|
|
584
|
+
)
|
|
585
|
+
_CRON_AVAILABLE = True
|
|
586
|
+
except ImportError:
|
|
587
|
+
_cron_list = None
|
|
588
|
+
_cron_get = None
|
|
589
|
+
_cron_create = None
|
|
590
|
+
_cron_update = None
|
|
591
|
+
_cron_remove = None
|
|
592
|
+
_cron_pause = None
|
|
593
|
+
_cron_resume = None
|
|
594
|
+
_cron_trigger = None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class APIServerAdapter(BasePlatformAdapter):
|
|
598
|
+
"""
|
|
599
|
+
OpenAI-compatible HTTP API server adapter.
|
|
600
|
+
|
|
601
|
+
Runs an aiohttp web server that accepts OpenAI-format requests
|
|
602
|
+
and routes them through hermes-agent's AIAgent.
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
def __init__(self, config: PlatformConfig):
|
|
606
|
+
super().__init__(config, Platform.API_SERVER)
|
|
607
|
+
extra = config.extra or {}
|
|
608
|
+
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
|
|
609
|
+
raw_port = extra.get("port")
|
|
610
|
+
if raw_port is None:
|
|
611
|
+
raw_port = os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))
|
|
612
|
+
self._port: int = _coerce_port(raw_port, DEFAULT_PORT)
|
|
613
|
+
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
|
|
614
|
+
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
|
615
|
+
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
|
|
616
|
+
)
|
|
617
|
+
self._model_name: str = self._resolve_model_name(
|
|
618
|
+
extra.get("model_name", os.getenv("API_SERVER_MODEL_NAME", "")),
|
|
619
|
+
)
|
|
620
|
+
self._app: Optional["web.Application"] = None
|
|
621
|
+
self._runner: Optional["web.AppRunner"] = None
|
|
622
|
+
self._site: Optional["web.TCPSite"] = None
|
|
623
|
+
self._response_store = ResponseStore()
|
|
624
|
+
# Active run streams: run_id -> asyncio.Queue of SSE event dicts
|
|
625
|
+
self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {}
|
|
626
|
+
# Creation timestamps for orphaned-run TTL sweep
|
|
627
|
+
self._run_streams_created: Dict[str, float] = {}
|
|
628
|
+
# Active run agent/task references for stop support
|
|
629
|
+
self._active_run_agents: Dict[str, Any] = {}
|
|
630
|
+
self._active_run_tasks: Dict[str, "asyncio.Task"] = {}
|
|
631
|
+
# Pollable run status for dashboards and external control-plane UIs.
|
|
632
|
+
self._run_statuses: Dict[str, Dict[str, Any]] = {}
|
|
633
|
+
# Active approval session key for each run_id. The approval core
|
|
634
|
+
# resolves requests by session key, while API clients address the
|
|
635
|
+
# in-flight run by run_id.
|
|
636
|
+
self._run_approval_sessions: Dict[str, str] = {}
|
|
637
|
+
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
|
|
641
|
+
"""Normalize configured CORS origins into a stable tuple."""
|
|
642
|
+
if not value:
|
|
643
|
+
return ()
|
|
644
|
+
|
|
645
|
+
if isinstance(value, str):
|
|
646
|
+
items = value.split(",")
|
|
647
|
+
elif isinstance(value, (list, tuple, set)):
|
|
648
|
+
items = value
|
|
649
|
+
else:
|
|
650
|
+
items = [str(value)]
|
|
651
|
+
|
|
652
|
+
return tuple(str(item).strip() for item in items if str(item).strip())
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def _resolve_model_name(explicit: str) -> str:
|
|
656
|
+
"""Derive the advertised model name for /v1/models.
|
|
657
|
+
|
|
658
|
+
Priority:
|
|
659
|
+
1. Explicit override (config extra or API_SERVER_MODEL_NAME env var)
|
|
660
|
+
2. Active profile name (so each profile advertises a distinct model)
|
|
661
|
+
3. Fallback: "hermes-agent"
|
|
662
|
+
"""
|
|
663
|
+
if explicit and explicit.strip():
|
|
664
|
+
return explicit.strip()
|
|
665
|
+
try:
|
|
666
|
+
from hermes_cli.profiles import get_active_profile_name
|
|
667
|
+
profile = get_active_profile_name()
|
|
668
|
+
if profile and profile not in {"default", "custom"}:
|
|
669
|
+
return profile
|
|
670
|
+
except Exception:
|
|
671
|
+
pass
|
|
672
|
+
return "hermes-agent"
|
|
673
|
+
|
|
674
|
+
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
|
|
675
|
+
"""Return CORS headers for an allowed browser origin."""
|
|
676
|
+
if not origin or not self._cors_origins:
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
if "*" in self._cors_origins:
|
|
680
|
+
headers = dict(_CORS_HEADERS)
|
|
681
|
+
headers["Access-Control-Allow-Origin"] = "*"
|
|
682
|
+
headers["Access-Control-Max-Age"] = "600"
|
|
683
|
+
return headers
|
|
684
|
+
|
|
685
|
+
if origin not in self._cors_origins:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
headers = dict(_CORS_HEADERS)
|
|
689
|
+
headers["Access-Control-Allow-Origin"] = origin
|
|
690
|
+
headers["Vary"] = "Origin"
|
|
691
|
+
headers["Access-Control-Max-Age"] = "600"
|
|
692
|
+
return headers
|
|
693
|
+
|
|
694
|
+
def _origin_allowed(self, origin: str) -> bool:
|
|
695
|
+
"""Allow non-browser clients and explicitly configured browser origins."""
|
|
696
|
+
if not origin:
|
|
697
|
+
return True
|
|
698
|
+
|
|
699
|
+
if not self._cors_origins:
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
return "*" in self._cors_origins or origin in self._cors_origins
|
|
703
|
+
|
|
704
|
+
# ------------------------------------------------------------------
|
|
705
|
+
# Auth helper
|
|
706
|
+
# ------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
|
|
709
|
+
"""
|
|
710
|
+
Validate Bearer token from Authorization header.
|
|
711
|
+
|
|
712
|
+
Returns None if auth is OK, or a 401 web.Response on failure.
|
|
713
|
+
If no API key is configured, all requests are allowed (only when API
|
|
714
|
+
server is local).
|
|
715
|
+
"""
|
|
716
|
+
if not self._api_key:
|
|
717
|
+
return None # No key configured — allow all (local-only use)
|
|
718
|
+
|
|
719
|
+
auth_header = request.headers.get("Authorization", "")
|
|
720
|
+
if auth_header.startswith("Bearer "):
|
|
721
|
+
token = auth_header[7:].strip()
|
|
722
|
+
if hmac.compare_digest(token, self._api_key):
|
|
723
|
+
return None # Auth OK
|
|
724
|
+
|
|
725
|
+
return web.json_response(
|
|
726
|
+
{"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}},
|
|
727
|
+
status=401,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# ------------------------------------------------------------------
|
|
731
|
+
# Session header helpers
|
|
732
|
+
# ------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
# Soft length cap for session identifiers. Headers are bounded in
|
|
735
|
+
# aggregate by aiohttp (``client_max_size`` / default 8 KiB per
|
|
736
|
+
# header), but we impose a tighter limit on the session headers so a
|
|
737
|
+
# caller can't burn memory by passing a multi-kilobyte "session key".
|
|
738
|
+
# 256 chars is well above any realistic stable channel identifier
|
|
739
|
+
# (e.g. ``agent:main:webui:dm:user-42``) while staying small enough
|
|
740
|
+
# that the sanitized form is safe to pass into Honcho / state.db.
|
|
741
|
+
_MAX_SESSION_HEADER_LEN = 256
|
|
742
|
+
|
|
743
|
+
def _parse_session_key_header(
|
|
744
|
+
self, request: "web.Request"
|
|
745
|
+
) -> tuple[Optional[str], Optional["web.Response"]]:
|
|
746
|
+
"""Extract and validate the ``X-Hermes-Session-Key`` header.
|
|
747
|
+
|
|
748
|
+
The session key is a stable per-channel identifier that scopes
|
|
749
|
+
long-term memory (e.g. Honcho sessions) across transcripts. It
|
|
750
|
+
is independent of ``X-Hermes-Session-Id``: callers may send
|
|
751
|
+
either, both, or neither.
|
|
752
|
+
|
|
753
|
+
Returns ``(session_key, None)`` on success (with an empty/absent
|
|
754
|
+
header yielding ``None`` for the key), or ``(None, error_response)``
|
|
755
|
+
on validation failure.
|
|
756
|
+
|
|
757
|
+
Security: like session continuation, accepting a caller-supplied
|
|
758
|
+
memory scope requires API-key authentication so that an
|
|
759
|
+
unauthenticated client on a local-only server can't inject itself
|
|
760
|
+
into another user's long-term memory scope by guessing a key.
|
|
761
|
+
"""
|
|
762
|
+
raw = request.headers.get("X-Hermes-Session-Key", "").strip()
|
|
763
|
+
if not raw:
|
|
764
|
+
return None, None
|
|
765
|
+
|
|
766
|
+
if not self._api_key:
|
|
767
|
+
logger.warning(
|
|
768
|
+
"X-Hermes-Session-Key rejected: no API key configured. "
|
|
769
|
+
"Set API_SERVER_KEY to enable long-term memory scoping."
|
|
770
|
+
)
|
|
771
|
+
return None, web.json_response(
|
|
772
|
+
_openai_error(
|
|
773
|
+
"X-Hermes-Session-Key requires API key authentication. "
|
|
774
|
+
"Configure API_SERVER_KEY to enable this feature."
|
|
775
|
+
),
|
|
776
|
+
status=403,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Reject control characters that could enable header injection on
|
|
780
|
+
# the echo path.
|
|
781
|
+
if re.search(r'[\r\n\x00]', raw):
|
|
782
|
+
return None, web.json_response(
|
|
783
|
+
{"error": {"message": "Invalid session key", "type": "invalid_request_error"}},
|
|
784
|
+
status=400,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
if len(raw) > self._MAX_SESSION_HEADER_LEN:
|
|
788
|
+
return None, web.json_response(
|
|
789
|
+
{"error": {"message": "Session key too long", "type": "invalid_request_error"}},
|
|
790
|
+
status=400,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
return raw, None
|
|
794
|
+
|
|
795
|
+
# ------------------------------------------------------------------
|
|
796
|
+
# Session DB helper
|
|
797
|
+
# ------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
def _ensure_session_db(self):
|
|
800
|
+
"""Lazily initialise and return the shared SessionDB instance.
|
|
801
|
+
|
|
802
|
+
Sessions are persisted to ``state.db`` so that ``hermes sessions list``
|
|
803
|
+
shows API-server conversations alongside CLI and gateway ones.
|
|
804
|
+
"""
|
|
805
|
+
if self._session_db is None:
|
|
806
|
+
try:
|
|
807
|
+
from calvyn_state import SessionDB
|
|
808
|
+
self._session_db = SessionDB()
|
|
809
|
+
except Exception as e:
|
|
810
|
+
logger.debug("SessionDB unavailable for API server: %s", e)
|
|
811
|
+
return self._session_db
|
|
812
|
+
|
|
813
|
+
# ------------------------------------------------------------------
|
|
814
|
+
# Agent creation helper
|
|
815
|
+
# ------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
def _create_agent(
|
|
818
|
+
self,
|
|
819
|
+
ephemeral_system_prompt: Optional[str] = None,
|
|
820
|
+
session_id: Optional[str] = None,
|
|
821
|
+
stream_delta_callback=None,
|
|
822
|
+
tool_progress_callback=None,
|
|
823
|
+
tool_start_callback=None,
|
|
824
|
+
tool_complete_callback=None,
|
|
825
|
+
gateway_session_key: Optional[str] = None,
|
|
826
|
+
) -> Any:
|
|
827
|
+
"""
|
|
828
|
+
Create an AIAgent instance using the gateway's runtime config.
|
|
829
|
+
|
|
830
|
+
Uses _resolve_runtime_agent_kwargs() to pick up model, api_key,
|
|
831
|
+
base_url, etc. from config.yaml / env vars. Toolsets are resolved
|
|
832
|
+
from config.yaml platform_toolsets.api_server (same as all other
|
|
833
|
+
gateway platforms), falling back to the hermes-api-server default.
|
|
834
|
+
|
|
835
|
+
``gateway_session_key`` is a stable per-channel identifier supplied
|
|
836
|
+
by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id``
|
|
837
|
+
which scopes the short-term transcript and rotates on /new, this
|
|
838
|
+
key is meant to persist across transcripts so long-term memory
|
|
839
|
+
providers (e.g. Honcho) can scope their per-chat state correctly
|
|
840
|
+
— matching the semantics of the native gateway's ``session_key``.
|
|
841
|
+
"""
|
|
842
|
+
from run_agent import AIAgent
|
|
843
|
+
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner
|
|
844
|
+
from hermes_cli.tools_config import _get_platform_tools
|
|
845
|
+
|
|
846
|
+
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
|
847
|
+
reasoning_config = GatewayRunner._load_reasoning_config()
|
|
848
|
+
model = _resolve_gateway_model()
|
|
849
|
+
|
|
850
|
+
user_config = _load_gateway_config()
|
|
851
|
+
enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))
|
|
852
|
+
|
|
853
|
+
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
|
|
854
|
+
|
|
855
|
+
# Load fallback provider chain so the API server platform has the
|
|
856
|
+
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
|
|
857
|
+
fallback_model = GatewayRunner._load_fallback_model()
|
|
858
|
+
|
|
859
|
+
agent = AIAgent(
|
|
860
|
+
model=model,
|
|
861
|
+
**runtime_kwargs,
|
|
862
|
+
max_iterations=max_iterations,
|
|
863
|
+
quiet_mode=True,
|
|
864
|
+
verbose_logging=False,
|
|
865
|
+
ephemeral_system_prompt=ephemeral_system_prompt or None,
|
|
866
|
+
enabled_toolsets=enabled_toolsets,
|
|
867
|
+
session_id=session_id,
|
|
868
|
+
platform="api_server",
|
|
869
|
+
stream_delta_callback=stream_delta_callback,
|
|
870
|
+
tool_progress_callback=tool_progress_callback,
|
|
871
|
+
tool_start_callback=tool_start_callback,
|
|
872
|
+
tool_complete_callback=tool_complete_callback,
|
|
873
|
+
session_db=self._ensure_session_db(),
|
|
874
|
+
fallback_model=fallback_model,
|
|
875
|
+
reasoning_config=reasoning_config,
|
|
876
|
+
gateway_session_key=gateway_session_key,
|
|
877
|
+
)
|
|
878
|
+
return agent
|
|
879
|
+
|
|
880
|
+
# ------------------------------------------------------------------
|
|
881
|
+
# HTTP Handlers
|
|
882
|
+
# ------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
|
885
|
+
"""GET /health — simple health check."""
|
|
886
|
+
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
|
887
|
+
|
|
888
|
+
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
|
889
|
+
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
|
890
|
+
|
|
891
|
+
Returns gateway state, connected platforms, PID, and uptime so the
|
|
892
|
+
dashboard can display full status without needing a shared PID file or
|
|
893
|
+
/proc access. No authentication required.
|
|
894
|
+
"""
|
|
895
|
+
from gateway.status import read_runtime_status
|
|
896
|
+
|
|
897
|
+
runtime = read_runtime_status() or {}
|
|
898
|
+
return web.json_response({
|
|
899
|
+
"status": "ok",
|
|
900
|
+
"platform": "hermes-agent",
|
|
901
|
+
"gateway_state": runtime.get("gateway_state"),
|
|
902
|
+
"platforms": runtime.get("platforms", {}),
|
|
903
|
+
"active_agents": runtime.get("active_agents", 0),
|
|
904
|
+
"exit_reason": runtime.get("exit_reason"),
|
|
905
|
+
"updated_at": runtime.get("updated_at"),
|
|
906
|
+
"pid": os.getpid(),
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
async def _handle_models(self, request: "web.Request") -> "web.Response":
|
|
910
|
+
"""GET /v1/models — return hermes-agent as an available model."""
|
|
911
|
+
auth_err = self._check_auth(request)
|
|
912
|
+
if auth_err:
|
|
913
|
+
return auth_err
|
|
914
|
+
|
|
915
|
+
return web.json_response({
|
|
916
|
+
"object": "list",
|
|
917
|
+
"data": [
|
|
918
|
+
{
|
|
919
|
+
"id": self._model_name,
|
|
920
|
+
"object": "model",
|
|
921
|
+
"created": int(time.time()),
|
|
922
|
+
"owned_by": "hermes",
|
|
923
|
+
"permission": [],
|
|
924
|
+
"root": self._model_name,
|
|
925
|
+
"parent": None,
|
|
926
|
+
}
|
|
927
|
+
],
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
async def _handle_capabilities(self, request: "web.Request") -> "web.Response":
|
|
931
|
+
"""GET /v1/capabilities — advertise the stable API surface.
|
|
932
|
+
|
|
933
|
+
External UIs and orchestrators use this endpoint to discover the API
|
|
934
|
+
server's plugin-safe contract without scraping docs or assuming that
|
|
935
|
+
every Hermes version exposes the same endpoints.
|
|
936
|
+
"""
|
|
937
|
+
auth_err = self._check_auth(request)
|
|
938
|
+
if auth_err:
|
|
939
|
+
return auth_err
|
|
940
|
+
|
|
941
|
+
return web.json_response({
|
|
942
|
+
"object": "hermes.api_server.capabilities",
|
|
943
|
+
"platform": "hermes-agent",
|
|
944
|
+
"model": self._model_name,
|
|
945
|
+
"auth": {
|
|
946
|
+
"type": "bearer",
|
|
947
|
+
"required": bool(self._api_key),
|
|
948
|
+
},
|
|
949
|
+
"runtime": {
|
|
950
|
+
"mode": "server_agent",
|
|
951
|
+
"tool_execution": "server",
|
|
952
|
+
"split_runtime": False,
|
|
953
|
+
"description": (
|
|
954
|
+
"The API server creates a server-side Hermes AIAgent; "
|
|
955
|
+
"tools execute on the API-server host unless a future "
|
|
956
|
+
"explicit split-runtime mode is enabled."
|
|
957
|
+
),
|
|
958
|
+
},
|
|
959
|
+
"features": {
|
|
960
|
+
"chat_completions": True,
|
|
961
|
+
"chat_completions_streaming": True,
|
|
962
|
+
"responses_api": True,
|
|
963
|
+
"responses_streaming": True,
|
|
964
|
+
"run_submission": True,
|
|
965
|
+
"run_status": True,
|
|
966
|
+
"run_events_sse": True,
|
|
967
|
+
"run_stop": True,
|
|
968
|
+
"run_approval_response": True,
|
|
969
|
+
"tool_progress_events": True,
|
|
970
|
+
"approval_events": True,
|
|
971
|
+
"session_continuity_header": "X-Hermes-Session-Id",
|
|
972
|
+
"session_key_header": "X-Hermes-Session-Key",
|
|
973
|
+
"cors": bool(self._cors_origins),
|
|
974
|
+
},
|
|
975
|
+
"endpoints": {
|
|
976
|
+
"health": {"method": "GET", "path": "/health"},
|
|
977
|
+
"health_detailed": {"method": "GET", "path": "/health/detailed"},
|
|
978
|
+
"models": {"method": "GET", "path": "/v1/models"},
|
|
979
|
+
"chat_completions": {"method": "POST", "path": "/v1/chat/completions"},
|
|
980
|
+
"responses": {"method": "POST", "path": "/v1/responses"},
|
|
981
|
+
"runs": {"method": "POST", "path": "/v1/runs"},
|
|
982
|
+
"run_status": {"method": "GET", "path": "/v1/runs/{run_id}"},
|
|
983
|
+
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
|
984
|
+
"run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"},
|
|
985
|
+
"run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"},
|
|
986
|
+
},
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
|
990
|
+
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
|
991
|
+
auth_err = self._check_auth(request)
|
|
992
|
+
if auth_err:
|
|
993
|
+
return auth_err
|
|
994
|
+
|
|
995
|
+
# Parse request body
|
|
996
|
+
try:
|
|
997
|
+
body = await request.json()
|
|
998
|
+
except (json.JSONDecodeError, Exception):
|
|
999
|
+
return web.json_response(_openai_error("Invalid JSON in request body"), status=400)
|
|
1000
|
+
|
|
1001
|
+
messages = body.get("messages")
|
|
1002
|
+
if not messages or not isinstance(messages, list):
|
|
1003
|
+
return web.json_response(
|
|
1004
|
+
{"error": {"message": "Missing or invalid 'messages' field", "type": "invalid_request_error"}},
|
|
1005
|
+
status=400,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
stream = body.get("stream", False)
|
|
1009
|
+
|
|
1010
|
+
# Extract system message (becomes ephemeral system prompt layered ON TOP of core)
|
|
1011
|
+
system_prompt = None
|
|
1012
|
+
conversation_messages: List[Dict[str, str]] = []
|
|
1013
|
+
|
|
1014
|
+
for idx, msg in enumerate(messages):
|
|
1015
|
+
role = msg.get("role", "")
|
|
1016
|
+
raw_content = msg.get("content", "")
|
|
1017
|
+
if role == "system":
|
|
1018
|
+
# System messages don't support images (Anthropic rejects, OpenAI
|
|
1019
|
+
# text-model systems don't render them). Flatten to text.
|
|
1020
|
+
content = _normalize_chat_content(raw_content)
|
|
1021
|
+
if system_prompt is None:
|
|
1022
|
+
system_prompt = content
|
|
1023
|
+
else:
|
|
1024
|
+
system_prompt = system_prompt + "\n" + content
|
|
1025
|
+
elif role in {"user", "assistant"}:
|
|
1026
|
+
try:
|
|
1027
|
+
content = _normalize_multimodal_content(raw_content)
|
|
1028
|
+
except ValueError as exc:
|
|
1029
|
+
return _multimodal_validation_error(exc, param=f"messages[{idx}].content")
|
|
1030
|
+
conversation_messages.append({"role": role, "content": content})
|
|
1031
|
+
|
|
1032
|
+
# Extract the last user message as the primary input
|
|
1033
|
+
user_message: Any = ""
|
|
1034
|
+
history = []
|
|
1035
|
+
if conversation_messages:
|
|
1036
|
+
user_message = conversation_messages[-1].get("content", "")
|
|
1037
|
+
history = conversation_messages[:-1]
|
|
1038
|
+
|
|
1039
|
+
if not _content_has_visible_payload(user_message):
|
|
1040
|
+
return web.json_response(
|
|
1041
|
+
{"error": {"message": "No user message found in messages", "type": "invalid_request_error"}},
|
|
1042
|
+
status=400,
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
# Allow caller to scope long-term memory (e.g. Honcho) with a
|
|
1046
|
+
# stable per-channel identifier via X-Hermes-Session-Key. This
|
|
1047
|
+
# is independent of X-Hermes-Session-Id: the key persists across
|
|
1048
|
+
# transcripts while the id rotates when the caller starts a new
|
|
1049
|
+
# transcript (i.e. /new semantics). See _parse_session_key_header.
|
|
1050
|
+
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
1051
|
+
if key_err is not None:
|
|
1052
|
+
return key_err
|
|
1053
|
+
|
|
1054
|
+
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
|
|
1055
|
+
# When provided, history is loaded from state.db instead of from the request body.
|
|
1056
|
+
#
|
|
1057
|
+
# Security: session continuation exposes conversation history, so it is
|
|
1058
|
+
# only allowed when the API key is configured and the request is
|
|
1059
|
+
# authenticated. Without this gate, any unauthenticated client could
|
|
1060
|
+
# read arbitrary session history by guessing/enumerating session IDs.
|
|
1061
|
+
provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
|
|
1062
|
+
if provided_session_id:
|
|
1063
|
+
if not self._api_key:
|
|
1064
|
+
logger.warning(
|
|
1065
|
+
"Session continuation via X-Hermes-Session-Id rejected: "
|
|
1066
|
+
"no API key configured. Set API_SERVER_KEY to enable "
|
|
1067
|
+
"session continuity."
|
|
1068
|
+
)
|
|
1069
|
+
return web.json_response(
|
|
1070
|
+
_openai_error(
|
|
1071
|
+
"Session continuation requires API key authentication. "
|
|
1072
|
+
"Configure API_SERVER_KEY to enable this feature."
|
|
1073
|
+
),
|
|
1074
|
+
status=403,
|
|
1075
|
+
)
|
|
1076
|
+
# Sanitize: reject control characters that could enable header injection.
|
|
1077
|
+
if re.search(r'[\r\n\x00]', provided_session_id):
|
|
1078
|
+
return web.json_response(
|
|
1079
|
+
{"error": {"message": "Invalid session ID", "type": "invalid_request_error"}},
|
|
1080
|
+
status=400,
|
|
1081
|
+
)
|
|
1082
|
+
session_id = provided_session_id
|
|
1083
|
+
try:
|
|
1084
|
+
db = self._ensure_session_db()
|
|
1085
|
+
if db is not None:
|
|
1086
|
+
history = db.get_messages_as_conversation(session_id)
|
|
1087
|
+
except Exception as e:
|
|
1088
|
+
logger.warning("Failed to load session history for %s: %s", session_id, e)
|
|
1089
|
+
history = []
|
|
1090
|
+
else:
|
|
1091
|
+
# Derive a stable session ID from the conversation fingerprint so
|
|
1092
|
+
# that consecutive messages from the same Open WebUI (or similar)
|
|
1093
|
+
# conversation map to the same Hermes session. The first user
|
|
1094
|
+
# message + system prompt are constant across all turns.
|
|
1095
|
+
first_user = ""
|
|
1096
|
+
for cm in conversation_messages:
|
|
1097
|
+
if cm.get("role") == "user":
|
|
1098
|
+
first_user = cm.get("content", "")
|
|
1099
|
+
break
|
|
1100
|
+
session_id = _derive_chat_session_id(system_prompt, first_user)
|
|
1101
|
+
# history already set from request body above
|
|
1102
|
+
|
|
1103
|
+
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
|
1104
|
+
model_name = body.get("model", self._model_name)
|
|
1105
|
+
created = int(time.time())
|
|
1106
|
+
|
|
1107
|
+
if stream:
|
|
1108
|
+
import queue as _q
|
|
1109
|
+
_stream_q: _q.Queue = _q.Queue()
|
|
1110
|
+
|
|
1111
|
+
def _on_delta(delta):
|
|
1112
|
+
# Filter out None — the agent fires stream_delta_callback(None)
|
|
1113
|
+
# to signal the CLI display to close its response box before
|
|
1114
|
+
# tool execution, but the SSE writer uses None as end-of-stream
|
|
1115
|
+
# sentinel. Forwarding it would prematurely close the HTTP
|
|
1116
|
+
# response, causing Open WebUI (and similar frontends) to miss
|
|
1117
|
+
# the final answer after tool calls. The SSE loop detects
|
|
1118
|
+
# completion via agent_task.done() instead.
|
|
1119
|
+
if delta is not None:
|
|
1120
|
+
_stream_q.put(delta)
|
|
1121
|
+
|
|
1122
|
+
# Track which tool_call_ids we've emitted a "running" lifecycle
|
|
1123
|
+
# event for, so a "completed" event without a matching "running"
|
|
1124
|
+
# (e.g. internal/filtered tools) is silently dropped instead of
|
|
1125
|
+
# producing an orphaned event clients can't correlate.
|
|
1126
|
+
_started_tool_call_ids: set[str] = set()
|
|
1127
|
+
|
|
1128
|
+
def _on_tool_start(tool_call_id, function_name, function_args):
|
|
1129
|
+
"""Emit ``hermes.tool.progress`` with ``status: running``.
|
|
1130
|
+
|
|
1131
|
+
Replaces the old ``tool_progress_callback("tool.started",
|
|
1132
|
+
...)`` emit so SSE consumers receive a single event per
|
|
1133
|
+
tool start, carrying both the legacy ``tool``/``emoji``/
|
|
1134
|
+
``label`` payload (for #6972 frontends) and the new
|
|
1135
|
+
``toolCallId``/``status`` correlation fields (#16588).
|
|
1136
|
+
|
|
1137
|
+
Skips tools whose names start with ``_`` so internal
|
|
1138
|
+
events (``_thinking``, …) stay off the wire — matching
|
|
1139
|
+
the prior ``_on_tool_progress`` filter exactly.
|
|
1140
|
+
"""
|
|
1141
|
+
if not tool_call_id or function_name.startswith("_"):
|
|
1142
|
+
return
|
|
1143
|
+
_started_tool_call_ids.add(tool_call_id)
|
|
1144
|
+
from agent.display import build_tool_preview, get_tool_emoji
|
|
1145
|
+
label = build_tool_preview(function_name, function_args) or function_name
|
|
1146
|
+
_stream_q.put(("__tool_progress__", {
|
|
1147
|
+
"tool": function_name,
|
|
1148
|
+
"emoji": get_tool_emoji(function_name),
|
|
1149
|
+
"label": label,
|
|
1150
|
+
"toolCallId": tool_call_id,
|
|
1151
|
+
"status": "running",
|
|
1152
|
+
}))
|
|
1153
|
+
|
|
1154
|
+
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
|
|
1155
|
+
"""Emit the matching ``status: completed`` event.
|
|
1156
|
+
|
|
1157
|
+
Dropped if the start was filtered (internal tool, missing
|
|
1158
|
+
id, or never seen) so clients never get an orphaned
|
|
1159
|
+
``completed`` they can't correlate to a prior ``running``.
|
|
1160
|
+
"""
|
|
1161
|
+
if not tool_call_id or tool_call_id not in _started_tool_call_ids:
|
|
1162
|
+
return
|
|
1163
|
+
_started_tool_call_ids.discard(tool_call_id)
|
|
1164
|
+
_stream_q.put(("__tool_progress__", {
|
|
1165
|
+
"tool": function_name,
|
|
1166
|
+
"toolCallId": tool_call_id,
|
|
1167
|
+
"status": "completed",
|
|
1168
|
+
}))
|
|
1169
|
+
|
|
1170
|
+
# Start agent in background. agent_ref is a mutable container
|
|
1171
|
+
# so the SSE writer can interrupt the agent on client disconnect.
|
|
1172
|
+
#
|
|
1173
|
+
# ``tool_progress_callback`` is intentionally not wired here:
|
|
1174
|
+
# it would duplicate every emit because ``run_agent`` fires it
|
|
1175
|
+
# side-by-side with ``tool_start_callback``/``tool_complete_callback``.
|
|
1176
|
+
# The structured callbacks are strictly richer (they carry the
|
|
1177
|
+
# tool_call id), so they own the chat-completions SSE channel.
|
|
1178
|
+
agent_ref = [None]
|
|
1179
|
+
agent_task = asyncio.ensure_future(self._run_agent(
|
|
1180
|
+
user_message=user_message,
|
|
1181
|
+
conversation_history=history,
|
|
1182
|
+
ephemeral_system_prompt=system_prompt,
|
|
1183
|
+
session_id=session_id,
|
|
1184
|
+
stream_delta_callback=_on_delta,
|
|
1185
|
+
tool_start_callback=_on_tool_start,
|
|
1186
|
+
tool_complete_callback=_on_tool_complete,
|
|
1187
|
+
agent_ref=agent_ref,
|
|
1188
|
+
gateway_session_key=gateway_session_key,
|
|
1189
|
+
))
|
|
1190
|
+
# Ensure SSE drain loops can terminate without relying on polling
|
|
1191
|
+
# agent_task.done(), which can race with queue timeout checks.
|
|
1192
|
+
agent_task.add_done_callback(lambda _fut: _stream_q.put(None))
|
|
1193
|
+
|
|
1194
|
+
return await self._write_sse_chat_completion(
|
|
1195
|
+
request, completion_id, model_name, created, _stream_q,
|
|
1196
|
+
agent_task, agent_ref, session_id=session_id,
|
|
1197
|
+
gateway_session_key=gateway_session_key,
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
# Non-streaming: run the agent (with optional Idempotency-Key)
|
|
1201
|
+
async def _compute_completion():
|
|
1202
|
+
return await self._run_agent(
|
|
1203
|
+
user_message=user_message,
|
|
1204
|
+
conversation_history=history,
|
|
1205
|
+
ephemeral_system_prompt=system_prompt,
|
|
1206
|
+
session_id=session_id,
|
|
1207
|
+
gateway_session_key=gateway_session_key,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
idempotency_key = request.headers.get("Idempotency-Key")
|
|
1211
|
+
if idempotency_key:
|
|
1212
|
+
fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"])
|
|
1213
|
+
try:
|
|
1214
|
+
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion)
|
|
1215
|
+
except Exception as e:
|
|
1216
|
+
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
|
1217
|
+
return web.json_response(
|
|
1218
|
+
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
1219
|
+
status=500,
|
|
1220
|
+
)
|
|
1221
|
+
else:
|
|
1222
|
+
try:
|
|
1223
|
+
result, usage = await _compute_completion()
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
|
1226
|
+
return web.json_response(
|
|
1227
|
+
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
1228
|
+
status=500,
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
final_response = result.get("final_response") or ""
|
|
1232
|
+
is_partial = bool(result.get("partial"))
|
|
1233
|
+
is_failed = bool(result.get("failed"))
|
|
1234
|
+
completed = bool(result.get("completed", True))
|
|
1235
|
+
err_msg = result.get("error")
|
|
1236
|
+
|
|
1237
|
+
# Decide finish_reason. OpenAI uses "length" for truncation, "stop"
|
|
1238
|
+
# for normal completion, and downstream SDKs accept "error" / custom
|
|
1239
|
+
# codes. See issue #22496.
|
|
1240
|
+
if is_partial and err_msg and "truncat" in err_msg.lower():
|
|
1241
|
+
finish_reason = "length"
|
|
1242
|
+
elif is_failed or (not completed and err_msg):
|
|
1243
|
+
finish_reason = "error"
|
|
1244
|
+
else:
|
|
1245
|
+
finish_reason = "stop"
|
|
1246
|
+
|
|
1247
|
+
response_headers = {
|
|
1248
|
+
"X-Hermes-Session-Id": result.get("session_id", session_id),
|
|
1249
|
+
}
|
|
1250
|
+
if gateway_session_key:
|
|
1251
|
+
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
1252
|
+
|
|
1253
|
+
# Hard-fail path: no usable assistant text AND a real failure → 5xx
|
|
1254
|
+
# with OpenAI-style error envelope so SDK clients raise instead of
|
|
1255
|
+
# silently rendering the internal failure string as message.content.
|
|
1256
|
+
if not final_response and (is_failed or is_partial):
|
|
1257
|
+
err_body = _openai_error(
|
|
1258
|
+
err_msg or "Agent run did not produce a response.",
|
|
1259
|
+
err_type="server_error",
|
|
1260
|
+
code="agent_incomplete",
|
|
1261
|
+
)
|
|
1262
|
+
err_body["error"]["hermes"] = {
|
|
1263
|
+
"completed": completed,
|
|
1264
|
+
"partial": is_partial,
|
|
1265
|
+
"failed": is_failed,
|
|
1266
|
+
}
|
|
1267
|
+
response_headers["X-Hermes-Completed"] = "false"
|
|
1268
|
+
response_headers["X-Hermes-Partial"] = "true" if is_partial else "false"
|
|
1269
|
+
return web.json_response(err_body, status=502, headers=response_headers)
|
|
1270
|
+
|
|
1271
|
+
# Soft-partial path: we have *some* text but the run did not complete
|
|
1272
|
+
# (e.g. truncation with partial buffered output). Still 200 but signal
|
|
1273
|
+
# truncation via finish_reason="length" + Hermes-specific extras.
|
|
1274
|
+
response_data = {
|
|
1275
|
+
"id": completion_id,
|
|
1276
|
+
"object": "chat.completion",
|
|
1277
|
+
"created": created,
|
|
1278
|
+
"model": model_name,
|
|
1279
|
+
"choices": [
|
|
1280
|
+
{
|
|
1281
|
+
"index": 0,
|
|
1282
|
+
"message": {
|
|
1283
|
+
"role": "assistant",
|
|
1284
|
+
"content": final_response,
|
|
1285
|
+
},
|
|
1286
|
+
"finish_reason": finish_reason,
|
|
1287
|
+
}
|
|
1288
|
+
],
|
|
1289
|
+
"usage": {
|
|
1290
|
+
"prompt_tokens": usage.get("input_tokens", 0),
|
|
1291
|
+
"completion_tokens": usage.get("output_tokens", 0),
|
|
1292
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1293
|
+
},
|
|
1294
|
+
}
|
|
1295
|
+
if is_partial or is_failed or not completed:
|
|
1296
|
+
response_data["hermes"] = {
|
|
1297
|
+
"completed": completed,
|
|
1298
|
+
"partial": is_partial,
|
|
1299
|
+
"failed": is_failed,
|
|
1300
|
+
"error": err_msg,
|
|
1301
|
+
"error_code": "output_truncated" if finish_reason == "length" else "agent_error",
|
|
1302
|
+
}
|
|
1303
|
+
response_headers["X-Hermes-Completed"] = "false"
|
|
1304
|
+
response_headers["X-Hermes-Partial"] = "true" if is_partial else "false"
|
|
1305
|
+
if err_msg:
|
|
1306
|
+
response_headers["X-Hermes-Error"] = err_msg[:200]
|
|
1307
|
+
|
|
1308
|
+
return web.json_response(response_data, headers=response_headers)
|
|
1309
|
+
|
|
1310
|
+
async def _write_sse_chat_completion(
|
|
1311
|
+
self, request: "web.Request", completion_id: str, model: str,
|
|
1312
|
+
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
|
|
1313
|
+
gateway_session_key: str = None,
|
|
1314
|
+
) -> "web.StreamResponse":
|
|
1315
|
+
"""Write real streaming SSE from agent's stream_delta_callback queue.
|
|
1316
|
+
|
|
1317
|
+
If the client disconnects mid-stream (network drop, browser tab close),
|
|
1318
|
+
the agent is interrupted via ``agent.interrupt()`` so it stops making
|
|
1319
|
+
LLM API calls, and the asyncio task wrapper is cancelled.
|
|
1320
|
+
"""
|
|
1321
|
+
import queue as _q
|
|
1322
|
+
|
|
1323
|
+
sse_headers = {
|
|
1324
|
+
"Content-Type": "text/event-stream",
|
|
1325
|
+
"Cache-Control": "no-cache",
|
|
1326
|
+
"X-Accel-Buffering": "no",
|
|
1327
|
+
}
|
|
1328
|
+
# CORS middleware can't inject headers into StreamResponse after
|
|
1329
|
+
# prepare() flushes them, so resolve CORS headers up front.
|
|
1330
|
+
origin = request.headers.get("Origin", "")
|
|
1331
|
+
cors = self._cors_headers_for_origin(origin) if origin else None
|
|
1332
|
+
if cors:
|
|
1333
|
+
sse_headers.update(cors)
|
|
1334
|
+
if session_id:
|
|
1335
|
+
sse_headers["X-Hermes-Session-Id"] = session_id
|
|
1336
|
+
if gateway_session_key:
|
|
1337
|
+
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
1338
|
+
response = web.StreamResponse(status=200, headers=sse_headers)
|
|
1339
|
+
await response.prepare(request)
|
|
1340
|
+
|
|
1341
|
+
try:
|
|
1342
|
+
last_activity = time.monotonic()
|
|
1343
|
+
|
|
1344
|
+
# Role chunk
|
|
1345
|
+
role_chunk = {
|
|
1346
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
1347
|
+
"created": created, "model": model,
|
|
1348
|
+
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
|
1349
|
+
}
|
|
1350
|
+
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
|
|
1351
|
+
last_activity = time.monotonic()
|
|
1352
|
+
|
|
1353
|
+
# Helper — route a queue item to the correct SSE event.
|
|
1354
|
+
async def _emit(item):
|
|
1355
|
+
"""Write a single queue item to the SSE stream.
|
|
1356
|
+
|
|
1357
|
+
Plain strings are sent as normal ``delta.content`` chunks.
|
|
1358
|
+
Tagged tuples ``("__tool_progress__", payload)`` are sent
|
|
1359
|
+
as a custom ``event: hermes.tool.progress`` SSE event so
|
|
1360
|
+
frontends can display them without storing the markers in
|
|
1361
|
+
conversation history. See #6972 for the original event,
|
|
1362
|
+
#16588 for the ``toolCallId``/``status`` lifecycle fields.
|
|
1363
|
+
"""
|
|
1364
|
+
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__":
|
|
1365
|
+
event_data = json.dumps(item[1])
|
|
1366
|
+
await response.write(
|
|
1367
|
+
f"event: hermes.tool.progress\ndata: {event_data}\n\n".encode()
|
|
1368
|
+
)
|
|
1369
|
+
else:
|
|
1370
|
+
content_chunk = {
|
|
1371
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
1372
|
+
"created": created, "model": model,
|
|
1373
|
+
"choices": [{"index": 0, "delta": {"content": item}, "finish_reason": None}],
|
|
1374
|
+
}
|
|
1375
|
+
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
|
1376
|
+
return time.monotonic()
|
|
1377
|
+
|
|
1378
|
+
# Stream content chunks as they arrive from the agent
|
|
1379
|
+
loop = asyncio.get_running_loop()
|
|
1380
|
+
while True:
|
|
1381
|
+
try:
|
|
1382
|
+
delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
|
1383
|
+
except _q.Empty:
|
|
1384
|
+
if agent_task.done():
|
|
1385
|
+
# Drain any remaining items
|
|
1386
|
+
while True:
|
|
1387
|
+
try:
|
|
1388
|
+
delta = stream_q.get_nowait()
|
|
1389
|
+
if delta is None:
|
|
1390
|
+
break
|
|
1391
|
+
last_activity = await _emit(delta)
|
|
1392
|
+
except _q.Empty:
|
|
1393
|
+
break
|
|
1394
|
+
break
|
|
1395
|
+
if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
|
|
1396
|
+
await response.write(b": keepalive\n\n")
|
|
1397
|
+
last_activity = time.monotonic()
|
|
1398
|
+
continue
|
|
1399
|
+
|
|
1400
|
+
if delta is None: # End of stream sentinel
|
|
1401
|
+
break
|
|
1402
|
+
|
|
1403
|
+
last_activity = await _emit(delta)
|
|
1404
|
+
|
|
1405
|
+
# Get usage from completed agent
|
|
1406
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
|
1407
|
+
try:
|
|
1408
|
+
result, agent_usage = await agent_task
|
|
1409
|
+
usage = agent_usage or usage
|
|
1410
|
+
except Exception as exc:
|
|
1411
|
+
logger.warning("Agent task %s failed, usage data lost: %s", completion_id, exc)
|
|
1412
|
+
|
|
1413
|
+
# Finish chunk
|
|
1414
|
+
finish_chunk = {
|
|
1415
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
1416
|
+
"created": created, "model": model,
|
|
1417
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
1418
|
+
"usage": {
|
|
1419
|
+
"prompt_tokens": usage.get("input_tokens", 0),
|
|
1420
|
+
"completion_tokens": usage.get("output_tokens", 0),
|
|
1421
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1422
|
+
},
|
|
1423
|
+
}
|
|
1424
|
+
await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
|
|
1425
|
+
await response.write(b"data: [DONE]\n\n")
|
|
1426
|
+
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
|
1427
|
+
# Client disconnected mid-stream. Interrupt the agent so it
|
|
1428
|
+
# stops making LLM API calls at the next loop iteration, then
|
|
1429
|
+
# cancel the asyncio task wrapper.
|
|
1430
|
+
agent = agent_ref[0] if agent_ref else None
|
|
1431
|
+
if agent is not None:
|
|
1432
|
+
try:
|
|
1433
|
+
agent.interrupt("SSE client disconnected")
|
|
1434
|
+
except Exception:
|
|
1435
|
+
pass
|
|
1436
|
+
if not agent_task.done():
|
|
1437
|
+
agent_task.cancel()
|
|
1438
|
+
try:
|
|
1439
|
+
await agent_task
|
|
1440
|
+
except (asyncio.CancelledError, Exception):
|
|
1441
|
+
pass
|
|
1442
|
+
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
|
|
1443
|
+
except Exception as _exc:
|
|
1444
|
+
# Agent crashed mid-stream. Try to emit an error chunk
|
|
1445
|
+
# so the client gets a proper response instead of a
|
|
1446
|
+
# TransferEncodingError from incomplete chunked encoding.
|
|
1447
|
+
import traceback as _tb
|
|
1448
|
+
logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
|
|
1449
|
+
try:
|
|
1450
|
+
error_chunk = {
|
|
1451
|
+
"id": completion_id, "object": "chat.completion.chunk",
|
|
1452
|
+
"created": created, "model": model,
|
|
1453
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": "error"}],
|
|
1454
|
+
}
|
|
1455
|
+
await response.write(f"data: {json.dumps(error_chunk)}\n\n".encode())
|
|
1456
|
+
await response.write(b"data: [DONE]\n\n")
|
|
1457
|
+
except Exception:
|
|
1458
|
+
pass
|
|
1459
|
+
|
|
1460
|
+
return response
|
|
1461
|
+
|
|
1462
|
+
async def _write_sse_responses(
|
|
1463
|
+
self,
|
|
1464
|
+
request: "web.Request",
|
|
1465
|
+
response_id: str,
|
|
1466
|
+
model: str,
|
|
1467
|
+
created_at: int,
|
|
1468
|
+
stream_q,
|
|
1469
|
+
agent_task,
|
|
1470
|
+
agent_ref,
|
|
1471
|
+
conversation_history: List[Dict[str, str]],
|
|
1472
|
+
user_message: str,
|
|
1473
|
+
instructions: Optional[str],
|
|
1474
|
+
conversation: Optional[str],
|
|
1475
|
+
store: bool,
|
|
1476
|
+
session_id: str,
|
|
1477
|
+
gateway_session_key: Optional[str] = None,
|
|
1478
|
+
) -> "web.StreamResponse":
|
|
1479
|
+
"""Write an SSE stream for POST /v1/responses (OpenAI Responses API).
|
|
1480
|
+
|
|
1481
|
+
Emits spec-compliant event types as the agent runs:
|
|
1482
|
+
|
|
1483
|
+
- ``response.created`` — initial envelope (status=in_progress)
|
|
1484
|
+
- ``response.output_text.delta`` / ``response.output_text.done`` —
|
|
1485
|
+
streamed assistant text
|
|
1486
|
+
- ``response.output_item.added`` / ``response.output_item.done``
|
|
1487
|
+
with ``item.type == "function_call"`` — when the agent invokes a
|
|
1488
|
+
tool (both events fire; the ``done`` event carries the finalized
|
|
1489
|
+
``arguments`` string)
|
|
1490
|
+
- ``response.output_item.added`` with
|
|
1491
|
+
``item.type == "function_call_output"`` — tool result with
|
|
1492
|
+
``{call_id, output, status}``
|
|
1493
|
+
- ``response.completed`` — terminal event carrying the full
|
|
1494
|
+
response object with all output items + usage (same payload
|
|
1495
|
+
shape as the non-streaming path for parity)
|
|
1496
|
+
- ``response.failed`` — terminal event on agent error
|
|
1497
|
+
|
|
1498
|
+
If the client disconnects mid-stream, ``agent.interrupt()`` is
|
|
1499
|
+
called so the agent stops issuing upstream LLM calls, then the
|
|
1500
|
+
asyncio task is cancelled. When ``store=True`` an initial
|
|
1501
|
+
``in_progress`` snapshot is persisted immediately after
|
|
1502
|
+
``response.created`` and disconnects update it to an
|
|
1503
|
+
``incomplete`` snapshot so GET /v1/responses/{id} and
|
|
1504
|
+
``previous_response_id`` chaining still have something to
|
|
1505
|
+
recover from.
|
|
1506
|
+
"""
|
|
1507
|
+
import queue as _q
|
|
1508
|
+
|
|
1509
|
+
sse_headers = {
|
|
1510
|
+
"Content-Type": "text/event-stream",
|
|
1511
|
+
"Cache-Control": "no-cache",
|
|
1512
|
+
"X-Accel-Buffering": "no",
|
|
1513
|
+
}
|
|
1514
|
+
origin = request.headers.get("Origin", "")
|
|
1515
|
+
cors = self._cors_headers_for_origin(origin) if origin else None
|
|
1516
|
+
if cors:
|
|
1517
|
+
sse_headers.update(cors)
|
|
1518
|
+
if session_id:
|
|
1519
|
+
sse_headers["X-Hermes-Session-Id"] = session_id
|
|
1520
|
+
if gateway_session_key:
|
|
1521
|
+
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
1522
|
+
response = web.StreamResponse(status=200, headers=sse_headers)
|
|
1523
|
+
await response.prepare(request)
|
|
1524
|
+
|
|
1525
|
+
# State accumulated during the stream
|
|
1526
|
+
final_text_parts: List[str] = []
|
|
1527
|
+
# Track open function_call items by name so we can emit a matching
|
|
1528
|
+
# ``done`` event when the tool completes. Order preserved.
|
|
1529
|
+
pending_tool_calls: List[Dict[str, Any]] = []
|
|
1530
|
+
# Output items we've emitted so far (used to build the terminal
|
|
1531
|
+
# response.completed payload). Kept in the order they appeared.
|
|
1532
|
+
emitted_items: List[Dict[str, Any]] = []
|
|
1533
|
+
# Monotonic counter for output_index (spec requires it).
|
|
1534
|
+
output_index = 0
|
|
1535
|
+
# Monotonic counter for call_id generation if the agent doesn't
|
|
1536
|
+
# provide one (it doesn't, from tool_progress_callback).
|
|
1537
|
+
call_counter = 0
|
|
1538
|
+
# Canonical Responses SSE events include a monotonically increasing
|
|
1539
|
+
# sequence_number. Add it server-side for every emitted event so
|
|
1540
|
+
# clients that validate the OpenAI event schema can parse our stream.
|
|
1541
|
+
sequence_number = 0
|
|
1542
|
+
# Track the assistant message item id + content index for text
|
|
1543
|
+
# delta events — the spec ties deltas to a specific item.
|
|
1544
|
+
message_item_id = f"msg_{uuid.uuid4().hex[:24]}"
|
|
1545
|
+
message_output_index: Optional[int] = None
|
|
1546
|
+
message_opened = False
|
|
1547
|
+
|
|
1548
|
+
async def _write_event(event_type: str, data: Dict[str, Any]) -> None:
|
|
1549
|
+
nonlocal sequence_number
|
|
1550
|
+
if "sequence_number" not in data:
|
|
1551
|
+
data["sequence_number"] = sequence_number
|
|
1552
|
+
sequence_number += 1
|
|
1553
|
+
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
|
1554
|
+
await response.write(payload.encode())
|
|
1555
|
+
|
|
1556
|
+
def _envelope(status: str) -> Dict[str, Any]:
|
|
1557
|
+
env: Dict[str, Any] = {
|
|
1558
|
+
"id": response_id,
|
|
1559
|
+
"object": "response",
|
|
1560
|
+
"status": status,
|
|
1561
|
+
"created_at": created_at,
|
|
1562
|
+
"model": model,
|
|
1563
|
+
}
|
|
1564
|
+
return env
|
|
1565
|
+
|
|
1566
|
+
final_response_text = ""
|
|
1567
|
+
agent_error: Optional[str] = None
|
|
1568
|
+
usage: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
|
1569
|
+
terminal_snapshot_persisted = False
|
|
1570
|
+
|
|
1571
|
+
def _persist_response_snapshot(
|
|
1572
|
+
response_env: Dict[str, Any],
|
|
1573
|
+
*,
|
|
1574
|
+
conversation_history_snapshot: Optional[List[Dict[str, Any]]] = None,
|
|
1575
|
+
) -> None:
|
|
1576
|
+
if not store:
|
|
1577
|
+
return
|
|
1578
|
+
if conversation_history_snapshot is None:
|
|
1579
|
+
conversation_history_snapshot = list(conversation_history)
|
|
1580
|
+
conversation_history_snapshot.append({"role": "user", "content": user_message})
|
|
1581
|
+
self._response_store.put(response_id, {
|
|
1582
|
+
"response": response_env,
|
|
1583
|
+
"conversation_history": conversation_history_snapshot,
|
|
1584
|
+
"instructions": instructions,
|
|
1585
|
+
"session_id": session_id,
|
|
1586
|
+
})
|
|
1587
|
+
if conversation:
|
|
1588
|
+
self._response_store.set_conversation(conversation, response_id)
|
|
1589
|
+
|
|
1590
|
+
def _persist_incomplete_if_needed() -> None:
|
|
1591
|
+
"""Persist an ``incomplete`` snapshot if no terminal one was written.
|
|
1592
|
+
|
|
1593
|
+
Called from both the client-disconnect (``ConnectionResetError``)
|
|
1594
|
+
and server-cancellation (``asyncio.CancelledError``) paths so
|
|
1595
|
+
GET /v1/responses/{id} and ``previous_response_id`` chaining keep
|
|
1596
|
+
working after abrupt stream termination.
|
|
1597
|
+
"""
|
|
1598
|
+
if not store or terminal_snapshot_persisted:
|
|
1599
|
+
return
|
|
1600
|
+
incomplete_text = "".join(final_text_parts) or final_response_text
|
|
1601
|
+
incomplete_items: List[Dict[str, Any]] = list(emitted_items)
|
|
1602
|
+
if incomplete_text:
|
|
1603
|
+
incomplete_items.append({
|
|
1604
|
+
"type": "message",
|
|
1605
|
+
"role": "assistant",
|
|
1606
|
+
"content": [{"type": "output_text", "text": incomplete_text}],
|
|
1607
|
+
})
|
|
1608
|
+
incomplete_env = _envelope("incomplete")
|
|
1609
|
+
incomplete_env["output"] = incomplete_items
|
|
1610
|
+
incomplete_env["usage"] = {
|
|
1611
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
1612
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
1613
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1614
|
+
}
|
|
1615
|
+
incomplete_history = list(conversation_history)
|
|
1616
|
+
incomplete_history.append({"role": "user", "content": user_message})
|
|
1617
|
+
if incomplete_text:
|
|
1618
|
+
incomplete_history.append({"role": "assistant", "content": incomplete_text})
|
|
1619
|
+
_persist_response_snapshot(
|
|
1620
|
+
incomplete_env,
|
|
1621
|
+
conversation_history_snapshot=incomplete_history,
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
try:
|
|
1625
|
+
# response.created — initial envelope, status=in_progress
|
|
1626
|
+
created_env = _envelope("in_progress")
|
|
1627
|
+
created_env["output"] = []
|
|
1628
|
+
await _write_event("response.created", {
|
|
1629
|
+
"type": "response.created",
|
|
1630
|
+
"response": created_env,
|
|
1631
|
+
})
|
|
1632
|
+
_persist_response_snapshot(created_env)
|
|
1633
|
+
last_activity = time.monotonic()
|
|
1634
|
+
|
|
1635
|
+
async def _open_message_item() -> None:
|
|
1636
|
+
"""Emit response.output_item.added for the assistant message
|
|
1637
|
+
the first time any text delta arrives."""
|
|
1638
|
+
nonlocal message_opened, message_output_index, output_index
|
|
1639
|
+
if message_opened:
|
|
1640
|
+
return
|
|
1641
|
+
message_opened = True
|
|
1642
|
+
message_output_index = output_index
|
|
1643
|
+
output_index += 1
|
|
1644
|
+
item = {
|
|
1645
|
+
"id": message_item_id,
|
|
1646
|
+
"type": "message",
|
|
1647
|
+
"status": "in_progress",
|
|
1648
|
+
"role": "assistant",
|
|
1649
|
+
"content": [],
|
|
1650
|
+
}
|
|
1651
|
+
await _write_event("response.output_item.added", {
|
|
1652
|
+
"type": "response.output_item.added",
|
|
1653
|
+
"output_index": message_output_index,
|
|
1654
|
+
"item": item,
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1657
|
+
async def _emit_text_delta(delta_text: str) -> None:
|
|
1658
|
+
await _open_message_item()
|
|
1659
|
+
final_text_parts.append(delta_text)
|
|
1660
|
+
await _write_event("response.output_text.delta", {
|
|
1661
|
+
"type": "response.output_text.delta",
|
|
1662
|
+
"item_id": message_item_id,
|
|
1663
|
+
"output_index": message_output_index,
|
|
1664
|
+
"content_index": 0,
|
|
1665
|
+
"delta": delta_text,
|
|
1666
|
+
"logprobs": [],
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
async def _emit_tool_started(payload: Dict[str, Any]) -> str:
|
|
1670
|
+
"""Emit response.output_item.added for a function_call.
|
|
1671
|
+
|
|
1672
|
+
Returns the call_id so the matching completion event can
|
|
1673
|
+
reference it. Prefer the real ``tool_call_id`` from the
|
|
1674
|
+
agent when available; fall back to a generated call id for
|
|
1675
|
+
safety in tests or older code paths.
|
|
1676
|
+
"""
|
|
1677
|
+
nonlocal output_index, call_counter
|
|
1678
|
+
call_counter += 1
|
|
1679
|
+
call_id = payload.get("tool_call_id") or f"call_{response_id[5:]}_{call_counter}"
|
|
1680
|
+
args = payload.get("arguments", {})
|
|
1681
|
+
if isinstance(args, dict):
|
|
1682
|
+
arguments_str = json.dumps(args)
|
|
1683
|
+
else:
|
|
1684
|
+
arguments_str = str(args)
|
|
1685
|
+
item = {
|
|
1686
|
+
"id": f"fc_{uuid.uuid4().hex[:24]}",
|
|
1687
|
+
"type": "function_call",
|
|
1688
|
+
"status": "in_progress",
|
|
1689
|
+
"name": payload.get("name", ""),
|
|
1690
|
+
"call_id": call_id,
|
|
1691
|
+
"arguments": arguments_str,
|
|
1692
|
+
}
|
|
1693
|
+
idx = output_index
|
|
1694
|
+
output_index += 1
|
|
1695
|
+
pending_tool_calls.append({
|
|
1696
|
+
"call_id": call_id,
|
|
1697
|
+
"name": payload.get("name", ""),
|
|
1698
|
+
"arguments": arguments_str,
|
|
1699
|
+
"item_id": item["id"],
|
|
1700
|
+
"output_index": idx,
|
|
1701
|
+
})
|
|
1702
|
+
emitted_items.append({
|
|
1703
|
+
"type": "function_call",
|
|
1704
|
+
"name": payload.get("name", ""),
|
|
1705
|
+
"arguments": arguments_str,
|
|
1706
|
+
"call_id": call_id,
|
|
1707
|
+
})
|
|
1708
|
+
await _write_event("response.output_item.added", {
|
|
1709
|
+
"type": "response.output_item.added",
|
|
1710
|
+
"output_index": idx,
|
|
1711
|
+
"item": item,
|
|
1712
|
+
})
|
|
1713
|
+
return call_id
|
|
1714
|
+
|
|
1715
|
+
async def _emit_tool_completed(payload: Dict[str, Any]) -> None:
|
|
1716
|
+
"""Emit response.output_item.done (function_call) followed
|
|
1717
|
+
by response.output_item.added (function_call_output)."""
|
|
1718
|
+
nonlocal output_index
|
|
1719
|
+
call_id = payload.get("tool_call_id")
|
|
1720
|
+
result = payload.get("result", "")
|
|
1721
|
+
pending = None
|
|
1722
|
+
if call_id:
|
|
1723
|
+
for i, p in enumerate(pending_tool_calls):
|
|
1724
|
+
if p["call_id"] == call_id:
|
|
1725
|
+
pending = pending_tool_calls.pop(i)
|
|
1726
|
+
break
|
|
1727
|
+
if pending is None:
|
|
1728
|
+
# Completion without a matching start — skip to avoid
|
|
1729
|
+
# emitting orphaned done events.
|
|
1730
|
+
return
|
|
1731
|
+
|
|
1732
|
+
# function_call done
|
|
1733
|
+
done_item = {
|
|
1734
|
+
"id": pending["item_id"],
|
|
1735
|
+
"type": "function_call",
|
|
1736
|
+
"status": "completed",
|
|
1737
|
+
"name": pending["name"],
|
|
1738
|
+
"call_id": pending["call_id"],
|
|
1739
|
+
"arguments": pending["arguments"],
|
|
1740
|
+
}
|
|
1741
|
+
await _write_event("response.output_item.done", {
|
|
1742
|
+
"type": "response.output_item.done",
|
|
1743
|
+
"output_index": pending["output_index"],
|
|
1744
|
+
"item": done_item,
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
# function_call_output added (result)
|
|
1748
|
+
result_str = result if isinstance(result, str) else json.dumps(result)
|
|
1749
|
+
output_parts = [{"type": "input_text", "text": result_str}]
|
|
1750
|
+
output_item = {
|
|
1751
|
+
"id": f"fco_{uuid.uuid4().hex[:24]}",
|
|
1752
|
+
"type": "function_call_output",
|
|
1753
|
+
"call_id": pending["call_id"],
|
|
1754
|
+
"output": output_parts,
|
|
1755
|
+
"status": "completed",
|
|
1756
|
+
}
|
|
1757
|
+
idx = output_index
|
|
1758
|
+
output_index += 1
|
|
1759
|
+
emitted_items.append({
|
|
1760
|
+
"type": "function_call_output",
|
|
1761
|
+
"call_id": pending["call_id"],
|
|
1762
|
+
"output": output_parts,
|
|
1763
|
+
})
|
|
1764
|
+
await _write_event("response.output_item.added", {
|
|
1765
|
+
"type": "response.output_item.added",
|
|
1766
|
+
"output_index": idx,
|
|
1767
|
+
"item": output_item,
|
|
1768
|
+
})
|
|
1769
|
+
await _write_event("response.output_item.done", {
|
|
1770
|
+
"type": "response.output_item.done",
|
|
1771
|
+
"output_index": idx,
|
|
1772
|
+
"item": output_item,
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
# Main drain loop — thread-safe queue fed by agent callbacks.
|
|
1776
|
+
async def _dispatch(it) -> None:
|
|
1777
|
+
"""Route a queue item to the correct SSE emitter.
|
|
1778
|
+
|
|
1779
|
+
Plain strings are text deltas — they are batched (50ms)
|
|
1780
|
+
to reduce Open WebUI re-render storms. Tagged tuples
|
|
1781
|
+
with ``__tool_started__`` / ``__tool_completed__``
|
|
1782
|
+
prefixes are tool lifecycle events and flush the buffer
|
|
1783
|
+
before emitting.
|
|
1784
|
+
"""
|
|
1785
|
+
nonlocal _batch_timer
|
|
1786
|
+
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
|
|
1787
|
+
tag, payload = it
|
|
1788
|
+
# Flush batched text before tool events
|
|
1789
|
+
if _batch_buf:
|
|
1790
|
+
await _flush_batch()
|
|
1791
|
+
if tag == "__tool_started__":
|
|
1792
|
+
await _emit_tool_started(payload)
|
|
1793
|
+
elif tag == "__tool_completed__":
|
|
1794
|
+
await _emit_tool_completed(payload)
|
|
1795
|
+
elif isinstance(it, str):
|
|
1796
|
+
# Batch text deltas — append to buffer, flush on timer
|
|
1797
|
+
_batch_buf.append(it)
|
|
1798
|
+
if _batch_timer is None:
|
|
1799
|
+
_batch_timer = asyncio.create_task(_batch_flush_after(0.05))
|
|
1800
|
+
# Other types are silently dropped.
|
|
1801
|
+
|
|
1802
|
+
# ── Batching state ──
|
|
1803
|
+
_batch_buf: List[str] = []
|
|
1804
|
+
_batch_timer: Optional[asyncio.Task] = None
|
|
1805
|
+
_batch_lock = asyncio.Lock()
|
|
1806
|
+
|
|
1807
|
+
async def _batch_flush_after(delay: float) -> None:
|
|
1808
|
+
"""Wait delay seconds, then flush accumulated text deltas."""
|
|
1809
|
+
try:
|
|
1810
|
+
await asyncio.sleep(delay)
|
|
1811
|
+
except asyncio.CancelledError:
|
|
1812
|
+
return
|
|
1813
|
+
# Clear timer reference BEFORE flush so new deltas
|
|
1814
|
+
# can start a fresh timer while we emit
|
|
1815
|
+
nonlocal _batch_buf, _batch_timer
|
|
1816
|
+
_batch_timer = None
|
|
1817
|
+
await _flush_batch()
|
|
1818
|
+
|
|
1819
|
+
async def _flush_batch() -> None:
|
|
1820
|
+
"""Emit a single SSE delta for all accumulated text."""
|
|
1821
|
+
nonlocal _batch_buf
|
|
1822
|
+
async with _batch_lock:
|
|
1823
|
+
if _batch_buf:
|
|
1824
|
+
combined = "".join(_batch_buf)
|
|
1825
|
+
_batch_buf = []
|
|
1826
|
+
await _emit_text_delta(combined)
|
|
1827
|
+
|
|
1828
|
+
loop = asyncio.get_running_loop()
|
|
1829
|
+
while True:
|
|
1830
|
+
try:
|
|
1831
|
+
item = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
|
1832
|
+
except _q.Empty:
|
|
1833
|
+
if agent_task.done():
|
|
1834
|
+
# Drain remaining
|
|
1835
|
+
while True:
|
|
1836
|
+
try:
|
|
1837
|
+
item = stream_q.get_nowait()
|
|
1838
|
+
if item is None:
|
|
1839
|
+
break
|
|
1840
|
+
await _dispatch(item)
|
|
1841
|
+
last_activity = time.monotonic()
|
|
1842
|
+
except _q.Empty:
|
|
1843
|
+
break
|
|
1844
|
+
break
|
|
1845
|
+
if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
|
|
1846
|
+
await response.write(b": keepalive\n\n")
|
|
1847
|
+
last_activity = time.monotonic()
|
|
1848
|
+
continue
|
|
1849
|
+
|
|
1850
|
+
if item is None: # EOS sentinel
|
|
1851
|
+
# Cancel pending timer and flush remaining batched text
|
|
1852
|
+
if _batch_timer and not _batch_timer.done():
|
|
1853
|
+
_batch_timer.cancel()
|
|
1854
|
+
_batch_timer = None
|
|
1855
|
+
if _batch_buf:
|
|
1856
|
+
await _flush_batch()
|
|
1857
|
+
break
|
|
1858
|
+
|
|
1859
|
+
await _dispatch(item)
|
|
1860
|
+
last_activity = time.monotonic()
|
|
1861
|
+
|
|
1862
|
+
# Flush any final batched text before processing result
|
|
1863
|
+
if _batch_buf:
|
|
1864
|
+
await _flush_batch()
|
|
1865
|
+
|
|
1866
|
+
# Pick up agent result + usage from the completed task
|
|
1867
|
+
try:
|
|
1868
|
+
result, agent_usage = await agent_task
|
|
1869
|
+
usage = agent_usage or usage
|
|
1870
|
+
# If the agent produced a final_response but no text
|
|
1871
|
+
# deltas were streamed (e.g. some providers only emit
|
|
1872
|
+
# the full response at the end), emit a single fallback
|
|
1873
|
+
# delta so Responses clients still receive a live text part.
|
|
1874
|
+
agent_final = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
1875
|
+
if agent_final and not final_text_parts:
|
|
1876
|
+
await _emit_text_delta(agent_final)
|
|
1877
|
+
if agent_final and not final_response_text:
|
|
1878
|
+
final_response_text = agent_final
|
|
1879
|
+
if isinstance(result, dict) and result.get("error") and not final_response_text:
|
|
1880
|
+
agent_error = result["error"]
|
|
1881
|
+
except Exception as e: # noqa: BLE001
|
|
1882
|
+
logger.error("Error running agent for streaming responses: %s", e, exc_info=True)
|
|
1883
|
+
agent_error = str(e)
|
|
1884
|
+
|
|
1885
|
+
# Close the message item if it was opened
|
|
1886
|
+
final_response_text = "".join(final_text_parts) or final_response_text
|
|
1887
|
+
if message_opened:
|
|
1888
|
+
await _write_event("response.output_text.done", {
|
|
1889
|
+
"type": "response.output_text.done",
|
|
1890
|
+
"item_id": message_item_id,
|
|
1891
|
+
"output_index": message_output_index,
|
|
1892
|
+
"content_index": 0,
|
|
1893
|
+
"text": final_response_text,
|
|
1894
|
+
"logprobs": [],
|
|
1895
|
+
})
|
|
1896
|
+
msg_done_item = {
|
|
1897
|
+
"id": message_item_id,
|
|
1898
|
+
"type": "message",
|
|
1899
|
+
"status": "completed",
|
|
1900
|
+
"role": "assistant",
|
|
1901
|
+
"content": [
|
|
1902
|
+
{"type": "output_text", "text": final_response_text}
|
|
1903
|
+
],
|
|
1904
|
+
}
|
|
1905
|
+
await _write_event("response.output_item.done", {
|
|
1906
|
+
"type": "response.output_item.done",
|
|
1907
|
+
"output_index": message_output_index,
|
|
1908
|
+
"item": msg_done_item,
|
|
1909
|
+
})
|
|
1910
|
+
|
|
1911
|
+
# Always append a final message item in the completed
|
|
1912
|
+
# response envelope so clients that only parse the terminal
|
|
1913
|
+
# payload still see the assistant text. This mirrors the
|
|
1914
|
+
# shape produced by _extract_output_items in the batch path.
|
|
1915
|
+
final_items: List[Dict[str, Any]] = list(emitted_items)
|
|
1916
|
+
|
|
1917
|
+
# Trim large content from tool call arguments to keep the
|
|
1918
|
+
# response.completed event under ~100KB. Clients already
|
|
1919
|
+
# received full details via incremental events.
|
|
1920
|
+
for _item in final_items:
|
|
1921
|
+
if _item.get("type") == "function_call":
|
|
1922
|
+
try:
|
|
1923
|
+
_args = json.loads(_item.get("arguments", "{}")) if isinstance(_item.get("arguments"), str) else _item.get("arguments", {})
|
|
1924
|
+
if isinstance(_args, dict):
|
|
1925
|
+
for _k in ("content", "query", "pattern", "old_string", "new_string"):
|
|
1926
|
+
if isinstance(_args.get(_k), str) and len(_args[_k]) > 500:
|
|
1927
|
+
_args[_k] = "[" + str(len(_args[_k])) + " chars — truncated for response.completed]"
|
|
1928
|
+
_item["arguments"] = json.dumps(_args)
|
|
1929
|
+
except Exception:
|
|
1930
|
+
pass
|
|
1931
|
+
elif _item.get("type") == "function_call_output":
|
|
1932
|
+
_output = _item.get("output", [])
|
|
1933
|
+
if isinstance(_output, list) and _output:
|
|
1934
|
+
_first = _output[0]
|
|
1935
|
+
if isinstance(_first, dict) and _first.get("type") == "input_text":
|
|
1936
|
+
_text = _first.get("text", "")
|
|
1937
|
+
if len(_text) > 1000:
|
|
1938
|
+
_first["text"] = _text[:500] + "...[" + str(len(_text) - 500) + " more chars]"
|
|
1939
|
+
_item["output"] = [_first]
|
|
1940
|
+
|
|
1941
|
+
final_items.append({
|
|
1942
|
+
"type": "message",
|
|
1943
|
+
"role": "assistant",
|
|
1944
|
+
"content": [
|
|
1945
|
+
{"type": "output_text", "text": final_response_text or (agent_error or "")}
|
|
1946
|
+
],
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
if agent_error:
|
|
1950
|
+
failed_env = _envelope("failed")
|
|
1951
|
+
failed_env["output"] = final_items
|
|
1952
|
+
failed_env["error"] = {"message": agent_error, "type": "server_error"}
|
|
1953
|
+
failed_env["usage"] = {
|
|
1954
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
1955
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
1956
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1957
|
+
}
|
|
1958
|
+
_failed_history = list(conversation_history)
|
|
1959
|
+
_failed_history.append({"role": "user", "content": user_message})
|
|
1960
|
+
if final_response_text or agent_error:
|
|
1961
|
+
_failed_history.append({
|
|
1962
|
+
"role": "assistant",
|
|
1963
|
+
"content": final_response_text or agent_error,
|
|
1964
|
+
})
|
|
1965
|
+
_persist_response_snapshot(
|
|
1966
|
+
failed_env,
|
|
1967
|
+
conversation_history_snapshot=_failed_history,
|
|
1968
|
+
)
|
|
1969
|
+
terminal_snapshot_persisted = True
|
|
1970
|
+
await _write_event("response.failed", {
|
|
1971
|
+
"type": "response.failed",
|
|
1972
|
+
"response": failed_env,
|
|
1973
|
+
})
|
|
1974
|
+
else:
|
|
1975
|
+
completed_env = _envelope("completed")
|
|
1976
|
+
completed_env["output"] = final_items
|
|
1977
|
+
completed_env["usage"] = {
|
|
1978
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
1979
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
1980
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1981
|
+
}
|
|
1982
|
+
full_history = self._build_response_conversation_history(
|
|
1983
|
+
conversation_history,
|
|
1984
|
+
user_message,
|
|
1985
|
+
result,
|
|
1986
|
+
final_response_text,
|
|
1987
|
+
)
|
|
1988
|
+
_persist_response_snapshot(
|
|
1989
|
+
completed_env,
|
|
1990
|
+
conversation_history_snapshot=full_history,
|
|
1991
|
+
)
|
|
1992
|
+
terminal_snapshot_persisted = True
|
|
1993
|
+
await _write_event("response.completed", {
|
|
1994
|
+
"type": "response.completed",
|
|
1995
|
+
"response": completed_env,
|
|
1996
|
+
})
|
|
1997
|
+
|
|
1998
|
+
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
|
1999
|
+
_persist_incomplete_if_needed()
|
|
2000
|
+
# Client disconnected — interrupt the agent so it stops
|
|
2001
|
+
# making upstream LLM calls, then cancel the task.
|
|
2002
|
+
agent = agent_ref[0] if agent_ref else None
|
|
2003
|
+
if agent is not None:
|
|
2004
|
+
try:
|
|
2005
|
+
agent.interrupt("SSE client disconnected")
|
|
2006
|
+
except Exception:
|
|
2007
|
+
pass
|
|
2008
|
+
if not agent_task.done():
|
|
2009
|
+
agent_task.cancel()
|
|
2010
|
+
try:
|
|
2011
|
+
await agent_task
|
|
2012
|
+
except (asyncio.CancelledError, Exception):
|
|
2013
|
+
pass
|
|
2014
|
+
logger.info("SSE client disconnected; interrupted agent task %s", response_id)
|
|
2015
|
+
except asyncio.CancelledError:
|
|
2016
|
+
# Server-side cancellation (e.g. shutdown, request timeout) —
|
|
2017
|
+
# persist an incomplete snapshot so GET /v1/responses/{id} and
|
|
2018
|
+
# previous_response_id chaining still work, then re-raise so the
|
|
2019
|
+
# runtime's cancellation semantics are respected.
|
|
2020
|
+
_persist_incomplete_if_needed()
|
|
2021
|
+
agent = agent_ref[0] if agent_ref else None
|
|
2022
|
+
if agent is not None:
|
|
2023
|
+
try:
|
|
2024
|
+
agent.interrupt("SSE task cancelled")
|
|
2025
|
+
except Exception:
|
|
2026
|
+
pass
|
|
2027
|
+
if not agent_task.done():
|
|
2028
|
+
agent_task.cancel()
|
|
2029
|
+
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
|
|
2030
|
+
raise
|
|
2031
|
+
except Exception as _exc:
|
|
2032
|
+
# Agent crashed with an unhandled error (e.g. model API error like
|
|
2033
|
+
# BadRequestError, AuthenticationError). Emit a response.failed
|
|
2034
|
+
# event and properly terminate the SSE stream so the client doesn't
|
|
2035
|
+
# get a TransferEncodingError from incomplete chunked encoding.
|
|
2036
|
+
import traceback as _tb
|
|
2037
|
+
_persist_incomplete_if_needed()
|
|
2038
|
+
agent_error = _tb.format_exc()
|
|
2039
|
+
try:
|
|
2040
|
+
failed_env = _envelope("failed")
|
|
2041
|
+
failed_env["output"] = list(emitted_items)
|
|
2042
|
+
failed_env["error"] = {"message": str(_exc)[:500], "type": "server_error"}
|
|
2043
|
+
failed_env["usage"] = {
|
|
2044
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
2045
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
2046
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
2047
|
+
}
|
|
2048
|
+
await _write_event("response.failed", {
|
|
2049
|
+
"type": "response.failed",
|
|
2050
|
+
"response": failed_env,
|
|
2051
|
+
})
|
|
2052
|
+
except Exception:
|
|
2053
|
+
pass
|
|
2054
|
+
logger.error("Agent crashed mid-stream for %s: %s", response_id, str(agent_error)[:300])
|
|
2055
|
+
|
|
2056
|
+
return response
|
|
2057
|
+
|
|
2058
|
+
async def _handle_responses(self, request: "web.Request") -> "web.Response":
|
|
2059
|
+
"""POST /v1/responses — OpenAI Responses API format."""
|
|
2060
|
+
auth_err = self._check_auth(request)
|
|
2061
|
+
if auth_err:
|
|
2062
|
+
return auth_err
|
|
2063
|
+
|
|
2064
|
+
# Long-term memory scope header (see chat_completions for details).
|
|
2065
|
+
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
2066
|
+
if key_err is not None:
|
|
2067
|
+
return key_err
|
|
2068
|
+
|
|
2069
|
+
# Parse request body
|
|
2070
|
+
try:
|
|
2071
|
+
body = await request.json()
|
|
2072
|
+
except (json.JSONDecodeError, Exception):
|
|
2073
|
+
return web.json_response(
|
|
2074
|
+
{"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}},
|
|
2075
|
+
status=400,
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
raw_input = body.get("input")
|
|
2079
|
+
if raw_input is None:
|
|
2080
|
+
return web.json_response(_openai_error("Missing 'input' field"), status=400)
|
|
2081
|
+
|
|
2082
|
+
instructions = body.get("instructions")
|
|
2083
|
+
previous_response_id = body.get("previous_response_id")
|
|
2084
|
+
conversation = body.get("conversation")
|
|
2085
|
+
store = body.get("store", True)
|
|
2086
|
+
|
|
2087
|
+
# conversation and previous_response_id are mutually exclusive
|
|
2088
|
+
if conversation and previous_response_id:
|
|
2089
|
+
return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400)
|
|
2090
|
+
|
|
2091
|
+
# Resolve conversation name to latest response_id
|
|
2092
|
+
if conversation:
|
|
2093
|
+
previous_response_id = self._response_store.get_conversation(conversation)
|
|
2094
|
+
# No error if conversation doesn't exist yet — it's a new conversation
|
|
2095
|
+
|
|
2096
|
+
# Normalize input to message list
|
|
2097
|
+
input_messages: List[Dict[str, Any]] = []
|
|
2098
|
+
if isinstance(raw_input, str):
|
|
2099
|
+
input_messages = [{"role": "user", "content": raw_input}]
|
|
2100
|
+
elif isinstance(raw_input, list):
|
|
2101
|
+
for idx, item in enumerate(raw_input):
|
|
2102
|
+
if isinstance(item, str):
|
|
2103
|
+
input_messages.append({"role": "user", "content": item})
|
|
2104
|
+
elif isinstance(item, dict):
|
|
2105
|
+
role = item.get("role", "user")
|
|
2106
|
+
try:
|
|
2107
|
+
content = _normalize_multimodal_content(item.get("content", ""))
|
|
2108
|
+
except ValueError as exc:
|
|
2109
|
+
return _multimodal_validation_error(exc, param=f"input[{idx}].content")
|
|
2110
|
+
input_messages.append({"role": role, "content": content})
|
|
2111
|
+
else:
|
|
2112
|
+
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
|
|
2113
|
+
|
|
2114
|
+
# Accept explicit conversation_history from the request body.
|
|
2115
|
+
# This lets stateless clients supply their own history instead of
|
|
2116
|
+
# relying on server-side response chaining via previous_response_id.
|
|
2117
|
+
# Precedence: explicit conversation_history > previous_response_id.
|
|
2118
|
+
conversation_history: List[Dict[str, Any]] = []
|
|
2119
|
+
raw_history = body.get("conversation_history")
|
|
2120
|
+
if raw_history:
|
|
2121
|
+
if not isinstance(raw_history, list):
|
|
2122
|
+
return web.json_response(
|
|
2123
|
+
_openai_error("'conversation_history' must be an array of message objects"),
|
|
2124
|
+
status=400,
|
|
2125
|
+
)
|
|
2126
|
+
for i, entry in enumerate(raw_history):
|
|
2127
|
+
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
|
|
2128
|
+
return web.json_response(
|
|
2129
|
+
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
|
|
2130
|
+
status=400,
|
|
2131
|
+
)
|
|
2132
|
+
try:
|
|
2133
|
+
entry_content = _normalize_multimodal_content(entry["content"])
|
|
2134
|
+
except ValueError as exc:
|
|
2135
|
+
return _multimodal_validation_error(exc, param=f"conversation_history[{i}].content")
|
|
2136
|
+
conversation_history.append({"role": str(entry["role"]), "content": entry_content})
|
|
2137
|
+
if previous_response_id:
|
|
2138
|
+
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
|
2139
|
+
|
|
2140
|
+
stored_session_id = None
|
|
2141
|
+
if not conversation_history and previous_response_id:
|
|
2142
|
+
stored = self._response_store.get(previous_response_id)
|
|
2143
|
+
if stored is None:
|
|
2144
|
+
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
|
|
2145
|
+
conversation_history = list(stored.get("conversation_history", []))
|
|
2146
|
+
stored_session_id = stored.get("session_id")
|
|
2147
|
+
# If no instructions provided, carry forward from previous
|
|
2148
|
+
if instructions is None:
|
|
2149
|
+
instructions = stored.get("instructions")
|
|
2150
|
+
|
|
2151
|
+
# Append new input messages to history (all but the last become history)
|
|
2152
|
+
for msg in input_messages[:-1]:
|
|
2153
|
+
conversation_history.append(msg)
|
|
2154
|
+
|
|
2155
|
+
# Last input message is the user_message
|
|
2156
|
+
user_message: Any = input_messages[-1].get("content", "") if input_messages else ""
|
|
2157
|
+
if not _content_has_visible_payload(user_message):
|
|
2158
|
+
return web.json_response(_openai_error("No user message found in input"), status=400)
|
|
2159
|
+
|
|
2160
|
+
# Truncation support
|
|
2161
|
+
if body.get("truncation") == "auto" and len(conversation_history) > 100:
|
|
2162
|
+
conversation_history = conversation_history[-100:]
|
|
2163
|
+
|
|
2164
|
+
# Reuse session from previous_response_id chain so the dashboard
|
|
2165
|
+
# groups the entire conversation under one session entry.
|
|
2166
|
+
session_id = stored_session_id or str(uuid.uuid4())
|
|
2167
|
+
|
|
2168
|
+
stream = bool(body.get("stream", False))
|
|
2169
|
+
if stream:
|
|
2170
|
+
# Streaming branch — emit OpenAI Responses SSE events as the
|
|
2171
|
+
# agent runs so frontends can render text deltas and tool
|
|
2172
|
+
# calls in real time. See _write_sse_responses for details.
|
|
2173
|
+
import queue as _q
|
|
2174
|
+
_stream_q: _q.Queue = _q.Queue()
|
|
2175
|
+
|
|
2176
|
+
def _on_delta(delta):
|
|
2177
|
+
# None from the agent is a CLI box-close signal, not EOS.
|
|
2178
|
+
# Forwarding would kill the SSE stream prematurely; the
|
|
2179
|
+
# SSE writer detects completion via agent_task.done().
|
|
2180
|
+
if delta is not None:
|
|
2181
|
+
_stream_q.put(delta)
|
|
2182
|
+
|
|
2183
|
+
def _on_tool_progress(event_type, name, preview, args, **kwargs):
|
|
2184
|
+
"""Queue non-start tool progress events if needed in future.
|
|
2185
|
+
|
|
2186
|
+
The structured Responses stream uses ``tool_start_callback``
|
|
2187
|
+
and ``tool_complete_callback`` for exact call-id correlation,
|
|
2188
|
+
so progress events are currently ignored here.
|
|
2189
|
+
"""
|
|
2190
|
+
return
|
|
2191
|
+
|
|
2192
|
+
def _on_tool_start(tool_call_id, function_name, function_args):
|
|
2193
|
+
"""Queue a started tool for live function_call streaming."""
|
|
2194
|
+
_stream_q.put(("__tool_started__", {
|
|
2195
|
+
"tool_call_id": tool_call_id,
|
|
2196
|
+
"name": function_name,
|
|
2197
|
+
"arguments": function_args or {},
|
|
2198
|
+
}))
|
|
2199
|
+
|
|
2200
|
+
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
|
|
2201
|
+
"""Queue a completed tool result for live function_call_output streaming."""
|
|
2202
|
+
_stream_q.put(("__tool_completed__", {
|
|
2203
|
+
"tool_call_id": tool_call_id,
|
|
2204
|
+
"name": function_name,
|
|
2205
|
+
"arguments": function_args or {},
|
|
2206
|
+
"result": function_result,
|
|
2207
|
+
}))
|
|
2208
|
+
|
|
2209
|
+
agent_ref = [None]
|
|
2210
|
+
agent_task = asyncio.ensure_future(self._run_agent(
|
|
2211
|
+
user_message=user_message,
|
|
2212
|
+
conversation_history=conversation_history,
|
|
2213
|
+
ephemeral_system_prompt=instructions,
|
|
2214
|
+
session_id=session_id,
|
|
2215
|
+
stream_delta_callback=_on_delta,
|
|
2216
|
+
tool_progress_callback=_on_tool_progress,
|
|
2217
|
+
tool_start_callback=_on_tool_start,
|
|
2218
|
+
tool_complete_callback=_on_tool_complete,
|
|
2219
|
+
agent_ref=agent_ref,
|
|
2220
|
+
gateway_session_key=gateway_session_key,
|
|
2221
|
+
))
|
|
2222
|
+
# Ensure SSE drain loops can terminate without relying on polling
|
|
2223
|
+
# agent_task.done(), which can race with queue timeout checks.
|
|
2224
|
+
agent_task.add_done_callback(lambda _fut: _stream_q.put(None))
|
|
2225
|
+
|
|
2226
|
+
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
2227
|
+
model_name = body.get("model", self._model_name)
|
|
2228
|
+
created_at = int(time.time())
|
|
2229
|
+
|
|
2230
|
+
return await self._write_sse_responses(
|
|
2231
|
+
request=request,
|
|
2232
|
+
response_id=response_id,
|
|
2233
|
+
model=model_name,
|
|
2234
|
+
created_at=created_at,
|
|
2235
|
+
stream_q=_stream_q,
|
|
2236
|
+
agent_task=agent_task,
|
|
2237
|
+
agent_ref=agent_ref,
|
|
2238
|
+
conversation_history=conversation_history,
|
|
2239
|
+
user_message=user_message,
|
|
2240
|
+
instructions=instructions,
|
|
2241
|
+
conversation=conversation,
|
|
2242
|
+
store=store,
|
|
2243
|
+
session_id=session_id,
|
|
2244
|
+
gateway_session_key=gateway_session_key,
|
|
2245
|
+
)
|
|
2246
|
+
|
|
2247
|
+
async def _compute_response():
|
|
2248
|
+
return await self._run_agent(
|
|
2249
|
+
user_message=user_message,
|
|
2250
|
+
conversation_history=conversation_history,
|
|
2251
|
+
ephemeral_system_prompt=instructions,
|
|
2252
|
+
session_id=session_id,
|
|
2253
|
+
gateway_session_key=gateway_session_key,
|
|
2254
|
+
)
|
|
2255
|
+
|
|
2256
|
+
idempotency_key = request.headers.get("Idempotency-Key")
|
|
2257
|
+
if idempotency_key:
|
|
2258
|
+
fp = _make_request_fingerprint(
|
|
2259
|
+
body,
|
|
2260
|
+
keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"],
|
|
2261
|
+
)
|
|
2262
|
+
try:
|
|
2263
|
+
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response)
|
|
2264
|
+
except Exception as e:
|
|
2265
|
+
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
|
2266
|
+
return web.json_response(
|
|
2267
|
+
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
2268
|
+
status=500,
|
|
2269
|
+
)
|
|
2270
|
+
else:
|
|
2271
|
+
try:
|
|
2272
|
+
result, usage = await _compute_response()
|
|
2273
|
+
except Exception as e:
|
|
2274
|
+
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
|
2275
|
+
return web.json_response(
|
|
2276
|
+
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
2277
|
+
status=500,
|
|
2278
|
+
)
|
|
2279
|
+
|
|
2280
|
+
final_response = result.get("final_response", "")
|
|
2281
|
+
if not final_response:
|
|
2282
|
+
final_response = result.get("error", "(No response generated)")
|
|
2283
|
+
|
|
2284
|
+
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
2285
|
+
created_at = int(time.time())
|
|
2286
|
+
|
|
2287
|
+
# Build the full conversation history for storage
|
|
2288
|
+
# (includes tool calls from the agent run)
|
|
2289
|
+
full_history = self._build_response_conversation_history(
|
|
2290
|
+
conversation_history,
|
|
2291
|
+
user_message,
|
|
2292
|
+
result,
|
|
2293
|
+
final_response,
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
# Build output items from the current turn only. AIAgent returns a
|
|
2297
|
+
# full transcript in result["messages"], while older/mocked paths may
|
|
2298
|
+
# return only the current turn suffix.
|
|
2299
|
+
output_start_index = self._response_messages_turn_start_index(
|
|
2300
|
+
conversation_history,
|
|
2301
|
+
user_message,
|
|
2302
|
+
result,
|
|
2303
|
+
)
|
|
2304
|
+
output_items = self._extract_output_items(result, start_index=output_start_index)
|
|
2305
|
+
|
|
2306
|
+
response_data = {
|
|
2307
|
+
"id": response_id,
|
|
2308
|
+
"object": "response",
|
|
2309
|
+
"status": "completed",
|
|
2310
|
+
"created_at": created_at,
|
|
2311
|
+
"model": body.get("model", self._model_name),
|
|
2312
|
+
"output": output_items,
|
|
2313
|
+
"usage": {
|
|
2314
|
+
"input_tokens": usage.get("input_tokens", 0),
|
|
2315
|
+
"output_tokens": usage.get("output_tokens", 0),
|
|
2316
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
2317
|
+
},
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
# Store the complete response object for future chaining / GET retrieval
|
|
2321
|
+
if store:
|
|
2322
|
+
self._response_store.put(response_id, {
|
|
2323
|
+
"response": response_data,
|
|
2324
|
+
"conversation_history": full_history,
|
|
2325
|
+
"instructions": instructions,
|
|
2326
|
+
"session_id": session_id,
|
|
2327
|
+
})
|
|
2328
|
+
# Update conversation mapping so the next request with the same
|
|
2329
|
+
# conversation name automatically chains to this response
|
|
2330
|
+
if conversation:
|
|
2331
|
+
self._response_store.set_conversation(conversation, response_id)
|
|
2332
|
+
|
|
2333
|
+
response_headers = {"X-Hermes-Session-Id": session_id}
|
|
2334
|
+
if gateway_session_key:
|
|
2335
|
+
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
2336
|
+
return web.json_response(response_data, headers=response_headers)
|
|
2337
|
+
|
|
2338
|
+
# ------------------------------------------------------------------
|
|
2339
|
+
# GET / DELETE response endpoints
|
|
2340
|
+
# ------------------------------------------------------------------
|
|
2341
|
+
|
|
2342
|
+
async def _handle_get_response(self, request: "web.Request") -> "web.Response":
|
|
2343
|
+
"""GET /v1/responses/{response_id} — retrieve a stored response."""
|
|
2344
|
+
auth_err = self._check_auth(request)
|
|
2345
|
+
if auth_err:
|
|
2346
|
+
return auth_err
|
|
2347
|
+
|
|
2348
|
+
response_id = request.match_info["response_id"]
|
|
2349
|
+
stored = self._response_store.get(response_id)
|
|
2350
|
+
if stored is None:
|
|
2351
|
+
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
|
2352
|
+
|
|
2353
|
+
return web.json_response(stored["response"])
|
|
2354
|
+
|
|
2355
|
+
async def _handle_delete_response(self, request: "web.Request") -> "web.Response":
|
|
2356
|
+
"""DELETE /v1/responses/{response_id} — delete a stored response."""
|
|
2357
|
+
auth_err = self._check_auth(request)
|
|
2358
|
+
if auth_err:
|
|
2359
|
+
return auth_err
|
|
2360
|
+
|
|
2361
|
+
response_id = request.match_info["response_id"]
|
|
2362
|
+
deleted = self._response_store.delete(response_id)
|
|
2363
|
+
if not deleted:
|
|
2364
|
+
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
|
2365
|
+
|
|
2366
|
+
return web.json_response({
|
|
2367
|
+
"id": response_id,
|
|
2368
|
+
"object": "response",
|
|
2369
|
+
"deleted": True,
|
|
2370
|
+
})
|
|
2371
|
+
|
|
2372
|
+
# ------------------------------------------------------------------
|
|
2373
|
+
# Cron jobs API
|
|
2374
|
+
# ------------------------------------------------------------------
|
|
2375
|
+
|
|
2376
|
+
_JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}")
|
|
2377
|
+
# Allowed fields for update — prevents clients injecting arbitrary keys
|
|
2378
|
+
_UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"}
|
|
2379
|
+
_MAX_NAME_LENGTH = 200
|
|
2380
|
+
_MAX_PROMPT_LENGTH = 5000
|
|
2381
|
+
|
|
2382
|
+
@staticmethod
|
|
2383
|
+
def _check_jobs_available() -> Optional["web.Response"]:
|
|
2384
|
+
"""Return error response if cron module isn't available."""
|
|
2385
|
+
if not _CRON_AVAILABLE:
|
|
2386
|
+
return web.json_response(
|
|
2387
|
+
{"error": "Cron module not available"}, status=501,
|
|
2388
|
+
)
|
|
2389
|
+
return None
|
|
2390
|
+
|
|
2391
|
+
def _check_job_id(self, request: "web.Request") -> tuple:
|
|
2392
|
+
"""Validate and extract job_id. Returns (job_id, error_response)."""
|
|
2393
|
+
job_id = request.match_info["job_id"]
|
|
2394
|
+
if not self._JOB_ID_RE.fullmatch(job_id):
|
|
2395
|
+
return job_id, web.json_response(
|
|
2396
|
+
{"error": "Invalid job ID format"}, status=400,
|
|
2397
|
+
)
|
|
2398
|
+
return job_id, None
|
|
2399
|
+
|
|
2400
|
+
async def _handle_list_jobs(self, request: "web.Request") -> "web.Response":
|
|
2401
|
+
"""GET /api/jobs — list all cron jobs."""
|
|
2402
|
+
auth_err = self._check_auth(request)
|
|
2403
|
+
if auth_err:
|
|
2404
|
+
return auth_err
|
|
2405
|
+
cron_err = self._check_jobs_available()
|
|
2406
|
+
if cron_err:
|
|
2407
|
+
return cron_err
|
|
2408
|
+
try:
|
|
2409
|
+
include_disabled = request.query.get("include_disabled", "").lower() in {"true", "1"}
|
|
2410
|
+
jobs = _cron_list(include_disabled=include_disabled)
|
|
2411
|
+
return web.json_response({"jobs": jobs})
|
|
2412
|
+
except Exception as e:
|
|
2413
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2414
|
+
|
|
2415
|
+
async def _handle_create_job(self, request: "web.Request") -> "web.Response":
|
|
2416
|
+
"""POST /api/jobs — create a new cron job."""
|
|
2417
|
+
auth_err = self._check_auth(request)
|
|
2418
|
+
if auth_err:
|
|
2419
|
+
return auth_err
|
|
2420
|
+
cron_err = self._check_jobs_available()
|
|
2421
|
+
if cron_err:
|
|
2422
|
+
return cron_err
|
|
2423
|
+
try:
|
|
2424
|
+
body = await request.json()
|
|
2425
|
+
name = (body.get("name") or "").strip()
|
|
2426
|
+
schedule = (body.get("schedule") or "").strip()
|
|
2427
|
+
prompt = body.get("prompt", "")
|
|
2428
|
+
deliver = body.get("deliver", "local")
|
|
2429
|
+
skills = body.get("skills")
|
|
2430
|
+
repeat = body.get("repeat")
|
|
2431
|
+
|
|
2432
|
+
if not name:
|
|
2433
|
+
return web.json_response({"error": "Name is required"}, status=400)
|
|
2434
|
+
if len(name) > self._MAX_NAME_LENGTH:
|
|
2435
|
+
return web.json_response(
|
|
2436
|
+
{"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400,
|
|
2437
|
+
)
|
|
2438
|
+
if not schedule:
|
|
2439
|
+
return web.json_response({"error": "Schedule is required"}, status=400)
|
|
2440
|
+
if len(prompt) > self._MAX_PROMPT_LENGTH:
|
|
2441
|
+
return web.json_response(
|
|
2442
|
+
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
|
2443
|
+
)
|
|
2444
|
+
if repeat is not None and (not isinstance(repeat, int) or repeat < 1):
|
|
2445
|
+
return web.json_response({"error": "Repeat must be a positive integer"}, status=400)
|
|
2446
|
+
|
|
2447
|
+
kwargs = {
|
|
2448
|
+
"prompt": prompt,
|
|
2449
|
+
"schedule": schedule,
|
|
2450
|
+
"name": name,
|
|
2451
|
+
"deliver": deliver,
|
|
2452
|
+
}
|
|
2453
|
+
if skills:
|
|
2454
|
+
kwargs["skills"] = skills
|
|
2455
|
+
if repeat is not None:
|
|
2456
|
+
kwargs["repeat"] = repeat
|
|
2457
|
+
|
|
2458
|
+
job = _cron_create(**kwargs)
|
|
2459
|
+
return web.json_response({"job": job})
|
|
2460
|
+
except Exception as e:
|
|
2461
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2462
|
+
|
|
2463
|
+
async def _handle_get_job(self, request: "web.Request") -> "web.Response":
|
|
2464
|
+
"""GET /api/jobs/{job_id} — get a single cron job."""
|
|
2465
|
+
auth_err = self._check_auth(request)
|
|
2466
|
+
if auth_err:
|
|
2467
|
+
return auth_err
|
|
2468
|
+
cron_err = self._check_jobs_available()
|
|
2469
|
+
if cron_err:
|
|
2470
|
+
return cron_err
|
|
2471
|
+
job_id, id_err = self._check_job_id(request)
|
|
2472
|
+
if id_err:
|
|
2473
|
+
return id_err
|
|
2474
|
+
try:
|
|
2475
|
+
job = _cron_get(job_id)
|
|
2476
|
+
if not job:
|
|
2477
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2478
|
+
return web.json_response({"job": job})
|
|
2479
|
+
except Exception as e:
|
|
2480
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2481
|
+
|
|
2482
|
+
async def _handle_update_job(self, request: "web.Request") -> "web.Response":
|
|
2483
|
+
"""PATCH /api/jobs/{job_id} — update a cron job."""
|
|
2484
|
+
auth_err = self._check_auth(request)
|
|
2485
|
+
if auth_err:
|
|
2486
|
+
return auth_err
|
|
2487
|
+
cron_err = self._check_jobs_available()
|
|
2488
|
+
if cron_err:
|
|
2489
|
+
return cron_err
|
|
2490
|
+
job_id, id_err = self._check_job_id(request)
|
|
2491
|
+
if id_err:
|
|
2492
|
+
return id_err
|
|
2493
|
+
try:
|
|
2494
|
+
body = await request.json()
|
|
2495
|
+
# Whitelist allowed fields to prevent arbitrary key injection
|
|
2496
|
+
sanitized = {k: v for k, v in body.items() if k in self._UPDATE_ALLOWED_FIELDS}
|
|
2497
|
+
if not sanitized:
|
|
2498
|
+
return web.json_response({"error": "No valid fields to update"}, status=400)
|
|
2499
|
+
# Validate lengths if present
|
|
2500
|
+
if "name" in sanitized and len(sanitized["name"]) > self._MAX_NAME_LENGTH:
|
|
2501
|
+
return web.json_response(
|
|
2502
|
+
{"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400,
|
|
2503
|
+
)
|
|
2504
|
+
if "prompt" in sanitized and len(sanitized["prompt"]) > self._MAX_PROMPT_LENGTH:
|
|
2505
|
+
return web.json_response(
|
|
2506
|
+
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
|
2507
|
+
)
|
|
2508
|
+
job = _cron_update(job_id, sanitized)
|
|
2509
|
+
if not job:
|
|
2510
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2511
|
+
return web.json_response({"job": job})
|
|
2512
|
+
except Exception as e:
|
|
2513
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2514
|
+
|
|
2515
|
+
async def _handle_delete_job(self, request: "web.Request") -> "web.Response":
|
|
2516
|
+
"""DELETE /api/jobs/{job_id} — delete a cron job."""
|
|
2517
|
+
auth_err = self._check_auth(request)
|
|
2518
|
+
if auth_err:
|
|
2519
|
+
return auth_err
|
|
2520
|
+
cron_err = self._check_jobs_available()
|
|
2521
|
+
if cron_err:
|
|
2522
|
+
return cron_err
|
|
2523
|
+
job_id, id_err = self._check_job_id(request)
|
|
2524
|
+
if id_err:
|
|
2525
|
+
return id_err
|
|
2526
|
+
try:
|
|
2527
|
+
success = _cron_remove(job_id)
|
|
2528
|
+
if not success:
|
|
2529
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2530
|
+
return web.json_response({"ok": True})
|
|
2531
|
+
except Exception as e:
|
|
2532
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2533
|
+
|
|
2534
|
+
async def _handle_pause_job(self, request: "web.Request") -> "web.Response":
|
|
2535
|
+
"""POST /api/jobs/{job_id}/pause — pause a cron job."""
|
|
2536
|
+
auth_err = self._check_auth(request)
|
|
2537
|
+
if auth_err:
|
|
2538
|
+
return auth_err
|
|
2539
|
+
cron_err = self._check_jobs_available()
|
|
2540
|
+
if cron_err:
|
|
2541
|
+
return cron_err
|
|
2542
|
+
job_id, id_err = self._check_job_id(request)
|
|
2543
|
+
if id_err:
|
|
2544
|
+
return id_err
|
|
2545
|
+
try:
|
|
2546
|
+
job = _cron_pause(job_id)
|
|
2547
|
+
if not job:
|
|
2548
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2549
|
+
return web.json_response({"job": job})
|
|
2550
|
+
except Exception as e:
|
|
2551
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2552
|
+
|
|
2553
|
+
async def _handle_resume_job(self, request: "web.Request") -> "web.Response":
|
|
2554
|
+
"""POST /api/jobs/{job_id}/resume — resume a paused cron job."""
|
|
2555
|
+
auth_err = self._check_auth(request)
|
|
2556
|
+
if auth_err:
|
|
2557
|
+
return auth_err
|
|
2558
|
+
cron_err = self._check_jobs_available()
|
|
2559
|
+
if cron_err:
|
|
2560
|
+
return cron_err
|
|
2561
|
+
job_id, id_err = self._check_job_id(request)
|
|
2562
|
+
if id_err:
|
|
2563
|
+
return id_err
|
|
2564
|
+
try:
|
|
2565
|
+
job = _cron_resume(job_id)
|
|
2566
|
+
if not job:
|
|
2567
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2568
|
+
return web.json_response({"job": job})
|
|
2569
|
+
except Exception as e:
|
|
2570
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2571
|
+
|
|
2572
|
+
async def _handle_run_job(self, request: "web.Request") -> "web.Response":
|
|
2573
|
+
"""POST /api/jobs/{job_id}/run — trigger immediate execution."""
|
|
2574
|
+
auth_err = self._check_auth(request)
|
|
2575
|
+
if auth_err:
|
|
2576
|
+
return auth_err
|
|
2577
|
+
cron_err = self._check_jobs_available()
|
|
2578
|
+
if cron_err:
|
|
2579
|
+
return cron_err
|
|
2580
|
+
job_id, id_err = self._check_job_id(request)
|
|
2581
|
+
if id_err:
|
|
2582
|
+
return id_err
|
|
2583
|
+
try:
|
|
2584
|
+
job = _cron_trigger(job_id)
|
|
2585
|
+
if not job:
|
|
2586
|
+
return web.json_response({"error": "Job not found"}, status=404)
|
|
2587
|
+
return web.json_response({"job": job})
|
|
2588
|
+
except Exception as e:
|
|
2589
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
2590
|
+
|
|
2591
|
+
# ------------------------------------------------------------------
|
|
2592
|
+
# Output extraction helper
|
|
2593
|
+
# ------------------------------------------------------------------
|
|
2594
|
+
|
|
2595
|
+
@staticmethod
|
|
2596
|
+
def _build_response_conversation_history(
|
|
2597
|
+
conversation_history: List[Dict[str, Any]],
|
|
2598
|
+
user_message: Any,
|
|
2599
|
+
result: Dict[str, Any],
|
|
2600
|
+
final_response: Any,
|
|
2601
|
+
) -> List[Dict[str, Any]]:
|
|
2602
|
+
"""Build the stored Responses transcript without duplicating history."""
|
|
2603
|
+
prior = list(conversation_history)
|
|
2604
|
+
current_user = {"role": "user", "content": user_message}
|
|
2605
|
+
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
|
2606
|
+
|
|
2607
|
+
if isinstance(agent_messages, list) and agent_messages:
|
|
2608
|
+
turn_start = APIServerAdapter._response_messages_turn_start_index(
|
|
2609
|
+
conversation_history,
|
|
2610
|
+
user_message,
|
|
2611
|
+
result,
|
|
2612
|
+
)
|
|
2613
|
+
if turn_start:
|
|
2614
|
+
return list(agent_messages)
|
|
2615
|
+
|
|
2616
|
+
full_history = prior
|
|
2617
|
+
full_history.append(current_user)
|
|
2618
|
+
full_history.extend(agent_messages)
|
|
2619
|
+
return full_history
|
|
2620
|
+
|
|
2621
|
+
full_history = prior
|
|
2622
|
+
full_history.append(current_user)
|
|
2623
|
+
full_history.append({"role": "assistant", "content": final_response})
|
|
2624
|
+
return full_history
|
|
2625
|
+
|
|
2626
|
+
@staticmethod
|
|
2627
|
+
def _response_messages_turn_start_index(
|
|
2628
|
+
conversation_history: List[Dict[str, Any]],
|
|
2629
|
+
user_message: Any,
|
|
2630
|
+
result: Dict[str, Any],
|
|
2631
|
+
) -> int:
|
|
2632
|
+
"""Detect transcript-shaped result["messages"] and return turn start."""
|
|
2633
|
+
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
|
2634
|
+
if not isinstance(agent_messages, list) or not agent_messages:
|
|
2635
|
+
return 0
|
|
2636
|
+
|
|
2637
|
+
prior = list(conversation_history)
|
|
2638
|
+
current_user = {"role": "user", "content": user_message}
|
|
2639
|
+
expected_prefix = prior + [current_user]
|
|
2640
|
+
if agent_messages[:len(expected_prefix)] == expected_prefix:
|
|
2641
|
+
return len(expected_prefix)
|
|
2642
|
+
if prior and agent_messages[:len(prior)] == prior:
|
|
2643
|
+
return len(prior)
|
|
2644
|
+
return 0
|
|
2645
|
+
|
|
2646
|
+
@staticmethod
|
|
2647
|
+
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
|
2648
|
+
"""
|
|
2649
|
+
Build the output item array from the agent's messages.
|
|
2650
|
+
|
|
2651
|
+
Walks *result["messages"]* starting at *start_index* and emits:
|
|
2652
|
+
- ``function_call`` items for each tool_call on assistant messages
|
|
2653
|
+
- ``function_call_output`` items for each tool-role message
|
|
2654
|
+
- a final ``message`` item with the assistant's text reply
|
|
2655
|
+
"""
|
|
2656
|
+
items: List[Dict[str, Any]] = []
|
|
2657
|
+
messages = result.get("messages", [])
|
|
2658
|
+
if start_index > 0:
|
|
2659
|
+
messages = messages[start_index:]
|
|
2660
|
+
|
|
2661
|
+
for msg in messages:
|
|
2662
|
+
role = msg.get("role")
|
|
2663
|
+
if role == "assistant" and msg.get("tool_calls"):
|
|
2664
|
+
for tc in msg["tool_calls"]:
|
|
2665
|
+
func = tc.get("function", {})
|
|
2666
|
+
items.append({
|
|
2667
|
+
"type": "function_call",
|
|
2668
|
+
"name": func.get("name", ""),
|
|
2669
|
+
"arguments": func.get("arguments", ""),
|
|
2670
|
+
"call_id": tc.get("id", ""),
|
|
2671
|
+
})
|
|
2672
|
+
elif role == "tool":
|
|
2673
|
+
items.append({
|
|
2674
|
+
"type": "function_call_output",
|
|
2675
|
+
"call_id": msg.get("tool_call_id", ""),
|
|
2676
|
+
"output": msg.get("content", ""),
|
|
2677
|
+
})
|
|
2678
|
+
|
|
2679
|
+
# Final assistant message
|
|
2680
|
+
final = result.get("final_response", "")
|
|
2681
|
+
if not final:
|
|
2682
|
+
final = result.get("error", "(No response generated)")
|
|
2683
|
+
|
|
2684
|
+
items.append({
|
|
2685
|
+
"type": "message",
|
|
2686
|
+
"role": "assistant",
|
|
2687
|
+
"content": [
|
|
2688
|
+
{
|
|
2689
|
+
"type": "output_text",
|
|
2690
|
+
"text": final,
|
|
2691
|
+
}
|
|
2692
|
+
],
|
|
2693
|
+
})
|
|
2694
|
+
return items
|
|
2695
|
+
|
|
2696
|
+
# ------------------------------------------------------------------
|
|
2697
|
+
# Agent execution
|
|
2698
|
+
# ------------------------------------------------------------------
|
|
2699
|
+
|
|
2700
|
+
async def _run_agent(
|
|
2701
|
+
self,
|
|
2702
|
+
user_message: str,
|
|
2703
|
+
conversation_history: List[Dict[str, str]],
|
|
2704
|
+
ephemeral_system_prompt: Optional[str] = None,
|
|
2705
|
+
session_id: Optional[str] = None,
|
|
2706
|
+
stream_delta_callback=None,
|
|
2707
|
+
tool_progress_callback=None,
|
|
2708
|
+
tool_start_callback=None,
|
|
2709
|
+
tool_complete_callback=None,
|
|
2710
|
+
agent_ref: Optional[list] = None,
|
|
2711
|
+
gateway_session_key: Optional[str] = None,
|
|
2712
|
+
) -> tuple:
|
|
2713
|
+
"""
|
|
2714
|
+
Create an agent and run a conversation in a thread executor.
|
|
2715
|
+
|
|
2716
|
+
Returns ``(result_dict, usage_dict)`` where *usage_dict* contains
|
|
2717
|
+
``input_tokens``, ``output_tokens`` and ``total_tokens``.
|
|
2718
|
+
|
|
2719
|
+
If *agent_ref* is a one-element list, the AIAgent instance is stored
|
|
2720
|
+
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
|
|
2721
|
+
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
|
|
2722
|
+
another thread to stop in-progress LLM calls.
|
|
2723
|
+
"""
|
|
2724
|
+
loop = asyncio.get_running_loop()
|
|
2725
|
+
|
|
2726
|
+
def _run():
|
|
2727
|
+
agent = self._create_agent(
|
|
2728
|
+
ephemeral_system_prompt=ephemeral_system_prompt,
|
|
2729
|
+
session_id=session_id,
|
|
2730
|
+
stream_delta_callback=stream_delta_callback,
|
|
2731
|
+
tool_progress_callback=tool_progress_callback,
|
|
2732
|
+
tool_start_callback=tool_start_callback,
|
|
2733
|
+
tool_complete_callback=tool_complete_callback,
|
|
2734
|
+
gateway_session_key=gateway_session_key,
|
|
2735
|
+
)
|
|
2736
|
+
if agent_ref is not None:
|
|
2737
|
+
agent_ref[0] = agent
|
|
2738
|
+
effective_task_id = session_id or str(uuid.uuid4())
|
|
2739
|
+
result = agent.run_conversation(
|
|
2740
|
+
user_message=user_message,
|
|
2741
|
+
conversation_history=conversation_history,
|
|
2742
|
+
task_id=effective_task_id,
|
|
2743
|
+
)
|
|
2744
|
+
usage = {
|
|
2745
|
+
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
|
2746
|
+
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
|
2747
|
+
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
|
2748
|
+
}
|
|
2749
|
+
# Include the effective session ID in the result so callers
|
|
2750
|
+
# (e.g. X-Hermes-Session-Id header) can track compression-
|
|
2751
|
+
# triggered session rotations. (#16938)
|
|
2752
|
+
_eff_sid = getattr(agent, "session_id", session_id)
|
|
2753
|
+
if isinstance(_eff_sid, str) and _eff_sid:
|
|
2754
|
+
result["session_id"] = _eff_sid
|
|
2755
|
+
return result, usage
|
|
2756
|
+
|
|
2757
|
+
return await loop.run_in_executor(None, _run)
|
|
2758
|
+
|
|
2759
|
+
# ------------------------------------------------------------------
|
|
2760
|
+
# /v1/runs — structured event streaming
|
|
2761
|
+
# ------------------------------------------------------------------
|
|
2762
|
+
|
|
2763
|
+
_MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation
|
|
2764
|
+
_RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept
|
|
2765
|
+
_RUN_STATUS_TTL = 3600 # seconds to retain terminal run status for polling
|
|
2766
|
+
|
|
2767
|
+
def _set_run_status(self, run_id: str, status: str, **fields: Any) -> Dict[str, Any]:
|
|
2768
|
+
"""Update pollable run status without exposing private agent objects."""
|
|
2769
|
+
now = time.time()
|
|
2770
|
+
current = self._run_statuses.get(run_id, {})
|
|
2771
|
+
current.update({
|
|
2772
|
+
"object": "hermes.run",
|
|
2773
|
+
"run_id": run_id,
|
|
2774
|
+
"status": status,
|
|
2775
|
+
"updated_at": now,
|
|
2776
|
+
})
|
|
2777
|
+
current.setdefault("created_at", fields.pop("created_at", now))
|
|
2778
|
+
current.update(fields)
|
|
2779
|
+
self._run_statuses[run_id] = current
|
|
2780
|
+
return current
|
|
2781
|
+
|
|
2782
|
+
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
|
|
2783
|
+
"""Return a tool_progress_callback that pushes structured events to the run's SSE queue."""
|
|
2784
|
+
def _push(event: Dict[str, Any]) -> None:
|
|
2785
|
+
self._set_run_status(
|
|
2786
|
+
run_id,
|
|
2787
|
+
self._run_statuses.get(run_id, {}).get("status", "running"),
|
|
2788
|
+
last_event=event.get("event"),
|
|
2789
|
+
)
|
|
2790
|
+
q = self._run_streams.get(run_id)
|
|
2791
|
+
if q is None:
|
|
2792
|
+
return
|
|
2793
|
+
try:
|
|
2794
|
+
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
2795
|
+
except Exception:
|
|
2796
|
+
pass
|
|
2797
|
+
|
|
2798
|
+
def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
|
|
2799
|
+
ts = time.time()
|
|
2800
|
+
if event_type == "tool.started":
|
|
2801
|
+
_push({
|
|
2802
|
+
"event": "tool.started",
|
|
2803
|
+
"run_id": run_id,
|
|
2804
|
+
"timestamp": ts,
|
|
2805
|
+
"tool": tool_name,
|
|
2806
|
+
"preview": preview,
|
|
2807
|
+
})
|
|
2808
|
+
elif event_type == "tool.completed":
|
|
2809
|
+
_push({
|
|
2810
|
+
"event": "tool.completed",
|
|
2811
|
+
"run_id": run_id,
|
|
2812
|
+
"timestamp": ts,
|
|
2813
|
+
"tool": tool_name,
|
|
2814
|
+
"duration": round(kwargs.get("duration", 0), 3),
|
|
2815
|
+
"error": kwargs.get("is_error", False),
|
|
2816
|
+
})
|
|
2817
|
+
elif event_type == "reasoning.available":
|
|
2818
|
+
_push({
|
|
2819
|
+
"event": "reasoning.available",
|
|
2820
|
+
"run_id": run_id,
|
|
2821
|
+
"timestamp": ts,
|
|
2822
|
+
"text": preview or "",
|
|
2823
|
+
})
|
|
2824
|
+
# _thinking and subagent_progress are intentionally not forwarded
|
|
2825
|
+
|
|
2826
|
+
return _callback
|
|
2827
|
+
|
|
2828
|
+
async def _handle_runs(self, request: "web.Request") -> "web.Response":
|
|
2829
|
+
"""POST /v1/runs — start an agent run, return run_id immediately."""
|
|
2830
|
+
auth_err = self._check_auth(request)
|
|
2831
|
+
if auth_err:
|
|
2832
|
+
return auth_err
|
|
2833
|
+
|
|
2834
|
+
# Long-term memory scope header (see chat_completions for details).
|
|
2835
|
+
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
2836
|
+
if key_err is not None:
|
|
2837
|
+
return key_err
|
|
2838
|
+
|
|
2839
|
+
# Enforce concurrency limit
|
|
2840
|
+
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
|
|
2841
|
+
return web.json_response(
|
|
2842
|
+
_openai_error(f"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})", code="rate_limit_exceeded"),
|
|
2843
|
+
status=429,
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
try:
|
|
2847
|
+
body = await request.json()
|
|
2848
|
+
except Exception:
|
|
2849
|
+
return web.json_response(_openai_error("Invalid JSON"), status=400)
|
|
2850
|
+
|
|
2851
|
+
raw_input = body.get("input")
|
|
2852
|
+
if not raw_input:
|
|
2853
|
+
return web.json_response(_openai_error("Missing 'input' field"), status=400)
|
|
2854
|
+
|
|
2855
|
+
user_message = raw_input if isinstance(raw_input, str) else (raw_input[-1].get("content", "") if isinstance(raw_input, list) else "")
|
|
2856
|
+
if not user_message:
|
|
2857
|
+
return web.json_response(_openai_error("No user message found in input"), status=400)
|
|
2858
|
+
|
|
2859
|
+
instructions = body.get("instructions")
|
|
2860
|
+
previous_response_id = body.get("previous_response_id")
|
|
2861
|
+
|
|
2862
|
+
# Accept explicit conversation_history from the request body.
|
|
2863
|
+
# Precedence: explicit conversation_history > previous_response_id.
|
|
2864
|
+
conversation_history: List[Dict[str, str]] = []
|
|
2865
|
+
raw_history = body.get("conversation_history")
|
|
2866
|
+
if raw_history:
|
|
2867
|
+
if not isinstance(raw_history, list):
|
|
2868
|
+
return web.json_response(
|
|
2869
|
+
_openai_error("'conversation_history' must be an array of message objects"),
|
|
2870
|
+
status=400,
|
|
2871
|
+
)
|
|
2872
|
+
for i, entry in enumerate(raw_history):
|
|
2873
|
+
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
|
|
2874
|
+
return web.json_response(
|
|
2875
|
+
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
|
|
2876
|
+
status=400,
|
|
2877
|
+
)
|
|
2878
|
+
conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])})
|
|
2879
|
+
if previous_response_id:
|
|
2880
|
+
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
|
2881
|
+
|
|
2882
|
+
stored_session_id = None
|
|
2883
|
+
if not conversation_history and previous_response_id:
|
|
2884
|
+
stored = self._response_store.get(previous_response_id)
|
|
2885
|
+
if stored:
|
|
2886
|
+
conversation_history = list(stored.get("conversation_history", []))
|
|
2887
|
+
stored_session_id = stored.get("session_id")
|
|
2888
|
+
if instructions is None:
|
|
2889
|
+
instructions = stored.get("instructions")
|
|
2890
|
+
|
|
2891
|
+
# When input is a multi-message array, extract all but the last
|
|
2892
|
+
# message as conversation history (the last becomes user_message).
|
|
2893
|
+
# Only fires when no explicit history was provided.
|
|
2894
|
+
if not conversation_history and isinstance(raw_input, list) and len(raw_input) > 1:
|
|
2895
|
+
for msg in raw_input[:-1]:
|
|
2896
|
+
if isinstance(msg, dict) and msg.get("role") and msg.get("content"):
|
|
2897
|
+
content = msg["content"]
|
|
2898
|
+
if isinstance(content, list):
|
|
2899
|
+
# Flatten multi-part content blocks to text
|
|
2900
|
+
content = " ".join(
|
|
2901
|
+
part.get("text", "") for part in content
|
|
2902
|
+
if isinstance(part, dict) and part.get("type") == "text"
|
|
2903
|
+
)
|
|
2904
|
+
conversation_history.append({"role": msg["role"], "content": str(content)})
|
|
2905
|
+
|
|
2906
|
+
run_id = f"run_{uuid.uuid4().hex}"
|
|
2907
|
+
session_id = body.get("session_id") or stored_session_id or run_id
|
|
2908
|
+
approval_session_key = gateway_session_key or session_id or run_id
|
|
2909
|
+
ephemeral_system_prompt = instructions
|
|
2910
|
+
loop = asyncio.get_running_loop()
|
|
2911
|
+
q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue()
|
|
2912
|
+
created_at = time.time()
|
|
2913
|
+
self._run_streams[run_id] = q
|
|
2914
|
+
self._run_streams_created[run_id] = created_at
|
|
2915
|
+
self._run_approval_sessions[run_id] = approval_session_key
|
|
2916
|
+
|
|
2917
|
+
event_cb = self._make_run_event_callback(run_id, loop)
|
|
2918
|
+
|
|
2919
|
+
# Also wire stream_delta_callback so message.delta events flow through.
|
|
2920
|
+
def _text_cb(delta: Optional[str]) -> None:
|
|
2921
|
+
if delta is None:
|
|
2922
|
+
return
|
|
2923
|
+
try:
|
|
2924
|
+
loop.call_soon_threadsafe(q.put_nowait, {
|
|
2925
|
+
"event": "message.delta",
|
|
2926
|
+
"run_id": run_id,
|
|
2927
|
+
"timestamp": time.time(),
|
|
2928
|
+
"delta": delta,
|
|
2929
|
+
})
|
|
2930
|
+
except Exception:
|
|
2931
|
+
pass
|
|
2932
|
+
|
|
2933
|
+
self._set_run_status(
|
|
2934
|
+
run_id,
|
|
2935
|
+
"queued",
|
|
2936
|
+
created_at=created_at,
|
|
2937
|
+
session_id=session_id,
|
|
2938
|
+
model=body.get("model", self._model_name),
|
|
2939
|
+
)
|
|
2940
|
+
|
|
2941
|
+
async def _run_and_close():
|
|
2942
|
+
try:
|
|
2943
|
+
self._set_run_status(run_id, "running")
|
|
2944
|
+
agent = self._create_agent(
|
|
2945
|
+
ephemeral_system_prompt=ephemeral_system_prompt,
|
|
2946
|
+
session_id=session_id,
|
|
2947
|
+
stream_delta_callback=_text_cb,
|
|
2948
|
+
tool_progress_callback=event_cb,
|
|
2949
|
+
gateway_session_key=gateway_session_key,
|
|
2950
|
+
)
|
|
2951
|
+
self._active_run_agents[run_id] = agent
|
|
2952
|
+
|
|
2953
|
+
def _approval_notify(approval_data: Dict[str, Any]) -> None:
|
|
2954
|
+
event = dict(approval_data or {})
|
|
2955
|
+
event.update({
|
|
2956
|
+
"event": "approval.request",
|
|
2957
|
+
"run_id": run_id,
|
|
2958
|
+
"timestamp": time.time(),
|
|
2959
|
+
"choices": ["once", "session", "always", "deny"],
|
|
2960
|
+
})
|
|
2961
|
+
self._set_run_status(
|
|
2962
|
+
run_id,
|
|
2963
|
+
"waiting_for_approval",
|
|
2964
|
+
last_event="approval.request",
|
|
2965
|
+
)
|
|
2966
|
+
try:
|
|
2967
|
+
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
2968
|
+
except Exception:
|
|
2969
|
+
pass
|
|
2970
|
+
|
|
2971
|
+
def _run_sync():
|
|
2972
|
+
from gateway.session_context import clear_session_vars, set_session_vars
|
|
2973
|
+
from tools.approval import (
|
|
2974
|
+
register_gateway_notify,
|
|
2975
|
+
reset_current_session_key,
|
|
2976
|
+
set_current_session_key,
|
|
2977
|
+
unregister_gateway_notify,
|
|
2978
|
+
)
|
|
2979
|
+
|
|
2980
|
+
effective_task_id = session_id or run_id
|
|
2981
|
+
approval_token = None
|
|
2982
|
+
session_tokens = []
|
|
2983
|
+
try:
|
|
2984
|
+
# Bind approval/session identity for this API run via
|
|
2985
|
+
# contextvars so concurrent runs do not share process
|
|
2986
|
+
# environment state.
|
|
2987
|
+
approval_token = set_current_session_key(approval_session_key)
|
|
2988
|
+
session_tokens = set_session_vars(
|
|
2989
|
+
platform="api_server",
|
|
2990
|
+
session_key=approval_session_key,
|
|
2991
|
+
)
|
|
2992
|
+
register_gateway_notify(approval_session_key, _approval_notify)
|
|
2993
|
+
r = agent.run_conversation(
|
|
2994
|
+
user_message=user_message,
|
|
2995
|
+
conversation_history=conversation_history,
|
|
2996
|
+
task_id=effective_task_id,
|
|
2997
|
+
)
|
|
2998
|
+
finally:
|
|
2999
|
+
try:
|
|
3000
|
+
unregister_gateway_notify(approval_session_key)
|
|
3001
|
+
finally:
|
|
3002
|
+
if approval_token is not None:
|
|
3003
|
+
try:
|
|
3004
|
+
reset_current_session_key(approval_token)
|
|
3005
|
+
except Exception:
|
|
3006
|
+
pass
|
|
3007
|
+
if session_tokens:
|
|
3008
|
+
try:
|
|
3009
|
+
clear_session_vars(session_tokens)
|
|
3010
|
+
except Exception:
|
|
3011
|
+
pass
|
|
3012
|
+
u = {
|
|
3013
|
+
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
|
3014
|
+
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
|
3015
|
+
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
|
3016
|
+
}
|
|
3017
|
+
return r, u
|
|
3018
|
+
|
|
3019
|
+
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
|
|
3020
|
+
# Check for structured failure (non-retryable client errors like
|
|
3021
|
+
# 401/400 return failed=True instead of raising, so the except
|
|
3022
|
+
# block below never fires — issue #15561).
|
|
3023
|
+
if isinstance(result, dict) and result.get("failed"):
|
|
3024
|
+
error_msg = result.get("error") or "agent run failed"
|
|
3025
|
+
q.put_nowait({
|
|
3026
|
+
"event": "run.failed",
|
|
3027
|
+
"run_id": run_id,
|
|
3028
|
+
"timestamp": time.time(),
|
|
3029
|
+
"error": error_msg,
|
|
3030
|
+
})
|
|
3031
|
+
self._set_run_status(
|
|
3032
|
+
run_id,
|
|
3033
|
+
"failed",
|
|
3034
|
+
error=error_msg,
|
|
3035
|
+
last_event="run.failed",
|
|
3036
|
+
)
|
|
3037
|
+
else:
|
|
3038
|
+
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
3039
|
+
q.put_nowait({
|
|
3040
|
+
"event": "run.completed",
|
|
3041
|
+
"run_id": run_id,
|
|
3042
|
+
"timestamp": time.time(),
|
|
3043
|
+
"output": final_response,
|
|
3044
|
+
"usage": usage,
|
|
3045
|
+
})
|
|
3046
|
+
self._set_run_status(
|
|
3047
|
+
run_id,
|
|
3048
|
+
"completed",
|
|
3049
|
+
output=final_response,
|
|
3050
|
+
usage=usage,
|
|
3051
|
+
last_event="run.completed",
|
|
3052
|
+
)
|
|
3053
|
+
except asyncio.CancelledError:
|
|
3054
|
+
self._set_run_status(
|
|
3055
|
+
run_id,
|
|
3056
|
+
"cancelled",
|
|
3057
|
+
last_event="run.cancelled",
|
|
3058
|
+
)
|
|
3059
|
+
try:
|
|
3060
|
+
q.put_nowait({
|
|
3061
|
+
"event": "run.cancelled",
|
|
3062
|
+
"run_id": run_id,
|
|
3063
|
+
"timestamp": time.time(),
|
|
3064
|
+
})
|
|
3065
|
+
except Exception:
|
|
3066
|
+
pass
|
|
3067
|
+
raise
|
|
3068
|
+
except Exception as exc:
|
|
3069
|
+
logger.exception("[api_server] run %s failed", run_id)
|
|
3070
|
+
self._set_run_status(
|
|
3071
|
+
run_id,
|
|
3072
|
+
"failed",
|
|
3073
|
+
error=str(exc),
|
|
3074
|
+
last_event="run.failed",
|
|
3075
|
+
)
|
|
3076
|
+
try:
|
|
3077
|
+
q.put_nowait({
|
|
3078
|
+
"event": "run.failed",
|
|
3079
|
+
"run_id": run_id,
|
|
3080
|
+
"timestamp": time.time(),
|
|
3081
|
+
"error": str(exc),
|
|
3082
|
+
})
|
|
3083
|
+
except Exception:
|
|
3084
|
+
pass
|
|
3085
|
+
finally:
|
|
3086
|
+
# If the asyncio wrapper is cancelled (for example via
|
|
3087
|
+
# /stop), the executor thread can still be blocked waiting
|
|
3088
|
+
# on an approval Event. Unregistering here releases those
|
|
3089
|
+
# waits immediately; the in-thread unregister is harmlessly
|
|
3090
|
+
# idempotent on normal completion.
|
|
3091
|
+
try:
|
|
3092
|
+
from tools.approval import unregister_gateway_notify
|
|
3093
|
+
|
|
3094
|
+
unregister_gateway_notify(approval_session_key)
|
|
3095
|
+
except Exception:
|
|
3096
|
+
pass
|
|
3097
|
+
# Sentinel: signal SSE stream to close
|
|
3098
|
+
try:
|
|
3099
|
+
q.put_nowait(None)
|
|
3100
|
+
except Exception:
|
|
3101
|
+
pass
|
|
3102
|
+
self._active_run_agents.pop(run_id, None)
|
|
3103
|
+
self._active_run_tasks.pop(run_id, None)
|
|
3104
|
+
self._run_approval_sessions.pop(run_id, None)
|
|
3105
|
+
|
|
3106
|
+
task = asyncio.create_task(_run_and_close())
|
|
3107
|
+
self._active_run_tasks[run_id] = task
|
|
3108
|
+
try:
|
|
3109
|
+
self._background_tasks.add(task)
|
|
3110
|
+
except TypeError:
|
|
3111
|
+
pass
|
|
3112
|
+
if hasattr(task, "add_done_callback"):
|
|
3113
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
3114
|
+
|
|
3115
|
+
response_headers = (
|
|
3116
|
+
{"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {}
|
|
3117
|
+
)
|
|
3118
|
+
return web.json_response(
|
|
3119
|
+
{"run_id": run_id, "status": "started"},
|
|
3120
|
+
status=202,
|
|
3121
|
+
headers=response_headers,
|
|
3122
|
+
)
|
|
3123
|
+
|
|
3124
|
+
async def _handle_get_run(self, request: "web.Request") -> "web.Response":
|
|
3125
|
+
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""
|
|
3126
|
+
auth_err = self._check_auth(request)
|
|
3127
|
+
if auth_err:
|
|
3128
|
+
return auth_err
|
|
3129
|
+
|
|
3130
|
+
run_id = request.match_info["run_id"]
|
|
3131
|
+
status = self._run_statuses.get(run_id)
|
|
3132
|
+
if status is None:
|
|
3133
|
+
return web.json_response(
|
|
3134
|
+
_openai_error(f"Run not found: {run_id}", code="run_not_found"),
|
|
3135
|
+
status=404,
|
|
3136
|
+
)
|
|
3137
|
+
return web.json_response(status)
|
|
3138
|
+
|
|
3139
|
+
async def _handle_run_events(self, request: "web.Request") -> "web.StreamResponse":
|
|
3140
|
+
"""GET /v1/runs/{run_id}/events — SSE stream of structured agent lifecycle events."""
|
|
3141
|
+
auth_err = self._check_auth(request)
|
|
3142
|
+
if auth_err:
|
|
3143
|
+
return auth_err
|
|
3144
|
+
|
|
3145
|
+
run_id = request.match_info["run_id"]
|
|
3146
|
+
|
|
3147
|
+
# Allow subscribing slightly before the run is registered (race condition window)
|
|
3148
|
+
for _ in range(20):
|
|
3149
|
+
if run_id in self._run_streams:
|
|
3150
|
+
break
|
|
3151
|
+
await asyncio.sleep(0.05)
|
|
3152
|
+
else:
|
|
3153
|
+
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
|
3154
|
+
|
|
3155
|
+
q = self._run_streams[run_id]
|
|
3156
|
+
|
|
3157
|
+
response = web.StreamResponse(
|
|
3158
|
+
status=200,
|
|
3159
|
+
headers={
|
|
3160
|
+
"Content-Type": "text/event-stream",
|
|
3161
|
+
"Cache-Control": "no-cache",
|
|
3162
|
+
"X-Accel-Buffering": "no",
|
|
3163
|
+
},
|
|
3164
|
+
)
|
|
3165
|
+
await response.prepare(request)
|
|
3166
|
+
|
|
3167
|
+
try:
|
|
3168
|
+
while True:
|
|
3169
|
+
try:
|
|
3170
|
+
event = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
3171
|
+
except asyncio.TimeoutError:
|
|
3172
|
+
await response.write(b": keepalive\n\n")
|
|
3173
|
+
continue
|
|
3174
|
+
if event is None:
|
|
3175
|
+
# Run finished — send final SSE comment and close
|
|
3176
|
+
await response.write(b": stream closed\n\n")
|
|
3177
|
+
break
|
|
3178
|
+
payload = f"data: {json.dumps(event)}\n\n"
|
|
3179
|
+
await response.write(payload.encode())
|
|
3180
|
+
except Exception as exc:
|
|
3181
|
+
logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc)
|
|
3182
|
+
finally:
|
|
3183
|
+
self._run_streams.pop(run_id, None)
|
|
3184
|
+
self._run_streams_created.pop(run_id, None)
|
|
3185
|
+
|
|
3186
|
+
return response
|
|
3187
|
+
|
|
3188
|
+
|
|
3189
|
+
async def _handle_run_approval(self, request: "web.Request") -> "web.Response":
|
|
3190
|
+
"""POST /v1/runs/{run_id}/approval — resolve a pending run approval."""
|
|
3191
|
+
auth_err = self._check_auth(request)
|
|
3192
|
+
if auth_err:
|
|
3193
|
+
return auth_err
|
|
3194
|
+
|
|
3195
|
+
run_id = request.match_info["run_id"]
|
|
3196
|
+
status = self._run_statuses.get(run_id)
|
|
3197
|
+
if status is None:
|
|
3198
|
+
return web.json_response(
|
|
3199
|
+
_openai_error(f"Run not found: {run_id}", code="run_not_found"),
|
|
3200
|
+
status=404,
|
|
3201
|
+
)
|
|
3202
|
+
|
|
3203
|
+
try:
|
|
3204
|
+
body = await request.json()
|
|
3205
|
+
except Exception:
|
|
3206
|
+
return web.json_response(_openai_error("Invalid JSON"), status=400)
|
|
3207
|
+
|
|
3208
|
+
raw_choice = str(body.get("choice", "")).strip().lower()
|
|
3209
|
+
aliases = {"approve": "once", "approved": "once", "allow": "once"}
|
|
3210
|
+
choice = aliases.get(raw_choice, raw_choice)
|
|
3211
|
+
allowed = {"once", "session", "always", "deny"}
|
|
3212
|
+
if choice not in allowed:
|
|
3213
|
+
return web.json_response(
|
|
3214
|
+
_openai_error(
|
|
3215
|
+
"Invalid approval choice; expected one of: once, session, always, deny",
|
|
3216
|
+
code="invalid_approval_choice",
|
|
3217
|
+
),
|
|
3218
|
+
status=400,
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
approval_session_key = self._run_approval_sessions.get(run_id)
|
|
3222
|
+
if not approval_session_key:
|
|
3223
|
+
return web.json_response(
|
|
3224
|
+
_openai_error(
|
|
3225
|
+
f"Run has no active approval session: {run_id}",
|
|
3226
|
+
code="approval_not_active",
|
|
3227
|
+
),
|
|
3228
|
+
status=409,
|
|
3229
|
+
)
|
|
3230
|
+
|
|
3231
|
+
resolve_all = bool(body.get("all") or body.get("resolve_all"))
|
|
3232
|
+
try:
|
|
3233
|
+
from tools.approval import resolve_gateway_approval
|
|
3234
|
+
|
|
3235
|
+
resolved = resolve_gateway_approval(
|
|
3236
|
+
approval_session_key,
|
|
3237
|
+
choice,
|
|
3238
|
+
resolve_all=resolve_all,
|
|
3239
|
+
)
|
|
3240
|
+
except Exception as exc:
|
|
3241
|
+
logger.exception("[api_server] approval resolution failed for run %s", run_id)
|
|
3242
|
+
return web.json_response(_openai_error(str(exc)), status=500)
|
|
3243
|
+
|
|
3244
|
+
if resolved <= 0:
|
|
3245
|
+
return web.json_response(
|
|
3246
|
+
_openai_error(
|
|
3247
|
+
f"Run has no pending approval: {run_id}",
|
|
3248
|
+
code="approval_not_pending",
|
|
3249
|
+
),
|
|
3250
|
+
status=409,
|
|
3251
|
+
)
|
|
3252
|
+
|
|
3253
|
+
self._set_run_status(run_id, "running", last_event="approval.responded")
|
|
3254
|
+
q = self._run_streams.get(run_id)
|
|
3255
|
+
if q is not None:
|
|
3256
|
+
try:
|
|
3257
|
+
q.put_nowait({
|
|
3258
|
+
"event": "approval.responded",
|
|
3259
|
+
"run_id": run_id,
|
|
3260
|
+
"timestamp": time.time(),
|
|
3261
|
+
"choice": choice,
|
|
3262
|
+
"resolved": resolved,
|
|
3263
|
+
})
|
|
3264
|
+
except Exception:
|
|
3265
|
+
pass
|
|
3266
|
+
|
|
3267
|
+
return web.json_response({
|
|
3268
|
+
"object": "hermes.run.approval_response",
|
|
3269
|
+
"run_id": run_id,
|
|
3270
|
+
"choice": choice,
|
|
3271
|
+
"resolved": resolved,
|
|
3272
|
+
})
|
|
3273
|
+
|
|
3274
|
+
async def _handle_stop_run(self, request: "web.Request") -> "web.Response":
|
|
3275
|
+
"""POST /v1/runs/{run_id}/stop — interrupt a running agent."""
|
|
3276
|
+
auth_err = self._check_auth(request)
|
|
3277
|
+
if auth_err:
|
|
3278
|
+
return auth_err
|
|
3279
|
+
|
|
3280
|
+
run_id = request.match_info["run_id"]
|
|
3281
|
+
agent = self._active_run_agents.get(run_id)
|
|
3282
|
+
task = self._active_run_tasks.get(run_id)
|
|
3283
|
+
|
|
3284
|
+
if agent is None and task is None:
|
|
3285
|
+
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
|
3286
|
+
|
|
3287
|
+
self._set_run_status(run_id, "stopping", last_event="run.stopping")
|
|
3288
|
+
|
|
3289
|
+
if agent is not None:
|
|
3290
|
+
try:
|
|
3291
|
+
agent.interrupt("Stop requested via API")
|
|
3292
|
+
except Exception:
|
|
3293
|
+
pass
|
|
3294
|
+
|
|
3295
|
+
if task is not None and not task.done():
|
|
3296
|
+
task.cancel()
|
|
3297
|
+
# Bounded wait: run_conversation() executes in the default
|
|
3298
|
+
# executor thread which task.cancel() cannot preempt — we rely on
|
|
3299
|
+
# agent.interrupt() above to break the loop. Cap the wait so a
|
|
3300
|
+
# slow/unresponsive interrupt can't hang this handler.
|
|
3301
|
+
try:
|
|
3302
|
+
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
|
3303
|
+
except asyncio.TimeoutError:
|
|
3304
|
+
logger.warning(
|
|
3305
|
+
"[api_server] stop for run %s timed out after 5s; "
|
|
3306
|
+
"agent may still be finishing the current step",
|
|
3307
|
+
run_id,
|
|
3308
|
+
)
|
|
3309
|
+
except (asyncio.CancelledError, Exception):
|
|
3310
|
+
pass
|
|
3311
|
+
|
|
3312
|
+
return web.json_response({"run_id": run_id, "status": "stopping"})
|
|
3313
|
+
|
|
3314
|
+
async def _sweep_orphaned_runs(self) -> None:
|
|
3315
|
+
"""Periodically clean up run streams that were never consumed."""
|
|
3316
|
+
while True:
|
|
3317
|
+
await asyncio.sleep(60)
|
|
3318
|
+
now = time.time()
|
|
3319
|
+
stale = [
|
|
3320
|
+
run_id
|
|
3321
|
+
for run_id, created_at in list(self._run_streams_created.items())
|
|
3322
|
+
if now - created_at > self._RUN_STREAM_TTL
|
|
3323
|
+
]
|
|
3324
|
+
for run_id in stale:
|
|
3325
|
+
logger.debug("[api_server] sweeping orphaned run %s", run_id)
|
|
3326
|
+
try:
|
|
3327
|
+
from tools.approval import unregister_gateway_notify
|
|
3328
|
+
|
|
3329
|
+
approval_session_key = self._run_approval_sessions.get(run_id)
|
|
3330
|
+
if approval_session_key:
|
|
3331
|
+
unregister_gateway_notify(approval_session_key)
|
|
3332
|
+
except Exception:
|
|
3333
|
+
pass
|
|
3334
|
+
self._run_streams.pop(run_id, None)
|
|
3335
|
+
self._run_streams_created.pop(run_id, None)
|
|
3336
|
+
self._active_run_agents.pop(run_id, None)
|
|
3337
|
+
self._active_run_tasks.pop(run_id, None)
|
|
3338
|
+
self._run_approval_sessions.pop(run_id, None)
|
|
3339
|
+
|
|
3340
|
+
stale_statuses = [
|
|
3341
|
+
run_id
|
|
3342
|
+
for run_id, status in list(self._run_statuses.items())
|
|
3343
|
+
if status.get("status") in {"completed", "failed", "cancelled"}
|
|
3344
|
+
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
|
|
3345
|
+
]
|
|
3346
|
+
for run_id in stale_statuses:
|
|
3347
|
+
self._run_statuses.pop(run_id, None)
|
|
3348
|
+
|
|
3349
|
+
# ------------------------------------------------------------------
|
|
3350
|
+
# BasePlatformAdapter interface
|
|
3351
|
+
# ------------------------------------------------------------------
|
|
3352
|
+
|
|
3353
|
+
async def connect(self) -> bool:
|
|
3354
|
+
"""Start the aiohttp web server."""
|
|
3355
|
+
if not AIOHTTP_AVAILABLE:
|
|
3356
|
+
logger.warning("[%s] aiohttp not installed", self.name)
|
|
3357
|
+
return False
|
|
3358
|
+
|
|
3359
|
+
try:
|
|
3360
|
+
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
|
3361
|
+
self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)
|
|
3362
|
+
self._app["api_server_adapter"] = self
|
|
3363
|
+
self._app.router.add_get("/health", self._handle_health)
|
|
3364
|
+
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
|
3365
|
+
self._app.router.add_get("/v1/health", self._handle_health)
|
|
3366
|
+
self._app.router.add_get("/v1/models", self._handle_models)
|
|
3367
|
+
self._app.router.add_get("/v1/capabilities", self._handle_capabilities)
|
|
3368
|
+
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
|
3369
|
+
self._app.router.add_post("/v1/responses", self._handle_responses)
|
|
3370
|
+
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
|
3371
|
+
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
|
|
3372
|
+
# Cron jobs management API
|
|
3373
|
+
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
|
|
3374
|
+
self._app.router.add_post("/api/jobs", self._handle_create_job)
|
|
3375
|
+
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
|
|
3376
|
+
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
|
|
3377
|
+
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
|
|
3378
|
+
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
|
|
3379
|
+
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
|
|
3380
|
+
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
|
3381
|
+
# Structured event streaming
|
|
3382
|
+
self._app.router.add_post("/v1/runs", self._handle_runs)
|
|
3383
|
+
self._app.router.add_get("/v1/runs/{run_id}", self._handle_get_run)
|
|
3384
|
+
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
|
|
3385
|
+
self._app.router.add_post("/v1/runs/{run_id}/approval", self._handle_run_approval)
|
|
3386
|
+
self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run)
|
|
3387
|
+
# Start background sweep to clean up orphaned (unconsumed) run streams
|
|
3388
|
+
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
|
|
3389
|
+
try:
|
|
3390
|
+
self._background_tasks.add(sweep_task)
|
|
3391
|
+
except TypeError:
|
|
3392
|
+
pass
|
|
3393
|
+
if hasattr(sweep_task, "add_done_callback"):
|
|
3394
|
+
sweep_task.add_done_callback(self._background_tasks.discard)
|
|
3395
|
+
|
|
3396
|
+
# Refuse to start network-accessible without authentication
|
|
3397
|
+
if is_network_accessible(self._host) and not self._api_key:
|
|
3398
|
+
logger.error(
|
|
3399
|
+
"[%s] Refusing to start: binding to %s requires API_SERVER_KEY. "
|
|
3400
|
+
"Set API_SERVER_KEY or use the default 127.0.0.1.",
|
|
3401
|
+
self.name, self._host,
|
|
3402
|
+
)
|
|
3403
|
+
return False
|
|
3404
|
+
|
|
3405
|
+
# Refuse to start network-accessible with a placeholder key.
|
|
3406
|
+
# Ported from openclaw/openclaw#64586.
|
|
3407
|
+
if is_network_accessible(self._host) and self._api_key:
|
|
3408
|
+
try:
|
|
3409
|
+
from hermes_cli.auth import has_usable_secret
|
|
3410
|
+
if not has_usable_secret(self._api_key, min_length=8):
|
|
3411
|
+
logger.error(
|
|
3412
|
+
"[%s] Refusing to start: API_SERVER_KEY is set to a "
|
|
3413
|
+
"placeholder value. Generate a real secret "
|
|
3414
|
+
"(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY "
|
|
3415
|
+
"before exposing the API server on %s.",
|
|
3416
|
+
self.name, self._host,
|
|
3417
|
+
)
|
|
3418
|
+
return False
|
|
3419
|
+
except ImportError:
|
|
3420
|
+
pass
|
|
3421
|
+
|
|
3422
|
+
# Port conflict detection — fail fast if port is already in use
|
|
3423
|
+
try:
|
|
3424
|
+
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
|
3425
|
+
_s.settimeout(1)
|
|
3426
|
+
_s.connect(('127.0.0.1', self._port))
|
|
3427
|
+
logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port)
|
|
3428
|
+
return False
|
|
3429
|
+
except (ConnectionRefusedError, OSError):
|
|
3430
|
+
pass # port is free
|
|
3431
|
+
|
|
3432
|
+
self._runner = web.AppRunner(self._app)
|
|
3433
|
+
await self._runner.setup()
|
|
3434
|
+
self._site = web.TCPSite(self._runner, self._host, self._port)
|
|
3435
|
+
await self._site.start()
|
|
3436
|
+
|
|
3437
|
+
self._mark_connected()
|
|
3438
|
+
if not self._api_key:
|
|
3439
|
+
logger.warning(
|
|
3440
|
+
"[%s] ⚠️ No API key configured (API_SERVER_KEY / platforms.api_server.key). "
|
|
3441
|
+
"All requests will be accepted without authentication. "
|
|
3442
|
+
"Set an API key for production deployments to prevent "
|
|
3443
|
+
"unauthorized access to sessions, responses, and cron jobs.",
|
|
3444
|
+
self.name,
|
|
3445
|
+
)
|
|
3446
|
+
logger.info(
|
|
3447
|
+
"[%s] API server listening on http://%s:%d (model: %s)",
|
|
3448
|
+
self.name, self._host, self._port, self._model_name,
|
|
3449
|
+
)
|
|
3450
|
+
return True
|
|
3451
|
+
|
|
3452
|
+
except Exception as e:
|
|
3453
|
+
logger.error("[%s] Failed to start API server: %s", self.name, e)
|
|
3454
|
+
return False
|
|
3455
|
+
|
|
3456
|
+
async def disconnect(self) -> None:
|
|
3457
|
+
"""Stop the aiohttp web server."""
|
|
3458
|
+
self._mark_disconnected()
|
|
3459
|
+
if self._site:
|
|
3460
|
+
await self._site.stop()
|
|
3461
|
+
self._site = None
|
|
3462
|
+
if self._runner:
|
|
3463
|
+
await self._runner.cleanup()
|
|
3464
|
+
self._runner = None
|
|
3465
|
+
self._app = None
|
|
3466
|
+
logger.info("[%s] API server stopped", self.name)
|
|
3467
|
+
|
|
3468
|
+
async def send(
|
|
3469
|
+
self,
|
|
3470
|
+
chat_id: str,
|
|
3471
|
+
content: str,
|
|
3472
|
+
reply_to: Optional[str] = None,
|
|
3473
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
3474
|
+
) -> SendResult:
|
|
3475
|
+
"""
|
|
3476
|
+
Not used — HTTP request/response cycle handles delivery directly.
|
|
3477
|
+
"""
|
|
3478
|
+
return SendResult(success=False, error="API server uses HTTP request/response, not send()")
|
|
3479
|
+
|
|
3480
|
+
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
3481
|
+
"""Return basic info about the API server."""
|
|
3482
|
+
return {
|
|
3483
|
+
"name": "API Server",
|
|
3484
|
+
"type": "api",
|
|
3485
|
+
"host": self._host,
|
|
3486
|
+
"port": self._port,
|
|
3487
|
+
}
|
|
3488
|
+
|