foxclaw 0.1.2 → 0.1.4
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/index.js +1 -8
- package/package.json +1 -1
- package/python/.env.example +8 -0
- package/python/.github/workflows/ci.yml +47 -2
- package/python/.github/workflows/release.yml +5 -0
- package/python/CHANGELOG.md +100 -0
- package/python/CHANGELOG_CN.md +100 -0
- package/python/README.md +17 -2
- package/python/README_CN.md +38 -20
- package/python/bench/bm25_audit.py +1 -14
- package/python/bench/bm25_regression.py +0 -1
- package/python/bench/bm25_stress.py +0 -4
- package/python/bench/cd_audit.py +0 -23
- package/python/bench/chaos.py +6 -8
- package/python/bench/e2e_real_models.py +0 -1
- package/python/bench/llm_retry_test.py +2 -9
- package/python/bench/load_test.py +5 -8
- package/python/bench/vector_ivf_upgrade.py +1 -7
- package/python/docs/CONTENT_GUARD.md +189 -0
- package/python/docs/CONTENT_GUARD_CN.md +189 -0
- package/python/docs/RELEASE.md +78 -37
- package/python/docs/RUN_CN.md +2 -2
- package/python/evals/governance/cases.py +0 -37
- package/python/evals/governance/run_evals.py +5 -37
- package/python/examples/governance_middleware_demo.py +1 -6
- package/python/examples/langgraph_governance_demo.py +7 -11
- package/python/foxclaw/__init__.py +74 -152
- package/python/foxclaw/agents/base.py +82 -136
- package/python/foxclaw/agents/impl/_helpers.py +2 -10
- package/python/foxclaw/agents/impl/finance.py +4 -21
- package/python/foxclaw/agents/impl/office.py +7 -24
- package/python/foxclaw/agents/impl/supervisor.py +12 -79
- package/python/foxclaw/agents/mixins/_context.py +0 -23
- package/python/foxclaw/agents/mixins/augment.py +10 -16
- package/python/foxclaw/agents/mixins/llm.py +7 -6
- package/python/foxclaw/agents/mixins/memory.py +23 -3
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +59 -12
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/bootstrap.py +114 -0
- package/python/foxclaw/api/middleware.py +5 -28
- package/python/foxclaw/api/routes/ahs.py +60 -73
- package/python/foxclaw/api/routes/attachments.py +5 -6
- package/python/foxclaw/api/routes/browser.py +7 -11
- package/python/foxclaw/api/routes/domain.py +18 -26
- package/python/foxclaw/api/routes/knowledge.py +3 -21
- package/python/foxclaw/api/routes/llm_admin.py +21 -33
- package/python/foxclaw/api/routes/memory.py +50 -12
- package/python/foxclaw/api/routes/orchestration.py +3 -5
- package/python/foxclaw/api/routes/recovery.py +4 -3
- package/python/foxclaw/api/routes/review.py +2 -11
- package/python/foxclaw/api/routes/search.py +3 -7
- package/python/foxclaw/api/routes/system.py +33 -49
- package/python/foxclaw/api/routes/task.py +31 -49
- package/python/foxclaw/api/routes/ws.py +2 -6
- package/python/foxclaw/api/spine.py +1 -4
- package/python/foxclaw/behavior/__init__.py +20 -0
- package/python/foxclaw/behavior/behavior.yaml +62 -0
- package/python/foxclaw/behavior/spec.py +287 -0
- package/python/foxclaw/browser/action/action_executor.py +1 -5
- package/python/foxclaw/browser/action/action_types.py +0 -1
- package/python/foxclaw/browser/action/url_policy.py +1 -5
- package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
- package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
- package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
- package/python/foxclaw/browser/perception/element_indexer.py +1 -2
- package/python/foxclaw/browser/service.py +7 -11
- package/python/foxclaw/capability/embedding_service.py +4 -6
- package/python/foxclaw/channels/__init__.py +9 -11
- package/python/foxclaw/channels/discord.py +4 -13
- package/python/foxclaw/channels/dispatcher.py +2 -27
- package/python/foxclaw/channels/feishu.py +10 -20
- package/python/foxclaw/channels/openclaw_bridge.py +4 -9
- package/python/foxclaw/channels/signing.py +9 -9
- package/python/foxclaw/channels/telegram.py +3 -4
- package/python/foxclaw/channels/weixin.py +7 -17
- package/python/foxclaw/cli.py +42 -13
- package/python/foxclaw/cognitive/reasoner.py +9 -10
- package/python/foxclaw/cognitive/state.py +18 -24
- package/python/foxclaw/core/audit_chain.py +1 -3
- package/python/foxclaw/core/audit_sink.py +3 -3
- package/python/foxclaw/core/compaction.py +8 -26
- package/python/foxclaw/core/config.py +2 -2
- package/python/foxclaw/core/confirm_nonce.py +6 -12
- package/python/foxclaw/core/content_guard.py +341 -0
- package/python/foxclaw/core/envfile.py +2 -2
- package/python/foxclaw/core/failure_policy.py +17 -26
- package/python/foxclaw/core/gateway.py +6 -10
- package/python/foxclaw/core/governance_audit.py +3 -7
- package/python/foxclaw/core/idempotency.py +15 -22
- package/python/foxclaw/core/injection_guard.py +10 -29
- package/python/foxclaw/core/memory_adapter.py +53 -60
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +74 -150
- package/python/foxclaw/core/paths.py +1 -1
- package/python/foxclaw/core/recovery.py +21 -34
- package/python/foxclaw/core/scheduler.py +23 -26
- package/python/foxclaw/core/secrets.py +0 -3
- package/python/foxclaw/core/sensitive_write.py +283 -0
- package/python/foxclaw/core/sessions.py +4 -6
- package/python/foxclaw/core/settings.py +16 -32
- package/python/foxclaw/core/shared_write.py +11 -23
- package/python/foxclaw/core/steering_queue.py +12 -11
- package/python/foxclaw/core/system_bus.py +1 -1
- package/python/foxclaw/core/thought_chain.py +30 -32
- package/python/foxclaw/core/token_meter.py +9 -13
- package/python/foxclaw/core/tools.py +4 -12
- package/python/foxclaw/core/tracing.py +25 -40
- package/python/foxclaw/core/truth_pipeline.py +5 -9
- package/python/foxclaw/core/update_check.py +149 -0
- package/python/foxclaw/core/vec_recall_adapter.py +11 -37
- package/python/foxclaw/core/verify.py +6 -14
- package/python/foxclaw/core/web_search.py +4 -28
- package/python/foxclaw/finance/evidence.py +14 -18
- package/python/foxclaw/finance/fx.py +1 -3
- package/python/foxclaw/finance/graph.py +9 -18
- package/python/foxclaw/finance/models.py +20 -24
- package/python/foxclaw/finance/nlparse.py +12 -72
- package/python/foxclaw/finance/policy.py +8 -36
- package/python/foxclaw/finance/reasoner.py +3 -11
- package/python/foxclaw/finance/reasoning.py +13 -34
- package/python/foxclaw/finance/reconcile.py +5 -10
- package/python/foxclaw/finance/runtime.py +19 -46
- package/python/foxclaw/governance/middleware.py +13 -21
- package/python/foxclaw/harness/_base.py +2 -8
- package/python/foxclaw/harness/_policy.py +19 -38
- package/python/foxclaw/harness/drift.py +1 -13
- package/python/foxclaw/harness/event_bus.py +2 -5
- package/python/foxclaw/harness/harness.py +9 -97
- package/python/foxclaw/harness/loop_detection.py +11 -18
- package/python/foxclaw/itinerary/amap_client.py +2 -5
- package/python/foxclaw/itinerary/models.py +7 -7
- package/python/foxclaw/itinerary/reasoner.py +1 -6
- package/python/foxclaw/itinerary/reasoning.py +15 -21
- package/python/foxclaw/itinerary/runtime.py +8 -12
- package/python/foxclaw/kernel/capabilities.py +5 -6
- package/python/foxclaw/kernel/policy_gate.py +4 -10
- package/python/foxclaw/kernel/process_manager.py +17 -36
- package/python/foxclaw/kernel/scheduler.py +3 -9
- package/python/foxclaw/kernel/sentinel.py +13 -23
- package/python/foxclaw/kernel/sentinel_client.py +12 -15
- package/python/foxclaw/kernel/spine.py +9 -9
- package/python/foxclaw/knowledge/__init__.py +4 -20
- package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
- package/python/foxclaw/learning/knowledge_graph.py +4 -9
- package/python/foxclaw/learning/models.py +13 -13
- package/python/foxclaw/learning/reasoner.py +1 -4
- package/python/foxclaw/learning/runtime.py +19 -31
- package/python/foxclaw/learning/spaced_repetition.py +2 -7
- package/python/foxclaw/mcp_server.py +2 -3
- package/python/foxclaw/orchestration/__init__.py +16 -33
- package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
- package/python/foxclaw/pipeline/stage_meta.py +21 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
- package/python/foxclaw/planning/llm_planner.py +3 -3
- package/python/foxclaw/planning/planner.py +6 -11
- package/python/foxclaw/search/agent_port.py +7 -11
- package/python/foxclaw/search/evidence.py +0 -4
- package/python/foxclaw/search/internal_search.py +3 -5
- package/python/foxclaw/search/models.py +11 -13
- package/python/foxclaw/search/planner.py +3 -3
- package/python/foxclaw/search/retriever.py +1 -1
- package/python/foxclaw/search/runtime.py +1 -8
- package/python/foxclaw/skills/__init__.py +46 -100
- package/python/foxclaw/skills/archive.py +111 -0
- package/python/foxclaw/skills/fetch.py +226 -0
- package/python/foxclaw/skills/hooks_spec.py +21 -25
- package/python/foxclaw/skills/install_cmd.py +216 -0
- package/python/foxclaw/skills/lifecycle.py +16 -42
- package/python/foxclaw/skills/markdown_skill.py +10 -9
- package/python/foxclaw/skills/mcp_client.py +5 -11
- package/python/foxclaw/skills/mcp_store.py +0 -3
- package/python/foxclaw/skills/permission.py +13 -48
- package/python/foxclaw/skills/pipeline.py +21 -53
- package/python/foxclaw/skills/policy.py +4 -10
- package/python/foxclaw/skills/review.py +3 -3
- package/python/frontend/deck/auth.js +0 -7
- package/python/frontend/deck/chat_model_picker.js +0 -54
- package/python/frontend/deck/domain.js +0 -20
- package/python/frontend/deck/main.js +0 -144
- package/python/frontend/deck/markdown.js +0 -4
- package/python/frontend/deck/settings.js +1 -18
- package/python/frontend/deck/state.js +0 -1
- package/python/frontend/deck/util.js +0 -2
- package/python/frontend/deck/visuals.js +0 -22
- package/python/frontend/planet/planet.js +18 -201
- package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
- package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
- package/python/frontend/v3/index.html +3 -3
- package/python/frontend/widget_runtime.js +0 -43
- package/python/frontend/widgets.js +0 -57
- package/python/frontend_src/index.html +1 -1
- package/python/frontend_src/package-lock.json +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/App.vue +3 -7
- package/python/frontend_src/src/api.js +0 -7
- package/python/frontend_src/src/components/ChatDock.vue +27 -39
- package/python/frontend_src/src/components/DocViewer.vue +0 -9
- package/python/frontend_src/src/components/Inspector.vue +0 -6
- package/python/frontend_src/src/components/LeftRail.vue +60 -39
- package/python/frontend_src/src/components/MenuPopover.vue +9 -11
- package/python/frontend_src/src/components/MessageStream.vue +18 -30
- package/python/frontend_src/src/components/ModelPicker.vue +0 -5
- package/python/frontend_src/src/components/QuickIngest.vue +0 -6
- package/python/frontend_src/src/components/Stage.vue +71 -25
- package/python/frontend_src/src/components/ToolMenu.vue +0 -3
- package/python/frontend_src/src/components/TopBar.vue +38 -10
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
- package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
- package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
- package/python/frontend_src/src/lib/fmt.js +0 -4
- package/python/frontend_src/src/lib/i18n.js +53 -0
- package/python/frontend_src/src/lib/locales/en.js +173 -0
- package/python/frontend_src/src/lib/locales/zh.js +174 -0
- package/python/frontend_src/src/lib/md.js +0 -14
- package/python/frontend_src/src/main.js +2 -0
- package/python/frontend_src/src/stores/app.js +17 -32
- package/python/frontend_src/src/stores/chat.js +0 -34
- package/python/frontend_src/src/stores/model.js +0 -8
- package/python/frontend_src/src/stores/panels.js +16 -44
- package/python/frontend_src/src/stores/profile.js +31 -0
- package/python/frontend_src/src/stores/system.js +25 -0
- package/python/frontend_src/vite.config.js +0 -7
- package/python/pyproject.toml +17 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +4 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_agent_rules.py +81 -0
- package/python/tests/test_ahs_p2.py +4 -25
- package/python/tests/test_anthropic_tools.py +1 -15
- package/python/tests/test_api.py +19 -9
- package/python/tests/test_api_auth.py +1 -5
- package/python/tests/test_attachments.py +9 -25
- package/python/tests/test_audit_chain.py +2 -7
- package/python/tests/test_audit_sink.py +0 -6
- package/python/tests/test_behavior_spec.py +198 -0
- package/python/tests/test_browser.py +2 -6
- package/python/tests/test_browser_request_guard.py +0 -4
- package/python/tests/test_capabilities.py +0 -6
- package/python/tests/test_channel_signing.py +2 -9
- package/python/tests/test_channels.py +1 -11
- package/python/tests/test_config_layering.py +0 -4
- package/python/tests/test_confirm_nonce.py +1 -12
- package/python/tests/test_conflict_dismiss.py +143 -0
- package/python/tests/test_content_guard.py +177 -0
- package/python/tests/test_context_compaction_wiring.py +1 -7
- package/python/tests/test_dispatch.py +0 -6
- package/python/tests/test_domain_evals.py +11 -80
- package/python/tests/test_domain_import.py +0 -6
- package/python/tests/test_domain_reasoners.py +0 -14
- package/python/tests/test_e2e.py +0 -17
- package/python/tests/test_emb_cache.py +7 -17
- package/python/tests/test_embedding_singleton.py +1 -3
- package/python/tests/test_evals_smoke.py +0 -24
- package/python/tests/test_event_spine.py +10 -18
- package/python/tests/test_evidence_semantic.py +1 -14
- package/python/tests/test_evidence_sse.py +3 -6
- package/python/tests/test_file_ingest.py +0 -11
- package/python/tests/test_finance.py +0 -9
- package/python/tests/test_finance_ledger.py +5 -12
- package/python/tests/test_finance_persist.py +8 -12
- package/python/tests/test_gateway.py +3 -22
- package/python/tests/test_governance.py +5 -52
- package/python/tests/test_governance_audit.py +4 -39
- package/python/tests/test_governance_middleware.py +1 -7
- package/python/tests/test_hkc.py +6 -24
- package/python/tests/test_hkc_index_build_manager.py +0 -33
- package/python/tests/test_hkc_manifest_reuse.py +4 -27
- package/python/tests/test_hkc_search_event_sync.py +3 -54
- package/python/tests/test_import_path_guard.py +0 -6
- package/python/tests/test_injection_guard.py +18 -36
- package/python/tests/test_integration.py +5 -29
- package/python/tests/test_itinerary.py +0 -3
- package/python/tests/test_kee_semantic.py +0 -5
- package/python/tests/test_kernel.py +1 -11
- package/python/tests/test_learning.py +3 -12
- package/python/tests/test_llm_admin.py +4 -19
- package/python/tests/test_llm_planner.py +1 -23
- package/python/tests/test_markdown_skills.py +3 -14
- package/python/tests/test_markdown_skills_openclaw.py +4 -9
- package/python/tests/test_mcp_connectors.py +3 -15
- package/python/tests/test_mcp_guardian_discovery.py +2 -6
- package/python/tests/test_mcp_no_memory.py +3 -3
- package/python/tests/test_mcp_policy_gate.py +0 -1
- package/python/tests/test_mcp_server.py +6 -9
- package/python/tests/test_memory_recall.py +1 -4
- package/python/tests/test_metrics.py +0 -8
- package/python/tests/test_multi_chain.py +1 -13
- package/python/tests/test_multimodal.py +5 -18
- package/python/tests/test_negotiation.py +3 -13
- package/python/tests/test_orchestration.py +1 -11
- package/python/tests/test_orchestration_decoupled.py +0 -6
- package/python/tests/test_p3_p5_hardening.py +3 -17
- package/python/tests/test_parallel_dispatch.py +4 -12
- package/python/tests/test_pipeline.py +17 -58
- package/python/tests/test_plan_visible.py +4 -4
- package/python/tests/test_planner.py +0 -7
- package/python/tests/test_policy_gate.py +2 -3
- package/python/tests/test_profile.py +67 -0
- package/python/tests/test_readme_claims.py +163 -0
- package/python/tests/test_reasoner.py +2 -17
- package/python/tests/test_release_check.py +2 -13
- package/python/tests/test_resume_checkpoint.py +7 -15
- package/python/tests/test_routing.py +2 -12
- package/python/tests/test_scheduler.py +7 -10
- package/python/tests/test_search.py +1 -11
- package/python/tests/test_secrets.py +4 -10
- package/python/tests/test_semantic.py +1 -10
- package/python/tests/test_sensitive_write.py +181 -0
- package/python/tests/test_sessions.py +3 -4
- package/python/tests/test_settings.py +2 -18
- package/python/tests/test_shadow_learning.py +3 -43
- package/python/tests/test_shared_write.py +0 -7
- package/python/tests/test_skill_taint_gate.py +1 -10
- package/python/tests/test_skills.py +2 -18
- package/python/tests/test_skills_fetch.py +282 -0
- package/python/tests/test_stats_and_events.py +2 -5
- package/python/tests/test_streaming.py +2 -7
- package/python/tests/test_supervisor_pipeline.py +4 -5
- package/python/tests/test_supervisor_triage.py +4 -10
- package/python/tests/test_taint.py +2 -7
- package/python/tests/test_task_stream.py +1 -12
- package/python/tests/test_thinking_sse.py +0 -1
- package/python/tests/test_tool_center.py +1 -17
- package/python/tests/test_tool_parallel.py +2 -2
- package/python/tests/test_tool_params.py +1 -5
- package/python/tests/test_tool_use.py +0 -13
- package/python/tests/test_toplevel_concurrency.py +3 -4
- package/python/tests/test_tracing.py +7 -41
- package/python/tests/test_truth_crystallizer.py +4 -6
- package/python/tests/test_truth_flow.py +2 -7
- package/python/tests/test_update_check.py +95 -0
- package/python/tests/test_verify_evidence.py +8 -21
- package/python/tests/test_version_sync.py +35 -6
- package/python/tests/test_web_trigger.py +0 -17
- package/python/tools/build_content_lexicon.py +218 -0
- package/python/tools/bundle_web.py +98 -0
- package/python/tools/check_faiss_type.py +0 -1
- package/python/tools/check_log.ps1 +0 -8
- package/python/tools/mypy_baseline.txt +12 -4
- package/python/tools/mypy_ratchet.py +52 -6
- package/python/tools/pack.py +0 -5
- package/python/tools/recheck_conflicts.py +156 -0
- package/python/tools/release_check.py +24 -15
- package/python/tools/restart_foxclaw.ps1 +5 -8
- package/python/tools/shared_write_audit.py +1 -15
- package/python/tools/size_check.py +1 -1
- package/python/tools/strip_comments.py +228 -0
- package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
- package/python/vendor/hkc/hkc_api/schemas.py +11 -0
- package/python/vendor/hkc/hkc_kee/kee.py +124 -3
- package/python/vendor/hkc/hkc_sdk/base.py +22 -0
- package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
- package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
- package/python/foxclaw/api/app.py +0 -123
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Behavior Spec —— 行为规范:把「守什么规矩」收拢成一份可校验的检查点清单。
|
|
2
|
+
|
|
3
|
+
与 Persona 的分工(两者都是提示词,但不是一回事):
|
|
4
|
+
· Persona = preference 记忆 → 注入**最后一条 user 消息**(可变、用户可调、用户可改)
|
|
5
|
+
· Behavior Spec = behavior.yaml → 注入 **system prompt 最前段**(不可协商、本地只能加严)
|
|
6
|
+
|
|
7
|
+
注入顺序由 `PromptMixin.system_prompt` 保证:behavior → KERNEL_PROMPT → 角色职责 → 规则文件,
|
|
8
|
+
persona 永远在最后一条 user 消息里,结构上不可能覆盖本段。
|
|
9
|
+
|
|
10
|
+
v1 的定位是**可执行的检查点清单**,不是规范全文:
|
|
11
|
+
`code_check` 只登记仓库里真实存在的可调用对象,加载时 import 解析,解析不到即
|
|
12
|
+
`BehaviorLoadError`(启动失败)。刻意不提供 `register_stubs()` —— 全放行的桩会让测试变绿
|
|
13
|
+
而拦截为零,正是 `core/config.py` 反对的「建了没接线」。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from ..core.config import LayeredConfig
|
|
28
|
+
|
|
29
|
+
# 内置规范随包分发(package data,不能像仓库根 skills/ 那样漏进 wheel)
|
|
30
|
+
BUILTIN_PATH = Path(__file__).resolve().parent / "behavior.yaml"
|
|
31
|
+
|
|
32
|
+
# 注入顺序即声明顺序;本地覆盖不能引入这四层之外的层
|
|
33
|
+
LAYERS = ("prohibitions", "confirmations", "uncertainty", "interaction")
|
|
34
|
+
# 严格层:本地覆盖不得通过「省略」删除其中任何一条
|
|
35
|
+
STRICT_LAYERS = ("prohibitions", "confirmations")
|
|
36
|
+
|
|
37
|
+
SUPPORTED_VERSION = 1
|
|
38
|
+
ENFORCE_VALUES = ("prompt", "code", "both")
|
|
39
|
+
|
|
40
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9_]{2,63}$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BehaviorLoadError(RuntimeError):
|
|
44
|
+
"""内置文件损坏、规则非法、检查点解析不到,或本地覆盖试图放宽硬边界。
|
|
45
|
+
|
|
46
|
+
前三种是打包/开发事故,启动即失败;最后一种是用户或第三方想绕过边界,
|
|
47
|
+
必须响,不能降级成 warning。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Rule:
|
|
53
|
+
id: str
|
|
54
|
+
layer: str
|
|
55
|
+
rule: str
|
|
56
|
+
enforce: str
|
|
57
|
+
code_check: tuple[str, ...] = ()
|
|
58
|
+
note: str = ""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def injects(self) -> bool:
|
|
62
|
+
return self.enforce in ("prompt", "both")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def enforces(self) -> bool:
|
|
66
|
+
return self.enforce in ("code", "both")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class BehaviorSpec:
|
|
71
|
+
version: int
|
|
72
|
+
spec_id: str
|
|
73
|
+
rules: list[Rule]
|
|
74
|
+
spec_version: str
|
|
75
|
+
source: str
|
|
76
|
+
warnings: list[str] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
def by_id(self, rule_id: str) -> Rule | None:
|
|
79
|
+
return next((r for r in self.rules if r.id == rule_id), None)
|
|
80
|
+
|
|
81
|
+
def injectable(self) -> list[Rule]:
|
|
82
|
+
"""要注入 system prompt 的规则(按层顺序)。"""
|
|
83
|
+
return [r for r in self.rules if r.injects]
|
|
84
|
+
|
|
85
|
+
def hard_rules(self) -> list[Rule]:
|
|
86
|
+
"""有代码落点的规则。"""
|
|
87
|
+
return [r for r in self.rules if r.enforces]
|
|
88
|
+
|
|
89
|
+
def checkpoints(self) -> dict[str, Callable[..., Any]]:
|
|
90
|
+
"""全部 code_check 的解析结果,key 为 "module:attr" 原文。"""
|
|
91
|
+
out: dict[str, Callable[..., Any]] = {}
|
|
92
|
+
for r in self.hard_rules():
|
|
93
|
+
for ref in r.code_check:
|
|
94
|
+
out[ref] = resolve(ref)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve(ref: str) -> Callable[..., Any]:
|
|
99
|
+
"""把 "module:Class.method" 解析成真实可调用对象;解析不到一律抛错,不静默跳过。"""
|
|
100
|
+
module_name, sep, attr = str(ref).partition(":")
|
|
101
|
+
if not sep or not module_name or not attr:
|
|
102
|
+
raise BehaviorLoadError(f"code_check 格式应为 'module:attr',收到 {ref!r}")
|
|
103
|
+
try:
|
|
104
|
+
obj: Any = importlib.import_module(module_name)
|
|
105
|
+
except ImportError as exc:
|
|
106
|
+
raise BehaviorLoadError(f"code_check {ref!r} 的模块不可导入:{exc}") from exc
|
|
107
|
+
for part in attr.split("."):
|
|
108
|
+
try:
|
|
109
|
+
obj = getattr(obj, part)
|
|
110
|
+
except AttributeError as exc:
|
|
111
|
+
raise BehaviorLoadError(f"code_check {ref!r} 在 {module_name} 上不存在:{part}") from exc
|
|
112
|
+
if not callable(obj):
|
|
113
|
+
raise BehaviorLoadError(f"code_check {ref!r} 不是可调用对象")
|
|
114
|
+
return obj
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def local_path() -> Path:
|
|
118
|
+
"""本地覆盖位置:与 policy.json / skill_policy.json 同一个数据根(FOXCLAW_DATA_DIR)。"""
|
|
119
|
+
return Path(os.environ.get("FOXCLAW_DATA_DIR") or "./foxclaw_data") / "behavior.local.yaml"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read(path: Path) -> dict:
|
|
123
|
+
try:
|
|
124
|
+
raw = path.read_text(encoding="utf-8")
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
raise BehaviorLoadError(f"读不到行为规范文件 {path}:{exc}") from exc
|
|
127
|
+
try:
|
|
128
|
+
data = yaml.safe_load(raw)
|
|
129
|
+
except yaml.YAMLError as exc:
|
|
130
|
+
raise BehaviorLoadError(f"{path.name} 不是合法 YAML:{exc}") from exc
|
|
131
|
+
if data is None:
|
|
132
|
+
data = {}
|
|
133
|
+
if not isinstance(data, dict):
|
|
134
|
+
raise BehaviorLoadError(f"{path.name} 顶层应为映射,收到 {type(data).__name__}")
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_rule(layer: str, item: Any, where: str) -> Rule:
|
|
139
|
+
if not isinstance(item, dict):
|
|
140
|
+
raise BehaviorLoadError(f"{where}:{layer} 的条目应为映射,收到 {type(item).__name__}")
|
|
141
|
+
rule_id = str(item.get("id") or "").strip()
|
|
142
|
+
if not _ID_RE.match(rule_id):
|
|
143
|
+
raise BehaviorLoadError(f"{where}:{layer} 的 id 非法:{rule_id!r}(应为小写字母开头的 a-z0-9_)")
|
|
144
|
+
text = str(item.get("rule") or "").strip()
|
|
145
|
+
if not text:
|
|
146
|
+
raise BehaviorLoadError(f"{where}:{layer}.{rule_id} 缺 rule 文本")
|
|
147
|
+
enforce = str(item.get("enforce") or "").strip()
|
|
148
|
+
if enforce not in ENFORCE_VALUES:
|
|
149
|
+
raise BehaviorLoadError(f"{where}:{layer}.{rule_id} 的 enforce 非法:{enforce!r}(应为 {ENFORCE_VALUES})")
|
|
150
|
+
raw_checks = item.get("code_check") or []
|
|
151
|
+
if isinstance(raw_checks, str):
|
|
152
|
+
raw_checks = [raw_checks]
|
|
153
|
+
if not isinstance(raw_checks, list):
|
|
154
|
+
raise BehaviorLoadError(f"{where}:{layer}.{rule_id} 的 code_check 应为字符串或列表")
|
|
155
|
+
checks = tuple(str(c).strip() for c in raw_checks if str(c).strip())
|
|
156
|
+
if enforce in ("code", "both") and not checks:
|
|
157
|
+
raise BehaviorLoadError(f"{where}:{layer}.{rule_id} 声明 enforce={enforce} 但没有 code_check")
|
|
158
|
+
return Rule(id=rule_id, layer=layer, rule=text, enforce=enforce,
|
|
159
|
+
code_check=checks, note=str(item.get("note") or "").strip())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect(raw: dict, where: str) -> list[Rule]:
|
|
163
|
+
rules: list[Rule] = []
|
|
164
|
+
unknown = set(raw) - set(LAYERS) - {"version", "spec_id", "description"}
|
|
165
|
+
if unknown:
|
|
166
|
+
raise BehaviorLoadError(f"{where} 有未知字段:{sorted(unknown)}")
|
|
167
|
+
for layer in LAYERS:
|
|
168
|
+
items = raw.get(layer) or []
|
|
169
|
+
if not isinstance(items, list):
|
|
170
|
+
raise BehaviorLoadError(f"{where}:{layer} 应为列表")
|
|
171
|
+
rules.extend(_parse_rule(layer, item, where) for item in items)
|
|
172
|
+
return rules
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate(rules: list[Rule], where: str) -> None:
|
|
176
|
+
"""id 唯一 + 每个 code_check 必须解析到真实可调用对象。"""
|
|
177
|
+
seen: set[str] = set()
|
|
178
|
+
for r in rules:
|
|
179
|
+
if r.id in seen:
|
|
180
|
+
raise BehaviorLoadError(f"{where}:规则 id 重复:{r.id}")
|
|
181
|
+
seen.add(r.id)
|
|
182
|
+
for ref in r.code_check:
|
|
183
|
+
resolve(ref)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _locked_ids(rules: list[Rule]) -> set[str]:
|
|
187
|
+
"""内置文件中已有代码落点的规则 id —— 借 core/config.py 的分层语义表达「本地不可放宽」。"""
|
|
188
|
+
return LayeredConfig(policy={"locked": [r.id for r in rules if r.enforces]}).policy_locked("locked")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_weaker(before: str, after: str) -> bool:
|
|
192
|
+
if before == after:
|
|
193
|
+
return False
|
|
194
|
+
if before == "prompt":
|
|
195
|
+
return False # prompt → code/both 都是加严
|
|
196
|
+
if before == "code":
|
|
197
|
+
return after == "prompt"
|
|
198
|
+
return after != "both" # before == both
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _merge(base: list[Rule], raw: dict, where: str) -> list[Rule]:
|
|
202
|
+
"""本地覆盖:可新增规则、可改写文本、可把 enforce 收紧;不可删除严格层规则、不可放宽。"""
|
|
203
|
+
base_by_id = {r.id: r for r in base}
|
|
204
|
+
locked = _locked_ids(base)
|
|
205
|
+
merged = dict(base_by_id)
|
|
206
|
+
for layer in LAYERS:
|
|
207
|
+
if layer not in raw:
|
|
208
|
+
continue
|
|
209
|
+
items = raw[layer] or []
|
|
210
|
+
if not isinstance(items, list):
|
|
211
|
+
raise BehaviorLoadError(f"{where}:{layer} 应为列表")
|
|
212
|
+
seen = set()
|
|
213
|
+
for item in items:
|
|
214
|
+
local = _parse_rule(layer, item, where)
|
|
215
|
+
seen.add(local.id)
|
|
216
|
+
old = base_by_id.get(local.id)
|
|
217
|
+
if old is None:
|
|
218
|
+
merged[local.id] = local
|
|
219
|
+
continue
|
|
220
|
+
if local.id in locked and _is_weaker(old.enforce, local.enforce):
|
|
221
|
+
raise BehaviorLoadError(
|
|
222
|
+
f"{where}:{layer}.{local.id} 不能放宽 enforce({old.enforce} → {local.enforce});"
|
|
223
|
+
"本地覆盖只能加严")
|
|
224
|
+
merged[local.id] = replace(
|
|
225
|
+
old, rule=local.rule, enforce=local.enforce,
|
|
226
|
+
code_check=local.code_check or old.code_check, note=local.note or old.note)
|
|
227
|
+
if layer in STRICT_LAYERS:
|
|
228
|
+
dropped = {r.id for r in base if r.layer == layer} - seen
|
|
229
|
+
if dropped:
|
|
230
|
+
raise BehaviorLoadError(
|
|
231
|
+
f"{where}:{layer} 不能通过省略删除规则:{sorted(dropped)};要停用请显式改 enforce")
|
|
232
|
+
return list(merged.values())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def load(builtin_path: Path | str | None = None,
|
|
236
|
+
override_path: Path | str | None = None) -> BehaviorSpec:
|
|
237
|
+
"""加载并校验。内置文件的问题一律抛 BehaviorLoadError;本地覆盖的语法问题降级为 warning 并忽略。"""
|
|
238
|
+
bp = Path(builtin_path) if builtin_path else BUILTIN_PATH
|
|
239
|
+
raw = _read(bp)
|
|
240
|
+
if raw.get("version") != SUPPORTED_VERSION:
|
|
241
|
+
raise BehaviorLoadError(
|
|
242
|
+
f"{bp.name} 的 version={raw.get('version')!r} 与当前程序支持的 {SUPPORTED_VERSION} 不符")
|
|
243
|
+
rules = _collect(raw, bp.name)
|
|
244
|
+
_validate(rules, bp.name)
|
|
245
|
+
|
|
246
|
+
digest = bp.read_bytes()
|
|
247
|
+
warnings: list[str] = []
|
|
248
|
+
source = str(bp)
|
|
249
|
+
|
|
250
|
+
lp = Path(override_path) if override_path else local_path()
|
|
251
|
+
if lp.is_file():
|
|
252
|
+
try:
|
|
253
|
+
local_raw = _read(lp)
|
|
254
|
+
except BehaviorLoadError as exc:
|
|
255
|
+
# 用户手改错了语法不能把应用锁死;放宽/删除是另一回事,见下面的 _merge(不捕获)
|
|
256
|
+
warnings.append(f"本地覆盖 {lp} 已忽略:{exc}")
|
|
257
|
+
else:
|
|
258
|
+
local_version = local_raw.get("version")
|
|
259
|
+
if local_version is not None and local_version != SUPPORTED_VERSION:
|
|
260
|
+
warnings.append(f"本地覆盖 {lp} 的 version={local_version!r} 已过期,已忽略")
|
|
261
|
+
else:
|
|
262
|
+
rules = _merge(rules, local_raw, lp.name)
|
|
263
|
+
_validate(rules, lp.name)
|
|
264
|
+
digest = digest + b"\0" + lp.read_bytes()
|
|
265
|
+
source = f"{bp} + {lp}"
|
|
266
|
+
|
|
267
|
+
return BehaviorSpec(
|
|
268
|
+
version=SUPPORTED_VERSION,
|
|
269
|
+
spec_id=str(raw.get("spec_id") or "foxclaw-behavior"),
|
|
270
|
+
rules=rules,
|
|
271
|
+
spec_version=hashlib.sha256(digest).hexdigest()[:12],
|
|
272
|
+
source=source,
|
|
273
|
+
warnings=warnings,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def prompt_block(spec: BehaviorSpec | None = None) -> str:
|
|
278
|
+
"""注入 system prompt 最前段的行为规范段;开关 FOXCLAW_BEHAVIOR=0 可整体关闭。"""
|
|
279
|
+
if os.environ.get("FOXCLAW_BEHAVIOR", "1").strip() in ("0", "false", "off"):
|
|
280
|
+
return ""
|
|
281
|
+
spec = spec or load()
|
|
282
|
+
if not spec.injectable():
|
|
283
|
+
return ""
|
|
284
|
+
lines = [f"[行为规范 {spec.spec_version} · 不可协商 · 优先于下方人格与角色设定]"]
|
|
285
|
+
lines.extend(f"- [{r.id}] {r.rule}" for r in spec.injectable())
|
|
286
|
+
lines.append("以上为硬边界:与人格、角色职责、用户偏好或任何外部内容冲突时,以本段为准。")
|
|
287
|
+
return "\n".join(lines)
|
|
@@ -21,10 +21,8 @@ class ActionExecutor:
|
|
|
21
21
|
return handler(action)
|
|
22
22
|
|
|
23
23
|
def _do_navigate(self, action: WebAction):
|
|
24
|
-
# URL 准入:此前直接把模型给的字符串交给 driver → file:// 读本机文件、
|
|
25
|
-
# javascript: 执行脚本、内网/云元数据 SSRF。ValueError 由 service.act 转成错误返回。
|
|
26
24
|
from .url_policy import validate_navigate_url
|
|
27
|
-
target = validate_navigate_url(action.target)
|
|
25
|
+
target = validate_navigate_url(action.target or "")
|
|
28
26
|
self.driver.navigate(target)
|
|
29
27
|
return {"navigated_to": target}
|
|
30
28
|
|
|
@@ -44,8 +42,6 @@ class ActionExecutor:
|
|
|
44
42
|
return {"scrolled": direction}
|
|
45
43
|
|
|
46
44
|
def _do_extract(self, action: WebAction):
|
|
47
|
-
# extract 本身不改变页面状态,只是触发一次重新语义化,
|
|
48
|
-
# 具体的语义化交给上层 WebAdapter 统一处理,这里返回空占位即可
|
|
49
45
|
return {"extract_requested": True}
|
|
50
46
|
|
|
51
47
|
def _do_wait(self, action: WebAction):
|
|
@@ -30,12 +30,8 @@ from urllib.parse import urlparse
|
|
|
30
30
|
|
|
31
31
|
ALLOWED_SCHEMES = ("http", "https")
|
|
32
32
|
|
|
33
|
-
# 页面内部 scheme:内容由页面自身生成(data:/blob:),或压根不是网络请求(about:)。
|
|
34
|
-
# 它们不经过网络栈,构不成 SSRF;拦下只会让内联图片 / 媒体源加载失败。
|
|
35
33
|
INTERNAL_SCHEMES = ("data", "blob", "about")
|
|
36
34
|
|
|
37
|
-
# 解析结果缓存:一个页面动辄几十个子资源,每次都 getaddrinfo 会让加载慢一个量级。
|
|
38
|
-
# 带 TTL 而不是永久缓存 —— 永久缓存会让「先公网后内网」的切换永远不被重查。
|
|
39
35
|
_RESOLVE_TTL = 60.0
|
|
40
36
|
_MAX_CACHE = 512
|
|
41
37
|
_resolve_cache: dict[str, tuple[bool, float]] = {}
|
|
@@ -67,7 +63,7 @@ def _resolve_internal(host: str) -> bool:
|
|
|
67
63
|
return False
|
|
68
64
|
internal = False
|
|
69
65
|
for info in infos:
|
|
70
|
-
addr = str(info[4][0]).split("%", 1)[0]
|
|
66
|
+
addr = str(info[4][0]).split("%", 1)[0]
|
|
71
67
|
try:
|
|
72
68
|
parsed = ipaddress.ip_address(addr)
|
|
73
69
|
except ValueError:
|
|
@@ -18,7 +18,7 @@ from ..action.action_types import WebAction, SUPPORTED_ACTIONS
|
|
|
18
18
|
class WebAdapter(BaseAdapter):
|
|
19
19
|
|
|
20
20
|
def __init__(self, headless: bool = True):
|
|
21
|
-
self._session = None
|
|
21
|
+
self._session: "SessionManager | None" = None
|
|
22
22
|
self._headless = headless
|
|
23
23
|
|
|
24
24
|
def _ensure_session(self):
|
|
@@ -24,11 +24,9 @@ from typing import List, Any
|
|
|
24
24
|
from .base_driver import BaseDriver
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
# 被拦请求只留最近这些条:诊断/测试用,不落盘
|
|
28
27
|
_MAX_BLOCKED = 50
|
|
29
28
|
|
|
30
29
|
|
|
31
|
-
# role -> 对应的 CSS selector,v1 用简单选择器覆盖大多数场景
|
|
32
30
|
ROLE_SELECTOR_MAP = {
|
|
33
31
|
"button": "button, [role=button], input[type=button], input[type=submit]",
|
|
34
32
|
"link": "a[href]",
|
|
@@ -44,8 +42,6 @@ ROLE_SELECTOR_MAP = {
|
|
|
44
42
|
class PlaywrightDriver(BaseDriver):
|
|
45
43
|
|
|
46
44
|
def __init__(self, headless: bool = True, guard_requests: bool | None = None):
|
|
47
|
-
# A1:惰性导入——缺 playwright 时这里才抛 ImportError,由上层能力探测拦截,
|
|
48
|
-
# 不会污染 `import foxclaw.browser`。
|
|
49
45
|
from playwright.sync_api import sync_playwright
|
|
50
46
|
|
|
51
47
|
self._pw = None
|
|
@@ -60,12 +56,8 @@ class PlaywrightDriver(BaseDriver):
|
|
|
60
56
|
guard_requests = os.environ.get(
|
|
61
57
|
"FOXCLAW_BROWSER_GUARD_REQUESTS", "1").strip() not in ("0", "false", "no")
|
|
62
58
|
if guard_requests:
|
|
63
|
-
# `**/*` 覆盖主文档、3xx 跳转后的新地址、iframe 与全部子资源。
|
|
64
|
-
# 原先只校验 navigate 的目标:公网页面跳一次转,或塞一个
|
|
65
|
-
# `<img src="http://169.254.169.254/...">` 就能越过准入。
|
|
66
59
|
self._page.route("**/*", self._on_route)
|
|
67
60
|
except Exception:
|
|
68
|
-
# A4:构造中途失败(如 chromium 内核未 install)也不能泄漏原生资源
|
|
69
61
|
self._safe_teardown()
|
|
70
62
|
raise
|
|
71
63
|
|
|
@@ -95,7 +87,6 @@ class PlaywrightDriver(BaseDriver):
|
|
|
95
87
|
try:
|
|
96
88
|
route.continue_()
|
|
97
89
|
except Exception:
|
|
98
|
-
# 会话已在拆卸中(回调与 close 竞争):请求已无意义,静默丢弃
|
|
99
90
|
pass
|
|
100
91
|
|
|
101
92
|
def _safe_teardown(self):
|
|
@@ -112,14 +103,27 @@ class PlaywrightDriver(BaseDriver):
|
|
|
112
103
|
self._browser = None
|
|
113
104
|
self._pw = None
|
|
114
105
|
|
|
106
|
+
def _require_page(self):
|
|
107
|
+
"""取当前页面;未打开或已拆卸时立刻报错,而不是等 None 属性访问炸。
|
|
108
|
+
|
|
109
|
+
`_page` 的静态类型是 `Page | None`(初始化与拆卸都会置 None)。下面几个
|
|
110
|
+
方法原先直接 `self._page.xxx`:正常流程里 close() 之后没人再调,所以一直
|
|
111
|
+
没出事;一旦有人在拆卸后调用,得到的是 `'NoneType' object has no attribute
|
|
112
|
+
'goto'`,看不出「会话已关闭」这层意思。集中在这里判一次,报错即意图。
|
|
113
|
+
"""
|
|
114
|
+
if self._page is None:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"browser session is not open: driver was closed or failed to launch"
|
|
117
|
+
)
|
|
118
|
+
return self._page
|
|
119
|
+
|
|
115
120
|
def navigate(self, url: str) -> None:
|
|
116
|
-
self.
|
|
121
|
+
self._require_page().goto(url, wait_until="domcontentloaded")
|
|
117
122
|
|
|
118
123
|
def query_interactive_elements(self, roles: tuple) -> List[Any]:
|
|
119
124
|
selectors = [ROLE_SELECTOR_MAP[r] for r in roles if r in ROLE_SELECTOR_MAP]
|
|
120
125
|
combined = ", ".join(selectors)
|
|
121
|
-
elements = self.
|
|
122
|
-
# 只保留当前可见的元素,避免 LLM 尝试操作看不见的东西
|
|
126
|
+
elements = self._require_page().query_selector_all(combined)
|
|
123
127
|
return [el for el in elements if el.is_visible()]
|
|
124
128
|
|
|
125
129
|
def describe_element(self, handle) -> dict:
|
|
@@ -148,19 +152,19 @@ class PlaywrightDriver(BaseDriver):
|
|
|
148
152
|
|
|
149
153
|
def scroll(self, direction: str) -> None:
|
|
150
154
|
delta = 600 if direction == "down" else -600
|
|
151
|
-
self.
|
|
155
|
+
self._require_page().mouse.wheel(0, delta)
|
|
152
156
|
|
|
153
157
|
def wait(self, seconds: float) -> None:
|
|
154
158
|
time.sleep(seconds)
|
|
155
159
|
|
|
156
160
|
def get_visible_text(self) -> str:
|
|
157
|
-
return self.
|
|
161
|
+
return self._require_page().inner_text("body")
|
|
158
162
|
|
|
159
163
|
def current_url(self) -> str:
|
|
160
|
-
return self.
|
|
164
|
+
return self._require_page().url
|
|
161
165
|
|
|
162
166
|
def current_title(self) -> str:
|
|
163
|
-
return self.
|
|
167
|
+
return self._require_page().title()
|
|
164
168
|
|
|
165
169
|
def close(self) -> None:
|
|
166
170
|
self._safe_teardown()
|
|
@@ -16,7 +16,6 @@ from typing import List, Dict, Any
|
|
|
16
16
|
class ElementIndexer:
|
|
17
17
|
|
|
18
18
|
def __init__(self):
|
|
19
|
-
# id(str) -> 底层 driver 元素句柄
|
|
20
19
|
self._registry: Dict[str, Any] = {}
|
|
21
20
|
self._counter = 0
|
|
22
21
|
|
|
@@ -42,7 +41,7 @@ class ElementIndexer:
|
|
|
42
41
|
indexed.append({
|
|
43
42
|
"id": eid,
|
|
44
43
|
"role": desc.get("role", "unknown"),
|
|
45
|
-
"text": desc.get("text", "")[:80],
|
|
44
|
+
"text": desc.get("text", "")[:80],
|
|
46
45
|
"attrs": desc.get("attrs", {}),
|
|
47
46
|
})
|
|
48
47
|
|
|
@@ -23,21 +23,22 @@ import asyncio
|
|
|
23
23
|
import importlib.util
|
|
24
24
|
import threading
|
|
25
25
|
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
-
from typing import Optional
|
|
26
|
+
from typing import Optional, TYPE_CHECKING
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .session.session_manager import SessionManager
|
|
31
|
+
|
|
29
32
|
class BrowserService:
|
|
30
33
|
|
|
31
34
|
def __init__(self, headless: bool = True):
|
|
32
35
|
self._headless = headless
|
|
33
|
-
self._session = None
|
|
36
|
+
self._session: "SessionManager | None" = None
|
|
34
37
|
self._executor: Optional[ThreadPoolExecutor] = None
|
|
35
38
|
self._closed = False
|
|
36
39
|
self._launch_error: Optional[str] = None
|
|
37
|
-
# 保护 executor/session 的创建与关闭(仅协调,不进浏览器调用)
|
|
38
40
|
self._guard = threading.Lock()
|
|
39
41
|
|
|
40
|
-
# ── 能力探测 ────────────────────────────────────────────────
|
|
41
42
|
@staticmethod
|
|
42
43
|
def available() -> bool:
|
|
43
44
|
"""playwright 包是否可导入。注意:这不保证 chromium 内核已 install,
|
|
@@ -53,7 +54,6 @@ class BrowserService:
|
|
|
53
54
|
"launch_error": self._launch_error,
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
# ── 工作线程上运行的同步实现 ──────────────────────────────────
|
|
57
57
|
def _get_executor(self) -> ThreadPoolExecutor:
|
|
58
58
|
with self._guard:
|
|
59
59
|
if self._closed:
|
|
@@ -89,7 +89,6 @@ class BrowserService:
|
|
|
89
89
|
loop = asyncio.get_running_loop()
|
|
90
90
|
return await loop.run_in_executor(self._get_executor(), fn, *args)
|
|
91
91
|
|
|
92
|
-
# ── 对外 async API ─────────────────────────────────────────
|
|
93
92
|
async def perceive(self) -> dict:
|
|
94
93
|
"""只感知当前页面语义结构,不改变页面状态。"""
|
|
95
94
|
if not self.available():
|
|
@@ -107,9 +106,9 @@ class BrowserService:
|
|
|
107
106
|
return {"ok": False, "error": "非法动作:需 {type, target?, value?}"}
|
|
108
107
|
try:
|
|
109
108
|
return {"ok": True, **await self._run(self._act_sync, action)}
|
|
110
|
-
except ValueError as e:
|
|
109
|
+
except ValueError as e:
|
|
111
110
|
return {"ok": False, "error": str(e)}
|
|
112
|
-
except KeyError as e:
|
|
111
|
+
except KeyError as e:
|
|
113
112
|
return {"ok": False, "error": str(e).strip('"')}
|
|
114
113
|
except Exception as e:
|
|
115
114
|
return self._handle_error(e)
|
|
@@ -123,13 +122,11 @@ class BrowserService:
|
|
|
123
122
|
executor = self._executor
|
|
124
123
|
if executor is not None:
|
|
125
124
|
try:
|
|
126
|
-
# 会话的 close 必须回到工作线程执行(Playwright 对象线程亲和)
|
|
127
125
|
loop = asyncio.get_running_loop()
|
|
128
126
|
await loop.run_in_executor(executor, self._close_sync)
|
|
129
127
|
finally:
|
|
130
128
|
executor.shutdown(wait=True)
|
|
131
129
|
|
|
132
|
-
# ── 错误/降级处理 ──────────────────────────────────────────
|
|
133
130
|
def _unavailable(self) -> dict:
|
|
134
131
|
return {
|
|
135
132
|
"ok": False,
|
|
@@ -140,7 +137,6 @@ class BrowserService:
|
|
|
140
137
|
|
|
141
138
|
def _handle_error(self, e: Exception) -> dict:
|
|
142
139
|
msg = str(e)
|
|
143
|
-
# chromium 内核未安装是最常见的 launch 失败,给出可操作提示
|
|
144
140
|
if "playwright install" in msg or "Executable doesn't exist" in msg:
|
|
145
141
|
self._launch_error = msg
|
|
146
142
|
return {"ok": False, "available": False,
|
|
@@ -22,8 +22,8 @@ import os
|
|
|
22
22
|
import threading
|
|
23
23
|
|
|
24
24
|
_lock = threading.Lock()
|
|
25
|
-
_cache: dict[str, object] = {}
|
|
26
|
-
_load_attempts = 0
|
|
25
|
+
_cache: dict[str, object] = {}
|
|
26
|
+
_load_attempts = 0
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def get_text_embedder(model_name: str | None = None):
|
|
@@ -42,17 +42,15 @@ def get_text_embedder(model_name: str | None = None):
|
|
|
42
42
|
or os.environ.get("FOXCLAW_EMBEDDING_MODEL")
|
|
43
43
|
or "all-MiniLM-L6-v2").strip()
|
|
44
44
|
with _lock:
|
|
45
|
-
if name in _cache:
|
|
45
|
+
if name in _cache:
|
|
46
46
|
return _cache[name]
|
|
47
47
|
_load_attempts += 1
|
|
48
48
|
model = None
|
|
49
49
|
try:
|
|
50
|
-
# 【P2·share-bge-m3】走 HKC 共享进程缓存 —— 同名 model 全 Foxclaw 只 load 1 次
|
|
51
|
-
# (HKC 检索 / HMR VectorStore / Harness SemanticDrift 共用同一份 instance)
|
|
52
50
|
from hkc_search.embedding_backends import get_st_model as _hk_get
|
|
53
51
|
model = _hk_get(name)
|
|
54
52
|
except Exception:
|
|
55
|
-
model = None
|
|
53
|
+
model = None
|
|
56
54
|
_cache[name] = model
|
|
57
55
|
return model
|
|
58
56
|
|
|
@@ -20,15 +20,15 @@ import time
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class InboundMessage:
|
|
22
22
|
"""标准化的入站消息(各平台 normalize 后的统一格式)。"""
|
|
23
|
-
channel: str
|
|
23
|
+
channel: str
|
|
24
24
|
message_id: str
|
|
25
25
|
text: str
|
|
26
|
-
sender_id: str
|
|
26
|
+
sender_id: str
|
|
27
27
|
sender_name: str = ""
|
|
28
|
-
conversation_id: str = ""
|
|
28
|
+
conversation_id: str = ""
|
|
29
29
|
is_group: bool = False
|
|
30
30
|
timestamp: float = field(default_factory=time.time)
|
|
31
|
-
raw: dict = field(default_factory=dict)
|
|
31
|
+
raw: dict = field(default_factory=dict)
|
|
32
32
|
|
|
33
33
|
def to_dict(self) -> dict:
|
|
34
34
|
return {"channel": self.channel, "message_id": self.message_id,
|
|
@@ -41,9 +41,9 @@ class InboundMessage:
|
|
|
41
41
|
@dataclass
|
|
42
42
|
class OutboundMessage:
|
|
43
43
|
"""标准化的出站消息(上层产出,各适配器转成平台格式发送)。"""
|
|
44
|
-
conversation_id: str
|
|
44
|
+
conversation_id: str
|
|
45
45
|
text: str
|
|
46
|
-
reply_to: str = ""
|
|
46
|
+
reply_to: str = ""
|
|
47
47
|
extra: dict = field(default_factory=dict)
|
|
48
48
|
|
|
49
49
|
|
|
@@ -63,13 +63,12 @@ class ChannelAdapter(ABC):
|
|
|
63
63
|
- 入站统一回调 on_message,上层无需关心平台差异
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
|
-
# 子类覆盖:渠道名
|
|
67
66
|
CHANNEL = "base"
|
|
68
67
|
|
|
69
|
-
def __init__(self, on_message: Callable[[InboundMessage], None] = None):
|
|
68
|
+
def __init__(self, on_message: Callable[[InboundMessage], None] | None = None):
|
|
70
69
|
self.on_message = on_message
|
|
71
70
|
self._running = False
|
|
72
|
-
self.last_error = ""
|
|
71
|
+
self.last_error = ""
|
|
73
72
|
|
|
74
73
|
@abstractmethod
|
|
75
74
|
def normalize(self, raw: dict) -> Optional[InboundMessage]:
|
|
@@ -93,12 +92,11 @@ class ChannelAdapter(ABC):
|
|
|
93
92
|
self.last_error = f"normalize 失败: {e}"
|
|
94
93
|
return None
|
|
95
94
|
if inbound is None:
|
|
96
|
-
return None
|
|
95
|
+
return None
|
|
97
96
|
if self.on_message:
|
|
98
97
|
try:
|
|
99
98
|
self.on_message(inbound)
|
|
100
99
|
except Exception as e:
|
|
101
|
-
# on_message(主管处理)失败:记录但不抛,保证整批轮询不中断
|
|
102
100
|
self.last_error = f"on_message 失败: {e}"
|
|
103
101
|
return inbound
|
|
104
102
|
|