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
|
@@ -50,25 +50,22 @@ def _pid_alive(pid: int) -> bool:
|
|
|
50
50
|
handle = kernel32.OpenProcess(
|
|
51
51
|
PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
52
52
|
if not handle:
|
|
53
|
-
return False
|
|
53
|
+
return False
|
|
54
54
|
try:
|
|
55
55
|
code = wintypes.DWORD()
|
|
56
56
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
57
57
|
return False
|
|
58
|
-
# STILL_ACTIVE=259 表示仍在运行。极少数进程恰以 259 退出会被误判存活,
|
|
59
|
-
# 但这是"只读探测"的可接受代价——绝不误杀才是首要目标(psutil 同此取舍)。
|
|
60
58
|
return code.value == STILL_ACTIVE
|
|
61
59
|
finally:
|
|
62
60
|
kernel32.CloseHandle(handle)
|
|
63
61
|
|
|
64
|
-
# POSIX
|
|
65
62
|
try:
|
|
66
63
|
os.kill(pid, 0)
|
|
67
64
|
return True
|
|
68
65
|
except ProcessLookupError:
|
|
69
66
|
return False
|
|
70
67
|
except PermissionError:
|
|
71
|
-
return True
|
|
68
|
+
return True
|
|
72
69
|
except Exception:
|
|
73
70
|
return False
|
|
74
71
|
|
|
@@ -79,14 +76,13 @@ class TaskRecord:
|
|
|
79
76
|
description: str
|
|
80
77
|
pid: int
|
|
81
78
|
started: float
|
|
82
|
-
status: str = "in_progress"
|
|
79
|
+
status: str = "in_progress"
|
|
83
80
|
session_id: str = ""
|
|
84
81
|
channel: str = ""
|
|
85
82
|
finished: float = 0.0
|
|
86
|
-
boot_id: str = ""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
start_count: int = 1 # 同一 task_id 被 start 的次数(重复 start 保留最后一次)
|
|
83
|
+
boot_id: str = ""
|
|
84
|
+
retryable: bool = False
|
|
85
|
+
start_count: int = 1
|
|
90
86
|
|
|
91
87
|
def to_dict(self) -> dict:
|
|
92
88
|
return asdict(self)
|
|
@@ -95,10 +91,10 @@ class TaskRecord:
|
|
|
95
91
|
@dataclass
|
|
96
92
|
class RecoveryPlan:
|
|
97
93
|
"""【P2-4】恢复计划:把中断任务分类,调用方(API 层/前端)据此决定动作。"""
|
|
98
|
-
retryable: list = field(default_factory=list)
|
|
99
|
-
notify_user: list = field(default_factory=list)
|
|
100
|
-
needs_manual: list = field(default_factory=list)
|
|
101
|
-
details: list = field(default_factory=list)
|
|
94
|
+
retryable: list = field(default_factory=list)
|
|
95
|
+
notify_user: list = field(default_factory=list)
|
|
96
|
+
needs_manual: list = field(default_factory=list)
|
|
97
|
+
details: list = field(default_factory=list)
|
|
102
98
|
|
|
103
99
|
def to_dict(self) -> dict:
|
|
104
100
|
return asdict(self)
|
|
@@ -107,20 +103,17 @@ class RecoveryPlan:
|
|
|
107
103
|
class RestartRecovery:
|
|
108
104
|
"""任务生命周期日志 + 启动恢复扫描。"""
|
|
109
105
|
|
|
110
|
-
def __init__(self, log_path: str, current_pid: int = None, boot_id: str = None,
|
|
106
|
+
def __init__(self, log_path: str, current_pid: int | None = None, boot_id: str | None = None,
|
|
111
107
|
spine=None, fsync: bool = True):
|
|
112
108
|
self.log_path = log_path
|
|
113
109
|
self.current_pid = current_pid or os.getpid()
|
|
114
|
-
# boot_id:每次启动唯一。任务记录带上它,扫描时"非本次启动的 in_progress"
|
|
115
|
-
# 即属于上一次运行 —— 比单纯靠 pid 可靠(pid 会被 OS 复用)。
|
|
116
110
|
import uuid
|
|
117
111
|
self.boot_id = boot_id or uuid.uuid4().hex[:16]
|
|
118
|
-
self._active: dict[str, TaskRecord] = {}
|
|
119
|
-
self._spine = spine
|
|
120
|
-
self._fsync = fsync
|
|
112
|
+
self._active: dict[str, TaskRecord] = {}
|
|
113
|
+
self._spine = spine
|
|
114
|
+
self._fsync = fsync
|
|
121
115
|
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
|
|
122
116
|
|
|
123
|
-
# ── 任务生命周期 ──────────────────────────────────────
|
|
124
117
|
def task_started(self, task_id: str, description: str,
|
|
125
118
|
session_id: str = "", channel: str = "",
|
|
126
119
|
retryable: bool = False) -> TaskRecord:
|
|
@@ -142,7 +135,6 @@ class RestartRecovery:
|
|
|
142
135
|
"status": status, "pid": self.current_pid,
|
|
143
136
|
"finished": time.time()})
|
|
144
137
|
|
|
145
|
-
# ── 步骤级 checkpoint(P1 断点可续)─────────────────────
|
|
146
138
|
def record_step(self, task_id: str, step: dict) -> None:
|
|
147
139
|
"""记一条「已完成的步骤」。管线每完成一个 stage、工具轮每完成一个工具调用各落一条,
|
|
148
140
|
崩溃/断连后 `steps_of()` 能回答「跑到哪一步」,也让续跑知道哪些步骤不必重做。
|
|
@@ -171,7 +163,7 @@ class RestartRecovery:
|
|
|
171
163
|
try:
|
|
172
164
|
ev = json.loads(line)
|
|
173
165
|
except Exception:
|
|
174
|
-
continue
|
|
166
|
+
continue
|
|
175
167
|
if ev.get("event") == "step" and ev.get("task_id") == task_id:
|
|
176
168
|
out.append({k: v for k, v in ev.items()
|
|
177
169
|
if k not in ("event", "task_id")})
|
|
@@ -179,7 +171,6 @@ class RestartRecovery:
|
|
|
179
171
|
return out
|
|
180
172
|
return out[-limit:] if limit else out
|
|
181
173
|
|
|
182
|
-
# ── 启动恢复扫描 ──────────────────────────────────────
|
|
183
174
|
def scan_interrupted(self) -> list[TaskRecord]:
|
|
184
175
|
"""启动时调用:重建日志状态,找出被中断的任务。
|
|
185
176
|
|
|
@@ -194,9 +185,7 @@ class RestartRecovery:
|
|
|
194
185
|
if rec.status != "in_progress":
|
|
195
186
|
continue
|
|
196
187
|
if rec.boot_id == self.boot_id:
|
|
197
|
-
continue
|
|
198
|
-
# 非本次启动的 in_progress = 上次运行遗留 = 被中断
|
|
199
|
-
# (pid 是否存活作为辅助信息记录,但不作为唯一判据)
|
|
188
|
+
continue
|
|
200
189
|
rec.status = "interrupted"
|
|
201
190
|
interrupted.append(rec)
|
|
202
191
|
self._append({"event": "interrupted", "task_id": task_id,
|
|
@@ -220,7 +209,7 @@ class RestartRecovery:
|
|
|
220
209
|
try:
|
|
221
210
|
ev = json.loads(line)
|
|
222
211
|
except Exception:
|
|
223
|
-
continue
|
|
212
|
+
continue
|
|
224
213
|
self._apply_event(records, ev)
|
|
225
214
|
except Exception:
|
|
226
215
|
pass
|
|
@@ -233,7 +222,6 @@ class RestartRecovery:
|
|
|
233
222
|
return
|
|
234
223
|
kind = ev.get("event")
|
|
235
224
|
if kind == "start":
|
|
236
|
-
# 【P2-4】同一 task_id 多次 start:保留最后一次,但累计 start_count
|
|
237
225
|
existing = records.get(tid)
|
|
238
226
|
start_count = (existing.start_count + 1) if existing else 1
|
|
239
227
|
records[tid] = TaskRecord(
|
|
@@ -249,7 +237,7 @@ class RestartRecovery:
|
|
|
249
237
|
if kind == "finish":
|
|
250
238
|
records[tid].finished = ev.get("finished", 0)
|
|
251
239
|
|
|
252
|
-
def plan_recovery(self, interrupted: list = None) -> RecoveryPlan:
|
|
240
|
+
def plan_recovery(self, interrupted: list | None = None) -> RecoveryPlan:
|
|
253
241
|
"""【P2-4】给中断任务分类,返回恢复计划(不改 scan_interrupted 的返回,兼容)。
|
|
254
242
|
|
|
255
243
|
分类规则:
|
|
@@ -304,9 +292,9 @@ class RestartRecovery:
|
|
|
304
292
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
305
293
|
f.flush()
|
|
306
294
|
if self._fsync:
|
|
307
|
-
os.fsync(f.fileno())
|
|
295
|
+
os.fsync(f.fileno())
|
|
308
296
|
except Exception:
|
|
309
|
-
pass
|
|
297
|
+
pass
|
|
310
298
|
|
|
311
299
|
def compact_log(self, keep_recent_days: float = 7):
|
|
312
300
|
"""压实日志:丢弃已完成且超过 N 天的记录,防文件无限膨胀。
|
|
@@ -317,7 +305,6 @@ class RestartRecovery:
|
|
|
317
305
|
cutoff = time.time() - keep_recent_days * 86400
|
|
318
306
|
keep = []
|
|
319
307
|
for rec in records.values():
|
|
320
|
-
# 保留:仍在进行的,或最近有活动(开始或完成)的
|
|
321
308
|
last_activity = max(rec.started, rec.finished or 0)
|
|
322
309
|
if rec.status == "in_progress" or last_activity >= cutoff:
|
|
323
310
|
keep.append({"event": "start", **rec.to_dict()})
|
|
@@ -331,7 +318,7 @@ class RestartRecovery:
|
|
|
331
318
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
332
319
|
for e in keep:
|
|
333
320
|
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
334
|
-
for e in steps:
|
|
321
|
+
for e in steps:
|
|
335
322
|
f.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
336
323
|
os.replace(tmp, self.log_path)
|
|
337
324
|
except Exception:
|
|
@@ -9,45 +9,46 @@
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@dataclass
|
|
15
16
|
class TaskIntent:
|
|
16
17
|
"""任务意图:粗粒度类别 + 三个亲和度提示。"""
|
|
17
|
-
category: str
|
|
18
|
+
category: str
|
|
18
19
|
confidence: float
|
|
19
|
-
memory_affinity: float
|
|
20
|
-
reasoning_depth: float
|
|
20
|
+
memory_affinity: float
|
|
21
|
+
reasoning_depth: float
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
@dataclass
|
|
24
25
|
class TaskFeatures:
|
|
25
|
-
complexity: float
|
|
26
|
-
memory_dependency: float
|
|
27
|
-
tool_cost: float
|
|
28
|
-
reasoning_depth: float
|
|
29
|
-
urgency: float
|
|
30
|
-
expected_runtime: float
|
|
26
|
+
complexity: float
|
|
27
|
+
memory_dependency: float
|
|
28
|
+
tool_cost: float
|
|
29
|
+
reasoning_depth: float
|
|
30
|
+
urgency: float
|
|
31
|
+
expected_runtime: float
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
@dataclass
|
|
34
35
|
class SystemContext:
|
|
35
36
|
"""系统负载上下文。主管据此调整优先级、决定是否抢占。"""
|
|
36
|
-
active_agents: int = 0
|
|
37
|
-
queue_depth: int = 0
|
|
38
|
-
system_load: float = 0.1
|
|
37
|
+
active_agents: int = 0
|
|
38
|
+
queue_depth: int = 0
|
|
39
|
+
system_load: float = 0.1
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
@dataclass
|
|
42
43
|
class ScheduleDecision:
|
|
43
|
-
priority: float
|
|
44
|
-
route_hint: str
|
|
45
|
-
execution_mode: str
|
|
46
|
-
preemptible: bool
|
|
47
|
-
expected_budget: float
|
|
48
|
-
intent: TaskIntent = None
|
|
49
|
-
features: TaskFeatures = None
|
|
50
|
-
preemption_reason: str = None
|
|
44
|
+
priority: float
|
|
45
|
+
route_hint: str
|
|
46
|
+
execution_mode: str
|
|
47
|
+
preemptible: bool
|
|
48
|
+
expected_budget: float
|
|
49
|
+
intent: Optional[TaskIntent] = None
|
|
50
|
+
features: Optional[TaskFeatures] = None
|
|
51
|
+
preemption_reason: Optional[str] = None
|
|
51
52
|
reason: str = ""
|
|
52
53
|
|
|
53
54
|
def to_dict(self) -> dict:
|
|
@@ -63,7 +64,6 @@ class ScheduleDecision:
|
|
|
63
64
|
class CognitiveScheduler:
|
|
64
65
|
"""意图感知的认知调度器。"""
|
|
65
66
|
|
|
66
|
-
# 意图识别关键词(粗分类)
|
|
67
67
|
_REASONING = ["规划", "计划", "分析", "设计", "推理", "为什么", "怎么办", "策略", "方案"]
|
|
68
68
|
_MEMORY = ["记得", "记忆", "之前", "上次", "回忆", "查一下我", "我说过"]
|
|
69
69
|
_WORKFLOW = ["然后", "接着", "之后", "依次", "批量", "流程", "再然后", "下一步"]
|
|
@@ -86,7 +86,6 @@ class CognitiveScheduler:
|
|
|
86
86
|
complexity, tool_cost, urgency, runtime = 0.7, 0.6, 0.65, 0.8
|
|
87
87
|
elif intent.category == "memory":
|
|
88
88
|
complexity, tool_cost, urgency, runtime = 0.5, 0.2, 0.55, 0.6
|
|
89
|
-
# 紧急词加成
|
|
90
89
|
if any(w in task for w in ["紧急", "立刻", "马上", "尽快", "急"]):
|
|
91
90
|
urgency = min(1.0, urgency + 0.3)
|
|
92
91
|
return TaskFeatures(complexity, intent.memory_affinity, tool_cost,
|
|
@@ -117,16 +116,14 @@ class CognitiveScheduler:
|
|
|
117
116
|
return "tool"
|
|
118
117
|
return "agent" if priority > 0.35 else "tool"
|
|
119
118
|
|
|
120
|
-
def preemption_reason(self, priority: float, ctx: SystemContext) -> str:
|
|
121
|
-
# 阈值匹配实际优先级范围(本调度器优先级多在 0.4 以内,
|
|
122
|
-
# 原 0.55/0.7 几乎不可达,会让抢占形同虚设)
|
|
119
|
+
def preemption_reason(self, priority: float, ctx: SystemContext) -> str | None:
|
|
123
120
|
if priority > 0.35 and ctx.active_agents >= 3:
|
|
124
121
|
return "高优先级 + Agent 压力大"
|
|
125
122
|
if priority > 0.4 and ctx.queue_depth >= 2:
|
|
126
123
|
return "高优先级 + 队列积压"
|
|
127
124
|
return None
|
|
128
125
|
|
|
129
|
-
def schedule(self, task: str, ctx: SystemContext = None) -> ScheduleDecision:
|
|
126
|
+
def schedule(self, task: str, ctx: SystemContext | None = None) -> ScheduleDecision:
|
|
130
127
|
"""主入口:给一个任务做完整调度决策。"""
|
|
131
128
|
ctx = ctx or SystemContext()
|
|
132
129
|
intent = self.parse_intent(task)
|
|
@@ -22,8 +22,6 @@ import base64
|
|
|
22
22
|
|
|
23
23
|
SERVICE = "foxclaw.llm"
|
|
24
24
|
|
|
25
|
-
# 后端探测结果缓存(含失败):每个请求都 import + 探测一次没必要。
|
|
26
|
-
# 失败也缓存是有意的 —— 没有可用后端时不该反复重试。
|
|
27
25
|
_kr_cache = None
|
|
28
26
|
_kr_probed = False
|
|
29
27
|
|
|
@@ -37,7 +35,6 @@ def _keyring():
|
|
|
37
35
|
try:
|
|
38
36
|
import keyring
|
|
39
37
|
from keyring.backends.fail import Keyring as _FailBackend
|
|
40
|
-
# 默认后端是 Fail 时说明系统上没有可用凭据库(典型:无桌面会话的 Linux)
|
|
41
38
|
if isinstance(keyring.get_keyring(), _FailBackend):
|
|
42
39
|
_kr_cache = None
|
|
43
40
|
else:
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""高敏感记忆写入的同意闸门 (SensitiveWritePolicy)。
|
|
2
|
+
|
|
3
|
+
问题
|
|
4
|
+
────
|
|
5
|
+
`memory_adapter.ingest` 此前对「写什么内容」不做任何判定:凭据、证件号、诊断结论
|
|
6
|
+
一旦被抽进长期记忆,就会在之后每次召回里反复出现在上下文中,而且会随 prompt 发往云端
|
|
7
|
+
模型。spec 的 `confirm_high_sensitivity` 要求这类内容落库前得到用户显式同意。
|
|
8
|
+
|
|
9
|
+
判定必须是确定性的
|
|
10
|
+
──────────────────
|
|
11
|
+
不用 LLM 判定敏感度:写入路径再调一次模型既贵,结果又不可复现,同一句话今天拦明天放,
|
|
12
|
+
用户无法建立预期。这里只用正则 + Luhn 校验,命中即命中,可单测。
|
|
13
|
+
|
|
14
|
+
误判方向是刻意的
|
|
15
|
+
────────────────
|
|
16
|
+
漏判 = 该拦的不拦,等于没做;误判 = 用户多点一次确认。所以宁可多拦:「我最近焦虑」
|
|
17
|
+
也会被拦一次。反过来,财务 Agent 天天谈金额,因此 finance 类**只匹配卡号/账户号**,
|
|
18
|
+
绝不匹配普通金额 —— 否则功能一开就无法使用。
|
|
19
|
+
|
|
20
|
+
三档模式 (FOXCLAW_SENSITIVE_WRITE_MODE)
|
|
21
|
+
──────────────────────────────────────
|
|
22
|
+
audit (默认) 只记录不拦截。落库照常,事件里标 allowed。
|
|
23
|
+
warn 记录 + 进程内告警,仍不落 pending。
|
|
24
|
+
enforce 真正拦截:高敏感内容不进长期记忆,转成待确认项,用户同意后才落库。
|
|
25
|
+
|
|
26
|
+
为什么默认 audit
|
|
27
|
+
────────────────
|
|
28
|
+
分类规则在真实流量上跑通之前只是**待验证的假设**。先在 audit 下看清命中长什么样、
|
|
29
|
+
误判率多少,再切 enforce —— 与 shared_write 的灰度立场一致。
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import threading
|
|
37
|
+
import uuid
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Iterable
|
|
41
|
+
|
|
42
|
+
MODE_ENV = "FOXCLAW_SENSITIVE_WRITE_MODE"
|
|
43
|
+
VALID_MODES = ("audit", "warn", "enforce")
|
|
44
|
+
|
|
45
|
+
PENDING_FILE = "sensitive_pending.json"
|
|
46
|
+
MAX_PENDING = 200
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _mask(s: str) -> str:
|
|
50
|
+
"""打码后才允许进事件/日志 —— 审计出口本身不能变成泄露点。"""
|
|
51
|
+
s = str(s or "")
|
|
52
|
+
if len(s) <= 6:
|
|
53
|
+
return "*" * len(s)
|
|
54
|
+
return f"{s[:2]}{'*' * (len(s) - 4)}{s[-2:]}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _luhn_ok(digits: str) -> bool:
|
|
58
|
+
total, alt = 0, False
|
|
59
|
+
for ch in reversed(digits):
|
|
60
|
+
if not ch.isdigit():
|
|
61
|
+
return False
|
|
62
|
+
d = int(ch)
|
|
63
|
+
if alt:
|
|
64
|
+
d *= 2
|
|
65
|
+
if d > 9:
|
|
66
|
+
d -= 9
|
|
67
|
+
total += d
|
|
68
|
+
alt = not alt
|
|
69
|
+
return total % 10 == 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Hit:
|
|
74
|
+
category: str
|
|
75
|
+
pattern: str
|
|
76
|
+
sample: str
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
return {"category": self.category, "pattern": self.pattern, "sample": self.sample}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# 每个模式都是「有上下文锚点」的,不裸匹配长数字 —— 裸匹配会把订单号、时间戳全卷进来。
|
|
83
|
+
_RULES: dict[str, tuple[str, ...]] = {
|
|
84
|
+
"credential": (
|
|
85
|
+
r"(?:密码|口令|password|passwd|pwd)\s*[::=是为]\s*\S{3,}",
|
|
86
|
+
r"(?:api[_-]?key|secret|token|access[_-]?key)\s*[::=]\s*[A-Za-z0-9_\-]{12,}",
|
|
87
|
+
r"\bsk-[A-Za-z0-9]{16,}",
|
|
88
|
+
r"\bghp_[A-Za-z0-9]{20,}",
|
|
89
|
+
r"\bAKIA[0-9A-Z]{16}\b",
|
|
90
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
|
91
|
+
),
|
|
92
|
+
"identity": (
|
|
93
|
+
r"\b[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b",
|
|
94
|
+
r"(?:身份证|护照|港澳通行证|社保卡|驾驶证)[号编]?\s*[::]?\s*[A-Za-z0-9]{6,}",
|
|
95
|
+
),
|
|
96
|
+
"health": (
|
|
97
|
+
r"(?:确诊|诊断为|病历|住院|手术|处方|服药|用药|过敏史|既往史)",
|
|
98
|
+
r"(?:抑郁症|焦虑症|糖尿病|高血压|心脏病|癌|HIV|乙肝)",
|
|
99
|
+
),
|
|
100
|
+
"finance_detail": (
|
|
101
|
+
r"(?:银行卡|账户|卡号|账号)\s*[::]?\s*\d{6,}",
|
|
102
|
+
r"(?:CVV|CVC|安全码|有效期)\s*[::]?\s*\d{3,4}",
|
|
103
|
+
r"\b(?:信用卡|储蓄卡|借记卡)\b",
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_COMPILED = {cat: [re.compile(p) for p in pats] for cat, pats in _RULES.items()}
|
|
108
|
+
|
|
109
|
+
# 卡号单独走一次 Luhn:16-19 位裸数字只有校验通过才算,避免误伤订单号/流水号。
|
|
110
|
+
_CARD_CANDIDATE = re.compile(r"\b(?:\d[ -]?){15,18}\d\b")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SensitivityClassifier:
|
|
114
|
+
"""确定性判定:返回命中的类别与打码后的样本。无状态,并发安全。"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, categories: Iterable[str] | None = None):
|
|
117
|
+
allowed = set(categories) if categories is not None else set(_COMPILED)
|
|
118
|
+
self.categories = tuple(c for c in _COMPILED if c in allowed)
|
|
119
|
+
|
|
120
|
+
def classify(self, content: str) -> tuple[Hit, ...]:
|
|
121
|
+
text = str(content or "")
|
|
122
|
+
if not text:
|
|
123
|
+
return ()
|
|
124
|
+
hits: list[Hit] = []
|
|
125
|
+
for cat in self.categories:
|
|
126
|
+
for rx in _COMPILED[cat]:
|
|
127
|
+
m = rx.search(text)
|
|
128
|
+
if m:
|
|
129
|
+
hits.append(Hit(cat, rx.pattern, _mask(m.group(0))))
|
|
130
|
+
break
|
|
131
|
+
if "finance_detail" in self.categories:
|
|
132
|
+
for m in _CARD_CANDIDATE.finditer(text):
|
|
133
|
+
digits = re.sub(r"\D", "", m.group(0))
|
|
134
|
+
if len(digits) >= 16 and _luhn_ok(digits):
|
|
135
|
+
hits.append(Hit("finance_detail", "luhn", _mask(digits)))
|
|
136
|
+
break
|
|
137
|
+
return tuple(hits)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Decision:
|
|
142
|
+
allowed: bool
|
|
143
|
+
reason: str
|
|
144
|
+
mode: str
|
|
145
|
+
categories: tuple = ()
|
|
146
|
+
hits: tuple = ()
|
|
147
|
+
pending_id: str = ""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_pending(self) -> bool:
|
|
151
|
+
return bool(self.pending_id)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict:
|
|
154
|
+
return {
|
|
155
|
+
"allowed": self.allowed, "reason": self.reason, "mode": self.mode,
|
|
156
|
+
"categories": list(self.categories),
|
|
157
|
+
"hits": [h.to_dict() for h in self.hits],
|
|
158
|
+
"pending_id": self.pending_id,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PendingStore:
|
|
163
|
+
"""待确认的高敏感写入。落盘是因为写入可能跨进程/重启发生,内存队列会静默丢内容。"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, path: str | Path):
|
|
166
|
+
self.path = Path(path)
|
|
167
|
+
self._lock = threading.RLock()
|
|
168
|
+
|
|
169
|
+
def _read(self) -> list:
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
172
|
+
except Exception:
|
|
173
|
+
return []
|
|
174
|
+
return data if isinstance(data, list) else []
|
|
175
|
+
|
|
176
|
+
def _write(self, items: list) -> None:
|
|
177
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
tmp = self.path.with_suffix(".tmp")
|
|
179
|
+
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
180
|
+
os.replace(tmp, self.path)
|
|
181
|
+
|
|
182
|
+
def add(self, entry: dict) -> str:
|
|
183
|
+
with self._lock:
|
|
184
|
+
items = self._read()
|
|
185
|
+
pid = uuid.uuid4().hex[:12]
|
|
186
|
+
items.append({"id": pid, **entry})
|
|
187
|
+
self._write(items[-MAX_PENDING:])
|
|
188
|
+
return pid
|
|
189
|
+
|
|
190
|
+
def list(self) -> list:
|
|
191
|
+
with self._lock:
|
|
192
|
+
return self._read()
|
|
193
|
+
|
|
194
|
+
def pop(self, pid: str) -> dict | None:
|
|
195
|
+
with self._lock:
|
|
196
|
+
items = self._read()
|
|
197
|
+
kept = [it for it in items if it.get("id") != pid]
|
|
198
|
+
found = next((it for it in items if it.get("id") == pid), None)
|
|
199
|
+
if found is not None:
|
|
200
|
+
self._write(kept)
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SensitiveWritePolicy:
|
|
205
|
+
"""判定「这条内容能否直接写入长期记忆」。纯函数式判定 + 挂载式装配(同 shared_write)。"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, mode: str | None = None,
|
|
208
|
+
classifier: SensitivityClassifier | None = None,
|
|
209
|
+
pending_path: str | Path | None = None):
|
|
210
|
+
self.mode = self._resolve_mode(mode)
|
|
211
|
+
self.classifier = classifier or SensitivityClassifier()
|
|
212
|
+
self.pending = PendingStore(pending_path or _default_pending_path())
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _resolve_mode(mode: str | None = None) -> str:
|
|
216
|
+
m = (mode or os.environ.get(MODE_ENV, "") or "audit").strip().lower()
|
|
217
|
+
return m if m in VALID_MODES else "audit"
|
|
218
|
+
|
|
219
|
+
def evaluate(self, content: str, **entry: Any) -> Decision:
|
|
220
|
+
hits = self.classifier.classify(content)
|
|
221
|
+
if not hits:
|
|
222
|
+
return Decision(True, "无高敏感特征", self.mode)
|
|
223
|
+
cats = tuple(dict.fromkeys(h.category for h in hits))
|
|
224
|
+
if self.mode == "enforce":
|
|
225
|
+
return Decision(False, "高敏感内容需用户同意后才能写入长期记忆",
|
|
226
|
+
self.mode, cats, hits)
|
|
227
|
+
label = "audit 不拦截" if self.mode == "audit" else "warn 不拦截"
|
|
228
|
+
return Decision(True, f"高敏感特征({label})", self.mode, cats, hits)
|
|
229
|
+
|
|
230
|
+
def stash(self, content: str, entry: dict, decision: Decision) -> Decision:
|
|
231
|
+
"""enforce 下把被拦的写入转成待确认项,返回带 pending_id 的 Decision。"""
|
|
232
|
+
pid = self.pending.add({**entry, "content": str(content or ""),
|
|
233
|
+
"categories": list(decision.categories)})
|
|
234
|
+
return Decision(decision.allowed, decision.reason, decision.mode,
|
|
235
|
+
decision.categories, decision.hits, pid)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _default_pending_path() -> Path:
|
|
239
|
+
root = os.environ.get("FOXCLAW_DATA_DIR") or "./foxclaw_data"
|
|
240
|
+
return Path(root) / PENDING_FILE
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def guard_sensitive_write(backend, agent_id: str | None, content: str,
|
|
244
|
+
memory_type: str = "concept", **entry: Any) -> Decision | None:
|
|
245
|
+
"""记忆落库前的统一闸门。未挂策略时返回 None(向后兼容,与 guard_shared_write 一致)。
|
|
246
|
+
|
|
247
|
+
enforce 且命中高敏感 → 返回 allowed=False 的 Decision,调用方**必须**据此跳过落库;
|
|
248
|
+
被拦内容此时已进入待确认队列,不会丢。
|
|
249
|
+
"""
|
|
250
|
+
policy = getattr(backend, "sensitive_write_policy", None)
|
|
251
|
+
if policy is None:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
decision = policy.evaluate(content, **entry)
|
|
255
|
+
spine = getattr(backend, "sensitive_write_spine", None)
|
|
256
|
+
if spine is not None:
|
|
257
|
+
try:
|
|
258
|
+
spine.publish("memory.sensitive", {
|
|
259
|
+
"agent_id": agent_id or "",
|
|
260
|
+
"type": memory_type,
|
|
261
|
+
"allowed": decision.allowed,
|
|
262
|
+
"mode": decision.mode,
|
|
263
|
+
"categories": list(decision.categories),
|
|
264
|
+
"hits": [_mask(h.sample) for h in decision.hits],
|
|
265
|
+
}, source=agent_id or "memory")
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if not decision.allowed:
|
|
270
|
+
if policy.mode == "enforce":
|
|
271
|
+
return policy.stash(content, {"agent_id": agent_id or "",
|
|
272
|
+
"type": memory_type}, decision)
|
|
273
|
+
if policy.mode == "warn":
|
|
274
|
+
print(f"[Foxclaw] ⚠ 高敏感记忆写入(未拦截): agent={agent_id!r} "
|
|
275
|
+
f"categories={list(decision.categories)}")
|
|
276
|
+
return decision
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def attach(backend, policy: SensitiveWritePolicy | None = None, spine=None):
|
|
280
|
+
"""挂到记忆后端。与 shared_write.attach 同因:装配顺序上脊柱晚于 memory_adapter。"""
|
|
281
|
+
backend.sensitive_write_policy = policy or SensitiveWritePolicy()
|
|
282
|
+
backend.sensitive_write_spine = spine
|
|
283
|
+
return backend.sensitive_write_policy
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
import json
|
|
17
17
|
import re
|
|
18
|
+
import builtins
|
|
18
19
|
import threading
|
|
19
20
|
import time
|
|
20
21
|
import uuid
|
|
@@ -42,9 +43,7 @@ class SessionStore:
|
|
|
42
43
|
self.base.mkdir(parents=True, exist_ok=True)
|
|
43
44
|
self._lock = threading.RLock()
|
|
44
45
|
|
|
45
|
-
# ── 内部 ──────────────────────────────────────────────
|
|
46
46
|
def _path(self, sid: str) -> Path:
|
|
47
|
-
# 只允许我们自己生成的 id 形态,杜绝 ../ 之类路径穿越
|
|
48
47
|
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(sid or "")):
|
|
49
48
|
raise ValueError("非法会话 id")
|
|
50
49
|
return self.base / f"{sid}.json"
|
|
@@ -64,9 +63,8 @@ class SessionStore:
|
|
|
64
63
|
tmp = p.with_suffix(".tmp")
|
|
65
64
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
66
65
|
json.dump(data, f, ensure_ascii=False, indent=1)
|
|
67
|
-
tmp.replace(p)
|
|
66
|
+
tmp.replace(p)
|
|
68
67
|
|
|
69
|
-
# ── 公开 API ──────────────────────────────────────────
|
|
70
68
|
def create(self, title: str = "", agent_id: str = "supervisor") -> dict:
|
|
71
69
|
sid = uuid.uuid4().hex[:12]
|
|
72
70
|
data = {"id": sid, "title": (title or "新会话")[:60], "agent_id": agent_id,
|
|
@@ -105,7 +103,8 @@ class SessionStore:
|
|
|
105
103
|
with self._lock:
|
|
106
104
|
return self._read(sid)
|
|
107
105
|
|
|
108
|
-
def append(self, sid: str, role: str, content: str,
|
|
106
|
+
def append(self, sid: str, role: str, content: str,
|
|
107
|
+
attachments: builtins.list | None = None) -> bool:
|
|
109
108
|
"""追加一条消息;会话不存在则忽略。首条用户消息会顺带定标题。
|
|
110
109
|
attachments(可选)= 聊天附件元数据 [{name,size,mime,url}];只落盘轻字段(不存 text,
|
|
111
110
|
避免会话文件随附件正文膨胀),供「会话历史」回看时渲染附件卡片。"""
|
|
@@ -128,7 +127,6 @@ class SessionStore:
|
|
|
128
127
|
if atts:
|
|
129
128
|
msg["attachments"] = atts
|
|
130
129
|
msgs.append(msg)
|
|
131
|
-
# 标题仍是默认值时,用首条用户消息提炼
|
|
132
130
|
if role == "user" and (not d.get("title") or d["title"] == "新会话"):
|
|
133
131
|
d["title"] = derive_title(content)
|
|
134
132
|
d["updated_at"] = _now()
|