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
|
@@ -7,7 +7,6 @@ import os, sys, tempfile, shutil
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
9
9
|
|
|
10
|
-
# ── 环境隔离:必须在 import foxclaw 之前(import 会读这些键初始化) ─────────────
|
|
11
10
|
for _k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY",
|
|
12
11
|
"GOOGLE_API_KEY", "DASHSCOPE_API_KEY", "FOXCLAW_API_KEY"):
|
|
13
12
|
os.environ.pop(_k, None)
|
|
@@ -15,10 +14,10 @@ os.environ["HKC_EMBEDDING"] = "stub"
|
|
|
15
14
|
os.environ["FOXCLAW_SHADOW_ROUNDS"] = "6"
|
|
16
15
|
os.environ["FOXCLAW_DATA_DIR"] = tempfile.mkdtemp(prefix="foxclaw_att_")
|
|
17
16
|
os.environ["FOXCLAW_SKIP_DOTENV"] = "1"
|
|
18
|
-
os.environ["FOXCLAW_PROVIDER"] = ""
|
|
17
|
+
os.environ["FOXCLAW_PROVIDER"] = ""
|
|
19
18
|
os.environ["FOXCLAW_MODEL"] = ""
|
|
20
19
|
os.environ["FOXCLAW_MCP_SERVER"] = "0"
|
|
21
|
-
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
20
|
+
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
22
21
|
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
23
22
|
|
|
24
23
|
import pytest
|
|
@@ -40,31 +39,25 @@ def store():
|
|
|
40
39
|
|
|
41
40
|
|
|
42
41
|
def test_sanitize_attachments():
|
|
43
|
-
# 数量上限
|
|
44
42
|
many = [{"name": f"f{i}.txt", "text": "t"} for i in range(_MAX_ATTACHMENTS + 4)]
|
|
45
43
|
assert len(_sanitize_attachments(many)) == _MAX_ATTACHMENTS
|
|
46
44
|
|
|
47
|
-
# 单文件正文截断到上限
|
|
48
45
|
one = _sanitize_attachments([{"name": "a.txt", "text": "x" * (_ATTACH_TEXT_MAX + 100)}])
|
|
49
46
|
assert len(one[0]["text"]) == _ATTACH_TEXT_MAX
|
|
50
47
|
|
|
51
|
-
# name 截断到 200
|
|
52
48
|
assert _sanitize_attachments([{"name": "a" * 300, "text": "x"}])[0]["name"] == "a" * 200
|
|
53
49
|
|
|
54
|
-
# 累计正文封顶:单文件先截到 20000,4×20000 累计 60000 封顶 → 第 4 个正文被清空(名字仍在)
|
|
55
50
|
big = "x" * (_ATTACH_TEXT_MAX + 1000)
|
|
56
51
|
four = _sanitize_attachments([{"name": f"f{i}.txt", "text": big} for i in range(4)])
|
|
57
52
|
assert len(four) == 4
|
|
58
53
|
assert sum(len(a["text"]) for a in four) == _ATTACH_TEXT_TOTAL
|
|
59
54
|
assert four[3]["text"] == ""
|
|
60
55
|
|
|
61
|
-
# 非列表 / 非 dict / 空 name+空 text → 全部剔除
|
|
62
56
|
assert _sanitize_attachments("notalist") == []
|
|
63
57
|
assert _sanitize_attachments([1, "x"]) == []
|
|
64
58
|
assert _sanitize_attachments([{"name": "", "text": ""}]) == []
|
|
65
59
|
assert _sanitize_attachments(None) == []
|
|
66
60
|
|
|
67
|
-
# 字段类型清洗:size 非数字 → 0;mime/url 转 str
|
|
68
61
|
out = _sanitize_attachments([{"name": "a", "size": "big", "mime": None, "url": None, "text": "t"}])
|
|
69
62
|
assert out[0]["size"] == 0 and out[0]["mime"] == "" and out[0]["url"] == ""
|
|
70
63
|
print("护栏(数量/单文件/累计/类型清洗/边界)OK")
|
|
@@ -78,10 +71,10 @@ def test_attachments_context():
|
|
|
78
71
|
ctx = CognitiveAgent._attachments_context(atts)
|
|
79
72
|
assert "【附件「a.txt」内容如下】" in ctx and "hello world" in ctx
|
|
80
73
|
assert "【附件「b.txt」" not in ctx
|
|
81
|
-
assert "b.pdf" in ctx and "无法提取" in ctx
|
|
74
|
+
assert "b.pdf" in ctx and "无法提取" in ctx
|
|
82
75
|
assert CognitiveAgent._attachments_context([]) == ""
|
|
83
76
|
assert CognitiveAgent._attachments_context(None) == ""
|
|
84
|
-
assert CognitiveAgent._attachments_context([1, "x"]) == ""
|
|
77
|
+
assert CognitiveAgent._attachments_context([1, "x"]) == ""
|
|
85
78
|
print("LLM 上下文注入(正文/降级/空)OK")
|
|
86
79
|
|
|
87
80
|
|
|
@@ -98,7 +91,6 @@ def test_sessions_append_attachments(store):
|
|
|
98
91
|
assert len(m["attachments"]) == 2
|
|
99
92
|
assert m["attachments"][0] == {"name": "a.txt", "size": 11, "mime": "text/plain", "url": "/api/attachments/abc"}
|
|
100
93
|
assert "text" not in m["attachments"][0], "text 不得落盘(会话文件不膨胀)"
|
|
101
|
-
# 不带附件 → 无 attachments 字段(向后兼容)
|
|
102
94
|
assert store.append(s["id"], "assistant", "好")
|
|
103
95
|
assert "attachments" not in store.get(s["id"])["messages"][1]
|
|
104
96
|
print("会话落盘(轻字段/不存 text/向后兼容)OK")
|
|
@@ -106,42 +98,35 @@ def test_sessions_append_attachments(store):
|
|
|
106
98
|
|
|
107
99
|
def test_upload_and_download():
|
|
108
100
|
with TestClient(app) as c:
|
|
109
|
-
# 上传文本文件 → 提取正文
|
|
110
101
|
r = c.post("/api/upload", files={"file": ("笔记.txt", "今天学了三件事".encode(), "text/plain")})
|
|
111
102
|
assert r.status_code == 200, r.text
|
|
112
103
|
a = r.json()
|
|
113
104
|
assert a["name"] == "笔记.txt" and a["text"] == "今天学了三件事"
|
|
114
105
|
assert a["url"].startswith("/api/attachments/") and a["id"] and a["size"] > 0
|
|
115
106
|
|
|
116
|
-
# 下载 → 同一内容
|
|
117
107
|
r2 = c.get(a["url"])
|
|
118
108
|
assert r2.status_code == 200
|
|
119
109
|
assert r2.content.decode("utf-8") == "今天学了三件事"
|
|
120
110
|
|
|
121
|
-
# 二进制(不可提取)→ text 为空,降级
|
|
122
111
|
r3 = c.post("/api/upload", files={"file": ("img.bin", b"\x00\x01\x02", "application/octet-stream")})
|
|
123
112
|
assert r3.status_code == 200 and r3.json()["text"] == ""
|
|
124
113
|
|
|
125
|
-
# 空文件 → 400;超大 → 413
|
|
126
114
|
assert c.post("/api/upload", files={"file": ("e.txt", b"", "text/plain")}).status_code == 400
|
|
127
115
|
big = b"x" * (21 * 1024 * 1024)
|
|
128
116
|
assert c.post("/api/upload", files={"file": ("big.txt", big, "text/plain")}).status_code == 413
|
|
129
117
|
|
|
130
|
-
|
|
131
|
-
assert c.get("/api/attachments
|
|
132
|
-
assert c.get("/api/attachments/
|
|
133
|
-
assert c.get("/api/attachments/deadbeef0000").status_code == 404 # 合法 id 但文件不存在
|
|
118
|
+
assert c.get("/api/attachments/../../etc/passwd").status_code == 404
|
|
119
|
+
assert c.get("/api/attachments/a.b").status_code == 400
|
|
120
|
+
assert c.get("/api/attachments/deadbeef0000").status_code == 404
|
|
134
121
|
print("上传/下载(正文提取/二进制降级/空/超大/穿越/404)OK")
|
|
135
122
|
|
|
136
123
|
|
|
137
124
|
def test_chat_stream_with_attachments():
|
|
138
125
|
import foxclaw.api as _api
|
|
139
126
|
with TestClient(app) as c:
|
|
140
|
-
# 先上传一个带正文的附件,拿到引用
|
|
141
127
|
up = c.post("/api/upload", files={"file": ("要点.txt", "预算 100 万".encode(), "text/plain")})
|
|
142
128
|
att = up.json()
|
|
143
|
-
|
|
144
|
-
sid = _api._os_instance.sessions.create()["id"]
|
|
129
|
+
sid = _api.os_instance().sessions.create()["id"]
|
|
145
130
|
payload = {
|
|
146
131
|
"task": "帮我总结要点",
|
|
147
132
|
"agent_id": "supervisor",
|
|
@@ -153,8 +138,7 @@ def test_chat_stream_with_attachments():
|
|
|
153
138
|
assert r.status_code == 200, r.text[:200]
|
|
154
139
|
assert "data:" in r.text and '"error"' not in r.text, r.text[:300]
|
|
155
140
|
|
|
156
|
-
|
|
157
|
-
d = _api._os_instance.sessions.get(sid)
|
|
141
|
+
d = _api.os_instance().sessions.get(sid)
|
|
158
142
|
assert d is not None, "会话未落盘"
|
|
159
143
|
um = d["messages"][0]
|
|
160
144
|
assert um["role"] == "user" and um["content"] == "帮我总结要点"
|
|
@@ -24,8 +24,6 @@ def _write_chain(path: Path, records) -> None:
|
|
|
24
24
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
# ── 链 ─────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
27
|
def test_chain_passes(tmp_path):
|
|
30
28
|
from foxclaw.core import audit_chain
|
|
31
29
|
|
|
@@ -42,7 +40,7 @@ def test_tampered_line_detected(tmp_path):
|
|
|
42
40
|
_write_chain(p, [{"verdict": "deny"}, {"verdict": "allow"}])
|
|
43
41
|
lines = p.read_text(encoding="utf-8").splitlines()
|
|
44
42
|
rec = json.loads(lines[0])
|
|
45
|
-
rec["verdict"] = "allow"
|
|
43
|
+
rec["verdict"] = "allow"
|
|
46
44
|
lines[0] = json.dumps(rec, ensure_ascii=False)
|
|
47
45
|
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
48
46
|
|
|
@@ -69,7 +67,7 @@ def test_resume_after_restart(tmp_path):
|
|
|
69
67
|
p = tmp_path / "governance.jsonl"
|
|
70
68
|
GovernanceAuditor(sink_path=p).record(
|
|
71
69
|
layer="skill", actor="office", action="write_note", verdict="success")
|
|
72
|
-
aud = GovernanceAuditor(sink_path=p)
|
|
70
|
+
aud = GovernanceAuditor(sink_path=p)
|
|
73
71
|
aud.record(layer="skill", actor="office", action="write_note", verdict="success")
|
|
74
72
|
|
|
75
73
|
ok, n, msg = aud.verify()
|
|
@@ -96,8 +94,6 @@ def test_tail_hash_ignores_torn_line(tmp_path):
|
|
|
96
94
|
assert audit_chain.tail_hash(p) == "h1"
|
|
97
95
|
|
|
98
96
|
|
|
99
|
-
# ── recovery 脱敏 ──────────────────────────────────────────────────
|
|
100
|
-
|
|
101
97
|
def test_recovery_step_args_and_result_redacted():
|
|
102
98
|
from types import SimpleNamespace
|
|
103
99
|
|
|
@@ -117,5 +113,4 @@ def test_recovery_step_args_and_result_redacted():
|
|
|
117
113
|
|
|
118
114
|
assert "sk-" not in json.dumps(got["args"]), got["args"]
|
|
119
115
|
assert "[已脱敏]" in got["result"], got["result"]
|
|
120
|
-
# key 必须用未脱敏的 args 算,否则同一步骤前后 key 不一致(缓存/回放对不上)
|
|
121
116
|
assert got["key"] == _step_key("http", {"api_key": key})
|
|
@@ -42,7 +42,6 @@ def test_unit():
|
|
|
42
42
|
"reason": "office 的职责域不含 ['type:decision','finance.transfer']",
|
|
43
43
|
"mode": "audit", "content_sha": "0123456789abcdef",
|
|
44
44
|
"content_preview": "财务应给办公转账1000元"}, source="office")
|
|
45
|
-
# 非本主题的事件不应落盘(subscribe 前缀精确命中)
|
|
46
45
|
spine.publish("memory.updated", {"x": 1})
|
|
47
46
|
|
|
48
47
|
check(path.exists(), "审计文件已创建(含 audit/ 目录)")
|
|
@@ -55,7 +54,6 @@ def test_unit():
|
|
|
55
54
|
check("ts" in r and "T" in r["ts"], "ts 为 ISO 时间(人类可读)")
|
|
56
55
|
check(bool(r["correlation_id"]), "correlation_id 已记录(可与脊柱 history 对账)")
|
|
57
56
|
|
|
58
|
-
# 追加语义:第二条接在后面,顺序保持
|
|
59
57
|
spine.publish("memory.shared.write", {"agent_id": "system", "type": "execution",
|
|
60
58
|
"tags": ["truth_pipeline"], "allowed": True,
|
|
61
59
|
"reason": "命中全局允许 facet", "mode": "audit",
|
|
@@ -65,12 +63,10 @@ def test_unit():
|
|
|
65
63
|
check(len(recs) == 2 and recs[1]["payload"]["agent_id"] == "system",
|
|
66
64
|
"追加写不覆盖旧行,顺序保持")
|
|
67
65
|
|
|
68
|
-
# 坏行容错:夹一行垃圾,read() 跳过而不是崩
|
|
69
66
|
with open(path, "a", encoding="utf-8") as f:
|
|
70
67
|
f.write("{这不是JSON}\n")
|
|
71
68
|
check(len(sink.read()) == 2, "坏行被跳过,读取不崩")
|
|
72
69
|
|
|
73
|
-
# 开关:环境变量置 0 时 attach 不挂
|
|
74
70
|
old = os.environ.get(ENV_SWITCH)
|
|
75
71
|
os.environ[ENV_SWITCH] = "0"
|
|
76
72
|
try:
|
|
@@ -99,7 +95,6 @@ def test_end_to_end():
|
|
|
99
95
|
audit_path = Path(tmp) / "audit" / "shared_write.jsonl"
|
|
100
96
|
check(audit_path.parent.exists(), "audit/ 目录已建在 data_dir 下")
|
|
101
97
|
|
|
102
|
-
# 一条合法 + 一条越界(audit 模式不拦截,只记录)——真实闸门口径
|
|
103
98
|
osi.hmr.ingest("用户偏好:喜欢表格", type="concept", agent_id="finance",
|
|
104
99
|
tags=["preference"], shared=True)
|
|
105
100
|
osi.hmr.ingest("财务应给办公转账1000元", type="decision", agent_id="office",
|
|
@@ -112,7 +107,6 @@ def test_end_to_end():
|
|
|
112
107
|
denied = [r for r in recs if not r["payload"]["allowed"]]
|
|
113
108
|
check(bool(denied) and "office" in denied[0]["payload"]["reason"] or bool(denied),
|
|
114
109
|
"越界那条可从文件直接判读")
|
|
115
|
-
# 与脊柱 history 口径一致:文件条数 == 脊柱里该主题条数
|
|
116
110
|
spine_n = len(osi.event_spine.history("memory.shared.write", 100))
|
|
117
111
|
check(spine_n == len(recs), f"文件条数与脊柱 history 一致({len(recs)}=={spine_n})")
|
|
118
112
|
finally:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
行为规范(Behavior Spec)测试 —— 全离线。
|
|
3
|
+
|
|
4
|
+
覆盖:
|
|
5
|
+
1. 内置 behavior.yaml 可加载,id 唯一,每个 code_check 都解析到真实可调用对象。
|
|
6
|
+
2. 检查点不是装饰:policy_gate.check_action / confirms.consume / verify.verify 在业务代码里真有调用。
|
|
7
|
+
3. prompt 注入:行为规范在最前段(Markdown 内核之前、persona 之前),带 spec_version,可整体关闭。
|
|
8
|
+
4. 本地覆盖:可新增 / 可收紧;放宽或省略删除严格层规则 → BehaviorLoadError。
|
|
9
|
+
5. 本地覆盖的语法错、版本过期 → 降级为 warning 并忽略,不把应用锁死(内置文件的问题仍然硬失败)。
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
18
|
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
19
|
+
os.environ["FOXCLAW_EMBEDDING_MODEL"] = "foxclaw-nonexistent-fast"
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
21
|
+
|
|
22
|
+
from foxclaw.agents.mixins.prompt import PromptMixin
|
|
23
|
+
from foxclaw.behavior import BehaviorLoadError, load, local_path, prompt_block
|
|
24
|
+
|
|
25
|
+
ROOT = Path(__file__).parent.parent
|
|
26
|
+
|
|
27
|
+
BASE_YAML = """
|
|
28
|
+
version: 1
|
|
29
|
+
spec_id: test-behavior
|
|
30
|
+
prohibitions:
|
|
31
|
+
- id: no_x
|
|
32
|
+
rule: 不做 X
|
|
33
|
+
enforce: both
|
|
34
|
+
code_check: "foxclaw.core.verify:verify"
|
|
35
|
+
confirmations:
|
|
36
|
+
- id: confirm_y
|
|
37
|
+
rule: 做 Y 前先确认
|
|
38
|
+
enforce: prompt
|
|
39
|
+
uncertainty:
|
|
40
|
+
- id: zzz
|
|
41
|
+
rule: 不确定就问
|
|
42
|
+
enforce: prompt
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write(tmp_path, name, text):
|
|
47
|
+
p = tmp_path / name
|
|
48
|
+
p.write_text(text, encoding="utf-8")
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _loaded(tmp_path, local_text):
|
|
53
|
+
return load(_write(tmp_path, "behavior.yaml", BASE_YAML),
|
|
54
|
+
_write(tmp_path, "behavior.local.yaml", local_text))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_builtin_spec_loads_with_unique_ids():
|
|
58
|
+
spec = load()
|
|
59
|
+
ids = [r.id for r in spec.rules]
|
|
60
|
+
assert len(ids) == len(set(ids))
|
|
61
|
+
assert spec.version == 1 and spec.spec_version
|
|
62
|
+
assert {"confirm_side_effect", "no_silent_failure"} <= set(ids)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_every_code_check_resolves_to_callable():
|
|
66
|
+
spec = load()
|
|
67
|
+
checks = spec.checkpoints()
|
|
68
|
+
assert checks, "内置规范至少要登记一个真实检查点"
|
|
69
|
+
assert all(callable(fn) for fn in checks.values())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_checkpoints_are_really_wired_in_business_code():
|
|
73
|
+
"""反漂移:登记了检查点,业务代码里就必须真有调用,否则清单会退化成装饰。"""
|
|
74
|
+
blob = "\n".join(p.read_text(encoding="utf-8")
|
|
75
|
+
for p in (ROOT / "foxclaw").rglob("*.py")
|
|
76
|
+
if p.parts[len(ROOT.parts)] != "behavior")
|
|
77
|
+
assert "policy_gate.check_action(" in blob
|
|
78
|
+
assert "confirms.consume(" in blob
|
|
79
|
+
assert "verify.verify(" in blob
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_prompt_block_leads_with_spec_and_version():
|
|
83
|
+
spec = load()
|
|
84
|
+
block = prompt_block(spec)
|
|
85
|
+
assert block.startswith("[行为规范")
|
|
86
|
+
assert spec.spec_version in block
|
|
87
|
+
for rule in spec.injectable():
|
|
88
|
+
assert f"[{rule.id}]" in block
|
|
89
|
+
assert "不可协商" in block
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_behavior_precedes_kernel_and_role():
|
|
93
|
+
import types
|
|
94
|
+
fake = types.SimpleNamespace(_role_prompt="我是角色ROLEXYZ。", AGENT_ID="finance")
|
|
95
|
+
out = PromptMixin.system_prompt.fget(fake)
|
|
96
|
+
assert out.index("[行为规范") < out.index("认知操作系统") < out.index("ROLEXYZ")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_switch_disables_behavior_block(monkeypatch):
|
|
100
|
+
monkeypatch.setenv("FOXCLAW_BEHAVIOR", "0")
|
|
101
|
+
assert prompt_block() == ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_local_can_add_and_tighten(tmp_path):
|
|
105
|
+
spec = _loaded(tmp_path, """
|
|
106
|
+
version: 1
|
|
107
|
+
confirmations:
|
|
108
|
+
- id: confirm_y
|
|
109
|
+
rule: 做 Y 前先确认(收紧)
|
|
110
|
+
enforce: both
|
|
111
|
+
code_check: "foxclaw.core.verify:verify"
|
|
112
|
+
uncertainty:
|
|
113
|
+
- id: new_rule
|
|
114
|
+
rule: 新加的更严规则
|
|
115
|
+
enforce: prompt
|
|
116
|
+
""")
|
|
117
|
+
assert spec.by_id("confirm_y").enforce == "both"
|
|
118
|
+
assert spec.by_id("new_rule") is not None
|
|
119
|
+
assert spec.by_id("no_x").enforce == "both" # 未提及的保持不变
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_local_cannot_weaken_enforce(tmp_path):
|
|
123
|
+
with pytest.raises(BehaviorLoadError, match="不能放宽"):
|
|
124
|
+
_loaded(tmp_path, """
|
|
125
|
+
prohibitions:
|
|
126
|
+
- id: no_x
|
|
127
|
+
rule: 不做 X
|
|
128
|
+
enforce: prompt
|
|
129
|
+
""")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_local_cannot_drop_strict_layer_rule(tmp_path):
|
|
133
|
+
with pytest.raises(BehaviorLoadError, match="不能通过省略删除"):
|
|
134
|
+
_loaded(tmp_path, "confirmations: []\n")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_local_syntax_error_is_warning_not_fatal(tmp_path):
|
|
138
|
+
spec = _loaded(tmp_path, "prohibitions: [oops\n")
|
|
139
|
+
assert spec.warnings and "已忽略" in spec.warnings[0]
|
|
140
|
+
assert spec.by_id("no_x") is not None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_local_stale_version_is_ignored(tmp_path):
|
|
144
|
+
spec = _loaded(tmp_path, "version: 99\nuncertainty: []\n")
|
|
145
|
+
assert any("已过期" in w for w in spec.warnings)
|
|
146
|
+
assert spec.by_id("zzz") is not None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_builtin_version_mismatch_is_fatal(tmp_path):
|
|
150
|
+
p = _write(tmp_path, "behavior.yaml", "version: 99\nprohibitions: []\n")
|
|
151
|
+
with pytest.raises(BehaviorLoadError, match="version"):
|
|
152
|
+
load(p, tmp_path / "none.yaml")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_enforce_without_code_check_fails(tmp_path):
|
|
156
|
+
p = _write(tmp_path, "behavior.yaml", """
|
|
157
|
+
version: 1
|
|
158
|
+
prohibitions:
|
|
159
|
+
- id: no_x
|
|
160
|
+
rule: 不做 X
|
|
161
|
+
enforce: both
|
|
162
|
+
""")
|
|
163
|
+
with pytest.raises(BehaviorLoadError, match="没有 code_check"):
|
|
164
|
+
load(p, tmp_path / "none.yaml")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_unknown_checkpoint_fails(tmp_path):
|
|
168
|
+
p = _write(tmp_path, "behavior.yaml", """
|
|
169
|
+
version: 1
|
|
170
|
+
prohibitions:
|
|
171
|
+
- id: no_x
|
|
172
|
+
rule: 不做 X
|
|
173
|
+
enforce: code
|
|
174
|
+
code_check: "foxclaw.core.verify:no_such_function"
|
|
175
|
+
""")
|
|
176
|
+
with pytest.raises(BehaviorLoadError, match="不存在"):
|
|
177
|
+
load(p, tmp_path / "none.yaml")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_duplicate_id_fails(tmp_path):
|
|
181
|
+
p = _write(tmp_path, "behavior.yaml", """
|
|
182
|
+
version: 1
|
|
183
|
+
prohibitions:
|
|
184
|
+
- id: no_x
|
|
185
|
+
rule: A
|
|
186
|
+
enforce: prompt
|
|
187
|
+
uncertainty:
|
|
188
|
+
- id: no_x
|
|
189
|
+
rule: B
|
|
190
|
+
enforce: prompt
|
|
191
|
+
""")
|
|
192
|
+
with pytest.raises(BehaviorLoadError, match="重复"):
|
|
193
|
+
load(p, tmp_path / "none.yaml")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_local_path_follows_data_dir(monkeypatch, tmp_path):
|
|
197
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
198
|
+
assert local_path() == tmp_path / "behavior.local.yaml"
|
|
@@ -91,10 +91,8 @@ async def test_service_wrapper():
|
|
|
91
91
|
print("\n【3】BrowserService async 包装(注入假 session,经专用工作线程)")
|
|
92
92
|
svc = BrowserService()
|
|
93
93
|
fake = FakeSession()
|
|
94
|
-
# 绕过 playwright:预置会话,则真实 _ensure_session_sync 因 _session 非 None 直接返回它,
|
|
95
|
-
# 不构造 SessionManager/不 launch chromium。强制 available=True 走真实工作线程路径。
|
|
96
94
|
svc._session = fake
|
|
97
|
-
svc.available = lambda: True
|
|
95
|
+
svc.available = lambda: True
|
|
98
96
|
|
|
99
97
|
p = await svc.perceive()
|
|
100
98
|
assert p["ok"] and p["semantic_state"]["title"] == "P1"
|
|
@@ -104,16 +102,14 @@ async def test_service_wrapper():
|
|
|
104
102
|
assert r["ok"] and r["verified"]["success"]
|
|
105
103
|
print(" ✓ act(navigate) 返回校验后结果 verified.success=True")
|
|
106
104
|
|
|
107
|
-
bad = await svc.act({"target": "3"})
|
|
105
|
+
bad = await svc.act({"target": "3"})
|
|
108
106
|
assert bad["ok"] is False and "非法动作" in bad["error"]
|
|
109
107
|
print(" ✓ 非法动作(缺 type)被结构化拒绝")
|
|
110
108
|
|
|
111
109
|
await svc.close()
|
|
112
110
|
assert fake.closed, "close() 必须在工作线程上释放会话"
|
|
113
111
|
print(" ✓ close() 释放了会话")
|
|
114
|
-
# 幂等:再次 close 不报错
|
|
115
112
|
await svc.close()
|
|
116
|
-
# 关停后调用被拒(executor 已关)
|
|
117
113
|
after_close = await svc.perceive()
|
|
118
114
|
assert after_close["ok"] is False
|
|
119
115
|
print(" ✓ close() 幂等 + 关停后调用被安全拒绝")
|
|
@@ -10,8 +10,6 @@ from foxclaw.browser.action import url_policy
|
|
|
10
10
|
from foxclaw.browser.driver.playwright_driver import PlaywrightDriver
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
# ── 判定层 ────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
13
|
@pytest.mark.parametrize("url", [
|
|
16
14
|
"data:image/png;base64,iVBORw0KGgo=",
|
|
17
15
|
"blob:https://example.com/2f8c-4a",
|
|
@@ -112,8 +110,6 @@ def test_short_ipv6_with_scope_is_not_mistaken(monkeypatch):
|
|
|
112
110
|
url_policy._resolve_cache.clear()
|
|
113
111
|
|
|
114
112
|
|
|
115
|
-
# ── 路由回调 ──────────────────────────────────────────────────
|
|
116
|
-
|
|
117
113
|
class _FakeRoute:
|
|
118
114
|
def __init__(self, url):
|
|
119
115
|
self.request = type("Req", (), {"url": url})()
|
|
@@ -43,10 +43,8 @@ def test_cycles_broken_structural():
|
|
|
43
43
|
try:
|
|
44
44
|
from foxclaw import Foxclaw
|
|
45
45
|
os_ = Foxclaw(data_dir=d, shadow_rounds=50)
|
|
46
|
-
# 环1:Agent 不再直接持 search_runtime
|
|
47
46
|
assert not hasattr(os_.finance, "_search_runtime"), "Agent 不应有 _search_runtime 直接引用"
|
|
48
47
|
assert os_.finance._caps is os_.capabilities, "Agent 改持能力中枢"
|
|
49
|
-
# 环2:Orchestrator 不再把 agents 存为实例字段(改为经中枢解析的 property)
|
|
50
48
|
assert "agents" not in os_.orchestrator.__dict__, "orchestrator 不应把 agents 存为实例字段"
|
|
51
49
|
assert os_.orchestrator._caps is os_.capabilities
|
|
52
50
|
assert os_.orchestrator.agents is os_._agents, "orchestrator.agents 经中枢解析"
|
|
@@ -63,17 +61,14 @@ def test_behavior_preserved():
|
|
|
63
61
|
try:
|
|
64
62
|
from foxclaw import Foxclaw
|
|
65
63
|
os_ = Foxclaw(data_dir=d, shadow_rounds=50)
|
|
66
|
-
# 跨 Agent 编排(orchestrator 经中枢解析 finance)
|
|
67
64
|
r = os_.supervisor.cross_request(
|
|
68
65
|
from_agent="admin", to_agent="finance", request_type="budget_request",
|
|
69
66
|
payload={"amount": 150, "category": "travel", "entity": "高铁", "account": "default"},
|
|
70
67
|
reason="出差")
|
|
71
68
|
assert r["status"] in ("approved", "confirm_required", "rejected"), f"裁决异常 {r}"
|
|
72
69
|
print(f" ✓ cross_request 经中枢解析 agents → 状态 {r['status']}")
|
|
73
|
-
# Agent 反向调用内部搜索(经中枢解析 search_runtime)
|
|
74
70
|
res = os_.finance.search_internal("测试查询")
|
|
75
71
|
assert res is not None, "search_internal 应经中枢解析到 search_runtime"
|
|
76
|
-
# 中枢缓存:两次解析同一实例
|
|
77
72
|
assert os_.capabilities.get("search_runtime") is os_.search_runtime
|
|
78
73
|
print(" ✓ search_internal 经中枢解析 search_runtime(缓存一致)")
|
|
79
74
|
asyncio.run(os_.aclose())
|
|
@@ -101,7 +96,6 @@ def test_lazy_build_once_concurrent():
|
|
|
101
96
|
for t in ts: t.join()
|
|
102
97
|
assert calls["n"] == 1, f"并发下惰性应只构造 1 次,实得 {calls['n']}"
|
|
103
98
|
assert len({id(r) for r in results}) == 1, "12 线程应拿到同一实例"
|
|
104
|
-
# 不同能力不互相阻塞(构造锁 per-name;此处只验证正确性:各自构造一次)
|
|
105
99
|
reg.register_lazy("y", lambda: object())
|
|
106
100
|
assert reg.get("y") is reg.get("y")
|
|
107
101
|
print(" ✓ 12 线程并发 get 同一惰性 → 只构造 1 次、同一实例;不同能力独立")
|
|
@@ -23,8 +23,6 @@ def _headers(secret: str = SECRET, body: bytes = b'{"a":1}', ts: float = 1_700_0
|
|
|
23
23
|
return signer.headers(body, nonce="n0nce", now=ts)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
# ── canonical / 签名 ────────────────────────────────────────
|
|
27
|
-
|
|
28
26
|
def test_canonical_format_is_locked() -> None:
|
|
29
27
|
"""canonical 是两端的协议:字段顺序/分隔符一改,对方全部验签失败。"""
|
|
30
28
|
line = S.canonical("1700000000", "abc", b"body").decode()
|
|
@@ -63,7 +61,6 @@ def test_replay_guard_rejects_second_use() -> None:
|
|
|
63
61
|
guard = S.ReplayGuard(ttl=300)
|
|
64
62
|
assert S.verify(h, body, SECRET, replay=guard, now=1_700_000_000) == (True, "ok")
|
|
65
63
|
assert S.verify(h, body, SECRET, replay=guard, now=1_700_000_000) == (False, "replay")
|
|
66
|
-
# 换一个 nonce(正常新请求)→ 通过
|
|
67
64
|
h2 = S.OutboundSigner(SECRET).headers(body, nonce="other", now=1_700_000_000)
|
|
68
65
|
assert S.verify(h2, body, SECRET, replay=guard, now=1_700_000_000) == (True, "ok")
|
|
69
66
|
|
|
@@ -82,12 +79,10 @@ def test_verify_reason_codes() -> None:
|
|
|
82
79
|
|
|
83
80
|
def test_header_lookup_is_case_insensitive() -> None:
|
|
84
81
|
body = b"{}"
|
|
85
|
-
h = {k.lower(): v for k, v in _headers(body=body).items()}
|
|
82
|
+
h = {k.lower(): v for k, v in _headers(body=body).items()}
|
|
86
83
|
assert S.verify(h, body, SECRET, now=1_700_000_000) == (True, "ok")
|
|
87
84
|
|
|
88
85
|
|
|
89
|
-
# ── signer ─────────────────────────────────────────────────
|
|
90
|
-
|
|
91
86
|
def test_signer_optional_key_id_and_disabled_state() -> None:
|
|
92
87
|
h = S.OutboundSigner(SECRET, key_id="k9").headers(b"{}")
|
|
93
88
|
assert h[S.KEY_ID_HEADER] == "k9"
|
|
@@ -105,8 +100,6 @@ def test_from_env_opt_in() -> None:
|
|
|
105
100
|
assert S.from_env({S.ENV_SECRET: " "}) is None
|
|
106
101
|
|
|
107
102
|
|
|
108
|
-
# ── 桥接层 ─────────────────────────────────────────────────
|
|
109
|
-
|
|
110
103
|
class _Resp:
|
|
111
104
|
status_code = 200
|
|
112
105
|
text = ""
|
|
@@ -155,7 +148,7 @@ def test_bridge_signs_the_bytes_it_sends(fake_http) -> None:
|
|
|
155
148
|
assert isinstance(body, bytes) and json.loads(body)["text"] == "hi"
|
|
156
149
|
assert cap["headers"]["Content-Type"] == "application/json"
|
|
157
150
|
assert cap["headers"]["Authorization"] == "Bearer t"
|
|
158
|
-
ok, why = S.verify(cap["headers"], body, SECRET)
|
|
151
|
+
ok, why = S.verify(cap["headers"], body, SECRET)
|
|
159
152
|
assert (ok, why) == (True, "ok")
|
|
160
153
|
|
|
161
154
|
|
|
@@ -23,7 +23,6 @@ def check(cond, msg):
|
|
|
23
23
|
def test_abstraction():
|
|
24
24
|
print("\n【1】渠道抽象层")
|
|
25
25
|
from foxclaw.channels import ChannelAdapter, InboundMessage, SendResult, ChannelRegistry, OutboundMessage
|
|
26
|
-
# handle_raw 异常隔离:on_message 抛异常不冒泡
|
|
27
26
|
class A(ChannelAdapter):
|
|
28
27
|
CHANNEL = "t"
|
|
29
28
|
def normalize(self, raw):
|
|
@@ -38,7 +37,6 @@ def test_abstraction():
|
|
|
38
37
|
for txt in ["a", "bad", "c"]:
|
|
39
38
|
ad.handle_raw({"text": txt})
|
|
40
39
|
check(processed == ["a", "c"], "handle_raw 异常隔离(单条失败不影响其他)")
|
|
41
|
-
# 注册表
|
|
42
40
|
reg = ChannelRegistry(); reg.register(ad)
|
|
43
41
|
check(reg.get("t") is ad, "渠道注册表")
|
|
44
42
|
check(not reg.send("nope", OutboundMessage("c", "x")).ok, "未注册渠道发送失败")
|
|
@@ -69,14 +67,12 @@ def test_weixin():
|
|
|
69
67
|
check(h["Authorization"].startswith("Bearer "), "Bearer token")
|
|
70
68
|
check(int(base64.b64decode(h["X-WECHAT-UIN"]).decode()) >= 0, "X-WECHAT-UIN 格式")
|
|
71
69
|
check(wx._build_client_version("2.4.3") == 132099, "ClientVersion 计算")
|
|
72
|
-
# 标准化 + context_token 记忆
|
|
73
70
|
msg = {"message_id": 1, "from_user_id": "u1", "session_id": "s",
|
|
74
71
|
"message_type": MSG_FROM_USER, "context_token": "ctx1",
|
|
75
72
|
"item_list": [{"type": ITEM_TEXT, "text_item": {"text": "你好"}}]}
|
|
76
73
|
inb = wx._normalize_msg(msg)
|
|
77
74
|
check(inb.text == "你好", "消息标准化")
|
|
78
75
|
check(wx._context_tokens.get("u1") == "ctx1", "记住 context_token")
|
|
79
|
-
# bot 回显忽略
|
|
80
76
|
check(wx._normalize_msg({"message_type": 2, "item_list": []}) is None, "bot 回显忽略")
|
|
81
77
|
|
|
82
78
|
|
|
@@ -84,10 +80,8 @@ def test_feishu():
|
|
|
84
80
|
print("\n【4】飞书")
|
|
85
81
|
from foxclaw.channels.feishu import FeishuAdapter
|
|
86
82
|
fs = FeishuAdapter(app_id="x", app_secret="y", encrypt_key="my_secret_key_123")
|
|
87
|
-
# URL 验证
|
|
88
83
|
r = fs.handle_event({"type": "url_verification", "challenge": "c1"})
|
|
89
84
|
check(r.get("challenge") == "c1", "URL 验证 challenge")
|
|
90
|
-
# 加密解密端到端
|
|
91
85
|
def encrypt(plaintext, key_str):
|
|
92
86
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
93
87
|
key = hashlib.sha256(key_str.encode()).digest()
|
|
@@ -100,10 +94,8 @@ def test_feishu():
|
|
|
100
94
|
original = {"type": "event", "x": "测试"}
|
|
101
95
|
dec = fs.decrypt_event(encrypt(json.dumps(original), "my_secret_key_123"))
|
|
102
96
|
check(dec == original, "AES 加密解密端到端")
|
|
103
|
-
# 错误密钥明确报错
|
|
104
97
|
fs2 = FeishuAdapter(app_id="x", app_secret="y", encrypt_key="wrong")
|
|
105
98
|
check(fs2.decrypt_event(encrypt(json.dumps(original), "my_secret_key_123")) is None, "错误密钥解密失败")
|
|
106
|
-
# 消息标准化
|
|
107
99
|
event = {"header": {"event_type": "im.message.receive_v1"},
|
|
108
100
|
"event": {"message": {"message_id": "om1", "chat_id": "oc1", "chat_type": "p2p",
|
|
109
101
|
"content": json.dumps({"text": "查日程"}), "create_time": "1718900000000"},
|
|
@@ -141,7 +133,7 @@ def test_closed_loop():
|
|
|
141
133
|
|
|
142
134
|
import tempfile, shutil
|
|
143
135
|
data_dir = tempfile.mkdtemp(prefix="foxclaw_ch_test_")
|
|
144
|
-
shutil.rmtree(data_dir, ignore_errors=True)
|
|
136
|
+
shutil.rmtree(data_dir, ignore_errors=True)
|
|
145
137
|
os_ = Foxclaw(data_dir=data_dir, shadow_rounds=50)
|
|
146
138
|
disp = ChannelDispatcher(os_.supervisor)
|
|
147
139
|
mock = Mock(); disp.register_channel(mock)
|
|
@@ -151,12 +143,10 @@ def test_closed_loop():
|
|
|
151
143
|
sender_id="u1", conversation_id="c1")
|
|
152
144
|
r1 = await disp.handle(msg)
|
|
153
145
|
check(r1["replied"], "闭环:收→主管→回")
|
|
154
|
-
# 幂等:同消息重发只处理一次
|
|
155
146
|
r2 = await disp.handle(msg)
|
|
156
147
|
check(r2.get("idempotent_skip"), "重发消息被幂等跳过")
|
|
157
148
|
check(len(mock.sent) == 1, "重发只回复一次")
|
|
158
149
|
asyncio.run(go())
|
|
159
|
-
# 清理
|
|
160
150
|
import shutil
|
|
161
151
|
shutil.rmtree(data_dir, ignore_errors=True)
|
|
162
152
|
|
|
@@ -9,7 +9,6 @@ def test_config_layering():
|
|
|
9
9
|
|
|
10
10
|
from foxclaw.core.config import LayeredConfig, load_policy_layer
|
|
11
11
|
|
|
12
|
-
# 1) LayeredConfig:标量高层覆盖;集合三层并集(只增不减);policy_locked
|
|
13
12
|
cfg = LayeredConfig(policy={"model": "P", "deny": ["a"]},
|
|
14
13
|
user={"model": "U", "deny": ["b"]},
|
|
15
14
|
runtime={"model": "R", "deny": ["c"], "x": 1})
|
|
@@ -19,7 +18,6 @@ def test_config_layering():
|
|
|
19
18
|
assert cfg.policy_locked("deny") == {"a"}, "policy 层锁定项"
|
|
20
19
|
print("[1] LayeredConfig 标量覆盖 + 集合并集 + policy_locked OK")
|
|
21
20
|
|
|
22
|
-
# 2) load_policy_layer:env + policy.json 合并;默认空
|
|
23
21
|
d = tempfile.mkdtemp(prefix="e1_")
|
|
24
22
|
try:
|
|
25
23
|
os.environ["FOXCLAW_POLICY_DENY"] = "gmail_send, danger_tool"
|
|
@@ -28,7 +26,6 @@ def test_config_layering():
|
|
|
28
26
|
assert set(pl["deny"]) == {"gmail_send", "danger_tool", "file_delete"}, pl
|
|
29
27
|
print(f"[2] load_policy_layer(env + policy.json) = {pl['deny']} OK")
|
|
30
28
|
|
|
31
|
-
# 3) SkillPolicy:policy 层 deny 生效 + undeny 撤不掉 + 从盘 user deny 生效(引用未断) + user 可撤
|
|
32
29
|
(Path(d) / "skill_policy.json").write_text(
|
|
33
30
|
json.dumps({"deny": ["user_denied"], "allow": []}), encoding="utf-8")
|
|
34
31
|
from foxclaw.skills.policy import SkillPolicy
|
|
@@ -45,7 +42,6 @@ def test_config_layering():
|
|
|
45
42
|
os.environ.pop("FOXCLAW_POLICY_DENY", None)
|
|
46
43
|
shutil.rmtree(d, ignore_errors=True)
|
|
47
44
|
|
|
48
|
-
# 4) behavior-preserving:无 policy 层(无 env、无 policy.json)→ SkillPolicy 行为 = 现状
|
|
49
45
|
d2 = tempfile.mkdtemp(prefix="e1b_")
|
|
50
46
|
try:
|
|
51
47
|
from foxclaw.skills.policy import SkillPolicy
|