foxclaw 0.1.2 → 0.1.3
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/CHANGELOG.md +57 -0
- package/python/CHANGELOG_CN.md +57 -0
- package/python/README.md +11 -2
- package/python/README_CN.md +32 -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/RELEASE.md +4 -3
- 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 +34 -152
- package/python/foxclaw/agents/base.py +65 -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 +7 -1
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +0 -6
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
- 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 +28 -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/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/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 +30 -57
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +36 -148
- 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/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 +0 -201
- package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
- package/python/frontend/v3/assets/index-D5nIsjms.css +1 -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 +0 -1
- 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 +0 -41
- 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 +6 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +3 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- 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_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_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_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/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/release_check.py +7 -7
- 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/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
|
@@ -20,7 +20,6 @@ from typing import Optional
|
|
|
20
20
|
|
|
21
21
|
from ..core.thought_chain import ThoughtChain, ThoughtNode, HarnessStatus
|
|
22
22
|
|
|
23
|
-
# ── S3 步骤 4:按检测维度拆分(纯搬移、零行为变更)────────────────────────
|
|
24
23
|
from ._base import (
|
|
25
24
|
HarnessResult,
|
|
26
25
|
GoalBaseline,
|
|
@@ -32,7 +31,6 @@ from .drift import SemanticDrift, ToolIntentDrift, ScopeCreepDrift
|
|
|
32
31
|
from .event_bus import HarnessEventBus
|
|
33
32
|
from ._policy import PolicyMixin
|
|
34
33
|
|
|
35
|
-
# 历史导入兼容:`from foxclaw.harness.harness import X` 的 X 在此 re-export。
|
|
36
34
|
__all__ = [
|
|
37
35
|
"Harness", "HarnessResult", "GoalBaseline",
|
|
38
36
|
"SemanticDrift", "ToolIntentDrift", "ScopeCreepDrift",
|
|
@@ -51,18 +49,10 @@ class Harness(PolicyMixin):
|
|
|
51
49
|
thought_chain: ThoughtChain,
|
|
52
50
|
policy_path: str = "./foxclaw_data/policy.yaml",
|
|
53
51
|
shadow_rounds: int = 20,
|
|
54
|
-
embedding_model: str = None,
|
|
55
|
-
spine=None,
|
|
56
|
-
promote_min_obs: int = None,
|
|
52
|
+
embedding_model: str | None = None,
|
|
53
|
+
spine=None,
|
|
54
|
+
promote_min_obs: int | None = None,
|
|
57
55
|
):
|
|
58
|
-
# embedding 模型解析 —— 【P2·单 embedding 入口】优先级:
|
|
59
|
-
# 1. 显式传参(测试/单 Agent 切模型用)
|
|
60
|
-
# 2. EMBEDDING_MODEL(系统唯一本地 embedding 入口,.env 一处配 bge-m3 全局生效)
|
|
61
|
-
# 3. HKC_EMBEDDING_MODEL(向后兼容,EMBEDDING_MODEL 未设时回退)
|
|
62
|
-
# 4. FOXCLAW_EMBEDDING_MODEL(向后兼容)
|
|
63
|
-
# 5. all-MiniLM-L6-v2(传统兜底)
|
|
64
|
-
# 中文部署设 paraphrase-multilingual-MiniLM-L12-v2 时,语义漂移对中文硬拦率
|
|
65
|
-
# 从 0 升到 1.0(见 evals/governance/compare_embeddings.py)。
|
|
66
56
|
embedding_model = (embedding_model
|
|
67
57
|
or os.environ.get("EMBEDDING_MODEL")
|
|
68
58
|
or os.environ.get("HKC_EMBEDDING_MODEL")
|
|
@@ -72,83 +62,47 @@ class Harness(PolicyMixin):
|
|
|
72
62
|
self.thought_chain = thought_chain
|
|
73
63
|
self.policy_path = Path(policy_path).resolve()
|
|
74
64
|
self.shadow_rounds = shadow_rounds
|
|
75
|
-
# per-实例分槽 token(目标基线走 contextvar,见文件顶部)
|
|
76
65
|
self._ctx_token = uuid.uuid4().hex
|
|
77
|
-
# 累积学习态(_delta_history/_run_count/weights/_policy/阈值)的并发锁。
|
|
78
|
-
# 为把请求 offload 到线程池提速做准备:多线程会并发调 check 打同一 harness,
|
|
79
|
-
# 昂贵的语义嵌入放锁外(线程安全、可并行),只有廉价的累积态改写进锁。RLock:
|
|
80
|
-
# _trigger_policy_learning 在锁内调用,同线程可重入。
|
|
81
66
|
self._lock = threading.RLock()
|
|
82
67
|
|
|
83
|
-
# 检测引擎
|
|
84
68
|
self.sem_drift = SemanticDrift(embedding_model)
|
|
85
69
|
self.tool_drift = ToolIntentDrift()
|
|
86
70
|
self.scope_drift = ScopeCreepDrift()
|
|
87
71
|
|
|
88
|
-
# 事件总线:本地订阅(向后兼容)+ 转发进全局脊柱(spine 非空时)
|
|
89
72
|
self.bus = HarnessEventBus(spine=spine, source=agent_id)
|
|
90
73
|
|
|
91
|
-
# 目标基线(每任务)—— 由 contextvar 承载;_baseline 是路由到 contextvar 的
|
|
92
|
-
# property,register_goal/check 里对 self._baseline 的读写代码完全不变。
|
|
93
74
|
self._baseline = None
|
|
94
75
|
|
|
95
|
-
# 权重(Policy 学习后动态调整)
|
|
96
76
|
self.weights = {"semantic": 0.5, "tool": 0.3, "scope": 0.2}
|
|
97
77
|
|
|
98
|
-
# 是否因「语义漂移」而 BLOCK。领域 Agent=True(执行业务任务时跑题要拦);
|
|
99
|
-
# 调度层(主管)=False(接收任意输入是其职责,语义距离大不算异常)。
|
|
100
78
|
self.block_on_semantic = True
|
|
101
79
|
|
|
102
|
-
# ── 降级方向修正(B 类修复)────────────────────────────────────
|
|
103
|
-
# 语义嵌入不可用时(SemanticDrift.mode == "hash"),3-gram 哈希对中文几乎
|
|
104
|
-
# 无区分度 —— 实测同域相似度 -0.17、跨域 +0.00,差异 <0.17 → 漂移分
|
|
105
|
-
# 1-sim 裁剪后**恒为 1.0**。该维度一旦参与加权(semantic 权重 0.5),
|
|
106
|
-
# Δ 恒 0.5 > block 阈值 0.25 → **所有请求一律 BLOCK**,包括评测集里
|
|
107
|
-
# ground-truth 标注为 pass 的正常请求。
|
|
108
|
-
# 治理层从「精准拦截」退化为「无差别拦截」,比不治理更危险(阻塞全部正常
|
|
109
|
-
# 业务),且失败是静默的(仅 mode 属性变化,无告警)。
|
|
110
|
-
#
|
|
111
|
-
# 正确处置:安全组件的降级方向必须是 **fail-open(失效但可见)**,
|
|
112
|
-
# 而不是 fail-closed-with-garbage(过度拦截且静默)。故降级时**停用语义
|
|
113
|
-
# 维度**,而 tool/scope 两个维度是确定性规则(工具类别匹配、实体集合运算),
|
|
114
|
-
# 不依赖模型质量,降级后仍可信,照常工作。
|
|
115
80
|
self.semantic_degraded: bool = (self.sem_drift.mode != "transformer")
|
|
116
|
-
# 降级只告警一次,避免每节点刷屏
|
|
117
81
|
self._degrade_warned: bool = False
|
|
118
82
|
|
|
119
|
-
# Shadow mode 状态
|
|
120
83
|
self._shadow_mode = True
|
|
121
84
|
self._run_count = 0
|
|
122
85
|
self._delta_history: list[float] = []
|
|
123
86
|
|
|
124
|
-
# 阈值(shadow mode 结束后用真实数据替换)
|
|
125
87
|
self._warn_threshold = 0.15
|
|
126
88
|
self._block_threshold = 0.25
|
|
127
89
|
|
|
128
|
-
# Policy
|
|
129
90
|
self._policy: dict = self._load_policy()
|
|
130
|
-
# 【P0-1】影子学习段(旧 YAML 没有 → 补空段;不重命名既有键,版本兼容)
|
|
131
91
|
self._policy.setdefault("shadow_rules", [])
|
|
132
92
|
self._policy.setdefault("history", [])
|
|
133
93
|
|
|
134
|
-
# 【P0-1】影子观察达标线:observations 凑满才做 promote/discard 判定。
|
|
135
|
-
# 显式构造参数 > 环境变量 > 默认 50(文稿建议值)。个人级低频使用下一条影子
|
|
136
|
-
# 规则可能长期 observing —— 这正是影子的目的:证据不够就不生效。
|
|
137
94
|
if promote_min_obs is not None:
|
|
138
95
|
self.promote_min_obs = promote_min_obs
|
|
139
96
|
else:
|
|
140
97
|
_env = os.environ.get("FOXCLAW_SHADOW_PROMOTE_MIN_OBS", "").strip()
|
|
141
98
|
self.promote_min_obs = int(_env) if _env.isdigit() else 50
|
|
142
99
|
|
|
143
|
-
# 把已学习规则的 action 编译成 check() 可直接应用的结构(规则级生效)
|
|
144
100
|
self._penalized_tools: set[str] = set()
|
|
145
101
|
self._scope_limit: Optional[int] = None
|
|
146
102
|
self._reindex_learned_actions()
|
|
147
103
|
|
|
148
|
-
# 注册默认事件处理
|
|
149
104
|
self._setup_default_handlers()
|
|
150
105
|
|
|
151
|
-
# ── 目标基线(每任务,contextvar 支撑,property 透明路由)──────
|
|
152
106
|
@property
|
|
153
107
|
def _baseline(self):
|
|
154
108
|
return _harness_ctx_get(self._ctx_token)
|
|
@@ -157,13 +111,12 @@ class Harness(PolicyMixin):
|
|
|
157
111
|
def _baseline(self, value):
|
|
158
112
|
_harness_ctx_set(self._ctx_token, value)
|
|
159
113
|
|
|
160
|
-
# ── 目标基线注册 ───────────────────────────────────────
|
|
161
114
|
|
|
162
115
|
def register_goal(
|
|
163
116
|
self,
|
|
164
117
|
goal_text: str,
|
|
165
118
|
allowed_tool_categories: set[str],
|
|
166
|
-
baseline_entities: set[str] = None,
|
|
119
|
+
baseline_entities: set[str] | None = None,
|
|
167
120
|
) -> GoalBaseline:
|
|
168
121
|
"""接口层调用 — 注册任务目标基线"""
|
|
169
122
|
baseline = GoalBaseline(
|
|
@@ -171,14 +124,11 @@ class Harness(PolicyMixin):
|
|
|
171
124
|
allowed_tool_categories=allowed_tool_categories,
|
|
172
125
|
baseline_entities=baseline_entities or set(),
|
|
173
126
|
)
|
|
174
|
-
# 用 encode(纯函数)算每任务自持的基线向量,不写共享的 _baseline_vec →
|
|
175
|
-
# 并发顶层任务的语义基线互不覆盖(check 用 self._baseline.goal_embedding 打分)。
|
|
176
127
|
baseline.goal_embedding = self.sem_drift.encode(goal_text)
|
|
177
128
|
self._baseline = baseline
|
|
178
129
|
self.bus.emit("goal_registered", baseline.to_dict())
|
|
179
130
|
return baseline
|
|
180
131
|
|
|
181
|
-
# ── 核心校验方法 ───────────────────────────────────────
|
|
182
132
|
|
|
183
133
|
def check(self, node: ThoughtNode, ignore_semantic: bool = False) -> HarnessResult:
|
|
184
134
|
"""
|
|
@@ -195,9 +145,8 @@ class Harness(PolicyMixin):
|
|
|
195
145
|
停用不可信维度,保留可信的 tool/scope 判定,并在 reason 显式标注降级
|
|
196
146
|
(失效但可见)。走 _check_degraded 分支,正常路径逻辑逐字不变。
|
|
197
147
|
"""
|
|
198
|
-
baseline = self._baseline
|
|
148
|
+
baseline = self._baseline
|
|
199
149
|
if baseline is None:
|
|
200
|
-
# 没有注册目标基线,只观测不截断
|
|
201
150
|
result = HarnessResult(
|
|
202
151
|
status="PASS", delta=0.0, dominant_dim="none",
|
|
203
152
|
scores={"semantic": 0.0, "tool": 0.0, "scope": 0.0},
|
|
@@ -208,12 +157,9 @@ class Harness(PolicyMixin):
|
|
|
208
157
|
if self.semantic_degraded and not ignore_semantic:
|
|
209
158
|
return self._check_degraded(node)
|
|
210
159
|
|
|
211
|
-
# 语义嵌入(昂贵、线程安全)—— **放锁外**,使请求 offload 到线程池后,并发请求的
|
|
212
|
-
# 嵌入计算真正并行(torch/ST CPU 前向会释放 GIL)。这是吞吐提速的关键点。
|
|
213
160
|
sem_score = 0.0 if ignore_semantic else \
|
|
214
161
|
self.sem_drift.score(node.content, baseline.goal_embedding)
|
|
215
162
|
|
|
216
|
-
# 以下累积态的读改写进锁(廉价):weights/_delta_history/_run_count/阈值/_policy。
|
|
217
163
|
with self._lock:
|
|
218
164
|
scores = {
|
|
219
165
|
"semantic": sem_score,
|
|
@@ -224,10 +170,6 @@ class Harness(PolicyMixin):
|
|
|
224
170
|
set(node.entities), baseline.baseline_entities),
|
|
225
171
|
}
|
|
226
172
|
|
|
227
|
-
# 应用已学习规则的 action(规则级生效,区别于权重级自学习):
|
|
228
|
-
# · penalize_tool —— 之前触发过 BLOCK 的工具,即便类别本在允许集里,也判最高
|
|
229
|
-
# 工具风险(1.0),让「学到某工具危险」下次真正拦得住,而非只微调 tool 权重。
|
|
230
|
-
# · restrict_scope —— 学到的实体扩张上限;新增实体超限直接判范围越界(scope=1.0)。
|
|
231
173
|
if node.tool_name and self._is_penalized_tool(node.tool_name):
|
|
232
174
|
scores["tool"] = 1.0
|
|
233
175
|
if (self._scope_limit is not None and baseline.baseline_entities):
|
|
@@ -236,27 +178,19 @@ class Harness(PolicyMixin):
|
|
|
236
178
|
scores["scope"] = 1.0
|
|
237
179
|
|
|
238
180
|
delta = sum(self.weights[k] * v for k, v in scores.items())
|
|
239
|
-
dominant = max(scores, key=scores
|
|
181
|
+
dominant = max(scores, key=lambda k: scores[k])
|
|
240
182
|
|
|
241
|
-
# 更新 shadow mode 历史。【深审修复·长期运行泄漏】原先**无条件**每次 check 都 append,
|
|
242
|
-
# 但 _delta_history 仅在 _calibrate_thresholds(shadow 结束时跑一次)被读;shadow_mode 只在
|
|
243
|
-
# init 置 True、永不重入,故校准后继续 append 是**纯无界泄漏**(每个推理节点一个 float)。
|
|
244
|
-
# 改为**仅 shadow 期 append**:样本上限=shadow_rounds,校准看到的样本完全一致(行为不变)。
|
|
245
183
|
if self._shadow_mode:
|
|
246
184
|
self._delta_history.append(delta)
|
|
247
185
|
self._run_count += 1
|
|
248
186
|
|
|
249
|
-
# Shadow mode 结束:用真实数据校准阈值
|
|
250
187
|
if self._shadow_mode and self._run_count >= self.shadow_rounds:
|
|
251
188
|
self._calibrate_thresholds()
|
|
252
189
|
|
|
253
|
-
# 判断状态
|
|
254
190
|
if self._shadow_mode:
|
|
255
|
-
status: HarnessStatus = "PASS"
|
|
191
|
+
status: HarnessStatus = "PASS"
|
|
256
192
|
reason = None
|
|
257
193
|
elif delta > self._block_threshold:
|
|
258
|
-
# 调度层(如主管)豁免「语义漂移」拦截:它的职责就是接收任意输入,
|
|
259
|
-
# 语义距离天然大,不应因此 BLOCK。但工具越权/范围越界仍然拦截。
|
|
260
194
|
if not self.block_on_semantic and dominant == "semantic":
|
|
261
195
|
status = "WARN"
|
|
262
196
|
reason = f"semantic drift Δ={delta:.3f}(调度层仅告警不拦截)"
|
|
@@ -275,19 +209,12 @@ class Harness(PolicyMixin):
|
|
|
275
209
|
scores=scores, reason=reason,
|
|
276
210
|
)
|
|
277
211
|
|
|
278
|
-
# 【P0-1】影子规则双轨观察(锁内):用**已算好的 scores** 模拟应用影子规则的
|
|
279
|
-
# action,只记录 would_block/would_pass,绝不改变上面的 real result。
|
|
280
|
-
# 不重新做语义嵌入 —— 开销是 O(观察中规则数) 的纯算术,远优于文稿预估的 2x。
|
|
281
|
-
# 顺序有意**先于**下方的 BLOCK 学习:本次 check 是对**既有**候选的一次观察样本;
|
|
282
|
-
# 本次 BLOCK 诞生的新候选从**下一次** check 才开始积累证据(诞生事件≠观察样本)。
|
|
283
212
|
if self._policy.get("shadow_rules"):
|
|
284
213
|
self._observe_shadow_rules(node, result, scores)
|
|
285
214
|
|
|
286
|
-
# BLOCK:触发 Policy 学习(mutates weights/_policy,须在锁内)
|
|
287
215
|
if status == "BLOCK":
|
|
288
216
|
self._trigger_policy_learning(node, result)
|
|
289
217
|
|
|
290
|
-
# 锁外:SQLite 写(WAL 线程安全)+ 事件广播(HarnessEventBus 独立)
|
|
291
218
|
self.thought_chain.update_harness(node.id, status, reason, delta, dominant)
|
|
292
219
|
self.bus.emit(f"harness_{status.lower()}", {
|
|
293
220
|
"agent_id": self.agent_id, "node_id": node.id, "node_type": node.type,
|
|
@@ -311,7 +238,6 @@ class Harness(PolicyMixin):
|
|
|
311
238
|
baseline = self._baseline
|
|
312
239
|
if not self._degrade_warned:
|
|
313
240
|
self._degrade_warned = True
|
|
314
|
-
# 一次性告警:降级 = 治理能力缺失,属需人工知晓的事件,不能只靠属性静默。
|
|
315
241
|
import logging as _lg
|
|
316
242
|
_lg.getLogger("foxclaw.harness").warning(
|
|
317
243
|
"[harness] 语义嵌入不可用(mode=%s),语义漂移维度已停用 —— "
|
|
@@ -320,8 +246,6 @@ class Harness(PolicyMixin):
|
|
|
320
246
|
self.sem_drift.mode)
|
|
321
247
|
|
|
322
248
|
with self._lock:
|
|
323
|
-
# 降级期间不污染 shadow 样本:把语义分置 0 后再 append,否则恒 1.0 的
|
|
324
|
-
# 语义分会污染阈值校准,装回 ST 后阈值仍不准。
|
|
325
249
|
saved_w = self.weights.get("semantic", 0.0)
|
|
326
250
|
self.weights["semantic"] = 0.0
|
|
327
251
|
try:
|
|
@@ -333,7 +257,6 @@ class Harness(PolicyMixin):
|
|
|
333
257
|
"scope": self.scope_drift.score(
|
|
334
258
|
set(node.entities), baseline.baseline_entities),
|
|
335
259
|
}
|
|
336
|
-
# 规则级学习(action)照常生效 —— 这部分不依赖 embedding
|
|
337
260
|
if node.tool_name and self._is_penalized_tool(node.tool_name):
|
|
338
261
|
scores["tool"] = 1.0
|
|
339
262
|
if (self._scope_limit is not None and baseline.baseline_entities):
|
|
@@ -342,7 +265,7 @@ class Harness(PolicyMixin):
|
|
|
342
265
|
scores["scope"] = 1.0
|
|
343
266
|
|
|
344
267
|
delta = sum(self.weights[k] * v for k, v in scores.items())
|
|
345
|
-
dominant = max(scores, key=scores
|
|
268
|
+
dominant = max(scores, key=lambda k: scores[k])
|
|
346
269
|
if self._shadow_mode:
|
|
347
270
|
self._delta_history.append(delta)
|
|
348
271
|
self._run_count += 1
|
|
@@ -350,7 +273,7 @@ class Harness(PolicyMixin):
|
|
|
350
273
|
self._calibrate_thresholds()
|
|
351
274
|
|
|
352
275
|
if self._shadow_mode:
|
|
353
|
-
status: HarnessStatus = "PASS"
|
|
276
|
+
status: HarnessStatus = "PASS"
|
|
354
277
|
reason = None
|
|
355
278
|
elif delta > self._block_threshold:
|
|
356
279
|
status = "BLOCK"
|
|
@@ -361,7 +284,6 @@ class Harness(PolicyMixin):
|
|
|
361
284
|
else:
|
|
362
285
|
status = "PASS"
|
|
363
286
|
reason = None
|
|
364
|
-
# 降级标记:让上层/前端能识别「本次判定不含语义维度」(失效但可见)
|
|
365
287
|
reason = ((reason + " [semantic-degraded:治理降级]") if reason
|
|
366
288
|
else "[semantic-degraded:语义维度停用]")
|
|
367
289
|
finally:
|
|
@@ -376,7 +298,6 @@ class Harness(PolicyMixin):
|
|
|
376
298
|
if status == "BLOCK":
|
|
377
299
|
self._trigger_policy_learning(node, result)
|
|
378
300
|
|
|
379
|
-
# 锁外:SQLite 写 + 事件广播(同 check,额外带 degraded 标记)
|
|
380
301
|
self.thought_chain.update_harness(node.id, status, reason, delta, dominant)
|
|
381
302
|
self.bus.emit(f"harness_{status.lower()}", {
|
|
382
303
|
"agent_id": self.agent_id, "node_id": node.id, "node_type": node.type,
|
|
@@ -385,7 +306,6 @@ class Harness(PolicyMixin):
|
|
|
385
306
|
})
|
|
386
307
|
return result
|
|
387
308
|
|
|
388
|
-
# ── 默认事件处理 ───────────────────────────────────────
|
|
389
309
|
|
|
390
310
|
def _setup_default_handlers(self):
|
|
391
311
|
def on_block(data):
|
|
@@ -405,7 +325,6 @@ class Harness(PolicyMixin):
|
|
|
405
325
|
self.bus.on("harness_block", on_block)
|
|
406
326
|
self.bus.on("policy_updated", on_policy_updated)
|
|
407
327
|
|
|
408
|
-
# ── 状态查询 ───────────────────────────────────────────
|
|
409
328
|
|
|
410
329
|
@property
|
|
411
330
|
def is_shadow_mode(self) -> bool:
|
|
@@ -419,10 +338,6 @@ class Harness(PolicyMixin):
|
|
|
419
338
|
"shadow_mode": self._shadow_mode,
|
|
420
339
|
"run_count": self._run_count,
|
|
421
340
|
"shadow_rounds": self.shadow_rounds,
|
|
422
|
-
# ── 降级可见性(B 类修复)────────────────────────────────
|
|
423
|
-
# 语义嵌入是否降级。semantic_degraded=True 意味着漂移检测只剩
|
|
424
|
-
# tool/scope 两维,**跑题不会被拦截** —— 这是需要人工知晓的治理
|
|
425
|
-
# 能力缺失,不能只体现在日志里。
|
|
426
341
|
"semantic_degraded": self.semantic_degraded,
|
|
427
342
|
"embedding_mode": self.sem_drift.mode,
|
|
428
343
|
}
|
|
@@ -435,12 +350,9 @@ class Harness(PolicyMixin):
|
|
|
435
350
|
"total_rules": len(rules),
|
|
436
351
|
"manual_rules": sum(1 for r in rules if r.get("source") == "manual"),
|
|
437
352
|
"learned_rules": sum(1 for r in rules if r.get("source") == "harness_learned"),
|
|
438
|
-
# 【P0-1】影子学习账本:observing=观察中(不生效);discarded/rolled_back 保留供审计
|
|
439
353
|
"shadow_observing": sum(1 for r in shadow
|
|
440
354
|
if (r.get("shadow") or {}).get("status") == "observing"),
|
|
441
355
|
"shadow_total": len(shadow),
|
|
442
356
|
"history_events": len(self._policy.get("history", [])),
|
|
443
|
-
# 返回**副本**:此前直接返回 self.weights 引用 —— 调用方拿到的不是快照,
|
|
444
|
-
# 既可能被后续权重更新"隔空改写"(读到的值取决于时机),也能反向改坏内部状态。
|
|
445
357
|
"weights": dict(self.weights),
|
|
446
358
|
}
|
|
@@ -22,8 +22,8 @@ from dataclasses import dataclass
|
|
|
22
22
|
@dataclass
|
|
23
23
|
class LoopVerdict:
|
|
24
24
|
stuck: bool = False
|
|
25
|
-
level: str = "ok"
|
|
26
|
-
detector: str = ""
|
|
25
|
+
level: str = "ok"
|
|
26
|
+
detector: str = ""
|
|
27
27
|
count: int = 0
|
|
28
28
|
message: str = ""
|
|
29
29
|
|
|
@@ -35,9 +35,9 @@ class LoopVerdict:
|
|
|
35
35
|
|
|
36
36
|
@dataclass
|
|
37
37
|
class _ToolCall:
|
|
38
|
-
signature: str
|
|
38
|
+
signature: str
|
|
39
39
|
tool_name: str
|
|
40
|
-
result_hash: str = ""
|
|
40
|
+
result_hash: str = ""
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class ToolLoopDetector:
|
|
@@ -45,14 +45,11 @@ class ToolLoopDetector:
|
|
|
45
45
|
|
|
46
46
|
def __init__(self, history_size: int = 30, warning: int = 6,
|
|
47
47
|
critical: int = 12, circuit_break: int = 20):
|
|
48
|
-
# 阈值单调递增保证(warning < critical < circuit_break)
|
|
49
48
|
self.warning = max(2, warning)
|
|
50
49
|
self.critical = max(self.warning + 1, critical)
|
|
51
50
|
self.circuit_break = max(self.critical + 1, circuit_break)
|
|
52
51
|
self.history: deque = deque(maxlen=max(history_size, self.circuit_break))
|
|
53
|
-
self._last_verdict = None
|
|
54
|
-
# 【深审·线程安全】SkillAdapter 持单个共享实例,invoke(含管线内 dispatch)可被并发任务
|
|
55
|
-
# 同时调 record → history.append + _detect(list(history)) 非原子,需锁(线程安全铁律)。
|
|
52
|
+
self._last_verdict: LoopVerdict | None = None
|
|
56
53
|
self._lock = threading.RLock()
|
|
57
54
|
|
|
58
55
|
@staticmethod
|
|
@@ -72,18 +69,19 @@ class ToolLoopDetector:
|
|
|
72
69
|
s = str(result)[:500]
|
|
73
70
|
return hashlib.sha256(s.encode()).hexdigest()[:16]
|
|
74
71
|
|
|
75
|
-
def record(self, tool_name: str, args: dict = None, result=None) -> LoopVerdict:
|
|
72
|
+
def record(self, tool_name: str, args: dict | None = None, result=None) -> LoopVerdict:
|
|
76
73
|
"""记录一次工具调用并即时检测。返回 LoopVerdict。"""
|
|
77
74
|
sig = self._signature(tool_name, args or {})
|
|
78
75
|
rhash = self._result_sig(result) if result is not None else ""
|
|
79
|
-
with self._lock:
|
|
76
|
+
with self._lock:
|
|
80
77
|
self.history.append(_ToolCall(sig, tool_name, rhash))
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
verdict = self._detect()
|
|
79
|
+
self._last_verdict = verdict
|
|
80
|
+
return verdict
|
|
83
81
|
|
|
84
82
|
def _level_for(self, count: int) -> str:
|
|
85
83
|
if count >= self.circuit_break:
|
|
86
|
-
return "critical"
|
|
84
|
+
return "critical"
|
|
87
85
|
if count >= self.critical:
|
|
88
86
|
return "critical"
|
|
89
87
|
if count >= self.warning:
|
|
@@ -95,7 +93,6 @@ class ToolLoopDetector:
|
|
|
95
93
|
if len(h) < self.warning:
|
|
96
94
|
return LoopVerdict()
|
|
97
95
|
|
|
98
|
-
# 1. repeat / no_progress:末尾连续相同签名
|
|
99
96
|
last_sig = h[-1].signature
|
|
100
97
|
streak = 0
|
|
101
98
|
for c in reversed(h):
|
|
@@ -105,8 +102,6 @@ class ToolLoopDetector:
|
|
|
105
102
|
break
|
|
106
103
|
if streak >= self.warning:
|
|
107
104
|
lvl = self._level_for(streak)
|
|
108
|
-
# no_progress 优先:同签名重复 且 (有结果的那些)结果都相同
|
|
109
|
-
# (只比对非空 result_hash,避免混合有/无 result 时漏判轮询卡死)
|
|
110
105
|
streak_calls = h[-streak:]
|
|
111
106
|
with_result = [c.result_hash for c in streak_calls if c.result_hash]
|
|
112
107
|
if len(with_result) >= 2 and len(set(with_result)) == 1:
|
|
@@ -114,13 +109,11 @@ class ToolLoopDetector:
|
|
|
114
109
|
stuck=True, level=lvl, detector="no_progress", count=streak,
|
|
115
110
|
message=(f"工具「{h[-1].tool_name}」轮询 {streak} 次结果无变化"
|
|
116
111
|
+ ("(已达熔断阈值,建议中止)" if streak >= self.circuit_break else "")))
|
|
117
|
-
# 否则按 repeat
|
|
118
112
|
return LoopVerdict(
|
|
119
113
|
stuck=True, level=lvl, detector="repeat", count=streak,
|
|
120
114
|
message=(f"工具「{h[-1].tool_name}」连续重复调用 {streak} 次"
|
|
121
115
|
+ ("(已达熔断阈值,建议中止)" if streak >= self.circuit_break else "")))
|
|
122
116
|
|
|
123
|
-
# 3. ping_pong:末尾两签名交替(A→B→A→B)
|
|
124
117
|
if len(h) >= 4:
|
|
125
118
|
a, b = h[-1].signature, h[-2].signature
|
|
126
119
|
if a != b:
|
|
@@ -24,7 +24,7 @@ AMAP_BASE = "https://restapi.amap.com/v3"
|
|
|
24
24
|
class AMapClient:
|
|
25
25
|
"""高德 Web Service 客户端。无 Key 则 enabled=False,调用返回 None。"""
|
|
26
26
|
|
|
27
|
-
def __init__(self, api_key: str = None):
|
|
27
|
+
def __init__(self, api_key: str | None = None):
|
|
28
28
|
self.api_key = api_key or os.environ.get("AMAP_MAPS_API_KEY")
|
|
29
29
|
self.enabled = bool(self.api_key)
|
|
30
30
|
|
|
@@ -40,12 +40,11 @@ class AMapClient:
|
|
|
40
40
|
except Exception:
|
|
41
41
|
return None
|
|
42
42
|
|
|
43
|
-
# ── 地理编码 ──────────────────────────────────────────
|
|
44
43
|
def geocode(self, address: str, city: str = "") -> Optional[tuple]:
|
|
45
44
|
"""地址 → (lng, lat)。"""
|
|
46
45
|
data = self._get("geocode/geo", {"address": address, "city": city})
|
|
47
46
|
if data and data.get("geocodes"):
|
|
48
|
-
loc = data["geocodes"][0]["location"]
|
|
47
|
+
loc = data["geocodes"][0]["location"]
|
|
49
48
|
lng, lat = loc.split(",")
|
|
50
49
|
return float(lng), float(lat)
|
|
51
50
|
return None
|
|
@@ -57,7 +56,6 @@ class AMapClient:
|
|
|
57
56
|
return data["regeocode"].get("formatted_address")
|
|
58
57
|
return None
|
|
59
58
|
|
|
60
|
-
# ── 路线规划 ──────────────────────────────────────────
|
|
61
59
|
def route(self, origin: str, destination: str, mode: str = "driving",
|
|
62
60
|
city: str = "") -> Optional[dict]:
|
|
63
61
|
"""真实路线。origin/destination 为 'lng,lat'。
|
|
@@ -90,7 +88,6 @@ class AMapClient:
|
|
|
90
88
|
except Exception:
|
|
91
89
|
return None
|
|
92
90
|
|
|
93
|
-
# ── POI / 周边 / 天气 ─────────────────────────────────
|
|
94
91
|
def search_poi(self, keywords: str, city: str = "", offset: int = 10) -> Optional[list]:
|
|
95
92
|
data = self._get("place/text",
|
|
96
93
|
{"keywords": keywords, "city": city, "offset": offset})
|
|
@@ -19,12 +19,12 @@ TravelMode = Literal["walking", "driving", "transit", "cycling"]
|
|
|
19
19
|
@dataclass
|
|
20
20
|
class Waypoint:
|
|
21
21
|
"""一个行程点 = 时空中的一个约束"""
|
|
22
|
-
name: str
|
|
23
|
-
address: str = ""
|
|
24
|
-
lng: Optional[float] = None
|
|
25
|
-
lat: Optional[float] = None
|
|
26
|
-
start_time: Optional[str] = None
|
|
27
|
-
duration_min: int = 0
|
|
22
|
+
name: str
|
|
23
|
+
address: str = ""
|
|
24
|
+
lng: Optional[float] = None
|
|
25
|
+
lat: Optional[float] = None
|
|
26
|
+
start_time: Optional[str] = None
|
|
27
|
+
duration_min: int = 0
|
|
28
28
|
city: str = ""
|
|
29
29
|
note: str = ""
|
|
30
30
|
id: str = field(default_factory=lambda: f"wp_{uuid.uuid4().hex[:10]}")
|
|
@@ -58,7 +58,7 @@ class RouteLeg:
|
|
|
58
58
|
mode: TravelMode = "driving"
|
|
59
59
|
distance_km: float = 0.0
|
|
60
60
|
duration_min: float = 0.0
|
|
61
|
-
source: Literal["estimated", "amap"] = "estimated"
|
|
61
|
+
source: Literal["estimated", "amap"] = "estimated"
|
|
62
62
|
note: str = ""
|
|
63
63
|
|
|
64
64
|
def to_dict(self) -> dict:
|
|
@@ -14,8 +14,6 @@ from __future__ import annotations
|
|
|
14
14
|
from ..cognitive.reasoner import DomainReasoner, ReasonedVerdict, Evidence
|
|
15
15
|
from .reasoning import ItineraryReasoning
|
|
16
16
|
|
|
17
|
-
# "紧凑"阈值:某段空档 / 所需通勤 < 该比例的倍数即算逼近(未赶不及但很赶),
|
|
18
|
-
# 交给可选 LLM 复核是否提醒用户。仅在启用 LLM 时生效。
|
|
19
17
|
TIGHT_RATIO = 1.5
|
|
20
18
|
|
|
21
19
|
|
|
@@ -43,12 +41,11 @@ class ItineraryConflictReasoner(DomainReasoner):
|
|
|
43
41
|
def gather_evidence(self, subject, obs: dict) -> list:
|
|
44
42
|
waypoints, mode = subject
|
|
45
43
|
mode = obs["mode"]
|
|
46
|
-
conflicts = ItineraryReasoning.detect_conflicts(waypoints, mode)
|
|
44
|
+
conflicts = ItineraryReasoning.detect_conflicts(waypoints, mode)
|
|
47
45
|
ev = [Evidence(factor=c["type"], detail=c["reason"], weight=1.0, raw=c)
|
|
48
46
|
for c in conflicts]
|
|
49
47
|
if not conflicts:
|
|
50
48
|
ev.append(Evidence("no_conflict", "未检测到时间冲突或通勤不可达", 0.5))
|
|
51
|
-
# informational(raw=None):顺路簇/密度,丰满证据链但不参与裁决
|
|
52
49
|
try:
|
|
53
50
|
clusters = ItineraryReasoning.cluster_by_proximity(waypoints)
|
|
54
51
|
if clusters:
|
|
@@ -65,11 +62,9 @@ class ItineraryConflictReasoner(DomainReasoner):
|
|
|
65
62
|
def arbitrate(self, subject, obs, hyps, ev) -> ReasonedVerdict:
|
|
66
63
|
triggered = [e for e in ev if e.raw is not None]
|
|
67
64
|
if triggered:
|
|
68
|
-
# 【规则权威】检出硬冲突 → confirm,绝不被 LLM 推翻/隐藏
|
|
69
65
|
return ReasonedVerdict(
|
|
70
66
|
"confirm", ";".join(e.detail for e in triggered),
|
|
71
67
|
ev, hyps, "rules")
|
|
72
|
-
# 无硬冲突:可选让 LLM 就"紧凑排程"复核是否升级(只 approve→confirm)
|
|
73
68
|
if DomainReasoner.llm_available(self.llm, self.llm_enabled) and self._is_tight(subject, obs):
|
|
74
69
|
if DomainReasoner.llm_should_escalate(self.llm, *self._llm_prompt(subject, obs, ev)):
|
|
75
70
|
return ReasonedVerdict("confirm", "排程紧凑,LLM 建议人工确认", ev, hyps, "llm")
|
|
@@ -7,28 +7,29 @@ Foxclaw 行程子系统 — 推理引擎(行程 Agent 的大脑,纯算法)
|
|
|
7
7
|
import math
|
|
8
8
|
from datetime import timedelta
|
|
9
9
|
from itertools import permutations
|
|
10
|
+
from typing import Any, cast
|
|
10
11
|
|
|
11
|
-
from .models import
|
|
12
|
+
from .models import RouteLeg, TravelMode, Waypoint
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
# 各出行方式的估算平均速度(km/h),接高德后用真实数据替换
|
|
15
15
|
MODE_SPEED = {"walking": 5, "cycling": 15, "transit": 25, "driving": 35}
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class ItineraryReasoning:
|
|
19
19
|
"""行程推理:把地点+时间变成可决策的行程智能。"""
|
|
20
20
|
|
|
21
|
-
# ── 距离与可达性(直线估算)──────────────────────────
|
|
22
21
|
@staticmethod
|
|
23
22
|
def haversine_km(a: Waypoint, b: Waypoint) -> float:
|
|
24
23
|
"""两点直线距离(km)。Haversine 公式。"""
|
|
25
|
-
|
|
24
|
+
lat_a, lng_a = a.lat, a.lng
|
|
25
|
+
lat_b, lng_b = b.lat, b.lng
|
|
26
|
+
if lat_a is None or lng_a is None or lat_b is None or lng_b is None:
|
|
26
27
|
return 0.0
|
|
27
28
|
R = 6371.0
|
|
28
|
-
dlat = math.radians(
|
|
29
|
-
dlng = math.radians(
|
|
29
|
+
dlat = math.radians(lat_b - lat_a)
|
|
30
|
+
dlng = math.radians(lng_b - lng_a)
|
|
30
31
|
x = (math.sin(dlat/2)**2 +
|
|
31
|
-
math.cos(math.radians(
|
|
32
|
+
math.cos(math.radians(lat_a)) * math.cos(math.radians(lat_b)) *
|
|
32
33
|
math.sin(dlng/2)**2)
|
|
33
34
|
return R * 2 * math.atan2(math.sqrt(x), math.sqrt(1-x))
|
|
34
35
|
|
|
@@ -36,16 +37,16 @@ class ItineraryReasoning:
|
|
|
36
37
|
def estimate_leg(a: Waypoint, b: Waypoint, mode: str = "driving") -> RouteLeg:
|
|
37
38
|
"""估算两点路线段(直线距离 × 1.3 绕路系数 / 速度)。"""
|
|
38
39
|
straight = ItineraryReasoning.haversine_km(a, b)
|
|
39
|
-
road_km = straight * 1.3
|
|
40
|
+
road_km = straight * 1.3
|
|
40
41
|
speed = MODE_SPEED.get(mode, 35)
|
|
41
42
|
duration = (road_km / speed) * 60 if speed else 0
|
|
42
43
|
return RouteLeg(
|
|
43
44
|
from_id=a.id, to_id=b.id, from_name=a.name, to_name=b.name,
|
|
44
|
-
mode=
|
|
45
|
+
mode=cast(TravelMode, mode if mode in MODE_SPEED else "driving"),
|
|
46
|
+
distance_km=round(road_km, 2),
|
|
45
47
|
duration_min=round(duration, 1), source="estimated",
|
|
46
48
|
)
|
|
47
49
|
|
|
48
|
-
# ── 路线优化(TSP)────────────────────────────────────
|
|
49
50
|
@staticmethod
|
|
50
51
|
def optimize_order(waypoints: list, mode: str = "driving",
|
|
51
52
|
fixed_start: bool = True) -> dict:
|
|
@@ -61,10 +62,10 @@ class ItineraryReasoning:
|
|
|
61
62
|
for i in range(len(seq)-1))
|
|
62
63
|
|
|
63
64
|
if len(pts) <= 8:
|
|
64
|
-
# 精确解
|
|
65
65
|
start = pts[0] if fixed_start else None
|
|
66
66
|
rest = pts[1:] if fixed_start else pts
|
|
67
|
-
best
|
|
67
|
+
best: list = []
|
|
68
|
+
best_d = float("inf")
|
|
68
69
|
for perm in permutations(rest):
|
|
69
70
|
seq = ([start] + list(perm)) if start else list(perm)
|
|
70
71
|
d = total_dist(seq)
|
|
@@ -73,7 +74,6 @@ class ItineraryReasoning:
|
|
|
73
74
|
method = "exact"
|
|
74
75
|
ordered = best
|
|
75
76
|
else:
|
|
76
|
-
# 最近邻启发式
|
|
77
77
|
remaining = pts[1:]
|
|
78
78
|
ordered = [pts[0]]
|
|
79
79
|
while remaining:
|
|
@@ -83,7 +83,6 @@ class ItineraryReasoning:
|
|
|
83
83
|
remaining.remove(nxt)
|
|
84
84
|
method = "nearest_neighbor"
|
|
85
85
|
|
|
86
|
-
# 计算总距离/时间
|
|
87
86
|
legs = [ItineraryReasoning.estimate_leg(ordered[i], ordered[i+1], mode)
|
|
88
87
|
for i in range(len(ordered)-1)]
|
|
89
88
|
return {
|
|
@@ -95,17 +94,15 @@ class ItineraryReasoning:
|
|
|
95
94
|
"method": method,
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
# ── 时间冲突 + 可达性 ─────────────────────────────────
|
|
99
97
|
@staticmethod
|
|
100
98
|
def detect_conflicts(waypoints: list, mode: str = "driving") -> list:
|
|
101
99
|
"""按时间排序的行程,检测:① 时间重叠 ② 通勤赶不及。"""
|
|
102
100
|
timed = [w for w in waypoints if w.dt is not None]
|
|
103
101
|
timed.sort(key=lambda w: w.dt)
|
|
104
|
-
issues = []
|
|
102
|
+
issues: list[dict[str, Any]] = []
|
|
105
103
|
for i in range(len(timed) - 1):
|
|
106
104
|
cur, nxt = timed[i], timed[i+1]
|
|
107
105
|
cur_end = cur.dt + timedelta(minutes=cur.duration_min)
|
|
108
|
-
# 时间重叠
|
|
109
106
|
if cur_end > nxt.dt:
|
|
110
107
|
issues.append({
|
|
111
108
|
"type": "time_conflict",
|
|
@@ -115,7 +112,6 @@ class ItineraryReasoning:
|
|
|
115
112
|
f"晚于「{nxt.name}」开始({nxt.dt.strftime('%H:%M')})",
|
|
116
113
|
})
|
|
117
114
|
continue
|
|
118
|
-
# 可达性:中间通勤时间够不够
|
|
119
115
|
if cur.has_coord and nxt.has_coord:
|
|
120
116
|
leg = ItineraryReasoning.estimate_leg(cur, nxt, mode)
|
|
121
117
|
gap_min = (nxt.dt - cur_end).total_seconds() / 60
|
|
@@ -131,7 +127,6 @@ class ItineraryReasoning:
|
|
|
131
127
|
})
|
|
132
128
|
return issues
|
|
133
129
|
|
|
134
|
-
# ── 地理聚类(顺路归组)───────────────────────────────
|
|
135
130
|
@staticmethod
|
|
136
131
|
def cluster_by_proximity(waypoints: list, radius_km: float = 3.0) -> list:
|
|
137
132
|
"""把相近的行程点聚成簇,建议"顺路一起办"。"""
|
|
@@ -149,9 +144,8 @@ class ItineraryReasoning:
|
|
|
149
144
|
cluster.append(other)
|
|
150
145
|
used.add(other.id)
|
|
151
146
|
clusters.append([c.name for c in cluster])
|
|
152
|
-
return [c for c in clusters if len(c) > 1]
|
|
147
|
+
return [c for c in clusters if len(c) > 1]
|
|
153
148
|
|
|
154
|
-
# ── 行程密度 ──────────────────────────────────────────
|
|
155
149
|
@staticmethod
|
|
156
150
|
def density(waypoints: list, mode: str = "driving") -> dict:
|
|
157
151
|
"""行程密度:总点数、总通勤、跨城。"""
|