14 Commits
Author SHA1 Message Date
hataaandGitHub 92c8f2f03b feat(authz): add built-in RBAC provider and provider factory (#4260)
* feat(authz): add built-in RBAC provider and provider factory (Phase 1A-2, #4063)

Phase 1A-2: RBAC provider + provider factory. No runtime behavior change.

New authz/rbac.py — RbacAuthorizationProvider:
- allow: '*' / True / list / [] / missing → deny-wins semantics
- deny always overrides all allow forms
- resource name explicit mapping (tool→tools, model→models, etc.)
- unknown/missing role raises ValueError (never silent allow)
- config compiled to immutable frozensets at construction
- filter_resources preserves order, no mutation, consistent with authorize
- sync == async decisions

New authz/runtime.py — resolve_authorization_provider:
- disabled → None (no import attempted)
- enabled + no provider → ValueError
- invalid class path / construction failure → ValueError with path
- isinstance Protocol check post-construction
- no caching, no fail_closed/default_role injection

48 tests (37 RBAC + 11 factory). No config schema change, no config_version bump.
Per RFC #4063 Phase 1A-2. Layer 1/Layer 2 wiring deferred to Phase 1B.

* fix(authz): reject unknown RBAC provider config

Fail fast on misspelled top-level RBAC settings, cover factory error propagation, and record Phase 1B policy and audit caveats.

* fix(authz): reject unreachable resource aliases

Fail fast when RBAC config uses reserved request-side aliases, preserve same-name and custom resources, and add regression coverage for every mapped alias.

* fix(authz): validate RBAC request identifiers
2026-07-21 09:23:14 +08:00
hataaandGitHub 10890e10a8 feat(authz): propagate trusted authorization principal context (#4203) 2026-07-17 14:49:51 +08:00
hataaandGitHub 1300c6d36b feat(authz): add pluggable AuthorizationProvider protocol and config scaffolding (Phase 0, #4063) (#4127)
Phase 0 of the RFC in #4063: scaffolding only, zero behavior change.

New deerflow/authz/ package (sibling to deerflow/guardrails/):
- AuthorizationProvider Protocol: authorize() + aauthorize() + filter_resources()
- Principal/AuthzRequest/AuthzDecision/AuthzReason dataclasses
- GuardrailAuthorizationAdapter: bridges AuthorizationProvider → GuardrailProvider
  so existing GuardrailMiddleware can enforce authz decisions without a new middleware

New authorization config section (default enabled: false):
- AuthorizationConfig wired into AppConfig alongside guardrails
- Singleton load/reset mirrors GuardrailsConfig pattern
- config.example.yaml documents the RBAC provider schema

29 tests covering protocol conformance, dataclass construction, adapter
request/decision mapping, and config singleton behavior.

Per RFC #4063 Phase 0 (foundations). Layer 1/2 wiring and Principal builder
in services.py deferred to Phase 1.
2026-07-15 10:03:33 +08:00
hataaandGitHub 266883b3dd fix(subagents): inherit summarization middleware and harden step capture (#3875 Phase 3) (#4009)
Phase 3 of #3875 — subagents previously inherited none of the lead's
context-compaction, so a deep-research subagent (max_turns up to 150)
could accumulate >1M cumulative input before max_turns/timeout/token_budget
engaged, even after Phase 2's budget capped the pathological tail.

- Gate the subagent runtime chain on the SAME ``app_config.summarization.enabled``
  switch the lead reads (per maintainer guidance in #3875), via the shared
  ``create_summarization_middleware`` factory. One config covers both chains;
  no separate ``subagents.summarization`` field. No-op when summarization is
  off (factory returns None).
- ``skip_memory_flush=True`` on the subagent path: the factory otherwise
  attaches ``memory_flush_hook`` (when memory.enabled), which flushes
  pre-compaction messages into durable memory keyed by thread_id. Subagents
  share the parent's thread_id, so without skipping the hook a subagent's
  internal turns would pollute the PARENT thread's durable memory
  (#3875 Phase 3 review point).
- Harden ``capture_new_step_messages`` to tolerate history contraction:
  summarization rewrites the messages channel via
  ``RemoveMessage(id=REMOVE_ALL_MESSAGES)``, shrinking len(messages) below
  the step-capture cursor. Without a reset, every step appended after the
  compaction point was dropped until length overtook the stale cursor (#3845
  interaction, maintainer validation point (a)). Cursor now resets to the
  new tail; id/content dedup prevents re-emitting pre-compaction steps.
- Couple the DEFAULT token-budget ceiling to ``summarization.enabled``
  (#3875 Phase 3 review point): 1M when compaction is on, 2M when off
  (preserves Phase 2's deliberate headroom for summarization-off
  deep-research runs that can exceed 1M). A user-set budget (global or
  per-agent) always wins regardless of the switch. Flagged tunable.

The summarization middleware does not implement ``consume_stop_reason``, so
the Phase 2 guard-cap stop-reason channel is unaffected.

Refs: https://github.com/bytedance/deer-flow/issues/3875
2026-07-10 11:17:35 +08:00
hataaandGitHub c9fb9768d4 fix(subagents): unify guardrail caps on additive stop_reason + add token_budget (#3875 Phase 2) (#3980)
Phase 2 of #3875. Two guardrail axes can end a subagent run early — the turn
budget (GraphRecursionError) and the token budget (TokenBudgetMiddleware) —
and both now surface *why* through one additive `subagent_stop_reason` field
instead of a status enum.

This completes and course-corrects Phase 1 (#3949), which shipped the
turn-budget cap as a `max_turns_reached` status enum. The agreed Phase 2
design replaces that enum with an optional `stop_reason` field
(token_capped | turn_capped | loop_capped): a new enum value would break v1
consumers, while an additive field is ignored by older frontends and ledger
readers. `max_turns_reached` and SubagentStatus.MAX_TURNS_REACHED are removed.

- subagents.token_budget config (default enabled, 2,000,000 tokens, warn 0.7)
  with per-agent override; TokenBudgetMiddleware is now attached in
  build_subagent_runtime_middlewares so the cost-ceiling backstop engages for
  every subagent. The hard-stop does not raise — it strips tool_calls and
  lets the run finish with a final answer, recording the cap on a per-run
  consume_stop_reason() accessor.
- executor.py: on normal completion it reads consume_stop_reason() and stamps
  completed + token_capped when the budget fired; on GraphRecursionError it
  recovers the last AIMessage partial (completed + turn_capped) or, if nothing
  usable survived, failed + turn_capped. SubagentResult gains stop_reason.
- status_contract.py / contracts/subagent_status_contract.json (v2) /
  frontend subtask-result.ts: additive subagent_stop_reason field, pinned by
  test_status_values_match_contract / test_stop_reason_values_match_contract.
- task_tool.py + delegation_ledger.py: drop the max_turns_reached paths; the
  ledger captures stop_reason and renders model-facing "capped" guidance so
  the lead reuses a capped completion knowingly.

The 2,000,000-token default is deliberately loose (tighten to taste) — it
would have roughly halved the reported 4.4M burn while leaving legitimate
deep-research runs (max_turns=150) room. Subagent summarization is a follow-up.
2026-07-08 22:26:06 +08:00
0664ea2243 fix(subagents): surface turn-budget cap as MAX_TURNS_REACHED with partial result (#3875 Phase 2) (#3949)
* fix(subagents): surface turn-budget cap as MAX_TURNS_REACHED with partial result (#3875)

Phase 2 of #3875. When a subagent exhausts its turn budget
(recursion_limit == max_turns), LangGraph raises
GraphRecursionError from agent.astream. The generic
except Exception in _aexecute misclassified it as FAILED and
discarded the partial work already streamed into final_state, so the
lead could not tell 'broken subagent' from 'out of budget' and got an
empty failure.

Catch GraphRecursionError specifically (before the generic handler)
and set a distinct SubagentStatus.MAX_TURNS_REACHED terminal status,
recovering the partial result from the last streamed chunk via a shared
_extract_final_result helper (refactored out of the normal-completion
path so both paths render content identically).

Extend the cross-language status contract so the new value travels on
additional_kwargs.subagent_status: a capped run is result-bearing, so
make_subagent_additional_kwargs / read_subagent_result_metadata
carry subagent_result_brief + subagent_result_sha256 (the
recovered work, like completed) AND the cap notice on subagent_error
-- the one status that carries both. task_tool.py returns it via the
shared _task_result_command; the delegation ledger prefers the
partial result_brief and renders model-facing guidance (reuse / retry
tighter / raise max_turns). Frontend collapses max_turns_reached to the
failed pill with the cap notice on error.

No agent-loop, runner, or persistence behavior touched; default
max_turns is unchanged.

* refactor(subagents): consolidate content-stringify onto shared helper

Address review feedback on #3949 (willem-bd, copilot-pull-request-reviewer):

- executor.py: drop the private `_stringify_message_content` — a third
  near-duplicate of `utils/messages.py::message_content_to_text`.
  `_extract_final_result` now delegates to that canonical helper; the
  "No response generated" sentinel is pushed down to the consumer (the
  shared helper returns "" for no-text, matching every other call site).
- task_tool.py: align the live `task_failed` event's error string with
  the canonical "Reached max_turns=N" used by the logger, the structured
  `error=`, and the executor (was "Reached max turns (N)").

Behavior for real AIMessage content is unchanged; only atypical edge inputs
(consecutive bare-string list items; empty content) now match the canonical
helper that every other call site already uses.

`extract_response_text` is intentionally left as-is: it filters by OpenAI
content-block `type`, a different shape with many callers and its own tests.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 07:55:32 +08:00
hataaandGitHub b85c672cc1 fix(channels): offload blocking filesystem IO in Wechat channel (#3925)
WechatChannel made synchronous filesystem calls (mkdir, write_text,
read_bytes, Path.replace, unlink) directly inside async entry points:
_poll_loop, _bind_via_qrcode, _ensure_authenticated, _extract_image_file,
_extract_file_item, start, _send_image_attachment, _send_file_attachment.
Under slow disks, large files, or concurrent load these blocked the
asyncio event loop and stalled the channel worker.

Construction was also blocking: __init__ called _load_state() (os.stat +
read_text) synchronously, and ChannelService._start_channel() instantiates
the channel directly on the async path, so constructing WechatChannel in an
async context raised BlockingError. Persisted state (auth token + cursor)
is now loaded in start() via asyncio.to_thread, leaving __init__ IO-free.

Offload each call to a thread via asyncio.to_thread, matching the existing
pattern in channels/manager.py and dingtalk.py. The sync helpers
(_save_state, _save_auth_state, _load_auth_state, _stage_downloaded_file)
keep their signatures; only the async call sites wrap them.

Adds tests/blocking_io/test_wechat_channel_state.py as a regression anchor
covering the IO-free constructor (the production _start_channel path), the
staging write path, and the auth-state read path. Detected by
`make detect-blocking-io`.
2026-07-04 21:35:05 +08:00
80e031dcc6 fix(subagents): inherit LoopDetectionMiddleware to break tool loops (#3875) (#3931)
Subagents build their runtime chain via build_subagent_runtime_middlewares,
which attached none of the lead's runaway guards — no LoopDetectionMiddleware.
A degenerate subagent tool loop therefore ran unchecked until max_turns,
re-sending a growing context each turn (the #3875 4.4M-token burn).

Append LoopDetectionMiddleware.from_config(app_config.loop_detection) to the
subagent chain, gated by the existing loop_detection.enabled config (default
on) — no new config field. Registered before SafetyFinishReasonMiddleware to
match the lead's after-model ordering (the safety middleware requires placement
after LoopDetection). Subagents disallow task, so only the tool-loop heuristic
can fire here — no recursive-delegation false positives to worry about.

Phase 1 of #3875 (smallest blast radius). Phase 2 adds a deterministic
turn/token budget with a lead-visible stop reason; Phase 3 defers summarization.

Tests: enabled/disabled wiring + before-SafetyFinish ordering, mirroring the
lead chain's coverage.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 16:29:49 +08:00
e9161ff148 fix(channels): offload blocking filesystem IO in Discord channel (#3927)
DiscordChannel ran synchronous filesystem IO on the event loop: thread-mapping
persistence/restore and outbound attachment reads. Offload all of it via
asyncio.to_thread:
- start() -> _load_active_threads (restore mappings on startup)
- _on_message -> _persist_thread_mappings (flush mappings to disk)
- send_file -> _read_attachment_bytes (read bytes; handed to discord.File as
  an in-memory BytesIO buffer)

Thread-mapping state is split to avoid a race surfaced in review (#3927):
_record_thread_mapping updates the in-memory _active_threads dict and
_active_thread_ids set synchronously on the event loop, so a follow-up message
in a newly created thread is recognized immediately — before the offloaded
persistence write completes. Deferring that update into the worker thread
opened a window where _on_message's membership check misclassified the message
as orphaned and created a duplicate thread.

__init__ only computes paths, so construction stays IO-free.

Blockbuster regression tests cover the IO-free constructor, the
record-then-persist split (memory visible before persistence), discard of a
replaced thread id, and the load path.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 16:16:09 +08:00
hataaandGitHub 1783da42f4 fix(channels): close Discord file handle after upload (#3561)
send_file opened the attachment with a bare open() and never closed it,
leaking a file descriptor on every Discord file delivery. The handle was
also leaked on the failure path: when target.send raised, the except
branch logged and returned without closing fp. The "# noqa: SIM115"
suppressed the lint warning instead of fixing it.

Wrap open() in a with statement that stays open for the full upload —
the discord client reads fp while target.send runs on _discord_loop, and
once that future resolves the bytes are consumed, so closing here is
safe. This closes the handle on both the success and exception paths and
matches how telegram and feishu already handle their file uploads.

Adds regression tests asserting the handle is closed after send_file on
both the success and failure paths.

Refs #3544
2026-06-13 23:27:17 +08:00
hataaandGitHub 094296440f fix(history): strip base64 image data from REST endpoint responses (#3535)
ViewImageMiddleware persists full base64 image payloads in hide_from_ui
human messages inside checkpoints. All REST endpoints that returned
serialize_channel_values(channel_values) sent these multi-megabyte
payloads to the frontend, freezing the UI on threads with images.

Add strip_data_url_image_blocks() to remove data:-scheme image_url
content blocks from hide_from_ui messages, and
serialize_channel_values_for_api() as a convenience wrapper used by all
six affected call sites across threads, runs, and thread_runs routers.
SSE streaming is unaffected (still uses serialize_channel_values).

Fixes #3496
2026-06-13 08:58:19 +08:00
hataaandGitHub 76136d22b4 fix(channels): reload config on channel restart, fixes #3497 (#3514) 2026-06-12 14:45:22 +08:00
hataaandGitHub b3c2cc42cf fix(agents): require config.yaml in resolve_agent_dir to skip memory-only directories (#3390) (#3481)
When memory is enabled, the first conversation with a legacy shared agent
creates a per-user agent directory containing only memory.json (no
config.yaml). On the second turn, resolve_agent_dir() returned this
incomplete directory, causing load_agent_config() to fail with
"Agent config not found".

Require config.yaml to exist alongside the directory for both the
per-user and legacy paths, so that memory-only directories fall
through correctly. This aligns resolve_agent_dir with the existing
config.yaml check in list_custom_agents.

Refs: https://github.com/bytedance/deer-flow/issues/3390
2026-06-10 23:57:17 +08:00
hataaandGitHub 37337b77f9 feat(models): add StepFun reasoning model adapter (#3461)
Add PatchedChatStepFun adapter for StepFun reasoning models (step-3.7-flash,
step-3.5-flash). Captures reasoning from both streaming and non-streaming
responses and replays it on historical assistant messages for multi-turn
tool-call conversations.

- New: PatchedChatStepFun adapter with streaming/non-streaming reasoning capture
- Support both reasoning and reasoning_content field names
- 17 unit tests covering all response paths
- Updated: config.example.yaml with StepFun configuration example
2026-06-09 18:01:43 +08:00