foxclaw 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1 -8
- package/package.json +1 -1
- package/python/.env.example +8 -0
- package/python/.github/workflows/ci.yml +47 -2
- package/python/CHANGELOG.md +57 -0
- package/python/CHANGELOG_CN.md +57 -0
- package/python/README.md +11 -2
- package/python/README_CN.md +32 -20
- package/python/bench/bm25_audit.py +1 -14
- package/python/bench/bm25_regression.py +0 -1
- package/python/bench/bm25_stress.py +0 -4
- package/python/bench/cd_audit.py +0 -23
- package/python/bench/chaos.py +6 -8
- package/python/bench/e2e_real_models.py +0 -1
- package/python/bench/llm_retry_test.py +2 -9
- package/python/bench/load_test.py +5 -8
- package/python/bench/vector_ivf_upgrade.py +1 -7
- package/python/docs/RELEASE.md +4 -3
- package/python/docs/RUN_CN.md +2 -2
- package/python/evals/governance/cases.py +0 -37
- package/python/evals/governance/run_evals.py +5 -37
- package/python/examples/governance_middleware_demo.py +1 -6
- package/python/examples/langgraph_governance_demo.py +7 -11
- package/python/foxclaw/__init__.py +34 -152
- package/python/foxclaw/agents/base.py +65 -136
- package/python/foxclaw/agents/impl/_helpers.py +2 -10
- package/python/foxclaw/agents/impl/finance.py +4 -21
- package/python/foxclaw/agents/impl/office.py +7 -24
- package/python/foxclaw/agents/impl/supervisor.py +12 -79
- package/python/foxclaw/agents/mixins/_context.py +0 -23
- package/python/foxclaw/agents/mixins/augment.py +10 -16
- package/python/foxclaw/agents/mixins/llm.py +7 -6
- package/python/foxclaw/agents/mixins/memory.py +7 -1
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +0 -6
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
- package/python/foxclaw/api/middleware.py +5 -28
- package/python/foxclaw/api/routes/ahs.py +60 -73
- package/python/foxclaw/api/routes/attachments.py +5 -6
- package/python/foxclaw/api/routes/browser.py +7 -11
- package/python/foxclaw/api/routes/domain.py +18 -26
- package/python/foxclaw/api/routes/knowledge.py +3 -21
- package/python/foxclaw/api/routes/llm_admin.py +21 -33
- package/python/foxclaw/api/routes/memory.py +28 -12
- package/python/foxclaw/api/routes/orchestration.py +3 -5
- package/python/foxclaw/api/routes/recovery.py +4 -3
- package/python/foxclaw/api/routes/review.py +2 -11
- package/python/foxclaw/api/routes/search.py +3 -7
- package/python/foxclaw/api/routes/system.py +33 -49
- package/python/foxclaw/api/routes/task.py +31 -49
- package/python/foxclaw/api/routes/ws.py +2 -6
- package/python/foxclaw/api/spine.py +1 -4
- package/python/foxclaw/browser/action/action_executor.py +1 -5
- package/python/foxclaw/browser/action/action_types.py +0 -1
- package/python/foxclaw/browser/action/url_policy.py +1 -5
- package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
- package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
- package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
- package/python/foxclaw/browser/perception/element_indexer.py +1 -2
- package/python/foxclaw/browser/service.py +7 -11
- package/python/foxclaw/capability/embedding_service.py +4 -6
- package/python/foxclaw/channels/__init__.py +9 -11
- package/python/foxclaw/channels/discord.py +4 -13
- package/python/foxclaw/channels/dispatcher.py +2 -27
- package/python/foxclaw/channels/feishu.py +10 -20
- package/python/foxclaw/channels/openclaw_bridge.py +4 -9
- package/python/foxclaw/channels/signing.py +9 -9
- package/python/foxclaw/channels/telegram.py +3 -4
- package/python/foxclaw/channels/weixin.py +7 -17
- package/python/foxclaw/cli.py +42 -13
- package/python/foxclaw/cognitive/reasoner.py +9 -10
- package/python/foxclaw/cognitive/state.py +18 -24
- package/python/foxclaw/core/audit_chain.py +1 -3
- package/python/foxclaw/core/audit_sink.py +3 -3
- package/python/foxclaw/core/compaction.py +8 -26
- package/python/foxclaw/core/config.py +2 -2
- package/python/foxclaw/core/confirm_nonce.py +6 -12
- package/python/foxclaw/core/envfile.py +2 -2
- package/python/foxclaw/core/failure_policy.py +17 -26
- package/python/foxclaw/core/gateway.py +6 -10
- package/python/foxclaw/core/governance_audit.py +3 -7
- package/python/foxclaw/core/idempotency.py +15 -22
- package/python/foxclaw/core/injection_guard.py +10 -29
- package/python/foxclaw/core/memory_adapter.py +30 -57
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +36 -148
- package/python/foxclaw/core/paths.py +1 -1
- package/python/foxclaw/core/recovery.py +21 -34
- package/python/foxclaw/core/scheduler.py +23 -26
- package/python/foxclaw/core/secrets.py +0 -3
- package/python/foxclaw/core/sessions.py +4 -6
- package/python/foxclaw/core/settings.py +16 -32
- package/python/foxclaw/core/shared_write.py +11 -23
- package/python/foxclaw/core/steering_queue.py +12 -11
- package/python/foxclaw/core/system_bus.py +1 -1
- package/python/foxclaw/core/thought_chain.py +30 -32
- package/python/foxclaw/core/token_meter.py +9 -13
- package/python/foxclaw/core/tools.py +4 -12
- package/python/foxclaw/core/tracing.py +25 -40
- package/python/foxclaw/core/truth_pipeline.py +5 -9
- package/python/foxclaw/core/update_check.py +149 -0
- package/python/foxclaw/core/vec_recall_adapter.py +11 -37
- package/python/foxclaw/core/verify.py +6 -14
- package/python/foxclaw/core/web_search.py +4 -28
- package/python/foxclaw/finance/evidence.py +14 -18
- package/python/foxclaw/finance/fx.py +1 -3
- package/python/foxclaw/finance/graph.py +9 -18
- package/python/foxclaw/finance/models.py +20 -24
- package/python/foxclaw/finance/nlparse.py +12 -72
- package/python/foxclaw/finance/policy.py +8 -36
- package/python/foxclaw/finance/reasoner.py +3 -11
- package/python/foxclaw/finance/reasoning.py +13 -34
- package/python/foxclaw/finance/reconcile.py +5 -10
- package/python/foxclaw/finance/runtime.py +19 -46
- package/python/foxclaw/governance/middleware.py +13 -21
- package/python/foxclaw/harness/_base.py +2 -8
- package/python/foxclaw/harness/_policy.py +19 -38
- package/python/foxclaw/harness/drift.py +1 -13
- package/python/foxclaw/harness/event_bus.py +2 -5
- package/python/foxclaw/harness/harness.py +9 -97
- package/python/foxclaw/harness/loop_detection.py +11 -18
- package/python/foxclaw/itinerary/amap_client.py +2 -5
- package/python/foxclaw/itinerary/models.py +7 -7
- package/python/foxclaw/itinerary/reasoner.py +1 -6
- package/python/foxclaw/itinerary/reasoning.py +15 -21
- package/python/foxclaw/itinerary/runtime.py +8 -12
- package/python/foxclaw/kernel/capabilities.py +5 -6
- package/python/foxclaw/kernel/policy_gate.py +4 -10
- package/python/foxclaw/kernel/process_manager.py +17 -36
- package/python/foxclaw/kernel/scheduler.py +3 -9
- package/python/foxclaw/kernel/sentinel.py +13 -23
- package/python/foxclaw/kernel/sentinel_client.py +12 -15
- package/python/foxclaw/kernel/spine.py +9 -9
- package/python/foxclaw/knowledge/__init__.py +4 -20
- package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
- package/python/foxclaw/learning/knowledge_graph.py +4 -9
- package/python/foxclaw/learning/models.py +13 -13
- package/python/foxclaw/learning/reasoner.py +1 -4
- package/python/foxclaw/learning/runtime.py +19 -31
- package/python/foxclaw/learning/spaced_repetition.py +2 -7
- package/python/foxclaw/mcp_server.py +2 -3
- package/python/foxclaw/orchestration/__init__.py +16 -33
- package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
- package/python/foxclaw/pipeline/stage_meta.py +21 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
- package/python/foxclaw/planning/llm_planner.py +3 -3
- package/python/foxclaw/planning/planner.py +6 -11
- package/python/foxclaw/search/agent_port.py +7 -11
- package/python/foxclaw/search/evidence.py +0 -4
- package/python/foxclaw/search/internal_search.py +3 -5
- package/python/foxclaw/search/models.py +11 -13
- package/python/foxclaw/search/planner.py +3 -3
- package/python/foxclaw/search/retriever.py +1 -1
- package/python/foxclaw/search/runtime.py +1 -8
- package/python/foxclaw/skills/__init__.py +46 -100
- package/python/foxclaw/skills/archive.py +111 -0
- package/python/foxclaw/skills/fetch.py +226 -0
- package/python/foxclaw/skills/hooks_spec.py +21 -25
- package/python/foxclaw/skills/install_cmd.py +216 -0
- package/python/foxclaw/skills/lifecycle.py +16 -42
- package/python/foxclaw/skills/markdown_skill.py +10 -9
- package/python/foxclaw/skills/mcp_client.py +5 -11
- package/python/foxclaw/skills/mcp_store.py +0 -3
- package/python/foxclaw/skills/permission.py +13 -48
- package/python/foxclaw/skills/pipeline.py +21 -53
- package/python/foxclaw/skills/policy.py +4 -10
- package/python/foxclaw/skills/review.py +3 -3
- package/python/frontend/deck/auth.js +0 -7
- package/python/frontend/deck/chat_model_picker.js +0 -54
- package/python/frontend/deck/domain.js +0 -20
- package/python/frontend/deck/main.js +0 -144
- package/python/frontend/deck/markdown.js +0 -4
- package/python/frontend/deck/settings.js +1 -18
- package/python/frontend/deck/state.js +0 -1
- package/python/frontend/deck/util.js +0 -2
- package/python/frontend/deck/visuals.js +0 -22
- package/python/frontend/planet/planet.js +0 -201
- package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
- package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
- package/python/frontend/v3/index.html +3 -3
- package/python/frontend/widget_runtime.js +0 -43
- package/python/frontend/widgets.js +0 -57
- package/python/frontend_src/index.html +1 -1
- package/python/frontend_src/package-lock.json +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/App.vue +3 -7
- package/python/frontend_src/src/api.js +0 -7
- package/python/frontend_src/src/components/ChatDock.vue +27 -39
- package/python/frontend_src/src/components/DocViewer.vue +0 -9
- package/python/frontend_src/src/components/Inspector.vue +0 -6
- package/python/frontend_src/src/components/LeftRail.vue +60 -39
- package/python/frontend_src/src/components/MenuPopover.vue +9 -11
- package/python/frontend_src/src/components/MessageStream.vue +18 -30
- package/python/frontend_src/src/components/ModelPicker.vue +0 -5
- package/python/frontend_src/src/components/QuickIngest.vue +0 -6
- package/python/frontend_src/src/components/Stage.vue +71 -25
- package/python/frontend_src/src/components/ToolMenu.vue +0 -3
- package/python/frontend_src/src/components/TopBar.vue +38 -10
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
- package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
- package/python/frontend_src/src/lib/fmt.js +0 -4
- package/python/frontend_src/src/lib/i18n.js +53 -0
- package/python/frontend_src/src/lib/locales/en.js +173 -0
- package/python/frontend_src/src/lib/locales/zh.js +174 -0
- package/python/frontend_src/src/lib/md.js +0 -14
- package/python/frontend_src/src/main.js +2 -0
- package/python/frontend_src/src/stores/app.js +17 -32
- package/python/frontend_src/src/stores/chat.js +0 -34
- package/python/frontend_src/src/stores/model.js +0 -8
- package/python/frontend_src/src/stores/panels.js +0 -41
- package/python/frontend_src/src/stores/profile.js +31 -0
- package/python/frontend_src/src/stores/system.js +25 -0
- package/python/frontend_src/vite.config.js +0 -7
- package/python/pyproject.toml +6 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +3 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_ahs_p2.py +4 -25
- package/python/tests/test_anthropic_tools.py +1 -15
- package/python/tests/test_api.py +19 -9
- package/python/tests/test_api_auth.py +1 -5
- package/python/tests/test_attachments.py +9 -25
- package/python/tests/test_audit_chain.py +2 -7
- package/python/tests/test_audit_sink.py +0 -6
- package/python/tests/test_browser.py +2 -6
- package/python/tests/test_browser_request_guard.py +0 -4
- package/python/tests/test_capabilities.py +0 -6
- package/python/tests/test_channel_signing.py +2 -9
- package/python/tests/test_channels.py +1 -11
- package/python/tests/test_config_layering.py +0 -4
- package/python/tests/test_confirm_nonce.py +1 -12
- package/python/tests/test_context_compaction_wiring.py +1 -7
- package/python/tests/test_dispatch.py +0 -6
- package/python/tests/test_domain_evals.py +11 -80
- package/python/tests/test_domain_import.py +0 -6
- package/python/tests/test_domain_reasoners.py +0 -14
- package/python/tests/test_e2e.py +0 -17
- package/python/tests/test_emb_cache.py +7 -17
- package/python/tests/test_embedding_singleton.py +1 -3
- package/python/tests/test_evals_smoke.py +0 -24
- package/python/tests/test_event_spine.py +10 -18
- package/python/tests/test_evidence_semantic.py +1 -14
- package/python/tests/test_evidence_sse.py +3 -6
- package/python/tests/test_file_ingest.py +0 -11
- package/python/tests/test_finance.py +0 -9
- package/python/tests/test_finance_ledger.py +5 -12
- package/python/tests/test_finance_persist.py +8 -12
- package/python/tests/test_gateway.py +3 -22
- package/python/tests/test_governance.py +5 -52
- package/python/tests/test_governance_audit.py +4 -39
- package/python/tests/test_governance_middleware.py +1 -7
- package/python/tests/test_hkc.py +6 -24
- package/python/tests/test_hkc_index_build_manager.py +0 -33
- package/python/tests/test_hkc_manifest_reuse.py +4 -27
- package/python/tests/test_hkc_search_event_sync.py +3 -54
- package/python/tests/test_import_path_guard.py +0 -6
- package/python/tests/test_injection_guard.py +18 -36
- package/python/tests/test_integration.py +5 -29
- package/python/tests/test_itinerary.py +0 -3
- package/python/tests/test_kee_semantic.py +0 -5
- package/python/tests/test_kernel.py +1 -11
- package/python/tests/test_learning.py +3 -12
- package/python/tests/test_llm_admin.py +4 -19
- package/python/tests/test_llm_planner.py +1 -23
- package/python/tests/test_markdown_skills.py +3 -14
- package/python/tests/test_markdown_skills_openclaw.py +4 -9
- package/python/tests/test_mcp_connectors.py +3 -15
- package/python/tests/test_mcp_guardian_discovery.py +2 -6
- package/python/tests/test_mcp_no_memory.py +3 -3
- package/python/tests/test_mcp_policy_gate.py +0 -1
- package/python/tests/test_mcp_server.py +6 -9
- package/python/tests/test_memory_recall.py +1 -4
- package/python/tests/test_metrics.py +0 -8
- package/python/tests/test_multi_chain.py +1 -13
- package/python/tests/test_multimodal.py +5 -18
- package/python/tests/test_negotiation.py +3 -13
- package/python/tests/test_orchestration.py +1 -11
- package/python/tests/test_orchestration_decoupled.py +0 -6
- package/python/tests/test_p3_p5_hardening.py +3 -17
- package/python/tests/test_parallel_dispatch.py +4 -12
- package/python/tests/test_pipeline.py +17 -58
- package/python/tests/test_plan_visible.py +4 -4
- package/python/tests/test_planner.py +0 -7
- package/python/tests/test_policy_gate.py +2 -3
- package/python/tests/test_profile.py +67 -0
- package/python/tests/test_readme_claims.py +163 -0
- package/python/tests/test_reasoner.py +2 -17
- package/python/tests/test_release_check.py +2 -13
- package/python/tests/test_resume_checkpoint.py +7 -15
- package/python/tests/test_routing.py +2 -12
- package/python/tests/test_scheduler.py +7 -10
- package/python/tests/test_search.py +1 -11
- package/python/tests/test_secrets.py +4 -10
- package/python/tests/test_semantic.py +1 -10
- package/python/tests/test_sessions.py +3 -4
- package/python/tests/test_settings.py +2 -18
- package/python/tests/test_shadow_learning.py +3 -43
- package/python/tests/test_shared_write.py +0 -7
- package/python/tests/test_skill_taint_gate.py +1 -10
- package/python/tests/test_skills.py +2 -18
- package/python/tests/test_skills_fetch.py +282 -0
- package/python/tests/test_stats_and_events.py +2 -5
- package/python/tests/test_streaming.py +2 -7
- package/python/tests/test_supervisor_pipeline.py +4 -5
- package/python/tests/test_supervisor_triage.py +4 -10
- package/python/tests/test_taint.py +2 -7
- package/python/tests/test_task_stream.py +1 -12
- package/python/tests/test_thinking_sse.py +0 -1
- package/python/tests/test_tool_center.py +1 -17
- package/python/tests/test_tool_parallel.py +2 -2
- package/python/tests/test_tool_params.py +1 -5
- package/python/tests/test_tool_use.py +0 -13
- package/python/tests/test_toplevel_concurrency.py +3 -4
- package/python/tests/test_tracing.py +7 -41
- package/python/tests/test_truth_crystallizer.py +4 -6
- package/python/tests/test_truth_flow.py +2 -7
- package/python/tests/test_update_check.py +95 -0
- package/python/tests/test_verify_evidence.py +8 -21
- package/python/tests/test_version_sync.py +35 -6
- package/python/tests/test_web_trigger.py +0 -17
- package/python/tools/check_faiss_type.py +0 -1
- package/python/tools/check_log.ps1 +0 -8
- package/python/tools/mypy_baseline.txt +12 -4
- package/python/tools/mypy_ratchet.py +52 -6
- package/python/tools/pack.py +0 -5
- package/python/tools/release_check.py +7 -7
- package/python/tools/restart_foxclaw.ps1 +5 -8
- package/python/tools/shared_write_audit.py +1 -15
- package/python/tools/size_check.py +1 -1
- package/python/tools/strip_comments.py +228 -0
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
|
@@ -24,11 +24,9 @@ from typing import List, Any
|
|
|
24
24
|
from .base_driver import BaseDriver
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
# 被拦请求只留最近这些条:诊断/测试用,不落盘
|
|
28
27
|
_MAX_BLOCKED = 50
|
|
29
28
|
|
|
30
29
|
|
|
31
|
-
# role -> 对应的 CSS selector,v1 用简单选择器覆盖大多数场景
|
|
32
30
|
ROLE_SELECTOR_MAP = {
|
|
33
31
|
"button": "button, [role=button], input[type=button], input[type=submit]",
|
|
34
32
|
"link": "a[href]",
|
|
@@ -44,8 +42,6 @@ ROLE_SELECTOR_MAP = {
|
|
|
44
42
|
class PlaywrightDriver(BaseDriver):
|
|
45
43
|
|
|
46
44
|
def __init__(self, headless: bool = True, guard_requests: bool | None = None):
|
|
47
|
-
# A1:惰性导入——缺 playwright 时这里才抛 ImportError,由上层能力探测拦截,
|
|
48
|
-
# 不会污染 `import foxclaw.browser`。
|
|
49
45
|
from playwright.sync_api import sync_playwright
|
|
50
46
|
|
|
51
47
|
self._pw = None
|
|
@@ -60,12 +56,8 @@ class PlaywrightDriver(BaseDriver):
|
|
|
60
56
|
guard_requests = os.environ.get(
|
|
61
57
|
"FOXCLAW_BROWSER_GUARD_REQUESTS", "1").strip() not in ("0", "false", "no")
|
|
62
58
|
if guard_requests:
|
|
63
|
-
# `**/*` 覆盖主文档、3xx 跳转后的新地址、iframe 与全部子资源。
|
|
64
|
-
# 原先只校验 navigate 的目标:公网页面跳一次转,或塞一个
|
|
65
|
-
# `<img src="http://169.254.169.254/...">` 就能越过准入。
|
|
66
59
|
self._page.route("**/*", self._on_route)
|
|
67
60
|
except Exception:
|
|
68
|
-
# A4:构造中途失败(如 chromium 内核未 install)也不能泄漏原生资源
|
|
69
61
|
self._safe_teardown()
|
|
70
62
|
raise
|
|
71
63
|
|
|
@@ -95,7 +87,6 @@ class PlaywrightDriver(BaseDriver):
|
|
|
95
87
|
try:
|
|
96
88
|
route.continue_()
|
|
97
89
|
except Exception:
|
|
98
|
-
# 会话已在拆卸中(回调与 close 竞争):请求已无意义,静默丢弃
|
|
99
90
|
pass
|
|
100
91
|
|
|
101
92
|
def _safe_teardown(self):
|
|
@@ -112,14 +103,27 @@ class PlaywrightDriver(BaseDriver):
|
|
|
112
103
|
self._browser = None
|
|
113
104
|
self._pw = None
|
|
114
105
|
|
|
106
|
+
def _require_page(self):
|
|
107
|
+
"""取当前页面;未打开或已拆卸时立刻报错,而不是等 None 属性访问炸。
|
|
108
|
+
|
|
109
|
+
`_page` 的静态类型是 `Page | None`(初始化与拆卸都会置 None)。下面几个
|
|
110
|
+
方法原先直接 `self._page.xxx`:正常流程里 close() 之后没人再调,所以一直
|
|
111
|
+
没出事;一旦有人在拆卸后调用,得到的是 `'NoneType' object has no attribute
|
|
112
|
+
'goto'`,看不出「会话已关闭」这层意思。集中在这里判一次,报错即意图。
|
|
113
|
+
"""
|
|
114
|
+
if self._page is None:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"browser session is not open: driver was closed or failed to launch"
|
|
117
|
+
)
|
|
118
|
+
return self._page
|
|
119
|
+
|
|
115
120
|
def navigate(self, url: str) -> None:
|
|
116
|
-
self.
|
|
121
|
+
self._require_page().goto(url, wait_until="domcontentloaded")
|
|
117
122
|
|
|
118
123
|
def query_interactive_elements(self, roles: tuple) -> List[Any]:
|
|
119
124
|
selectors = [ROLE_SELECTOR_MAP[r] for r in roles if r in ROLE_SELECTOR_MAP]
|
|
120
125
|
combined = ", ".join(selectors)
|
|
121
|
-
elements = self.
|
|
122
|
-
# 只保留当前可见的元素,避免 LLM 尝试操作看不见的东西
|
|
126
|
+
elements = self._require_page().query_selector_all(combined)
|
|
123
127
|
return [el for el in elements if el.is_visible()]
|
|
124
128
|
|
|
125
129
|
def describe_element(self, handle) -> dict:
|
|
@@ -148,19 +152,19 @@ class PlaywrightDriver(BaseDriver):
|
|
|
148
152
|
|
|
149
153
|
def scroll(self, direction: str) -> None:
|
|
150
154
|
delta = 600 if direction == "down" else -600
|
|
151
|
-
self.
|
|
155
|
+
self._require_page().mouse.wheel(0, delta)
|
|
152
156
|
|
|
153
157
|
def wait(self, seconds: float) -> None:
|
|
154
158
|
time.sleep(seconds)
|
|
155
159
|
|
|
156
160
|
def get_visible_text(self) -> str:
|
|
157
|
-
return self.
|
|
161
|
+
return self._require_page().inner_text("body")
|
|
158
162
|
|
|
159
163
|
def current_url(self) -> str:
|
|
160
|
-
return self.
|
|
164
|
+
return self._require_page().url
|
|
161
165
|
|
|
162
166
|
def current_title(self) -> str:
|
|
163
|
-
return self.
|
|
167
|
+
return self._require_page().title()
|
|
164
168
|
|
|
165
169
|
def close(self) -> None:
|
|
166
170
|
self._safe_teardown()
|
|
@@ -16,7 +16,6 @@ from typing import List, Dict, Any
|
|
|
16
16
|
class ElementIndexer:
|
|
17
17
|
|
|
18
18
|
def __init__(self):
|
|
19
|
-
# id(str) -> 底层 driver 元素句柄
|
|
20
19
|
self._registry: Dict[str, Any] = {}
|
|
21
20
|
self._counter = 0
|
|
22
21
|
|
|
@@ -42,7 +41,7 @@ class ElementIndexer:
|
|
|
42
41
|
indexed.append({
|
|
43
42
|
"id": eid,
|
|
44
43
|
"role": desc.get("role", "unknown"),
|
|
45
|
-
"text": desc.get("text", "")[:80],
|
|
44
|
+
"text": desc.get("text", "")[:80],
|
|
46
45
|
"attrs": desc.get("attrs", {}),
|
|
47
46
|
})
|
|
48
47
|
|
|
@@ -23,21 +23,22 @@ import asyncio
|
|
|
23
23
|
import importlib.util
|
|
24
24
|
import threading
|
|
25
25
|
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
-
from typing import Optional
|
|
26
|
+
from typing import Optional, TYPE_CHECKING
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .session.session_manager import SessionManager
|
|
31
|
+
|
|
29
32
|
class BrowserService:
|
|
30
33
|
|
|
31
34
|
def __init__(self, headless: bool = True):
|
|
32
35
|
self._headless = headless
|
|
33
|
-
self._session = None
|
|
36
|
+
self._session: "SessionManager | None" = None
|
|
34
37
|
self._executor: Optional[ThreadPoolExecutor] = None
|
|
35
38
|
self._closed = False
|
|
36
39
|
self._launch_error: Optional[str] = None
|
|
37
|
-
# 保护 executor/session 的创建与关闭(仅协调,不进浏览器调用)
|
|
38
40
|
self._guard = threading.Lock()
|
|
39
41
|
|
|
40
|
-
# ── 能力探测 ────────────────────────────────────────────────
|
|
41
42
|
@staticmethod
|
|
42
43
|
def available() -> bool:
|
|
43
44
|
"""playwright 包是否可导入。注意:这不保证 chromium 内核已 install,
|
|
@@ -53,7 +54,6 @@ class BrowserService:
|
|
|
53
54
|
"launch_error": self._launch_error,
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
# ── 工作线程上运行的同步实现 ──────────────────────────────────
|
|
57
57
|
def _get_executor(self) -> ThreadPoolExecutor:
|
|
58
58
|
with self._guard:
|
|
59
59
|
if self._closed:
|
|
@@ -89,7 +89,6 @@ class BrowserService:
|
|
|
89
89
|
loop = asyncio.get_running_loop()
|
|
90
90
|
return await loop.run_in_executor(self._get_executor(), fn, *args)
|
|
91
91
|
|
|
92
|
-
# ── 对外 async API ─────────────────────────────────────────
|
|
93
92
|
async def perceive(self) -> dict:
|
|
94
93
|
"""只感知当前页面语义结构,不改变页面状态。"""
|
|
95
94
|
if not self.available():
|
|
@@ -107,9 +106,9 @@ class BrowserService:
|
|
|
107
106
|
return {"ok": False, "error": "非法动作:需 {type, target?, value?}"}
|
|
108
107
|
try:
|
|
109
108
|
return {"ok": True, **await self._run(self._act_sync, action)}
|
|
110
|
-
except ValueError as e:
|
|
109
|
+
except ValueError as e:
|
|
111
110
|
return {"ok": False, "error": str(e)}
|
|
112
|
-
except KeyError as e:
|
|
111
|
+
except KeyError as e:
|
|
113
112
|
return {"ok": False, "error": str(e).strip('"')}
|
|
114
113
|
except Exception as e:
|
|
115
114
|
return self._handle_error(e)
|
|
@@ -123,13 +122,11 @@ class BrowserService:
|
|
|
123
122
|
executor = self._executor
|
|
124
123
|
if executor is not None:
|
|
125
124
|
try:
|
|
126
|
-
# 会话的 close 必须回到工作线程执行(Playwright 对象线程亲和)
|
|
127
125
|
loop = asyncio.get_running_loop()
|
|
128
126
|
await loop.run_in_executor(executor, self._close_sync)
|
|
129
127
|
finally:
|
|
130
128
|
executor.shutdown(wait=True)
|
|
131
129
|
|
|
132
|
-
# ── 错误/降级处理 ──────────────────────────────────────────
|
|
133
130
|
def _unavailable(self) -> dict:
|
|
134
131
|
return {
|
|
135
132
|
"ok": False,
|
|
@@ -140,7 +137,6 @@ class BrowserService:
|
|
|
140
137
|
|
|
141
138
|
def _handle_error(self, e: Exception) -> dict:
|
|
142
139
|
msg = str(e)
|
|
143
|
-
# chromium 内核未安装是最常见的 launch 失败,给出可操作提示
|
|
144
140
|
if "playwright install" in msg or "Executable doesn't exist" in msg:
|
|
145
141
|
self._launch_error = msg
|
|
146
142
|
return {"ok": False, "available": False,
|
|
@@ -22,8 +22,8 @@ import os
|
|
|
22
22
|
import threading
|
|
23
23
|
|
|
24
24
|
_lock = threading.Lock()
|
|
25
|
-
_cache: dict[str, object] = {}
|
|
26
|
-
_load_attempts = 0
|
|
25
|
+
_cache: dict[str, object] = {}
|
|
26
|
+
_load_attempts = 0
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def get_text_embedder(model_name: str | None = None):
|
|
@@ -42,17 +42,15 @@ def get_text_embedder(model_name: str | None = None):
|
|
|
42
42
|
or os.environ.get("FOXCLAW_EMBEDDING_MODEL")
|
|
43
43
|
or "all-MiniLM-L6-v2").strip()
|
|
44
44
|
with _lock:
|
|
45
|
-
if name in _cache:
|
|
45
|
+
if name in _cache:
|
|
46
46
|
return _cache[name]
|
|
47
47
|
_load_attempts += 1
|
|
48
48
|
model = None
|
|
49
49
|
try:
|
|
50
|
-
# 【P2·share-bge-m3】走 HKC 共享进程缓存 —— 同名 model 全 Foxclaw 只 load 1 次
|
|
51
|
-
# (HKC 检索 / HMR VectorStore / Harness SemanticDrift 共用同一份 instance)
|
|
52
50
|
from hkc_search.embedding_backends import get_st_model as _hk_get
|
|
53
51
|
model = _hk_get(name)
|
|
54
52
|
except Exception:
|
|
55
|
-
model = None
|
|
53
|
+
model = None
|
|
56
54
|
_cache[name] = model
|
|
57
55
|
return model
|
|
58
56
|
|
|
@@ -20,15 +20,15 @@ import time
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class InboundMessage:
|
|
22
22
|
"""标准化的入站消息(各平台 normalize 后的统一格式)。"""
|
|
23
|
-
channel: str
|
|
23
|
+
channel: str
|
|
24
24
|
message_id: str
|
|
25
25
|
text: str
|
|
26
|
-
sender_id: str
|
|
26
|
+
sender_id: str
|
|
27
27
|
sender_name: str = ""
|
|
28
|
-
conversation_id: str = ""
|
|
28
|
+
conversation_id: str = ""
|
|
29
29
|
is_group: bool = False
|
|
30
30
|
timestamp: float = field(default_factory=time.time)
|
|
31
|
-
raw: dict = field(default_factory=dict)
|
|
31
|
+
raw: dict = field(default_factory=dict)
|
|
32
32
|
|
|
33
33
|
def to_dict(self) -> dict:
|
|
34
34
|
return {"channel": self.channel, "message_id": self.message_id,
|
|
@@ -41,9 +41,9 @@ class InboundMessage:
|
|
|
41
41
|
@dataclass
|
|
42
42
|
class OutboundMessage:
|
|
43
43
|
"""标准化的出站消息(上层产出,各适配器转成平台格式发送)。"""
|
|
44
|
-
conversation_id: str
|
|
44
|
+
conversation_id: str
|
|
45
45
|
text: str
|
|
46
|
-
reply_to: str = ""
|
|
46
|
+
reply_to: str = ""
|
|
47
47
|
extra: dict = field(default_factory=dict)
|
|
48
48
|
|
|
49
49
|
|
|
@@ -63,13 +63,12 @@ class ChannelAdapter(ABC):
|
|
|
63
63
|
- 入站统一回调 on_message,上层无需关心平台差异
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
|
-
# 子类覆盖:渠道名
|
|
67
66
|
CHANNEL = "base"
|
|
68
67
|
|
|
69
|
-
def __init__(self, on_message: Callable[[InboundMessage], None] = None):
|
|
68
|
+
def __init__(self, on_message: Callable[[InboundMessage], None] | None = None):
|
|
70
69
|
self.on_message = on_message
|
|
71
70
|
self._running = False
|
|
72
|
-
self.last_error = ""
|
|
71
|
+
self.last_error = ""
|
|
73
72
|
|
|
74
73
|
@abstractmethod
|
|
75
74
|
def normalize(self, raw: dict) -> Optional[InboundMessage]:
|
|
@@ -93,12 +92,11 @@ class ChannelAdapter(ABC):
|
|
|
93
92
|
self.last_error = f"normalize 失败: {e}"
|
|
94
93
|
return None
|
|
95
94
|
if inbound is None:
|
|
96
|
-
return None
|
|
95
|
+
return None
|
|
97
96
|
if self.on_message:
|
|
98
97
|
try:
|
|
99
98
|
self.on_message(inbound)
|
|
100
99
|
except Exception as e:
|
|
101
|
-
# on_message(主管处理)失败:记录但不抛,保证整批轮询不中断
|
|
102
100
|
self.last_error = f"on_message 失败: {e}"
|
|
103
101
|
return inbound
|
|
104
102
|
|
|
@@ -18,20 +18,18 @@ Foxclaw Channels — Discord 适配器
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
import json
|
|
21
|
-
from typing import Optional
|
|
21
|
+
from typing import Any, Optional
|
|
22
22
|
from . import ChannelAdapter, InboundMessage, OutboundMessage, SendResult
|
|
23
23
|
|
|
24
24
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
25
25
|
DISCORD_GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
26
26
|
|
|
27
|
-
# Gateway opcodes
|
|
28
27
|
OP_DISPATCH = 0
|
|
29
28
|
OP_HEARTBEAT = 1
|
|
30
29
|
OP_IDENTIFY = 2
|
|
31
30
|
OP_HELLO = 10
|
|
32
31
|
OP_HEARTBEAT_ACK = 11
|
|
33
32
|
|
|
34
|
-
# Intents(收消息内容需要 GUILD_MESSAGES + MESSAGE_CONTENT)
|
|
35
33
|
INTENT_GUILD_MESSAGES = 1 << 9
|
|
36
34
|
INTENT_DIRECT_MESSAGES = 1 << 12
|
|
37
35
|
INTENT_MESSAGE_CONTENT = 1 << 15
|
|
@@ -47,20 +45,18 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
47
45
|
self.bot_token = bot_token
|
|
48
46
|
self.intents = intents
|
|
49
47
|
self.timeout = timeout
|
|
50
|
-
self._bot_user_id = ""
|
|
48
|
+
self._bot_user_id = ""
|
|
51
49
|
|
|
52
50
|
def _headers(self) -> dict:
|
|
53
51
|
return {"Authorization": f"Bot {self.bot_token}",
|
|
54
52
|
"Content-Type": "application/json",
|
|
55
53
|
"User-Agent": "Foxclaw (https://github.com, 1.0)"}
|
|
56
54
|
|
|
57
|
-
# ── 入站:标准化 Gateway MESSAGE_CREATE 事件 ───────────
|
|
58
55
|
def normalize(self, raw: dict) -> Optional[InboundMessage]:
|
|
59
56
|
"""Discord Gateway 事件 → 标准 InboundMessage。
|
|
60
57
|
|
|
61
58
|
raw 可以是完整 Gateway 帧({op,t,d})或直接是 MESSAGE_CREATE 的 data。
|
|
62
59
|
"""
|
|
63
|
-
# 取出 data 部分
|
|
64
60
|
if "op" in raw:
|
|
65
61
|
if raw.get("op") != OP_DISPATCH or raw.get("t") != "MESSAGE_CREATE":
|
|
66
62
|
return None
|
|
@@ -70,15 +66,13 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
70
66
|
if not data:
|
|
71
67
|
return None
|
|
72
68
|
author = data.get("author", {})
|
|
73
|
-
# 忽略机器人消息(含自己),避免自循环
|
|
74
69
|
if author.get("bot"):
|
|
75
70
|
return None
|
|
76
71
|
if self._bot_user_id and str(author.get("id")) == self._bot_user_id:
|
|
77
72
|
return None
|
|
78
73
|
text = data.get("content", "")
|
|
79
74
|
if not text:
|
|
80
|
-
return None
|
|
81
|
-
# 有 guild_id = 服务器频道(群),无 = 私信
|
|
75
|
+
return None
|
|
82
76
|
is_group = bool(data.get("guild_id"))
|
|
83
77
|
return InboundMessage(
|
|
84
78
|
channel=self.CHANNEL,
|
|
@@ -90,7 +84,6 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
90
84
|
is_group=is_group,
|
|
91
85
|
raw=data)
|
|
92
86
|
|
|
93
|
-
# ── 出站:REST 发送 ────────────────────────────────────
|
|
94
87
|
def send(self, msg: OutboundMessage) -> SendResult:
|
|
95
88
|
"""POST /channels/{channel_id}/messages 发文本。"""
|
|
96
89
|
try:
|
|
@@ -99,7 +92,7 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
99
92
|
return SendResult(False, error="缺少 httpx 依赖")
|
|
100
93
|
if not self.bot_token:
|
|
101
94
|
return SendResult(False, error="未配置 Discord bot token")
|
|
102
|
-
payload = {"content": msg.text}
|
|
95
|
+
payload: dict[str, Any] = {"content": msg.text}
|
|
103
96
|
if msg.reply_to:
|
|
104
97
|
payload["message_reference"] = {"message_id": msg.reply_to}
|
|
105
98
|
url = f"{DISCORD_API_BASE}/channels/{msg.conversation_id}/messages"
|
|
@@ -156,10 +149,8 @@ class DiscordAdapter(ChannelAdapter):
|
|
|
156
149
|
raise RuntimeError("需要 websockets 库:pip install websockets")
|
|
157
150
|
import asyncio
|
|
158
151
|
async with websockets.connect(DISCORD_GATEWAY, max_size=None) as ws:
|
|
159
|
-
# 1. HELLO:拿心跳间隔
|
|
160
152
|
hello = json.loads(await ws.recv())
|
|
161
153
|
heartbeat_ms = hello["d"]["heartbeat_interval"]
|
|
162
|
-
# 2. IDENTIFY
|
|
163
154
|
await ws.send(json.dumps(self.build_identify()))
|
|
164
155
|
self._running = True
|
|
165
156
|
|
|
@@ -18,7 +18,7 @@ from . import InboundMessage, ChannelRegistry, ChannelAdapter
|
|
|
18
18
|
class ChannelDispatcher:
|
|
19
19
|
"""渠道闭环:把入站消息路由到主管,回复发回原渠道。"""
|
|
20
20
|
|
|
21
|
-
def __init__(self, supervisor, registry: ChannelRegistry = None,
|
|
21
|
+
def __init__(self, supervisor, registry: ChannelRegistry | None = None,
|
|
22
22
|
idempotency=None):
|
|
23
23
|
"""
|
|
24
24
|
supervisor: 主管 Agent(有 async run_task 方法)
|
|
@@ -27,7 +27,7 @@ class ChannelDispatcher:
|
|
|
27
27
|
"""
|
|
28
28
|
self.supervisor = supervisor
|
|
29
29
|
self.registry = registry or ChannelRegistry()
|
|
30
|
-
self._pending: set = set()
|
|
30
|
+
self._pending: set = set()
|
|
31
31
|
if idempotency is None:
|
|
32
32
|
from ..core.idempotency import IdempotencyGuard
|
|
33
33
|
idempotency = IdempotencyGuard(ttl_seconds=3600)
|
|
@@ -61,23 +61,18 @@ class ChannelDispatcher:
|
|
|
61
61
|
running = None
|
|
62
62
|
|
|
63
63
|
if running is not None:
|
|
64
|
-
# 在异步环境:发任务 + 持有引用(防止无引用 task 被 GC 静默回收)
|
|
65
64
|
task = running.create_task(self.handle(msg))
|
|
66
65
|
self._pending.add(task)
|
|
67
66
|
task.add_done_callback(self._pending.discard)
|
|
68
67
|
else:
|
|
69
|
-
# 同步环境(轮询渠道):跑到完成,调用方得到完整处理结果
|
|
70
68
|
asyncio.run(self.handle(msg))
|
|
71
69
|
|
|
72
70
|
async def handle(self, msg: InboundMessage) -> dict:
|
|
73
71
|
"""处理一条入站消息:主管思考 → 回复原渠道。返回处理结果。"""
|
|
74
|
-
# 幂等检查:同一渠道+消息ID只处理一次(防平台重发/轮询游标边界导致重复回复)
|
|
75
72
|
from ..core.idempotency import make_key
|
|
76
73
|
if msg.message_id:
|
|
77
74
|
idem_key = make_key("message", msg.channel, msg.message_id, msg.sender_id)
|
|
78
75
|
else:
|
|
79
|
-
# 无稳定 message_id 的渠道/消息:用 渠道+发送者+内容+时间窗(10秒)
|
|
80
|
-
# 算 fallback key——退化但仍能挡住短时间内的重发,不留盲区。
|
|
81
76
|
import time as _t
|
|
82
77
|
window = int(_t.time() // 10)
|
|
83
78
|
idem_key = make_key("message_fallback", msg.channel, msg.sender_id,
|
|
@@ -86,7 +81,6 @@ class ChannelDispatcher:
|
|
|
86
81
|
return {"handled": True, "replied": False, "reason": "重复消息,已跳过",
|
|
87
82
|
"idempotent_skip": True}
|
|
88
83
|
|
|
89
|
-
# 交给主管处理,带上渠道上下文(便于主管知道来源)
|
|
90
84
|
context = {
|
|
91
85
|
"channel": msg.channel,
|
|
92
86
|
"session_id": self._session_key(msg),
|
|
@@ -94,14 +88,7 @@ class ChannelDispatcher:
|
|
|
94
88
|
"sender_name": msg.sender_name,
|
|
95
89
|
"is_group": msg.is_group,
|
|
96
90
|
}
|
|
97
|
-
# 渠道入站文本来自外部(Telegram/Discord/…),默认按**不可信数据**处理:
|
|
98
|
-
# 此前 msg.text 原样当任务喂主管,等于把任意外部聊天用户当指令源(无发送者
|
|
99
|
-
# 白名单、无 <untrusted> 包裹)。可信发送者用 FOXCLAW_CHANNEL_TRUSTED_SENDERS
|
|
100
|
-
# 显式声明,逗号分隔,支持 `sender_id` 或 `channel:sender_id`,`*` 为该渠道全信任。
|
|
101
91
|
context["sender_trusted"] = self._sender_trusted(msg)
|
|
102
|
-
# 下传给权限判定:不可信来源驱动的任务里,写/发送/外发动作一律按「已接触敏感数据」
|
|
103
|
-
# 处理(见 agents/_context._untrusted_get → tool_exec._guard_tool / skillbox)。
|
|
104
|
-
# 只影响带此键的入口(渠道),本地 HTTP 对话不带 → 行为不变。
|
|
105
92
|
context["untrusted_source"] = not context["sender_trusted"]
|
|
106
93
|
text = msg.text
|
|
107
94
|
if not context["sender_trusted"]:
|
|
@@ -114,12 +101,10 @@ class ChannelDispatcher:
|
|
|
114
101
|
except Exception as e:
|
|
115
102
|
result = {"error": f"主管处理失败: {e}"}
|
|
116
103
|
|
|
117
|
-
# 提取回复文本
|
|
118
104
|
reply_text = self._extract_reply(result)
|
|
119
105
|
if not reply_text:
|
|
120
106
|
return {"handled": True, "replied": False, "reason": "无回复内容"}
|
|
121
107
|
|
|
122
|
-
# 通过原渠道回复
|
|
123
108
|
adapter = self.registry.get(msg.channel)
|
|
124
109
|
if adapter is None:
|
|
125
110
|
return {"handled": True, "replied": False, "reason": f"渠道 {msg.channel} 未注册"}
|
|
@@ -141,9 +126,6 @@ class ChannelDispatcher:
|
|
|
141
126
|
return False
|
|
142
127
|
sid = str(getattr(msg, "sender_id", "") or "")
|
|
143
128
|
chan = str(getattr(msg, "channel", "") or "")
|
|
144
|
-
# 裸 `*` 曾等于「所有渠道的所有发送者都可信」——一次手滑就把任意外部聊天用户提成
|
|
145
|
-
# 指令源,而列表里看不出它比 channel:sender_id 宽多少。现在必须二选一:
|
|
146
|
-
# 写 `channel:*`(限定渠道,推荐),或用 FOXCLAW_CHANNEL_TRUST_ALL=1 显式确认全局信任。
|
|
147
129
|
trust_all = _os.environ.get("FOXCLAW_CHANNEL_TRUST_ALL", "").strip() in ("1", "true", "True")
|
|
148
130
|
for item in (x.strip() for x in raw.split(",")):
|
|
149
131
|
if not item:
|
|
@@ -169,16 +151,12 @@ class ChannelDispatcher:
|
|
|
169
151
|
return str(result) if result else ""
|
|
170
152
|
|
|
171
153
|
inner = result.get("result")
|
|
172
|
-
# 情况1:result 直接是字符串
|
|
173
154
|
if isinstance(inner, str) and inner.strip():
|
|
174
155
|
return inner
|
|
175
|
-
# 情况2:result 是嵌套字典(主管分诊结果)
|
|
176
156
|
if isinstance(inner, dict):
|
|
177
|
-
# 单 Agent:sub_result
|
|
178
157
|
sub = inner.get("sub_result")
|
|
179
158
|
if isinstance(sub, str) and sub.strip():
|
|
180
159
|
return sub
|
|
181
|
-
# 多 Agent:拼接每步输出
|
|
182
160
|
steps = inner.get("multi_steps")
|
|
183
161
|
if isinstance(steps, list) and steps:
|
|
184
162
|
parts = []
|
|
@@ -188,17 +166,14 @@ class ChannelDispatcher:
|
|
|
188
166
|
parts.append(str(out))
|
|
189
167
|
if parts:
|
|
190
168
|
return "\n\n".join(parts)
|
|
191
|
-
# 元问题自答等:answer / summary
|
|
192
169
|
for k in ("answer", "summary", "response"):
|
|
193
170
|
v = inner.get(k)
|
|
194
171
|
if isinstance(v, str) and v.strip():
|
|
195
172
|
return v
|
|
196
|
-
# 情况3:顶层的 summary/response/output
|
|
197
173
|
for key in ("summary", "response", "output"):
|
|
198
174
|
val = result.get(key)
|
|
199
175
|
if isinstance(val, str) and val.strip():
|
|
200
176
|
return val
|
|
201
|
-
# 出错兜底
|
|
202
177
|
if result.get("error"):
|
|
203
178
|
return f"抱歉,处理时遇到问题:{result['error']}"
|
|
204
179
|
return ""
|
|
@@ -30,15 +30,14 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
30
30
|
super().__init__(on_message=on_message)
|
|
31
31
|
self.app_id = app_id
|
|
32
32
|
self.app_secret = app_secret
|
|
33
|
-
self.verification_token = verification_token
|
|
34
|
-
self.encrypt_key = encrypt_key
|
|
33
|
+
self.verification_token = verification_token
|
|
34
|
+
self.encrypt_key = encrypt_key
|
|
35
35
|
self.base = FEISHU_BASE if domain == "feishu" else LARK_BASE
|
|
36
36
|
self.timeout = timeout
|
|
37
|
-
self._token = ""
|
|
38
|
-
self._token_exp = 0.0
|
|
39
|
-
self._last_error = ""
|
|
37
|
+
self._token = ""
|
|
38
|
+
self._token_exp = 0.0
|
|
39
|
+
self._last_error = ""
|
|
40
40
|
|
|
41
|
-
# ── 入站:事件订阅 ──────────────────────────────────────
|
|
42
41
|
def decrypt_event(self, encrypt: str) -> Optional[dict]:
|
|
43
42
|
"""解密飞书加密事件(AES-256-CBC,key=sha256(encrypt_key))。
|
|
44
43
|
|
|
@@ -60,7 +59,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
60
59
|
plain = self._aes_cbc_decrypt(key, iv, ciphertext)
|
|
61
60
|
if plain is None:
|
|
62
61
|
return None
|
|
63
|
-
# PKCS7 去 padding(带校验,防损坏数据)
|
|
64
62
|
if not plain:
|
|
65
63
|
return None
|
|
66
64
|
pad = plain[-1]
|
|
@@ -76,7 +74,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
76
74
|
|
|
77
75
|
def _aes_cbc_decrypt(self, key: bytes, iv: bytes, ciphertext: bytes) -> Optional[bytes]:
|
|
78
76
|
"""AES-256-CBC 解密。优先用 cryptography,回退 pycryptodome。"""
|
|
79
|
-
# 优先 cryptography(更常见)
|
|
80
77
|
try:
|
|
81
78
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
82
79
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
@@ -85,7 +82,7 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
85
82
|
except ImportError:
|
|
86
83
|
pass
|
|
87
84
|
try:
|
|
88
|
-
from Crypto.Cipher import AES
|
|
85
|
+
from Crypto.Cipher import AES
|
|
89
86
|
return AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
|
|
90
87
|
except ImportError:
|
|
91
88
|
self._last_error = ("缺少 AES 库,无法解密飞书加密事件。"
|
|
@@ -99,12 +96,12 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
99
96
|
signature: str) -> bool:
|
|
100
97
|
"""验签:sha256(timestamp + nonce + encrypt_key + body)。"""
|
|
101
98
|
if not self.encrypt_key:
|
|
102
|
-
return True
|
|
99
|
+
return True
|
|
103
100
|
content = (timestamp + nonce + self.encrypt_key).encode() + body
|
|
104
101
|
digest = hashlib.sha256(content).hexdigest()
|
|
105
102
|
return digest == signature
|
|
106
103
|
|
|
107
|
-
def handle_event(self, payload: dict, headers: dict = None,
|
|
104
|
+
def handle_event(self, payload: dict, headers: dict | None = None,
|
|
108
105
|
raw_body: bytes = b"") -> dict:
|
|
109
106
|
"""处理飞书事件回调入口。返回应答给飞书的 dict。
|
|
110
107
|
|
|
@@ -114,23 +111,19 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
114
111
|
- 验签 → normalize → on_message
|
|
115
112
|
"""
|
|
116
113
|
headers = headers or {}
|
|
117
|
-
# 1. 加密:先解密外层
|
|
118
114
|
if "encrypt" in payload:
|
|
119
115
|
dec = self.decrypt_event(payload["encrypt"])
|
|
120
116
|
if dec is None:
|
|
121
117
|
return {"error": "decrypt_failed"}
|
|
122
118
|
payload = dec
|
|
123
|
-
# 2. URL 验证(首次配置回调地址时)
|
|
124
119
|
if payload.get("type") == "url_verification":
|
|
125
120
|
return {"challenge": payload.get("challenge", "")}
|
|
126
|
-
# 3. 验签(若有签名头)
|
|
127
121
|
sig = headers.get("X-Lark-Signature") or headers.get("x-lark-signature")
|
|
128
122
|
if sig:
|
|
129
123
|
ts = headers.get("X-Lark-Request-Timestamp", "")
|
|
130
124
|
nonce = headers.get("X-Lark-Request-Nonce", "")
|
|
131
125
|
if not self.verify_signature(ts, nonce, raw_body, sig):
|
|
132
126
|
return {"error": "invalid_signature"}
|
|
133
|
-
# 4. 消息事件 → normalize → 回调
|
|
134
127
|
inbound = self.normalize(payload)
|
|
135
128
|
if inbound and self.on_message:
|
|
136
129
|
self.on_message(inbound)
|
|
@@ -145,7 +138,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
145
138
|
message = event.get("message", {})
|
|
146
139
|
sender = event.get("sender", {})
|
|
147
140
|
sender_sid = sender.get("sender_id", {})
|
|
148
|
-
# 飞书消息内容是 JSON 字符串,文本在 {"text": "..."}
|
|
149
141
|
text = ""
|
|
150
142
|
try:
|
|
151
143
|
import json
|
|
@@ -155,19 +147,18 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
155
147
|
pass
|
|
156
148
|
if not text:
|
|
157
149
|
return None
|
|
158
|
-
chat_type = message.get("chat_type", "p2p")
|
|
150
|
+
chat_type = message.get("chat_type", "p2p")
|
|
159
151
|
return InboundMessage(
|
|
160
152
|
channel=self.CHANNEL,
|
|
161
153
|
message_id=message.get("message_id", ""),
|
|
162
154
|
text=text,
|
|
163
155
|
sender_id=sender_sid.get("open_id", ""),
|
|
164
|
-
sender_name="",
|
|
156
|
+
sender_name="",
|
|
165
157
|
conversation_id=message.get("chat_id", ""),
|
|
166
158
|
is_group=(chat_type == "group"),
|
|
167
159
|
timestamp=float(message.get("create_time", 0)) / 1000 or time.time(),
|
|
168
160
|
raw=raw)
|
|
169
161
|
|
|
170
|
-
# ── 出站:tenant_access_token + 发消息 ──────────────────
|
|
171
162
|
def _get_token(self) -> str:
|
|
172
163
|
"""取 tenant_access_token,带缓存与自动刷新(过期前 5 分钟刷新)。"""
|
|
173
164
|
if self._token and time.time() < self._token_exp - 300:
|
|
@@ -197,7 +188,6 @@ class FeishuAdapter(ChannelAdapter):
|
|
|
197
188
|
token = self._get_token()
|
|
198
189
|
if not token:
|
|
199
190
|
return SendResult(False, error="获取 tenant_access_token 失败")
|
|
200
|
-
# receive_id_type 用 chat_id(群和私聊的会话 ID 都用 chat_id 发)
|
|
201
191
|
payload = {
|
|
202
192
|
"receive_id": msg.conversation_id,
|
|
203
193
|
"msg_type": "text",
|