polyrob 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- webview/webgate.py +196 -0
modules/llm/profiles.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Declarative provider profiles (roadmap P8, Reference §28).
|
|
2
|
+
|
|
3
|
+
A ``ProviderProfile`` *describes* a provider — identity, auth, base URL, default
|
|
4
|
+
model, capability flags — and owns no client construction, credential rotation, or
|
|
5
|
+
streaming. Today POLYROB's ``modules/llm/*_client.py`` files conflate profile +
|
|
6
|
+
transport + adapter; this layer extracts the declarative half so it has a single
|
|
7
|
+
home. It is purely additive: nothing is rewired to consume it yet (that migration
|
|
8
|
+
is the rest of P8), so behavior is unchanged.
|
|
9
|
+
|
|
10
|
+
The default model deliberately reads from ``llm_client_registry.DEFAULT_MODELS`` so
|
|
11
|
+
the default-model *policy* stays single-sourced.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from modules.llm.llm_client_registry import get_default_model
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ProviderProfile:
|
|
23
|
+
name: str
|
|
24
|
+
display_name: str
|
|
25
|
+
env_key: str # API-key environment variable
|
|
26
|
+
auth_type: str = "api_key" # api_key | oauth | aws
|
|
27
|
+
base_url: Optional[str] = None # None => provider SDK default
|
|
28
|
+
supports_native_tools: bool = True
|
|
29
|
+
supports_vision: bool = True
|
|
30
|
+
signup_url: Optional[str] = None
|
|
31
|
+
initializable: bool = True # False = key alone can't bootstrap a client
|
|
32
|
+
# (deepseek: direct client disabled → use OpenRouter)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def default_model(self) -> str:
|
|
36
|
+
"""The default model for this provider (single-sourced from the registry)."""
|
|
37
|
+
return get_default_model(self.name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# NOTE: insertion order IS the canonical preference order for "first provider with
|
|
41
|
+
# a key" (see ``providers_with_keys`` / ``core.runtime_config``). OpenRouter is FIRST
|
|
42
|
+
# (2026-06-24): it is the preferred default client whenever its key is present —
|
|
43
|
+
# explicit ``-p`` and operator pins (DEFAULT_PROVIDER/CHAT_PROVIDER) still win.
|
|
44
|
+
PROFILES: Dict[str, ProviderProfile] = {
|
|
45
|
+
"openrouter": ProviderProfile(
|
|
46
|
+
name="openrouter", display_name="OpenRouter", env_key="OPENROUTER_API_KEY",
|
|
47
|
+
base_url="https://openrouter.ai/api/v1", supports_native_tools=True,
|
|
48
|
+
supports_vision=True, signup_url="https://openrouter.ai/",
|
|
49
|
+
),
|
|
50
|
+
"anthropic": ProviderProfile(
|
|
51
|
+
name="anthropic", display_name="Anthropic", env_key="ANTHROPIC_API_KEY",
|
|
52
|
+
base_url="https://api.anthropic.com", supports_native_tools=True,
|
|
53
|
+
supports_vision=True, signup_url="https://console.anthropic.com/",
|
|
54
|
+
),
|
|
55
|
+
"openai": ProviderProfile(
|
|
56
|
+
name="openai", display_name="OpenAI", env_key="OPENAI_API_KEY",
|
|
57
|
+
supports_native_tools=True, supports_vision=True,
|
|
58
|
+
signup_url="https://platform.openai.com/",
|
|
59
|
+
),
|
|
60
|
+
"gemini": ProviderProfile(
|
|
61
|
+
name="gemini", display_name="Google Gemini", env_key="GEMINI_API_KEY",
|
|
62
|
+
supports_native_tools=True, supports_vision=True,
|
|
63
|
+
signup_url="https://aistudio.google.com/",
|
|
64
|
+
),
|
|
65
|
+
"nvidia": ProviderProfile(
|
|
66
|
+
name="nvidia", display_name="NVIDIA NIM", env_key="NVIDIA_API_KEY",
|
|
67
|
+
base_url="https://integrate.api.nvidia.com/v1", supports_native_tools=True,
|
|
68
|
+
supports_vision=False, signup_url="https://build.nvidia.com/",
|
|
69
|
+
),
|
|
70
|
+
"deepseek": ProviderProfile(
|
|
71
|
+
name="deepseek", display_name="DeepSeek", env_key="DEEPSEEK_API_KEY",
|
|
72
|
+
base_url="https://api.deepseek.com/v1", supports_native_tools=False,
|
|
73
|
+
supports_vision=False, signup_url="https://platform.deepseek.com/",
|
|
74
|
+
# Direct client disabled (tool-calling broken) — reach DeepSeek via OpenRouter
|
|
75
|
+
# (OPENROUTER_API_KEY + model deepseek/deepseek-chat). A DEEPSEEK_API_KEY alone
|
|
76
|
+
# cannot bootstrap the agent, so it must NOT count toward "has a usable key".
|
|
77
|
+
initializable=False,
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def providers_with_keys(env=None) -> List[str]:
|
|
83
|
+
"""Return provider names whose API key is present in *env*, in PROFILES order.
|
|
84
|
+
|
|
85
|
+
This is the single source of truth for "which LLM providers have a key" — it
|
|
86
|
+
replaces the divergent per-call-site key lists across the CLI (config_store,
|
|
87
|
+
chat.py, doctor.py, model.py, banner.py). The returned order IS the canonical
|
|
88
|
+
preference order for "first provider with a key".
|
|
89
|
+
|
|
90
|
+
*env* defaults to ``os.environ``; pass a mapping for testability. A blank value
|
|
91
|
+
counts as absent.
|
|
92
|
+
"""
|
|
93
|
+
import os
|
|
94
|
+
env = os.environ if env is None else env
|
|
95
|
+
return [p.name for p in PROFILES.values() if env.get(p.env_key)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def initializable_providers_with_keys(env=None) -> List[str]:
|
|
99
|
+
"""``providers_with_keys`` restricted to providers a key ALONE can bootstrap.
|
|
100
|
+
|
|
101
|
+
This is the SSOT for "does the user have a *usable* provider key" — the gating
|
|
102
|
+
oracle that ``should_warn_no_key`` / the runtime resolver / the env-backfill and
|
|
103
|
+
``LLMManager._initialize``'s ``clients_to_try`` all derive from, so they can never
|
|
104
|
+
disagree. Excludes deepseek (direct client disabled — route via OpenRouter). The
|
|
105
|
+
raw ``providers_with_keys`` stays the DISPLAY oracle (doctor/webview show a key is
|
|
106
|
+
present even when it can't bootstrap directly).
|
|
107
|
+
"""
|
|
108
|
+
import os
|
|
109
|
+
env = os.environ if env is None else env
|
|
110
|
+
return [p.name for p in PROFILES.values() if env.get(p.env_key) and p.initializable]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Placeholder values that are "present" but not a real key (mirrors
|
|
114
|
+
# core.config.BotConfig.validate_api_keys, generalized across providers).
|
|
115
|
+
_PLACEHOLDER_KEYS = {
|
|
116
|
+
"your-openai-key", "your-anthropic-key", "your-api-key", "your-alchemy-key",
|
|
117
|
+
"your-gemini-key", "your-openrouter-key", "your-key", "changeme", "none", "null",
|
|
118
|
+
}
|
|
119
|
+
# Real provider API keys are comfortably longer than this; BotConfig blanks shorter
|
|
120
|
+
# ones for openai/anthropic. Every provider's real key (gemini ~39 is the shortest)
|
|
121
|
+
# clears 20, so the bar is safe to apply across providers.
|
|
122
|
+
_MIN_KEY_LEN = 20
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def looks_like_real_key(value) -> bool:
|
|
126
|
+
"""True when *value* is a plausibly-real API key (not blank / placeholder / stub).
|
|
127
|
+
|
|
128
|
+
Mirrors ``BotConfig.validate_api_keys`` so the GATING oracles agree with what the
|
|
129
|
+
LLM manager will actually accept — a malformed key must NOT pass a guard only to be
|
|
130
|
+
blanked by BotConfig and crash the manager with a misleading 'No API key found'.
|
|
131
|
+
"""
|
|
132
|
+
if not value:
|
|
133
|
+
return False
|
|
134
|
+
v = str(value).strip()
|
|
135
|
+
if v.lower() in _PLACEHOLDER_KEYS:
|
|
136
|
+
return False
|
|
137
|
+
return len(v) >= _MIN_KEY_LEN
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def usable_providers_with_keys(env=None) -> List[str]:
|
|
141
|
+
"""Initializable providers whose key VALUE is well-formed (mirrors BotConfig).
|
|
142
|
+
|
|
143
|
+
THE gating oracle wherever real key values are available (``should_warn_no_key``,
|
|
144
|
+
the env-backfill, config-store resolution). Stricter than
|
|
145
|
+
``initializable_providers_with_keys`` (which is presence-only, for the resolver's
|
|
146
|
+
name-based path) — it also rejects placeholder / too-short values. Raw
|
|
147
|
+
``providers_with_keys`` remains the DISPLAY oracle.
|
|
148
|
+
"""
|
|
149
|
+
import os
|
|
150
|
+
env = os.environ if env is None else env
|
|
151
|
+
return [
|
|
152
|
+
p.name for p in PROFILES.values()
|
|
153
|
+
if p.initializable and looks_like_real_key(env.get(p.env_key))
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def no_key_message() -> str:
|
|
158
|
+
"""The single canonical no-key message (neutral module — no ``cli`` import).
|
|
159
|
+
|
|
160
|
+
Re-exported from ``cli.keys`` and reused by ``LLMManager._initialize``'s raise so
|
|
161
|
+
every no-key surface says the same thing.
|
|
162
|
+
"""
|
|
163
|
+
return (
|
|
164
|
+
"No API key found. Run `polyrob init` to set one up, or put a provider key in "
|
|
165
|
+
"any of: process env, ./.polyrob/.env, ~/.polyrob/.env, root .env, "
|
|
166
|
+
"config/.env.development, or config/.env.production.\n"
|
|
167
|
+
"Supported providers: OPENROUTER_API_KEY (recommended), ANTHROPIC_API_KEY, "
|
|
168
|
+
"OPENAI_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY, DEEPSEEK_API_KEY "
|
|
169
|
+
"(direct client disabled — use OPENROUTER_API_KEY with model deepseek/deepseek-chat)."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_profile(name: str) -> Optional[ProviderProfile]:
|
|
174
|
+
return PROFILES.get(name)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def all_profiles() -> List[ProviderProfile]:
|
|
178
|
+
return list(PROFILES.values())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from modules.llm.anthropic_client import AnthropicClient
|
|
3
|
+
from modules.llm.model_registry import calculate_cost
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _client_with_usage(**usage):
|
|
7
|
+
c = AnthropicClient.__new__(AnthropicClient) # bypass __init__ (no network)
|
|
8
|
+
import logging; c.logger = logging.getLogger("t"); c.model_type = "claude-sonnet-4-5"
|
|
9
|
+
c.last_response = SimpleNamespace(usage=SimpleNamespace(**usage))
|
|
10
|
+
return c
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_anthropic_folds_cache_tokens_into_prompt_and_exposes_reads():
|
|
14
|
+
c = _client_with_usage(input_tokens=100, output_tokens=20,
|
|
15
|
+
cache_read_input_tokens=900, cache_creation_input_tokens=50)
|
|
16
|
+
u = c._extract_usage_data()
|
|
17
|
+
assert u["prompt_tokens"] == 1050 # 100 + 900 + 50
|
|
18
|
+
assert u["cached_tokens"] == 900 # reads only
|
|
19
|
+
assert u["completion_tokens"] == 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_anthropic_no_cache_fields_unchanged():
|
|
23
|
+
# Old-shape usage (no cache attrs) must behave exactly as before.
|
|
24
|
+
c = _client_with_usage(input_tokens=100, output_tokens=20)
|
|
25
|
+
u = c._extract_usage_data()
|
|
26
|
+
assert u["prompt_tokens"] == 100 and u["completion_tokens"] == 20
|
|
27
|
+
assert u["total_tokens"] == 120
|
|
28
|
+
assert u.get("cached_tokens", 0) == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cost_uses_cached_rate_for_anthropic_reads():
|
|
32
|
+
# sonnet-4-5: input $3, cached $0.30 (after A1). 900 cached reads + 150 regular input.
|
|
33
|
+
cost = calculate_cost("claude-sonnet-4-5", input_tokens=1050, output_tokens=20, cached_tokens=900)
|
|
34
|
+
# regular = 1050-900 = 150 @ $3/M; cached = 900 @ $0.30/M
|
|
35
|
+
assert round(cost, 8) == round(150/1e6*3.0 + 900/1e6*0.3 + 20/1e6*15.0, 8)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Test that cached_input_price is derived per provider when not explicitly set."""
|
|
2
|
+
|
|
3
|
+
from modules.llm.model_registry import get_model_config, ModelProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_anthropic_cached_price_derived_at_10pct():
|
|
7
|
+
"""Anthropic cache reads should be 10% of input price."""
|
|
8
|
+
m = get_model_config("claude-sonnet-4-5")
|
|
9
|
+
assert m.pricing.input_price == 3.0
|
|
10
|
+
assert m.pricing.cached_input_price == 0.3 # 0.1x
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_explicit_cached_price_is_preserved():
|
|
14
|
+
"""DeepSeek sets 0.028 explicitly (model_registry.py:722) — must not be overwritten."""
|
|
15
|
+
m = get_model_config("deepseek-chat")
|
|
16
|
+
assert m.pricing.cached_input_price == 0.028
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_derivation_only_fills_none_and_matches_multiplier():
|
|
20
|
+
"""All models should have cached_input_price derived or preserved."""
|
|
21
|
+
for name in ("claude-sonnet-4-5", "gpt-5"):
|
|
22
|
+
m = get_model_config(name)
|
|
23
|
+
assert m.pricing.cached_input_price is not None
|
|
24
|
+
assert m.pricing.cached_input_price <= m.pricing.input_price
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Stateful scrubber for reasoning/thinking blocks in streamed assistant text.
|
|
2
|
+
|
|
3
|
+
UP-07: ported essentially verbatim from Reference (`agent/think_scrubber.py`,
|
|
4
|
+
the reference agent) — pure, zero-dependency. POLYROB wires it at the
|
|
5
|
+
LLM-adapter content seam (modules/llm/adapters.py) so every provider's assistant text is
|
|
6
|
+
scrubbed before it becomes an AIMessage. The legacy `agents/task/utils_json.py` regex
|
|
7
|
+
strip stays for the fallback JSON-from-text path; this module covers the native seam and
|
|
8
|
+
a broader tag set. See docs/plans/agent-upgrades-2026-06/UP-07-*.md.
|
|
9
|
+
|
|
10
|
+
``run_agent._strip_think_blocks`` is regex-based and correct for a complete
|
|
11
|
+
string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys
|
|
12
|
+
the state that downstream consumers (CLI ``_stream_delta``, gateway
|
|
13
|
+
``GatewayStreamConsumer._filter_and_accumulate``) rely on.
|
|
14
|
+
|
|
15
|
+
Concretely, when MiniMax-M2.7 streams
|
|
16
|
+
|
|
17
|
+
delta1 = "<think>"
|
|
18
|
+
delta2 = "Let me check their config"
|
|
19
|
+
delta3 = "</think>"
|
|
20
|
+
|
|
21
|
+
the per-delta regex erases delta1 entirely (case 2: unterminated-open at
|
|
22
|
+
boundary matches ``^<think>...``), so the downstream state machine never
|
|
23
|
+
sees the open tag, treats delta2 as regular content, and leaks reasoning
|
|
24
|
+
to the user. Consumers that don't run their own state machine (ACP,
|
|
25
|
+
api_server, TTS) never had any defence at all — they just emitted
|
|
26
|
+
whatever survived the upstream regex.
|
|
27
|
+
|
|
28
|
+
This module centralises the tag-suppression state machine at the
|
|
29
|
+
upstream layer so every stream_delta_callback sees text that has
|
|
30
|
+
already had reasoning blocks removed. Partial tags at delta
|
|
31
|
+
boundaries are held back until the next delta resolves them, and
|
|
32
|
+
end-of-stream flushing surfaces any held-back prose that turned out
|
|
33
|
+
not to be a real tag.
|
|
34
|
+
|
|
35
|
+
Usage::
|
|
36
|
+
|
|
37
|
+
scrubber = StreamingThinkScrubber()
|
|
38
|
+
for delta in stream:
|
|
39
|
+
visible = scrubber.feed(delta)
|
|
40
|
+
if visible:
|
|
41
|
+
emit(visible)
|
|
42
|
+
tail = scrubber.flush() # at end of stream
|
|
43
|
+
if tail:
|
|
44
|
+
emit(tail)
|
|
45
|
+
|
|
46
|
+
The scrubber is re-entrant per agent instance. Call ``reset()`` at
|
|
47
|
+
the top of each new turn so a hung block from an interrupted prior
|
|
48
|
+
stream cannot taint the next turn's output.
|
|
49
|
+
|
|
50
|
+
Tag variants handled (case-insensitive):
|
|
51
|
+
``<think>``, ``<thinking>``, ``<reasoning>``, ``<thought>``,
|
|
52
|
+
``<REASONING_SCRATCHPAD>``.
|
|
53
|
+
|
|
54
|
+
Block-boundary rule for opens: an opening tag is only treated as a
|
|
55
|
+
reasoning-block opener when it appears at the start of the stream,
|
|
56
|
+
after a newline (optionally followed by whitespace), or when only
|
|
57
|
+
whitespace has been emitted on the current line. This prevents prose
|
|
58
|
+
that *mentions* the tag name (e.g. ``"use <think> tags here"``) from
|
|
59
|
+
being incorrectly suppressed. Closed pairs (``<think>X</think>``) are
|
|
60
|
+
always suppressed regardless of boundary; a closed pair is an
|
|
61
|
+
intentional, bounded construct.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
from typing import Tuple
|
|
67
|
+
|
|
68
|
+
__all__ = ["StreamingThinkScrubber", "scrub_think_blocks"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scrub_think_blocks(content: str) -> str:
|
|
72
|
+
"""Scrub reasoning blocks from a COMPLETE assistant string (non-streaming).
|
|
73
|
+
|
|
74
|
+
Equivalent to feeding the whole string as one delta then flushing. Safe no-op when
|
|
75
|
+
there's no '<' (the overwhelming-majority fast path), so a non-reasoning completion
|
|
76
|
+
is never touched. Used by the adapter content seam where the whole completion arrives
|
|
77
|
+
at once (UP-07).
|
|
78
|
+
"""
|
|
79
|
+
if not content or "<" not in content:
|
|
80
|
+
return content
|
|
81
|
+
s = StreamingThinkScrubber()
|
|
82
|
+
out = s.feed(content)
|
|
83
|
+
return out + s.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StreamingThinkScrubber:
|
|
87
|
+
"""Stateful scrubber for streaming reasoning/thinking blocks.
|
|
88
|
+
|
|
89
|
+
State machine:
|
|
90
|
+
- ``_in_block``: True while inside an opened block, waiting for
|
|
91
|
+
a close tag. All text inside is discarded.
|
|
92
|
+
- ``_buf``: held-back partial-tag tail. Emitted / discarded on
|
|
93
|
+
the next ``feed()`` call or by ``flush()``.
|
|
94
|
+
- ``_last_emitted_ended_newline``: True iff the most recent
|
|
95
|
+
emission to the consumer ended with ``\\n``, or nothing has
|
|
96
|
+
been emitted yet (start-of-stream counts as a boundary). Used
|
|
97
|
+
to decide whether an open tag at buffer position 0 is at a
|
|
98
|
+
block boundary.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_OPEN_TAG_NAMES: Tuple[str, ...] = (
|
|
102
|
+
"think",
|
|
103
|
+
"thinking",
|
|
104
|
+
"reasoning",
|
|
105
|
+
"thought",
|
|
106
|
+
"REASONING_SCRATCHPAD",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Materialise literal tag strings so the hot path does string
|
|
110
|
+
# operations, not regex compilation per feed().
|
|
111
|
+
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name}>" for name in _OPEN_TAG_NAMES)
|
|
112
|
+
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name}>" for name in _OPEN_TAG_NAMES)
|
|
113
|
+
|
|
114
|
+
# Pre-compute the longest tag (for partial-tag hold-back bound).
|
|
115
|
+
_MAX_TAG_LEN: int = max(len(tag) for tag in _OPEN_TAGS + _CLOSE_TAGS)
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
self._in_block: bool = False
|
|
119
|
+
self._buf: str = ""
|
|
120
|
+
self._last_emitted_ended_newline: bool = True
|
|
121
|
+
|
|
122
|
+
def reset(self) -> None:
|
|
123
|
+
"""Reset all state. Call at the top of every new turn."""
|
|
124
|
+
self._in_block = False
|
|
125
|
+
self._buf = ""
|
|
126
|
+
self._last_emitted_ended_newline = True
|
|
127
|
+
|
|
128
|
+
def feed(self, text: str) -> str:
|
|
129
|
+
"""Feed one delta; return the scrubbed visible portion.
|
|
130
|
+
|
|
131
|
+
May return an empty string when the entire delta is reasoning
|
|
132
|
+
content or is being held back pending resolution of a partial
|
|
133
|
+
tag at the boundary.
|
|
134
|
+
"""
|
|
135
|
+
if not text:
|
|
136
|
+
return ""
|
|
137
|
+
buf = self._buf + text
|
|
138
|
+
self._buf = ""
|
|
139
|
+
out: list[str] = []
|
|
140
|
+
|
|
141
|
+
while buf:
|
|
142
|
+
if self._in_block:
|
|
143
|
+
# Hunt for the earliest close tag.
|
|
144
|
+
close_idx, close_len = self._find_first_tag(
|
|
145
|
+
buf, self._CLOSE_TAGS,
|
|
146
|
+
)
|
|
147
|
+
if close_idx == -1:
|
|
148
|
+
# No close yet — hold back a potential partial
|
|
149
|
+
# close-tag prefix; discard everything else.
|
|
150
|
+
held = self._max_partial_suffix(buf, self._CLOSE_TAGS)
|
|
151
|
+
self._buf = buf[-held:] if held else ""
|
|
152
|
+
return "".join(out)
|
|
153
|
+
# Found close: discard block content + tag, continue.
|
|
154
|
+
buf = buf[close_idx + close_len:]
|
|
155
|
+
self._in_block = False
|
|
156
|
+
else:
|
|
157
|
+
# Priority 1 — closed <tag>X</tag> pair anywhere in
|
|
158
|
+
# buf. Closed pairs are always an intentional,
|
|
159
|
+
# bounded construct (even mid-line prose containing
|
|
160
|
+
# an open/close pair is almost certainly a model
|
|
161
|
+
# leaking reasoning inline), so no boundary gating.
|
|
162
|
+
pair = self._find_earliest_closed_pair(buf)
|
|
163
|
+
# Priority 2 — unterminated open tag at a block
|
|
164
|
+
# boundary. Boundary-gated so prose that mentions
|
|
165
|
+
# '<think>' isn't over-stripped.
|
|
166
|
+
open_idx, open_len = self._find_open_at_boundary(
|
|
167
|
+
buf, out,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Pick whichever match comes earliest in the buffer.
|
|
171
|
+
if pair is not None and (
|
|
172
|
+
open_idx == -1 or pair[0] <= open_idx
|
|
173
|
+
):
|
|
174
|
+
start_idx, end_idx = pair
|
|
175
|
+
preceding = buf[:start_idx]
|
|
176
|
+
if preceding:
|
|
177
|
+
preceding = self._strip_orphan_close_tags(preceding)
|
|
178
|
+
if preceding:
|
|
179
|
+
out.append(preceding)
|
|
180
|
+
self._last_emitted_ended_newline = (
|
|
181
|
+
preceding.endswith("\n")
|
|
182
|
+
)
|
|
183
|
+
buf = buf[end_idx:]
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if open_idx != -1:
|
|
187
|
+
# Unterminated open at boundary — emit preceding,
|
|
188
|
+
# enter block, continue loop with remainder.
|
|
189
|
+
preceding = buf[:open_idx]
|
|
190
|
+
if preceding:
|
|
191
|
+
preceding = self._strip_orphan_close_tags(preceding)
|
|
192
|
+
if preceding:
|
|
193
|
+
out.append(preceding)
|
|
194
|
+
self._last_emitted_ended_newline = (
|
|
195
|
+
preceding.endswith("\n")
|
|
196
|
+
)
|
|
197
|
+
self._in_block = True
|
|
198
|
+
buf = buf[open_idx + open_len:]
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# No resolvable tag structure in buf. Hold back any
|
|
202
|
+
# partial-tag prefix at the tail so a split tag
|
|
203
|
+
# across deltas isn't missed, then emit the rest.
|
|
204
|
+
held = self._max_partial_suffix(buf, self._OPEN_TAGS)
|
|
205
|
+
held_close = self._max_partial_suffix(
|
|
206
|
+
buf, self._CLOSE_TAGS,
|
|
207
|
+
)
|
|
208
|
+
held = max(held, held_close)
|
|
209
|
+
if held:
|
|
210
|
+
emit_text = buf[:-held]
|
|
211
|
+
self._buf = buf[-held:]
|
|
212
|
+
else:
|
|
213
|
+
emit_text = buf
|
|
214
|
+
self._buf = ""
|
|
215
|
+
if emit_text:
|
|
216
|
+
emit_text = self._strip_orphan_close_tags(emit_text)
|
|
217
|
+
if emit_text:
|
|
218
|
+
out.append(emit_text)
|
|
219
|
+
self._last_emitted_ended_newline = (
|
|
220
|
+
emit_text.endswith("\n")
|
|
221
|
+
)
|
|
222
|
+
return "".join(out)
|
|
223
|
+
|
|
224
|
+
return "".join(out)
|
|
225
|
+
|
|
226
|
+
def flush(self) -> str:
|
|
227
|
+
"""End-of-stream flush.
|
|
228
|
+
|
|
229
|
+
If still inside an unterminated block, held-back content is
|
|
230
|
+
discarded — leaking partial reasoning is worse than a
|
|
231
|
+
truncated answer. Otherwise the held-back partial-tag tail is
|
|
232
|
+
emitted verbatim (it turned out not to be a real tag prefix).
|
|
233
|
+
"""
|
|
234
|
+
if self._in_block:
|
|
235
|
+
self._buf = ""
|
|
236
|
+
self._in_block = False
|
|
237
|
+
return ""
|
|
238
|
+
tail = self._buf
|
|
239
|
+
self._buf = ""
|
|
240
|
+
if not tail:
|
|
241
|
+
return ""
|
|
242
|
+
tail = self._strip_orphan_close_tags(tail)
|
|
243
|
+
if tail:
|
|
244
|
+
self._last_emitted_ended_newline = tail.endswith("\n")
|
|
245
|
+
return tail
|
|
246
|
+
|
|
247
|
+
# ── internal helpers ───────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _find_first_tag(
|
|
251
|
+
buf: str, tags: Tuple[str, ...],
|
|
252
|
+
) -> Tuple[int, int]:
|
|
253
|
+
"""Return (earliest_index, tag_length) over *tags*, or (-1, 0).
|
|
254
|
+
|
|
255
|
+
Case-insensitive match.
|
|
256
|
+
"""
|
|
257
|
+
buf_lower = buf.lower()
|
|
258
|
+
best_idx = -1
|
|
259
|
+
best_len = 0
|
|
260
|
+
for tag in tags:
|
|
261
|
+
idx = buf_lower.find(tag.lower())
|
|
262
|
+
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
|
263
|
+
best_idx = idx
|
|
264
|
+
best_len = len(tag)
|
|
265
|
+
return best_idx, best_len
|
|
266
|
+
|
|
267
|
+
def _find_earliest_closed_pair(self, buf: str):
|
|
268
|
+
"""Return (start_idx, end_idx) of the earliest closed pair, else None.
|
|
269
|
+
|
|
270
|
+
A closed pair is ``<tag>...</tag>`` of any variant. Matches are
|
|
271
|
+
case-insensitive and non-greedy (the closest close tag after
|
|
272
|
+
an open tag wins), matching the regex ``<tag>.*?</tag>``
|
|
273
|
+
semantics of ``_strip_think_blocks`` case 1. When two tag
|
|
274
|
+
variants could both match, the one whose open tag appears
|
|
275
|
+
earlier wins.
|
|
276
|
+
"""
|
|
277
|
+
buf_lower = buf.lower()
|
|
278
|
+
best: "tuple[int, int] | None" = None
|
|
279
|
+
for open_tag, close_tag in zip(self._OPEN_TAGS, self._CLOSE_TAGS):
|
|
280
|
+
open_lower = open_tag.lower()
|
|
281
|
+
close_lower = close_tag.lower()
|
|
282
|
+
open_idx = buf_lower.find(open_lower)
|
|
283
|
+
if open_idx == -1:
|
|
284
|
+
continue
|
|
285
|
+
# P2-13: DEPTH-aware close matching. A nested same-variant block
|
|
286
|
+
# (<think>a<think>b</think>c</think>) used to match the FIRST inner close
|
|
287
|
+
# (non-greedy), which ended the block early and LEAKED the outer tail
|
|
288
|
+
# ("c") plus an orphan </think>. Count nested opens/closes and extend to
|
|
289
|
+
# the OUTER close. If the outer close isn't in the buffer yet (streaming,
|
|
290
|
+
# split mid-nest), matched_end stays -1 and we skip — the open-at-boundary
|
|
291
|
+
# / in-block path then handles it, same as an unterminated block.
|
|
292
|
+
depth = 1
|
|
293
|
+
pos = open_idx + len(open_lower)
|
|
294
|
+
matched_end = -1
|
|
295
|
+
while pos < len(buf_lower):
|
|
296
|
+
next_open = buf_lower.find(open_lower, pos)
|
|
297
|
+
next_close = buf_lower.find(close_lower, pos)
|
|
298
|
+
if next_close == -1:
|
|
299
|
+
break # no closing tag yet
|
|
300
|
+
if next_open != -1 and next_open < next_close:
|
|
301
|
+
depth += 1
|
|
302
|
+
pos = next_open + len(open_lower)
|
|
303
|
+
else:
|
|
304
|
+
depth -= 1
|
|
305
|
+
pos = next_close + len(close_lower)
|
|
306
|
+
if depth == 0:
|
|
307
|
+
matched_end = pos
|
|
308
|
+
break
|
|
309
|
+
if matched_end == -1:
|
|
310
|
+
continue
|
|
311
|
+
if best is None or open_idx < best[0]:
|
|
312
|
+
best = (open_idx, matched_end)
|
|
313
|
+
return best
|
|
314
|
+
|
|
315
|
+
def _find_open_at_boundary(
|
|
316
|
+
self, buf: str, already_emitted: list[str],
|
|
317
|
+
) -> Tuple[int, int]:
|
|
318
|
+
"""Return the earliest block-boundary open-tag (idx, len).
|
|
319
|
+
|
|
320
|
+
Returns (-1, 0) if no boundary-legal opener is present.
|
|
321
|
+
"""
|
|
322
|
+
buf_lower = buf.lower()
|
|
323
|
+
best_idx = -1
|
|
324
|
+
best_len = 0
|
|
325
|
+
for tag in self._OPEN_TAGS:
|
|
326
|
+
tag_lower = tag.lower()
|
|
327
|
+
search_start = 0
|
|
328
|
+
while True:
|
|
329
|
+
idx = buf_lower.find(tag_lower, search_start)
|
|
330
|
+
if idx == -1:
|
|
331
|
+
break
|
|
332
|
+
if self._is_block_boundary(buf, idx, already_emitted):
|
|
333
|
+
if best_idx == -1 or idx < best_idx:
|
|
334
|
+
best_idx = idx
|
|
335
|
+
best_len = len(tag)
|
|
336
|
+
break # first boundary hit for this tag is enough
|
|
337
|
+
search_start = idx + 1
|
|
338
|
+
return best_idx, best_len
|
|
339
|
+
|
|
340
|
+
def _is_block_boundary(
|
|
341
|
+
self, buf: str, idx: int, already_emitted: list[str],
|
|
342
|
+
) -> bool:
|
|
343
|
+
"""True iff position *idx* in *buf* is a block boundary.
|
|
344
|
+
|
|
345
|
+
A block boundary is:
|
|
346
|
+
- buf position 0 AND the most recent emission ended with
|
|
347
|
+
a newline (or nothing has been emitted yet)
|
|
348
|
+
- any position whose preceding text on the current line
|
|
349
|
+
(since the last newline in buf) is whitespace-only, AND
|
|
350
|
+
if there is no newline in the preceding buf portion, the
|
|
351
|
+
most recent prior emission ended with a newline
|
|
352
|
+
"""
|
|
353
|
+
if idx == 0:
|
|
354
|
+
# Check whether the last already-emitted chunk in THIS
|
|
355
|
+
# feed() call ended with a newline, otherwise fall back
|
|
356
|
+
# to the cross-feed flag.
|
|
357
|
+
if already_emitted:
|
|
358
|
+
return already_emitted[-1].endswith("\n")
|
|
359
|
+
return self._last_emitted_ended_newline
|
|
360
|
+
preceding = buf[:idx]
|
|
361
|
+
last_nl = preceding.rfind("\n")
|
|
362
|
+
if last_nl == -1:
|
|
363
|
+
# No newline in buf before the tag — boundary only if the
|
|
364
|
+
# prior emission ended with a newline AND everything since
|
|
365
|
+
# is whitespace.
|
|
366
|
+
if already_emitted:
|
|
367
|
+
prior_newline = already_emitted[-1].endswith("\n")
|
|
368
|
+
else:
|
|
369
|
+
prior_newline = self._last_emitted_ended_newline
|
|
370
|
+
return prior_newline and preceding.strip() == ""
|
|
371
|
+
# Newline present — text between it and the tag must be
|
|
372
|
+
# whitespace-only.
|
|
373
|
+
return preceding[last_nl + 1:].strip() == ""
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def _max_partial_suffix(
|
|
377
|
+
cls, buf: str, tags: Tuple[str, ...],
|
|
378
|
+
) -> int:
|
|
379
|
+
"""Return the longest buf-suffix that is a prefix of any tag.
|
|
380
|
+
|
|
381
|
+
Only prefixes strictly shorter than the tag itself count
|
|
382
|
+
(full-length suffixes are the tag and are handled as matches,
|
|
383
|
+
not held-back partials). Case-insensitive.
|
|
384
|
+
"""
|
|
385
|
+
if not buf:
|
|
386
|
+
return 0
|
|
387
|
+
buf_lower = buf.lower()
|
|
388
|
+
max_check = min(len(buf_lower), cls._MAX_TAG_LEN - 1)
|
|
389
|
+
for i in range(max_check, 0, -1):
|
|
390
|
+
suffix = buf_lower[-i:]
|
|
391
|
+
for tag in tags:
|
|
392
|
+
tag_lower = tag.lower()
|
|
393
|
+
if len(tag_lower) > i and tag_lower.startswith(suffix):
|
|
394
|
+
return i
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def _strip_orphan_close_tags(cls, text: str) -> str:
|
|
399
|
+
"""Remove *boundary* orphan close tags from *text*.
|
|
400
|
+
|
|
401
|
+
An orphan close tag has no matching open in the current scrubber
|
|
402
|
+
state. It is stripped (with trailing whitespace) ONLY when it sits
|
|
403
|
+
at a block boundary — the start of the text or after a newline with
|
|
404
|
+
nothing but whitespace before it on that line. A close tag that
|
|
405
|
+
appears mid-prose (e.g. the assistant legitimately writing
|
|
406
|
+
``"look for </think> in logs"`` or generating code/docs about
|
|
407
|
+
reasoning tags) is genuine output and is preserved verbatim.
|
|
408
|
+
|
|
409
|
+
Before boundary-gating, this stripped EVERY close tag unconditionally,
|
|
410
|
+
silently deleting legitimate prose that merely mentioned one.
|
|
411
|
+
"""
|
|
412
|
+
if "</" not in text:
|
|
413
|
+
return text
|
|
414
|
+
text_lower = text.lower()
|
|
415
|
+
out: list[str] = []
|
|
416
|
+
i = 0
|
|
417
|
+
while i < len(text):
|
|
418
|
+
matched = False
|
|
419
|
+
if text_lower[i:i + 2] == "</":
|
|
420
|
+
for tag in cls._CLOSE_TAGS:
|
|
421
|
+
tag_lower = tag.lower()
|
|
422
|
+
tag_len = len(tag_lower)
|
|
423
|
+
if text_lower[i:i + tag_len] != tag_lower:
|
|
424
|
+
continue
|
|
425
|
+
# Boundary gate: only an orphan close at the start of a
|
|
426
|
+
# line (preceding text on this line is whitespace-only) is
|
|
427
|
+
# treated as noise; mid-prose mentions are kept.
|
|
428
|
+
emitted = "".join(out)
|
|
429
|
+
last_nl = emitted.rfind("\n")
|
|
430
|
+
line_prefix = emitted[last_nl + 1:] if last_nl != -1 else emitted
|
|
431
|
+
if line_prefix.strip() != "":
|
|
432
|
+
break # mid-prose — keep the tag as literal text
|
|
433
|
+
# Skip the tag and any trailing whitespace,
|
|
434
|
+
# matching _strip_think_blocks case 3.
|
|
435
|
+
j = i + tag_len
|
|
436
|
+
while j < len(text) and text[j] in " \t\n\r":
|
|
437
|
+
j += 1
|
|
438
|
+
i = j
|
|
439
|
+
matched = True
|
|
440
|
+
break
|
|
441
|
+
if not matched:
|
|
442
|
+
out.append(text[i])
|
|
443
|
+
i += 1
|
|
444
|
+
return "".join(out)
|