Compare commits

...

241 Commits

Author SHA1 Message Date
2SinceJuly
04a9833763
fix(subagents): keep a steered subagent report in the parent snapshot (#8167)
* fix(subagents): keep a steered subagent report in the parent snapshot

A Steer frees the parent's turn before its subagent finishes: wait_tool
returns a 'result is pending' task result and the real report arrives
later as a hidden subagent-result HumanMessage.

ParentContextSnapshot only kept genuine user messages, so it dropped that
report and kept the pending placeholder. A later context_mode=snapshot
delegation therefore inherited a pending result and could not use quotes
the parent had already received.

Keep the hidden subagent-result message as historical conversation data.

* fix(subagents): share the delayed result message name

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 22:30:48 +08:00
hataa
bea75f3b23
fix(subagents): verify the returned row position in read_batch_item (#8191)
read_batch_item trusted list_items(offset=position) to land on position.
That only holds while item rows are dense: OFFSET-based paging skips
surviving rows over any gap (e.g. a future retention delete), so the
reader would serve the next item's report attributed to the requested
item key, and the tool layer would project next_position from the wrong
base. The browser projection (read_browser_batch_item) already verifies
items[0].position; align the model-facing reader with it and add the
same negative-position guard. Reading past a gap now returns not_found
instead of a shifted row, matching the browser path's semantics.
2026-11-12 22:12:07 +08:00
Loveca
bce5dc3327
fix(feishu): receive post attachments from the top-level files array (#8190)
* fix(feishu): keep attachments sent with post messages

* fix(feishu): keep user-typed file placeholders and malformed files arrays from losing attachments

* test(feishu): pin mixed inline attachment behavior and consume the post file count

* fix(feishu): parse post commands before adding attachment placeholders

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-12 22:09:20 +08:00
Wang guanyu
b16e197618
fix(models): close Codex async HTTP requests on cancellation (#8053)
* fix(models): close Codex async HTTP requests on cancellation

* test(models): isolate loopback cancellation checks from proxies

* docs: record Codex cancellation fix in changelog

* docs: translate Codex cancellation changelog entry

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 21:55:08 +08:00
Hyeonsang Cho
8853e68638
fix(gateway): keep server-owned checkpoint keys out of client thread metadata (#8019)
* fix(gateway): keep LangGraph checkpoint keys out of client thread metadata

POST /api/threads spread client metadata over the root checkpoint's
step/source/writes/parents. A non-mapping parents broke every history
read and a non-integer step broke every later turn, leaving deletion as
the only way out. Server values now come after the client's.

* docs(changelog): reference #8019 for thread-create checkpoint metadata

* fix(gateway): keep server-owned checkpoint keys out of client thread metadata

The root checkpoint still copied client keys the server reads back as
trusted. A non-mapping counters_since_delta_snapshot failed every state
update and turn in both checkpoint modes, and deerflow_agent_name was
copied into the next state rewrite as the server-authored agent binding.
Drop a named set of checkpoint-owned keys before the spread so the fix no
longer relies on dict ordering alone.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 20:53:04 +08:00
Eilen Shin
81de0761ed
feat: improve file attachments and add Office artifact previews (#8009)
* feat(frontend): improve file attachment recovery and artifact presentation

* feat(extensions): add Office artifact previews with search and navigation

* fix: preserve public viewers and HTTP Office previews

* fix: address file preview and upload review findings

* fix: use typed upload errors and linear bookmark allocation

* fix(office-preview): isolate parsing and bound preview resources

* fix(ci): install Office preview fixture dependencies for E2E

* fix(ci): provision matching Playwright browsers before E2E tests

* fix(ci): isolate Python libraries and include Office documentation

* test(frontend): wait for download status to finish rendering
2026-11-12 20:30:17 +08:00
Evan
b4410f64d4
fix(agents): keep the delegation ledger exact across HITL approval and reused call ids (#8128)
* fix(agents): count each delegation once when a provider reuses its call id

Tool-call ids are unique per response, not per conversation. Some providers
number every response's calls from call_0, so two task calls in one run can
share an id.

The delegation ledger used (run_id, tool_call_id) as a delegation's identity:

- extract_delegations built one id -> entry map over the whole run, so a
  later call_0 replaced the earlier delegation's description and took its
  result while it was still running.
- merge_delegations replaced the same (run_id, id), keeping one entry.
- SubagentLimitMiddleware counted distinct ids, so the per-run cap
  (subagents.max_total_per_run) never advanced past the first call_0 and a
  run could launch any number of subagents one response at a time.

Each entry now also records dispatch_message_id, the AIMessage that issued the
call, and results pair through pair_tool_call_results, which answers a call
only from its own response. The reducer, DurableContext's change filter and
the cap use the extended identity. An entry without a dispatch id still
updates its run's entry for that call id, and a dispatch-scoped entry adopts a
ledger entry written before the field existed, so runs in flight across the
upgrade are not counted twice. The steer path names the dispatching AIMessage
from the tool request's state, so its delegation update and the graph's own
capture stay one entry.

* fix(agents): close an earlier run's dangling dispatch by its own response

_close_delegations_left_by_earlier_runs decided "answered" by
(run_id, tool_call_id). When a stopped run had dispatched call_0 twice,
the first reply kept the second, dangling entry in_progress, so the
lead kept hearing "already delegated" for work nothing would finish.

Entries that carry dispatch_message_id are now decided by pairing that
response's calls through pair_tool_call_results. Legacy entries and
dispatches no longer in history keep the id-based inference.

Also pin that two runs reusing call_0 stay two ledger entries, and
cross-reference the three delegation identity helpers.

* fix(agents): settle an approval-gated task delegation in the ledger

With `task` in `hitl_approval.tools`, the paused run records the dispatch
as in_progress and the resume run executes it. The resume run opens with
no HumanMessage, so its capture window starts after the pre-existing
messages and never contains the dispatching AIMessage: the result was
dropped and the entry stayed in_progress with no result_brief, forever.

DurableContext now pairs results the resumed window receives with the
last pre-existing dispatch and settles the issuing run's entry, keeping
its run_id instead of re-tagging it. HITL denials of `task` carry
`cancelled` subagent status, so a rejected or denied delegation settles
too.

* fix(agents): address review on the delegation ledger

- Steer path: hold each wait's dispatch AIMessage locally and pass it to
  the observer, so a later response reusing the call id while the first
  call is still observed cannot take over its args or dispatch id.
- New-turn close: a dispatch whose reply carries subagent status settles
  from that reply instead of staying in_progress, covering a crash
  between the tools checkpoint and the next capture.
- Reuse thread_state.delegation_key instead of a duplicate identity
  helper in DurableContext.
- CHANGELOG: reference #8128.

* docs(changelog): drop the #8128 entry per review
2026-11-12 19:46:58 +08:00
kongxin
53c5195e5d
fix(gateway): name the agent problem when run admission rejects agent_name (#8087)
* fix(gateway): name the agent problem when run admission rejects agent_name

Since #5238, run admission loads the named custom agent's config for
every run, with or without a knowledge scope, and folded every failure
into "knowledge_scope assistant configuration could not be resolved".
Messaging a chat whose custom agent was deleted, or passing a malformed
agent_name, pointed callers at the knowledge base instead of the agent.

The 422 status is unchanged; the detail now says which problem it is:
- Agent '<name>' not found
- Invalid agent_name '<value>': must contain only letters, digits, and
  hyphens after normalization. (shared with custom_agent_name(), which
  gains a field= argument; existing callers keep the default)
- Invalid agent_name: expected a string, got <type>.
- Agent '<name>' configuration could not be loaded

* docs(changelog): reference #8087 for the run admission agent_name detail

* fix(gateway): bound the agent name echoed in run admission errors

Address review on #8087: admission now echoes the caller's raw
agent_name, which start_run merges from config/context without a size
check, so an oversized value was reflected verbatim into the 422 detail
and any log line that records it. Cap the echoed name at 200 characters
in the invalid-name message (shared with assistant_id validation) and in
the not-found / unreadable-config messages.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 19:34:10 +08:00
Harvey DU
eb97327ddf
fix(memory): reject duplicate fact ids in imports regardless of entry order (#8153)
* fix(memory): reject duplicate fact ids in imports regardless of entry order

A backup containing two entries for the same fact id was accepted or
rejected depending only on their order. The duplicate check in
_commit_changes_locked consulted the map of prepared writes, but an
entry whose normalized content matches the stored fact is skipped from
that map - so an unchanged entry followed by an edited duplicate passed
the check while the reverse order raised, and the saved snapshot ended
up with whichever entry came last.

Track every incoming id in a seen-set for duplicate detection, before
the unchanged-entry skip. The saved snapshot now survives the rejection
in both orders.

Fixes #8150

* fix(memory): validate duplicate import ids before capacity selection

Copilot review follow-up on #8153: the storage-level duplicate guard runs
after _select_for_capacity, and the capacity policy can drop one of two
same-id entries (e.g. lower-confidence copies trimmed to max_facts) - the
duplicate then never reaches the guard and the import outcome still
depends on entry order.

Validate all normalized incoming ids in import_memory_data before
capacity selection. The storage-level guard stays as defense in depth.
Regression test with max_facts=10 and 9 distinct high-confidence facts
plus two same-id copies.

* fix(memory): surface duplicate fact id errors with their own detail

willem-bd review on #8153, three follow-ups:

1. P2: the bare ValueError fell through to the router's generic catch, so
   POST /api/memory/import answered with the facts-array message even
   when every fact had non-empty content. Add
   MemoryDuplicateFactIdError (same type as #8151) at the manager
   boundary and map it to its own 400 detail that names the duplicated
   id; router test asserts the exact detail.

2. P3: the duplicate check lived inside the apply_changes-override
   branch, so the base MemoryStorage save() path bypassed both guards.
   Move it before the branch split, right after normalize_memory_data,
   matching #8151's validate-before-capacity posture.

3. P3: parametrize the order-dependence regression over the default
   bucket (agent_name=None) and "researcher", and assert the seeded
   snapshot survives the rejection in both orders.

* test(memory): pin the storage duplicate guard and fix the no-op assertion

willem-bd review follow-ups on #8153:

1. The snapshot-survival assertion compared storage.load against
   get_memory_data, which is itself storage.load - both reads happened
   after the rejected import, so a regression that partially applied the
   duplicate would pass. Capture the seeded snapshot before the rejected
   import and compare against it.

2. The updater gate rejects import duplicates before storage is touched,
   so nothing exercised the storage-level guard in _commit_changes_locked
   with an unchanged duplicate first - reverting it would keep the suite
   green. Add a direct apply_changes regression test; verified it fails
   with the guard removed.

* style(memory): apply ruff fixes after #8151 merge resolution

* test(memory): assert untouched store in the storage-guard pin; facade import

willem-bd review follow-ups on #8153:

1. The storage-guard regression test pinned rejection but not the
   untouched-store half: capture the seeded snapshot right after seeding
   and assert storage.load still equals it after the rejection, so a
   regression that partially applies the first entry before raising
   fails.

2. The router regression test imported MemoryDuplicateFactIdError from
   the manager module directly; use the package-facade import consistent
   with the other manager-error usage (the symbol is already re-exported
   from deerflow.agents.memory since #8151).

* test(memory): drop the mock router duplicate-id test

ShenAC-SAC review on #8153: #8151 already drives the real import route
with duplicate IDs and asserts the same 400 response plus an unchanged
snapshot, and the mock test's docstring implied a detail the assertion
did not check. The direct apply_changes() regression is kept.

Also removes the blank line after the docstring that tripped
ruff format --check on the repo-pinned Ruff version.

* test(memory): remove the orphaned mock-router test body

ShenAC-SAC review: my earlier deletion removed the function definition
and docstring but left the test body running inside
test_clear_route_keeps_working_for_windows_reserved_agent_name. Remove
the whole block and the now-unused MemoryDuplicateFactIdError import;
#8151 already covers the duplicate-ID response through the real import
route.
2026-11-12 19:23:44 +08:00
N
06634003a5
fix(subagents): apply tool policies to middleware-declared tools (#8136)
* fix(subagents): apply tool policies to middleware-declared tools

* fix(subagents): address tool-policy review feedback

Normalize declaration conversion errors with middleware context, document
generated discovery exemptions, and add the Unreleased changelog entry.
Audit final explicit and middleware tool sets before graph creation, with
identity-based exemptions for generated discovery helpers and regressions.

* fix(subagents): reuse tool name resolution and clarify policy errors
2026-11-12 19:12:02 +08:00
hataa
7892f79f9c
fix(mcp): drop cross-server duplicate bound tool names instead of silently shadowing (#8135)
Server-prefixed tool names are not unique across servers: server 'a'
exposing 'b_x' and server 'a_b' exposing 'x' both bind as 'a_b_x'. The
flatten step forwarded both declarations to the model, where the provider
silently keeps only one and the other server's tool becomes unreachable
with no diagnostic.

Deduplicate by bound name in enabled-server declaration order and drop
later duplicates with a warning.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 19:07:32 +08:00
ZhYQ1
314e2a3489
feat(task_tool): introduce read-only preflight validation before delegation side effects (#8120)
* feat(task_tool): introduce read-only preflight validation before delegation side effects

Add _preflight_delegation_checks() as a zero-side-effect preflight that runs before any delegation side effects (tool assembly, executor creation, background task registration). Existing inline checks for context_mode, bash host policy, and subagent_type config membership are centralized into this function.

A rejected preflight returns immediately with status='failed' and leaves state unchanged, so callers can safely retry with corrected arguments.

A reserved resume_task_id parameter is included for a future recovery feature, pointing to issue #7633 (validate before claim/cancel) to avoid the reject-after-claim anti-pattern.

* fix(task_tool): return config from preflight, drop dead resume_task_id param, soften side-effect comment

Fixes UnboundLocalError on successful delegation paths: the helper now returns (config, error) and callers destructure both. Previously config was scoped inside the helper so the happy-path references at lines 1044/1074/1082 raised UnboundLocalError. Verified against test_task_tool_core_logic.py.

Drops the unused resume_task_id parameter and its hardcoded rejection branch — dead code since the only call site never passes it, and a future recovery feature should plug real read-only recoverability checks here (see issue #7633).

Narrows the helper-side-effect claim in the caller comment: input_control.child(tool_call_id) already registers a benign retry-reusable child gate before the preflight runs, so the wording now scopes the zero-side-effect promise to the helper itself.

* test(task_tool): cover preflight abort and happy paths

Adds unit coverage for _preflight_delegation_checks (invalid context_mode, host bash disabled/enabled, subagent outside caller policy, no permitted subagents) plus a rejected-delegation test asserting no executor is created and no tools are assembled.

Adds a happy-path regression test that would have caught the UnboundLocalError: it asserts the config resolved by the preflight reaches SubagentExecutor, including the unconditional config.model == 'inherit' branch.

* fix(task_tool): resolve catalog inside preflight after guards

Copilot review flagged that catalog discovery (get_available_subagent_names) was hoisted out of the helper and ran before any guard, so an invalid context_mode could hit the managed-subagent store instead of returning status='failed'.

Now the helper takes only allowed_subagents and performs its own catalog lookup strictly after the context_mode and bash guards. The caller no longer calls get_available_subagent_names ahead of preflight.

Adds test_preflight_resolves_catalog_after_guards asserting get_available_subagent_names is never invoked when context_mode is invalid.

* test(runtime): use ToolRuntime for native task results

* docs(task_tool): translate preflight comments to English

Comment-only change; no behavior impact.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
2026-11-12 18:59:04 +08:00
coder-abu
7d45fea17e
fix(models): keep the OAuth betas when a Claude request sends its own betas (#8061)
* fix(models): keep the OAuth betas when a Claude request sends its own betas

ClaudeChatModel put the Claude Code OAuth betas (oauth-2025-04-20, ...)
only in the client's default anthropic-beta header. A request whose
payload carries betas is sent through beta.messages.create, and the
Anthropic SDK turns that list into a per-request anthropic-beta header
that replaces the default. So a configured betas list (even []),
per-call betas, or a server tool for which langchain-anthropic appends a
beta sent the request without oauth-2025-04-20, and Anthropic rejects
the Bearer token.

_get_request_payload now puts the OAuth betas first in any request betas
list before the thinking budget resolves the header. Requests without
betas keep the default header, and API-key models are unchanged. The
list is rebuilt because the payload's betas can be self.betas.

Fixes #8060

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

* docs(changelog): link the Claude OAuth betas fix to #8061

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

* fix(models): keep the OAuth betas in a caller's anthropic-beta extra header

The Anthropic SDK lets a request's extra_headers override both the
client default and the header built from betas. An anthropic-beta in
extra_headers, from model_kwargs or passed per call, therefore still
dropped the OAuth betas and Anthropic rejected the Bearer token.

_apply_oauth_betas now gives that header the same merge as betas: the
OAuth betas go first and the caller's betas follow without repeats.
The headers dict is copied because it can be the one in model_kwargs.
API-key models are unchanged.

Refs #8060

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
2026-11-12 18:29:48 +08:00
Loveca
70a4b83907
fix(community): return an error for malformed URLs in the SSRF guard (#8079)
* fix(community): return an error for malformed URLs in the SSRF guard

validate_public_http_url and validate_delegated_backend_url document
returning an "Error: ..." string, but urlparse() raises ValueError for
malformed authorities such as http://[::1/api, so the exception escaped
to callers (web_fetch/web_capture, browser_navigate, personal MCP, and
the browser stream navigation check) instead of a clean validation error.

Parse through a small _parse_url helper that returns None for malformed
input (including non-string values), and treat a raw bracket in the
userinfo slot as malformed on every Python version, mirroring the
SkillScan host parser merged in #8017.

The only URLs newly rejected are userinfo values containing a raw
bracket, which RFC 3986 forbids; no valid host becomes unreachable and
fail-closed behavior is otherwise unchanged.

* test(community): share malformed URL cases and add changelog entry

Drive both validators' malformed-URL parametrization from one list, and
document the contract fix in the Unreleased Fixed section (#8079).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 17:51:19 +08:00
Zeren Wang
95514245e2
chore(checkpoint): require fixed DeltaChannel replay backends (#8139)
* chore(checkpoint): require fixed DeltaChannel replay backends

* fix(checkpoint): correct Postgres floor and refresh docs

* test(checkpoint): update Postgres dependency floor assertion

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 17:49:41 +08:00
mosifu
51cc489c8f
fix(wecom): raise the image upload cap to the platform's 10 MB (#8174)
* fix(wecom): raise the image upload cap to the platform's 10 MB

`send_file` capped images at 2 MB and files at 20 MB. 2 MB is the *voice*
limit in WeCom's aibot media-upload protocol; images are allowed up to 10 MB
(developer.work.weixin.qq.com/document/path/101917). Every 2-10 MB image was
therefore dropped before upload, and the turn arrived without its chart.

Raise the image cap to 10 MB. The 512 KiB chunk size keeps a 10 MB upload at
20 chunks, well under the protocol's 100-chunk limit.

* docs(changelog): record the WeCom image-cap fix

Adds the Unreleased -> Fixed -> channels entry for #8174 to CHANGELOG.md and
CHANGELOG_zh.md.
2026-11-12 17:16:42 +08:00
hataa
416a0647a4
fix(subagents): resolve the inherit-path app config off the event loop (#8134)
* fix(subagents): resolve the inherit-path app config off the event loop

task_tool's inherit-model fallback called get_app_config() synchronously
inside the async tool body. get_app_config() reads config and extension
files on every cache miss (and may fully reload them), so the fallback
path stalls the calling streaming event loop for the duration of the
disk IO - the same class of loop stall removed for tool assembly in
issue #5172. Every other config resolution in this tool already
dispatches through run_assembly; route this one through it too.

* chore: drop the CHANGELOG entry per maintainer request

Changelogs for merged PRs are created in batch upstream; keeping the
entry here only risks merge conflicts.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 16:59:02 +08:00
Bas Cancrinus
7ae36b469d
fix(helm): make the gateway init containers' image and resources conf… (#8161)
* fix(helm): make the gateway init containers' image and resources configurable

init-home and init-extensions hard-coded busybox:1.36 and set no resources.
Clusters that pull only from a mirror could not start the gateway, and a
namespace ResourceQuota on limits.* rejected the pod, although every main
container already has a configurable image and requests+limits.

Add gateway.initContainers.image (repository/tag, default busybox:1.36, same
convention as nginx.image) and gateway.initContainers.resources (modest
requests+limits by default, matching the chart README's resource defaults).
Add a helm-template test for the defaults and the overrides.

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

* docs(helm): explain mirroring gateway init images

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 16:44:42 +08:00
paer
2dc68b242f
fix(subagents): prevent concurrent file-backed creates from overwriting definitions (#8132)
* docs(subagents): describe create-only file publication

* fix(subagents): prevent concurrent create overwrites

* test(subagents): cover independent create races

* docs(persistence): record create-only publication

* docs(config): explain managed subagent file claims

* docs(persistence): retain create-only publication rule

* docs(subagents): retain shared file create semantics

* fix(gateway): probe managed subagent hard links on startup

* test(gateway): cover managed subagent volume probes

* test(gateway): keep volume probes off the event loop

* docs(config): describe startup hard-link warnings

* docs(changelog): reference managed subagent fix #8132

* docs(changelog): keep the fix entry clear of append conflicts
2026-11-12 14:39:25 +08:00
ywy
98e8598adb
fix(config): delete thread dir through extended-length path to avoid WinError 145 (#8144)
Co-authored-by: Nan Gao <thunderggnn@gmail.com>
2026-11-12 14:24:24 +08:00
ZephyrWang
b80191fad3
fix(memory): reject duplicate fact IDs during import (#8151)
* fix(memory): reject duplicate fact IDs during import

* fix(memory): explain duplicate IDs in import response

* docs(changelog): link duplicate import fix PR

* fix(memory): keep duplicate validation portable

---------

Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 11:42:55 +08:00
Willem Jiang
b883deaab2
ci: split backend unit tests into six shards (#8164)
* ci: split backend unit tests into six shards

* test(runtime): synchronize rollback lease renewal
2026-11-12 11:08:31 +08:00
Bas Cancrinus
13e6fc29e5
feat(opensandbox): add opt-in secure_access sandbox option (#8162)
* feat(opensandbox): add opt-in secure_access sandbox option

Forward a new `sandbox.secure_access` option (default false) to
SandboxSync.create so OpenSandbox can require a per-sandbox endpoint
token at the ingress gateway and server proxy. The server returns the
token as an endpoint header and SDK 0.1.15 already attaches endpoint
headers to execd and file requests, so DeerFlow needs no token handling.

Non-boolean values are rejected at provider startup so a quoted "false"
cannot silently enable it. Documented in config.example.yaml,
CONFIGURATION.md, the SandboxConfig docstring and the provider README.

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

* fix(opensandbox): validate server proxy boolean

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 09:49:33 +08:00
2SinceJuly
6eb4833598
fix(client): scope memory management to the named agent's bucket (#8146)
* fix(client): scope memory management to the named agent's bucket

A named DeerFlowClient stored and read its memory in that agent's bucket:
MemoryMiddleware and the memory tools both pass agent_name. The management
methods on the client did not, so create_memory_fact wrote the fact into the
user's default bucket while the agent's injected context came from its own
bucket — the fact was persisted but never recalled by the same client.

Pass the client's agent_name through get_memory, export_memory,
import_memory, reload_memory, clear_memory and the single-fact CRUD calls.
An unnamed client keeps the default bucket, and a backend that ignores the
scoping keyword keeps raising its own unsupported-operation error.

Fixes #8142

* fix(client): refuse named memory calls a backend cannot scope

DeerFlowClient forwarded the agent name to every memory management call, but
a backend that declares no agent-scoped storage ignores the keyword and
answers normally. A named client then reported success while the call read,
cleared or exported the user's default bucket, the same silent
wrong-bucket behaviour #8142 is about. Check the capability flag before
forwarding the scope and raise an unsupported-operation error, as the
Gateway already does with 501.

Resolve the scope in reload_memory before the get_memory fallback so an
unsupported scope is not mistaken for a backend without reload support and
retried as a user-wide read.

* chore: drop the CHANGELOG entry

The changelog is generated at release time, and the hand-written entry points
at #8146 without a link definition, which breaks
tests/test_changelog_reference_links.py. Removing it keeps the file released-
generated, per the reviewer's request.

* docs(memory): explain scoped noop client behavior

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 09:21:28 +08:00
Wenchao An
9699f04cdc
feat(projects): add visible project memory with extraction controls (#8117)
* feat(projects): add visible project memory with extraction controls

* fix(projects): avoid replaying committed memory extractions

* chore(ci): retrigger project memory checks

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 09:09:44 +08:00
mosifu
041d9e375e
fix(wecom): treat "@bot /new" in group chats as a command (#8118)
WeCom leaves the bot mention in group message text, so `_publish_ws_inbound`
handed "@RobotA /new" to `is_known_channel_command`, which requires a bare
leading slash. The turn was classified as chat and the command never ran.

Strip leading mentions for the command lookup only, mirroring Feishu (#4229)
and DingTalk (#4724): ordinary chat keeps its mentions, and the stripped form
is what reaches ChannelManager so it parses `/new`. The comment spells out that
this drops any leading mention, not only the bot's -- WeCom text mentions carry
display names, so `aibotid` cannot be matched against them.

Recorded in CHANGELOG.md / CHANGELOG_zh.md under Unreleased -> Fixed.

Fixes #8099

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-12 08:01:39 +08:00
NanHan Qing
2baadff446
fix(scripts): make check.py and setup_wizard.py --help print usage (#8152)
Both entry points ignored their arguments entirely, so the first thing a
contributor tries ran the real work: `python scripts/check.py --help` ran the
full dependency probe (and, on a host with a missing tool, exited 1), and
`python scripts/setup_wizard.py --help` entered the wizard -- which on an
existing config.yaml asks "Do you want to reconfigure?" and then rewrites
config.yaml and .env. Unknown flags were silently ignored the same way.

Parse arguments in the __main__ block, following scripts/doctor.py: main()
stays directly callable for the in-process unit tests, --help prints usage and
exits 0 before any probe or prompt, and an unknown flag exits 2 with
"unrecognized arguments". The no-argument invocations behind `make check` and
`make setup` are unchanged.

Adds subprocess CLI-contract cases in a disposable checkout next to the
existing script tests, modelled on backend/tests/test_doctor_script.py.
2026-11-12 06:15:52 +08:00
SuKoYa07
fc7c7e012c
fix(frontend): restore handed-off artifacts from thread history (#8080)
* fix(frontend): restore handed-off artifacts from thread history

* fix(frontend): reject duplicate artifact handoff receipts

* docs(frontend): fit artifact guidance within budget

* fix(frontend): accept consistent retried artifact handoffs

---------

Co-authored-by: lovesky514514-cmd <273698517+lovesky514514-cmd@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-11 21:00:33 +08:00
Wenchao An
3d826c6d95
feat(approvals): add Ask, Auto and Full tool permission modes (#8033)
* feat(hitl): add human-in-the-loop approval for high-risk tool calls

* fix(hitl): address approval review findings

* docs(hitl): keep agent guidance under size limit

* fix(hitl): guard approval pause recovery

* docs(hitl): shrink inherited guidance

* fix(hitl): harden approval admission and recovery

* fix(approvals): address review safety and reconciliation gaps

* ci: retry approval checks after runner timeout

---------

Co-authored-by: wangzeren <1004695029@qq.com>
2026-11-11 20:59:05 +08:00
zuiqiangxijiejin
c7bc8bab67
fix(docker): handle inline YAML comments in sandbox mode detection (#8103)
* fix(docker): honor inline comments on the sandbox section header

* fix(docker): match the sandbox provider value exactly

* test(docker): ignore nested provisioner URLs

* docs(docker): explain exact sandbox provider matching
2026-11-11 20:11:53 +08:00
Heo Hao
80cb6404b6
fix(browserless): reject timeouts httpx cannot use (#8025)
* fix(browserless): reject timeouts httpx cannot use

`_coerce_timeout` forwarded any int, float or numeric string to
`httpx.AsyncClient(timeout=...)` unchanged, so a configured `timeout_s: 0`
or a negative value failed every fetch and capture with `ConnectTimeout`
against a healthy backend, `nan` raised `ValueError: deadline must not be
NaN`, and `inf` left the request without a deadline so it never completed.
Each of those silently disabled the provider.

Fall back to the documented 30s default with a warning when the number is
not a usable deadline. This matches `crawl4ai._coerce_timeout` after #7481;
`jina_ai._coerce_timeout` still forwards non-positive values.

* fix(browserless): address timeout review and format tests

Explain how HTTPX handles NaN and positive infinity. Document that
Browserless accepts positive finite timeouts without an upper limit,
and pin that behavior with a 1e308 test case.

Crawl4AI and Jina timeout validation are tracked in #7481 and #7823.
Remove the Browserless docstring's claim that Crawl4AI shares its
coercion behavior. Format the test file for the backend lint check.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-11 19:43:53 +08:00
Wistom Zhang
218c64180e
feat(summarization): align built-in compaction prompt to preserve user requirements and approval conditions (#8114) (#8114)
In long-running agent workflows with repeated context compaction, empirical gateway ablations across 132 real sessions revealed that standard boundary compliance remains robust, while semantic drift can occur when model-generated summaries rephrase literal approval phrases and action conditions.

This change converges PR #8114 into a clean built-in summary prompt enhancement:
- Replaces regex extraction, tag elevation, and heuristic revocation with an extended built-in DeerFlow summary prompt (_DEERFLOW_SUMMARY_PROMPT).
- Instructs the summarizer to faithfully record active user requirements, literal approval phrases, permitted actions, and later explicit revisions without paraphrasing.
- Treats summary text strictly as historical data, fully preserving the security boundary and control/data plane separation of DurableContextMiddleware.
- Unifies automatic and manual compaction factory paths to select the built-in template when unconfigured, while preserving explicit summary_prompt precedence.
- Adds comprehensive sync/async unit tests covering built-in and custom templates.

Co-authored-by: Totoro <Totoro-qaq@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-11 19:36:32 +08:00
fuduoduo
4d95bff550
test(utils): cover goal-objective and bash-exit input boundaries (#8048)
* test(utils): cover goal-objective and bash-exit input boundaries

* test(utils): make the string-comparison boundary case actually pin the contract

Review follow-up: 400 digits is below CPython's default int-max-str-digits cap (4300), so an implementation regressed to int(match.group(2)) would still pass. Derive the digit count from sys.get_int_max_str_digits() + 1 instead. Also document how this file differs from test_tool_result_meta.py (direct return contract vs message normalization). No production code changes.

---------

Co-authored-by: jack12178 <jack12178@users.noreply.github.com>
2026-11-11 19:04:09 +08:00
ZephyrWang
d9b2cb4bcb
fix(memory): require facts in replacement imports (#8131)
* fix(memory): require facts in replacement imports

* docs(memory): document import facts requirement

* docs(changelog): link memory import fix
2026-11-11 17:35:17 +08:00
Xinmin Zeng
2a97ec3e58
docs: turn README into an index and move reference detail to docs/guide (#8113)
* docs: turn README into an index and move reference detail to docs/guide

The README had grown to 393 KB because every change appended its notes there. The text moves verbatim into 18 topic guides under docs/guide/; only headings are re-leveled and relative links re-pointed. The README keeps the introduction, a quick start and a guide index. Contributor test notes move to CONTRIBUTING.md.

* docs: send user docs to topic guides and budget the root README

The rule 'update README.md for user-facing changes' is what grew the README. Guidance now points to docs/guide/, and check_agent_guidance.py fails when the root README exceeds 16 KiB (AG003).

* chore: remove unreferenced screenshots and finished plan notes

pr-build/ holds two PR screenshots (3 MB) from #1986. plans/ holds the plan for the merged #4049. docs/SKILL_NAME_CONFLICT_FIX.md is a February change note. Nothing references them.

* docs: route always-loaded AGENTS.md files to scoped guides

* docs(agents): cut scoped guides to scope, verify, traps, decisions

Each AGENTS.md keeps four kinds of content: the scope of the directory, the
verification commands, traps a local test run does not show, and decisions the
code cannot state. Values, field lists, function inventories, test names, issue
numbers and history are deleted, because the code, the tests and git state them.

Search and fetch providers share one community guide. Four guides with no
remaining content are removed. The size check now allows 6 KiB per file (hard
limit 8 KiB) and 24 KiB per chain (hard limit 32 KiB).

* style: format frontend guide table and guidance test imports

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-11 13:26:17 +08:00
Hyeonsang Cho
c8d5c108f3
fix(frontend): copy share links on the current origin (#8126)
* fix(frontend): copy share links on the current origin

The sidebar Share action rewrote the origin to the hosted demo
(https://deer-flow-v2.vercel.app) whenever the page ran on localhost or
127.0.0.1, which are the default make dev and Docker entry points. The
copied link never opened the thread, because the thread only exists on
the local deployment, and opening it sent the thread id (and a custom
agent name) to a host the operator does not control.

Always build the link from window.location.origin and pathOfThread().

* docs(changelog): reference #8126 for the share link origin fix
2026-11-11 13:06:19 +08:00
Bas Cancrinus
4a27b133f8
fix(helm): apply image.pullSecrets to the nginx Deployment (#8123)
The frontend, gateway, provisioner, Postgres and Redis pods include
deer-flow.imagePullSecrets, but the nginx Deployment did not. With
nginx.image mirrored to a private registry, the nginx pod stayed in
ImagePullBackOff while every other pod started.

Add a helm-template test that renders every workload and checks that
each one uses the configured pull secrets and none sets them by default.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:10:31 +08:00
Totoro
3df3b3c630
feat(sandbox): show source line numbers for short read_file ranges (#8109)
* feat(sandbox): show source line numbers for short read_file ranges

* fix(sandbox): address numbered read review feedback

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-11-11 11:08:39 +08:00
ZephyrWang
bf4aa82f09
fix(telegram): deliver rejected photos as documents (#8056)
* fix(telegram): deliver rejected photos as documents

* fix(telegram): log rejected-photo document fallback
2026-11-11 08:03:40 +08:00
Hyeonsang Cho
8850f8eb7e
fix(runtime): keep assembly evidence from reviving a run a peer reclaimed (#8046)
* fix(runtime): keep assembly evidence from reviving a run a peer reclaimed

In heartbeat mode every run-row write is conditional on this worker's
live lease except assembly evidence, which went through RunStore.put().
That upsert rewrote status, owner, lease, error and stop_reason from the
local record. When a peer had already reclaimed the run, it put the row
back to running and erased the takeover error. If the owner's local lease
was still live, its renewal then succeeded, the peer never reclaimed it
again, and the run finished as success after the peer had published its
terminal recovery event. If the write landed late, the row came back with
an expired lease and was reclaimed a second time.

Add RunStore.merge_owned_metadata(), which merges metadata keys only for
the owner holding a live lease on a pending/running row and writes no
other field. SQL follows the existing clock -> row lock -> conditional
UPDATE order; MemoryRunStore mirrors it. With heartbeat enabled,
record_assembly_evidence() uses it, refuses a locally fenced run before
any write, fences the run when the store rejects the merge, and fails
closed on stores without the method instead of falling back to put().
Single-worker mode keeps the full snapshot.

* docs(changelog): reference #8046 for the assembly evidence takeover fix

* test(runtime): scope the simulated lease expiry to the run's thread

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-11 07:56:53 +08:00
PeaceMaker-best
cf8c29c6b6
fix(agents): fence late progress assessments across graph entries (#8083)
* fix(agents): fence late progress assessments across graph entries

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

* docs(agents): keep combined progress guidance within budget

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>

---------

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-10 22:04:04 +08:00
一片一片
989b20b1dc
fix(hitl): never pause on an approval call without a usable id (#8070)
* fix(hitl): never pause on an approval call without a usable id

A provider can emit a tool call with no id (langchain_core keeps `id=None`),
an empty id, or the same id twice in one response. `HitlApprovalMiddleware`
copied that id into the interrupt payload, but the Gateway resume validator
requires unique non-empty strings: every resume answered 422 and every new run
stayed 409 `approval_pending`, so the thread was wedged permanently — the
all-reject escape is validated after that check. Denial paths also built
`ToolMessage(tool_call_id=None)`, which raises inside `after_model`, so a
non-interactive run failed instead of denying the call.

Treat such calls as unaddressable: strip them from the model response with a
visible rejection note instead of pausing, and deny by removal whenever a
`ToolMessage` could not pair. The tool never executes and the thread stays
usable.

Fixes #8069

* docs(hitl): state the empty-id rule accurately and track legacy recovery

Review follow-up on the unaddressable-approval guard:

- An empty tool-call id was technically resumable end to end (langchain-core
  accepts `ToolMessage("")`, and the Gateway validator accepts `""`), so only a
  null or duplicated id can wedge a pause. The rule still denies empty ids
  because strict providers reject them when the conversation is replayed; the
  docstring, CHANGELOG and interaction policy now say exactly that, and
  `_process_responses` uses the same `_addressable` predicate as the pause
  guard so the two checks cannot drift.
- Add an operator note for pauses stored before this fix: they are not cleared
  here, so continue in a new thread; in-place all-reject recovery is tracked in
  #8071.

* fix(hitl): drop a colliding call id from every serialized surface

Review follow-up: `_strip_calls` removed the unaddressable approval calls from
`tool_calls`, but `clone_ai_message_with_tool_calls` filters the raw
`additional_kwargs["tool_calls"]` payload and content tool-call blocks by id.
A non-target call sharing a removed id kept that id in `kept_ids`, so the
removed calls stayed in the raw provider payload and the next request would
advertise calls that have no result.

Drop the whole colliding id group: an id shared with a denied approval call
cannot be answered unambiguously either. The regression covers a mixed response
(duplicate approval ids plus an ordinary call with the same id) and asserts the
raw payload, content blocks, execution and visible note.

* fix(hitl): reject an id shared with an ordinary call before pausing

Review follow-up: `_addressable(targets)` only asked whether a `ToolMessage`
can answer the targeted calls. A single approval-required call whose id is
shared by an ordinary call in the same response still paused, even though
approving or rejecting it would also answer the ordinary call by id.

Add `_unambiguous_ids`: every targeted id must also be unique across the whole
response. A collision denies the colliding group (strip + visible note) before
the pause, and the resume path applies the same check to a legacy pause. New
regression: one approval-required call and one ordinary call sharing an id.

* fix(hitl): strip a deny target whose id an ordinary call shares

`_deny_all` routed only on `_addressable`, which checks uniqueness among the
targeted calls. A target whose id an ordinary non-target call also carried was
therefore answered with a deny `ToolMessage` while the ordinary call stayed
serialized, so the next request advertised the id twice with two results.

`_deny_all` now requires `_unambiguous_ids` and strips the whole colliding id
group, matching the pause path. The tool still never executes.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 21:30:43 +08:00
Wenchao An
6fe87b65fa
feat(extensions): show package-owned usage in extension details (#8031)
* feat(extensions): show package-owned usage in extension details

* test(extensions): align bookmark checks with detail UI

* fix(extensions): reconcile package documentation and locale fallbacks

* fix(extensions): align discovery locale fallback

* fix(extensions): isolate malformed optional documentation

* docs: keep backend guidance within inherited budget
2026-11-10 18:41:27 +08:00
Willem Jiang
72fb20d56d
docs(backend): shorten benchmark guidance to fit budget (#8106) 2026-11-10 18:39:52 +08:00
ZephyrWang
42da64a25c
fix(dingtalk): verify recipient status for file sends (#8089) 2026-11-10 17:50:40 +08:00
AochenShen99
f60e8b1f78
fix(ci): temporarily raise AGENTS.md chain limit to 100 KiB (#8102)
* fix(ci): temporarily raise agent guidance chain cap to 100 KiB

* docs: remove temporary guidance budget note
2026-11-10 17:26:38 +08:00
liyinlong
99e395aa9c
fix(runtime): recover completed thread reservations on admission (#8068)
* fix(runtime): recover completed thread reservations on admission

* Address thread reservation recovery review

* Place reservation recovery note below agent fixes

---------

Co-authored-by: liyinlong <173714821+lilong555@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 15:40:34 +08:00
yetuge
d49e0f3d7c
fix(gateway): reject unusable skill_context entries at the input boundary (#8091)
* fix(gateway): reject unusable skill_context entries at the input boundary

merge_skill_context indexes entry["path"] and calls entry.get(), so a
caller-supplied non-object entry or one without a usable path raised during
the state merge and failed the run with a 500. Validate the list shape at the
run-input and thread-state boundaries, mirroring the todos and delegations
channels.

* Normalize entry by ensuring 'name' is a string

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-11-10 15:36:01 +08:00
PeaceMaker-best
91f4fb4570
fix(memory): preserve retrieval indexes on operational startup failures (#8084)
Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
2026-11-10 15:14:52 +08:00
Evan
226d1bc5ce
fix(runtime): persist a rejected HITL call in the resume run's history (#8073)
* fix(runtime): persist a rejected HITL call in the resume run's history

An approval pause and its resolution are two Runs. The paused Run dispatches
the gated call; the resume Run produces its result. An approved call executes
and reaches on_tool_end, but a rejection is written by HITL after_model with no
tool callback. Journal reconciliation only persists such middleware-written
results for calls dispatched by the current Run, so the rejection stayed in
checkpoint state and never reached the run-event history: /messages and the
resume run's messages had no llm.tool.result for it.

The worker already reads the durable interrupt payload to recover the gated
tool names. It now also returns each paused tool_call_id with its name, and the
resume journal adopts those calls, so the existing reconciliation persists the
rejection under the resume Run. Approved results keep their tool-callback event
and are deduplicated by message id; results from other earlier Runs are still
not re-attributed.

* fix(runtime): keep HITL replay names independent of call adoption

Derive the replay-gate tool names and the adopted call ids separately
from the matched pause's actions, so an action without a usable
tool_call_id still gates replay. Pin that the all-reject escape paths
(approval disabled, gated tools changed or cleared while paused) reach
the history feed under the resume run, check resume-run attribution in
the mixed-decision test, and add a CHANGELOG entry.

* refactor(runtime): drop unused _pending_hitl_resume_calls wrapper

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 15:11:09 +08:00
SuKoYa07
6d1b12f650
fix(summarization): count replayed vLLM reasoning (#8077)
* fix(summarization): count replayed vLLM reasoning

* fix(summarization): unwrap nested runnable bindings

* test(summarization): cover replayed reasoning in content blocks

---------

Co-authored-by: lovesky514514-cmd <273698517+lovesky514514-cmd@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-10 15:08:00 +08:00
qkycir
d03f1234f5
fix(sandbox): recover BoxLite VMs on the provider event loop (#8072)
* fix: recover BoxLite sandboxes on the async runtime loop

* test: register the BoxLite scoped guidance

* test(sandbox): pin BoxLite recovery lifecycle signatures

* docs(changelog): link BoxLite recovery PR

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 15:05:33 +08:00
Wistom Zhang
1fbc06279a
feat(benchmark): add offline memory vs project-context comparison suite (part of #7884) (#8058)
* feat(benchmark): add offline memory vs project-context comparison suite (part of #7884)

* fix(benchmark): route prompts through DynamicContextMiddleware, flatten rows, and share test run

* refactor(benchmark): run through local Gateway and simplify first-pass suite (#7884)

* docs(agents): keep context memory benchmark guidance within chain budget

* fix(benchmark): verify DeerMem scope rejection

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 14:45:56 +08:00
xuilen
10bbcc92de
fix(serply): delegate max_results coercion to shared owner (#7869) (#7872)
* fix(serply): delegate max_results coercion to shared owner (#7869)

* refactor(serply): simplify count helper and consolidate tests

---------

Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
2026-11-10 14:09:40 +08:00
hdu_zhou
bfa2794c76
fix(uploads): keep dedupe counter before compound archive suffix (#8042)
Re-uploading a file whose name collides with a compound archive suffix
(e.g. .tar.gz, .tar.bz2) previously inserted the dedupe counter inside
the suffix: a second report.tar.gz became report.tar_1.gz, which broke
file-type detection. Treat the compound suffix as a single extension so
the counter lands before it (report_1.tar.gz), matching single-suffix
behavior (report_1.tar, report_1.txt).

- Add _COMPOUND_SUFFIXES and split stem/suffix as one unit, sorted
  longest-first with a closed, non-overlapping allowlist (.tar.br added).
- Expand tests to all 13 suffixes plus counter-escalation and plain .tar.
- Add a CHANGELOG [Unreleased] Fixed entry.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 09:52:01 +08:00
shunxinyi
54753636b1
fix(memory): recall CJK sub-phrases when jieba is not installed (#8022)
* fix(memory): recall CJK sub-phrases when jieba is not installed

Without the memory-zh extra, _tokenize fell back to whitespace splitting, so
the FTS5 index kept a whole CJK run as a single unicode61 token and every CJK
sub-phrase query returned no hits without any warning. The relevance ranker
already emitted CJK character bigrams for the same text, so the two retrieval
strategies disagreed on the same data.

Reuse relevance.cjk_token for CJK runs when jieba is unavailable and route
_preprocess_content through _tokenize, so the index and the query side agree.
Installs with jieba keep the existing jieba path.

Existing index rows are rebuilt on the first search or warm-up after the
upgrade; no schema or version change is involved.

* style(tests): format the CJK fallback regression test

`ruff format --check .` in the backend lint job reformats the new assertion in tests/test_memory_retrieval_adapter.py: the implicitly concatenated message is joined into one line. Apply the formatter output, no behavior change.

* fix(deermem): recall single CJK characters through an FTS5 prefix phrase

The fallback index stores CJK runs as character bigrams, so a one-character
query had no equal token and came back empty; `"重"*` is an FTS5 prefix phrase
that matches the bigram `重试` and still matches a standalone `重` token, while
multi-character tokens keep the plain quoted phrase. Also force the no-jieba
branch with a monkeypatch so the regression test runs on installs that have
the optional `memory-zh` extra, and cover the single-character and absent-
character cases.

* docs(deermem): scope the single-CJK prefix note to leading matches

The prefix phrase only reaches indexed tokens that start with the queried
character, so a character occurring only later inside a bigram (务 in 任务)
is not matched. Say that in the docstring and mark the residual gap as
accepted instead of widening the prefix to the run's trailing character.
2026-11-10 09:50:46 +08:00
zuiqiangxijiejin
ea7bb64f02
fix(nginx): use checkout-local temp paths for local development (#8075)
* fix(nginx): use checkout-local temp paths for local development

* docs(nginx): clarify local request buffering comment

* docs: keep nginx guidance within the inherited byte budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 09:47:37 +08:00
2SinceJuly
c612531213
fix(agents): drop empty typed text blocks from user content (#8066)
* fix(agents): drop empty text items from sanitized list content

InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but only replaces the items it collected. Empty
bare strings and empty text blocks are skipped by the extractor (matching
message_content_to_text) and then re-emitted as-is, so a request whose
content was ["", {"type": "text", ...}] still carried a bare "" content
part. The Chat Completions schema only allows typed content-part objects
in a content list, so endpoints that validate against it rejected the
whole turn — and kept rejecting every later call that resent the message.

Empty text items are now recognised during extraction and dropped when the
content list is rebuilt: leading, interleaved and trailing positions. A
list made only of empty items collapses to one empty typed block, and an
image-only message still passes through untouched. Nothing else changes:
non-text blocks keep their positions, and the transform stays
request-scoped.

* fix(agents): drop empty text items when rebuilding sanitized content

InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but it only replaced the items it had collected.
Empty bare strings and empty text blocks were skipped during extraction
(matching message_content_to_text) and then re-emitted unchanged, so a
message whose content was ["", {"type": "text", ...}] still carried a bare
"" content part. The Chat Completions schema only allows typed content-part
objects inside a content list, so endpoints that validate against it
rejected the whole turn, and kept rejecting every later model call that
resent the message.

Empty text items are now collected during extraction (they are text-shaped,
so the rebuild is what has to drop them) and therefore disappear in all
positions: leading, interleaved and trailing. A list made only of empty
items collapses to one empty typed block instead of leaking the bare
string, and an image-only message still passes through untouched.

Non-text blocks keep their existing placement rule, and the transform stays
request-scoped.

* docs(middleware): state that empty typed text blocks are dropped

The #8038 scope note in middlewares/AGENTS.md still said empty typed text
blocks remain unchanged and that providers can still reject them. This
change drops those blocks too, so the note described the opposite of what
the code does. Rewrite the summary and that bullet to match, and keep the
remaining limits explicit: user content only, and an empty user turn can
still be rejected.

* test(middleware): keep the legacy cases out of the new class and pin keep-alive

Inserting TestEmptyTextBlockItemsAreDropped before the existing
already-wrapped, graph-bubble-up and fail-open cases silently moved those
three tests into the new class, changing their node IDs and putting them
under a docstring about empty typed text blocks. Place the new class after
test_fail_open_on_processing_error so the legacy tests keep their home.

Also pin the boundary the sweep must not cross: a whitespace-only part,
typed or bare, carries no words but still keeps the turn alive. Assert
both shapes survive next to an image so tightening not block to not
block.strip() fails here rather than turning an empty user turn into a
rejected one.

* docs(middleware): keep the empty-text note inside the guidance size limit

The added paragraph pushed the effective AGENTS.md chain 55 bytes past its hard limit. Condense the same facts instead of adding a new line to the budget.

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
2026-11-10 08:04:43 +08:00
kongxin
f17e0504d0
fix(feedback): return 409 instead of 500 when feedback is POSTed twice (#8064)
* fix(feedback): return 409 instead of 500 when feedback is POSTed twice

POST /api/threads/{id}/runs/{rid}/feedback did a plain INSERT. A second
POST by the same user for the same run (a retry, or feedback on another
message of that run) hit uq_feedback_thread_run_user and the uncaught
IntegrityError surfaced as HTTP 500.

FeedbackRepository.create now rolls back and raises
FeedbackAlreadyExistsError on that collision, and the route maps it to
409 pointing callers at PUT, which already upserts.

* fix(feedback): only map the run/user unique key to 409; cover concurrent POSTs

Address review on #8064:
- create() now re-reads the (thread, run, user) row after an
  IntegrityError and raises FeedbackAlreadyExistsError only when it
  exists; any other violation (e.g. a feedback_id collision) propagates.
- Add a commit-barrier test where two first POSTs race: one 200, one
  409, and the winner's row is kept.
- Add CHANGELOG.md / CHANGELOG_zh.md Fixed entries.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 07:47:01 +08:00
ZephyrWang
f746da3a4c
fix(agents): count model calls independently of reused message IDs (#8050)
* fix(agents): count model calls independently of reused message IDs

* docs(agents): clarify budget accounting and changelog

* docs: avoid concurrent changelog insertion conflicts

* docs(agents): condense TokenBudgetMiddleware guidance to fit chain budget
2026-11-10 07:31:47 +08:00
inchang-ing
1647320ad1
fix(gateway): return 404 on cross-owner thread_id instead of 500 (#8057)
* fix(gateway): return 404 on cross-owner thread_id instead of 500

When a caller-supplied thread_id already belongs to another user, the SQL-backed store rejects the duplicate primary key with IntegrityError. The route's recovery path resolved the surviving row through the requesting owner's scope, so a foreign row could not be read back and the route surfaced a 500 instead of the documented 404 ownership-conflict response ("Thread not found").

Read the row back bypassing the owner scope before collapsing to a 500 so the cross-owner case mirrors ThreadOwnershipConflictError.

Fix #8052

Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>

* test(gateway): pin thread-ownership 404 and real-failure 500

Address review feedback on the cross-owner 404 recovery:
- Update test_insert_race_concurrent_trusted_claims_have_one_winner: the
  losing request now resolves the surviving unscoped row as a 404 ownership
  conflict instead of a 500, while the single-winner assertions and the
  non-disclosing detail are preserved.
- Add test_create_thread_keeps_real_integrity_failure_as_500 to pin the
  fall-through branch where a duplicate-key IntegrityError coincides with no
  surviving row, which must still surface as a 500 rather than collapse into
  the ownership-conflict 404.
- Document the user-facing error-surface change in the Unreleased Fixed
  section.

Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>

---------

Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 06:22:24 +08:00
yetuge
0dc423605a
fix(browserless): resolve the token through the shared credential rule (#8043)
* fix(browserless): resolve the token through the shared credential rule

Browserless read BROWSERLESS_TOKEN and the configured token raw, so a value
from a file kept its trailing newline and became the auth query parameter;
every request then failed authentication. A whitespace-only value was sent as
a token instead of counting as unset. Use provider_credentials.resolve_api_key
like the other search and fetch providers, with the blank-config fallback to
the environment.

* fix(browserless): keep an explicit blank token opt-out

An explicitly configured token ("" or null) is authoritative: a self-hosted
capture endpoint uses it to opt out of authentication, and falling back to
BROWSERLESS_TOKEN sent the cloud credential to that endpoint. Only an omitted
token key falls back to the environment. Regression tests cover the client
token and the outgoing capture query.

* docs(changelog): record the browserless token normalization

Match the sibling #7981 entry in both locales; the fix closes out the
credential-rule family for the bundled search and fetch providers.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 05:59:16 +08:00
lihongyuan99
b0b3a8aa22
fix(typesafe): build the async HTTPX client off the event loop (#7931)
* fix(typesafe): build the per-evaluation HTTPX client off the event loop

TypeSafeClient.aask constructs httpx.AsyncClient on the running loop, and
that constructor loads the certificate store synchronously
(create_ssl_context -> ssl.create_default_context(cafile=certifi.where())).
aask is the path GuardrailMiddleware.awrap_tool_call awaits, plus both
memory paths, so every guarded tool call stalls the Gateway loop for the
duration of the trust-store load.

Move the construction into asyncio.to_thread, the shape backend/AGENTS.md
records for the other HTTPX clients and the one the merged Crawl4AI anchor
tests. Requests and cleanup stay on-loop, so the deadline scope, retry
loop, bounded body read and the error taxonomy are untouched.

* fix(typesafe): close clients built during cancellation

Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>
2026-11-09 21:53:26 +08:00
Fake1new
45c1c22ffa
test(skills): declare PyYAML so the standalone suite runs as documented (#8067)
CONTRIBUTING documents the public skill tests as a self-contained suite:

    python -m pip install -r tests/skills/requirements.txt
    python -m pytest tests/skills -q

That install is short one dependency. skills/public/skill-creator/scripts
imports PyYAML at module level (quick_validate.py, utils.py), and its SKILL.md
already states PyYAML is required, but tests/skills/requirements.txt never
listed it. A fresh environment built from that file therefore fails 34 tests
with ModuleNotFoundError: No module named 'yaml' instead of running the suite -
test_skill_packager_cli.py, test_skill_creator_frontmatter.py and
test_skill_creator_eval_output.py all drive the skill's CLI in a subprocess,
so every one of their cases reports a traceback.

CI does not see this because skill-review-ci.yml runs the suite through
`uv run --locked` inside the backend environment, which already resolves
PyYAML transitively. Only the documented local path is affected.

Pin the version the backend lockfile already resolves (uv.lock: >=6.0.3).
2026-11-09 21:51:56 +08:00
Hyacinth-of-Security
5c5cf24ff2
fix(skills): read undecodable skill state as missing (#7963)
* fix(skills): read undecodable skill state as missing

`_read_manifest` and `_read_skill_states` document that an unreadable file
means "no manifest" / "no overrides", and both catch OSError and
JSONDecodeError. A file that is not valid UTF-8 raises UnicodeDecodeError,
which is a ValueError and matches neither, so the projection and the skill
state read raise instead of falling back.

Catch it alongside the existing exceptions.

* fix(skills): keep the reason when profile state cannot be decoded

Review feedback on #7963: the state reader dropped the exception, so a decode
failure that silently discards the user's disabled set left nothing to diagnose,
and the next toggle overwrites those bytes. Log the exception, and log a debug
line when an unreadable projection manifest forces a rebuild.

* docs(skills): state that undecodable skill state fails open

_read_skill_states already degraded a malformed file to {} before this change;
making undecodable bytes take the same path made that explicit, so say so next to
the sandbox check that documents the opposite verdict for a single named skill.

---------

Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
2026-11-09 21:49:46 +08:00
yu1128-L
7a8051c0d9
fix(artifacts): bind ranged reads to validated generation (#7978)
* fix(artifacts): bind ranged reads to validated generation

* fix(artifacts): drain pinned reads without Windows hard links

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 21:18:57 +08:00
lilei
31d97fc85b
fix(memory): select FTS5 matches by composite score (#8005)
* fix(memory): select FTS5 matches by composite score

Score every SQL-filtered match before selecting the final composite top-k.
Keep candidate retention bounded with a streaming heap and preserve the
existing scoring formula and adapter-level filtering behavior.

Add regressions for confidence and time-decay reversals, category filters,
result prefixes, ties, and the public DeerMem storage path.

Fixes #8003

* fix(memory): report matched and retained FTS5 rows

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 21:16:18 +08:00
jianyx1
2598940283
fix(frontend): make chat stream server errors actionable (#8041)
* fix(frontend): make chat stream server errors actionable

* fix(frontend): log chat stream server errors

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 20:42:29 +08:00
SpecStream
fa8e015023
feat(community): add SpecStream web search and fetch provider (#7968)
* feat(community): add SpecStream web search and fetch provider

* fix(specstream): keep a direct answer when sources are empty, add changelog entry

Signed-off-by: Nataliia <nati@macbook-pro-nataliia.home>

---------

Signed-off-by: Nataliia <nati@macbook-pro-nataliia.home>
Co-authored-by: Specstreamai <333137486+Specstreamai@users.noreply.github.com>
2026-11-09 20:32:22 +08:00
Severus
7adb2062eb
fix(gateway): keep retryable thread inputs ahead of terminal receipts (#7972) (#8008)
* fix(gateway): keep retryable thread inputs ahead of terminal receipts (#7972)

* fix(gateway): document thread-input ranking policy (#7972)

* test(gateway): cover thread-input ranking and receipt starvation (#7972)

* test(gateway): format thread-input regression tests with repo ruff config (#7972)
2026-11-09 20:28:09 +08:00
一片一片
3ebd4de993
fix(scheduler): isolate a malformed stored schedule from the poll batch (#7999)
remain from its review:

- `stored_next_run_at` caught the ValueErrors a dropped zone or a newer cron
  parser raises, but a stored `schedule_spec` that is JSON null, a list or a
  string fails on `.get` with AttributeError. The launch-failure handler
  recomputes the schedule, so that escaped `run_once`'s claimed-task loop and
  skipped the rest of the batch. Catch it too; the task is paused with the
  reason like the other unusable schedules.
- `run_once` dispatched its claimed batch with a bare loop, so any per-record
  raise in `dispatch_task` re-aborted the batch. Isolate each task's dispatch
  and keep claim-level store failures loud above the loop.

Tests: `test_scheduler_poll_batch_isolation.py` (non-object specs, end-to-end
pause, per-task isolation). Red on `91f20a7`, green at head.

Refs #7998
2026-11-09 20:25:32 +08:00
2SinceJuly
4e09e63a68
fix(threads): evict in-memory runs when a thread is deleted (#8036)
* fix(threads): evict in-memory runs when a thread is deleted

RunManager.get() and list_by_thread() answer from the process-local _runs
registry before consulting the RunStore. DELETE /api/threads/{id} only
removed the durable rows, so a worker that had admitted the thread's runs
kept serving them: after the delete returned 200 and the thread itself
answered 404, GET /api/threads/{id}/runs still listed the run and
GET /api/threads/{id}/runs/{run_id} still returned 200 with the original
prompt intact in kwargs. The residue disappeared only on restart, which
is what made it look like a cache rather than a leak.

Add RunManager.discard_thread_runs() to drop this worker's copies for the
thread and call it from the delete path, next to the durable run
deletion. Internal thread-operation rows are left alone: the reservation
protecting the deletion is one of them and its own release path owns it.
Historical runs, the thread row, run events and checkpoints are unchanged.

* fix(threads): keep a live finalize tail attached when evicting runs

discard_thread_runs() popped every record it found, but worker.py clears
finalizing before the tail that publishes the terminal frame, closes the
journal and releases the sandbox lease. Popping mid-tail turns the tail's
manager-mediated writes into warnings on a record that no longer exists.

Skip records whose task is still running, mirroring what cleanup() already
does through shield(), and pin the guard with a unit test that seeds both
a finished run and a delete reservation. Document that a DELETE served by
one Gateway bounds, rather than removes, a peer's copies: they survive
until that peer's deferred cleanup(run_id) fires.

* fix(threads): hide a deleted run from reads during its finalize tail

A DELETE that arrives after worker.py clears `finalizing` but before the
worker task finishes cannot drop the record: the tail still publishes the
terminal frame, closes the journal and releases the sandbox lease through it,
so popping turns those writes into `set_status called for unknown run`.
Leaving the record readable instead keeps the run and its prompt reachable
through `GET /threads/{id}/runs` until `cleanup()`'s 300s delay fires, which
is exactly the window #8034 reports.

Records that a deletion reaches mid-tail are now flagged instead of skipped.
`get()`, `list_by_thread()`, `get_many_by_thread()`, the regenerate-source and
edit-replay reads skip a flagged record (and never rebuild it from a store row,
since the durable rows were deleted first), and the tail's own completion
evicts it — no second delete request and no timer.

* fix(threads): keep the tail eviction task referenced until it settles

The done-callback eviction is a separate task because it cannot await the
registry lock inline. The loop holds only weak references to tasks, so an
unreferenced eviction could be collected before it drops the record and the
deleted run would come back within `cleanup()`'s 300s window. Track it in a
manager-owned set and discard on completion.

* fix(threads): keep hidden runs hidden on every read path

The deletion flag set by discard_thread_runs is authoritative for "this run
is deleted", but two reads still treated it as a hint.

get() narrowed its store-less guard to a missing record, so a flagged
record on a store-less RunManager fell through to self._store.get() on
None; the AttributeError was swallowed by the broad except below while
every read in the flag window logged a hydration warning with a full
traceback. The record now settles the read before any store call, which
also keeps the read side-effect free as the comment intends. aget()
shares the path.

get_many_by_thread() rebuilt flagged runs from store rows. list_by_thread
already refuses that, because the durable row is deleted before the flag
is set and anything left is a stale or partial read; the batch read now
applies the same guard so the invariant is uniform.

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
2026-11-09 20:21:07 +08:00
qkycir
f9767c65d5
fix(sandbox): run scoped env commands concurrently (#7841)
* fix(sandbox): run scoped env commands concurrently

* test(sandbox): gate concurrent command admission deterministically

* fix(sandbox): keep releasing scopes visible to client close

* docs(sandbox): clarify scoped env ownership and capacity

* fix(sandbox): retain cancellation ownership while scopes drain

* fix(sandbox): bound Bash admission to provisioned session capacity
2026-11-09 20:18:30 +08:00
Evan
3e59e22b4c
fix(subagents): keep tests_passed evidence per call when a provider reuses its id (#8012)
* fix(subagents): keep tests_passed evidence per call when a provider reuses its id

Tool-call ids are unique per response, not per conversation. Some providers
number calls per response, so two turns can both issue call_0.

The bash evidence for tests_passed acceptance assumed one call per id in two
places:

- _harvest_bash_executions built one id -> command map over the whole
  conversation, so every result under a reused id was labelled with the
  last call's command. A passing subset run (pytest tests/unit) became
  evidence for the full suite when a later call_0 ran pytest tests and had
  no result yet.
- update_bash_executions merged values chunks by tool_call_id. A dict keeps
  a key's first position, so a later run under a reused id overwrote the
  earlier entry in place. With call_0 failing, call_1 passing and call_0
  failing again, the merged list ended on the stale pass and the leaf held.

Pair results through pair_tool_call_results, which answers a call only from
its own response, and merge chunks by result message id, keeping the call id
for entries without one. Each run stays a separate entry in execution order,
so the latest run decides the leaf again.

* fix(subagents): key the summary cache per run and document id-less calls

- Cache full-output test summaries by result message id (call id only
  without one), so reused call ids keep one cache slot per run.
- Update the bash_executions field doc and the subagents guide to the
  result-id merge, and state that calls without an id record no
  evidence (the leaf stays UNVERIFIED).
- Tests pin both: one classification per run under a reused id, and no
  evidence for a call with a missing or empty id.

* chore(subagents): drop the stray empty deerflow/executor.py

The previous commit added a 0-byte module at the package root by mistake.
Nothing imports deerflow.executor; the real module is deerflow/subagents/executor.py.
2026-11-09 19:59:47 +08:00
Heo Hao
fb094984e3
fix(ci): label every harness tree as backend (#8029)
* fix(ci): label every harness tree as backend

`area:backend` enumerated the harness subdirectories that map to it instead
of the trees that map elsewhere, so thirteen of the twenty-eight directories
under `deerflow/` matched no area rule at all: community/, authz/, scheduler/,
storage/, tui/ and eight more. A PR that only touched `deerflow/community/`
therefore came out unlabelled, or with `area:docs` alone when it also edited a
markdown file — #7482 (crawl4ai) and #7826 (jina) are merged examples.

Match the harness as a whole and exclude the six trees that carry a more
specific label (agents, subagents, reflection, sandbox, skills, mcp). Checked
against all 28 directories: the 13 unlabelled ones now match `area:backend`,
and the sandbox / skills / mcp / agents / frontend results are unchanged.

* fix(ci): cover backend package paths in PR triage

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 18:28:54 +08:00
王一帆
463361c5ba
feat(weknora): allow opt-in shared retrieval for authenticated users (#7944)
* feat(weknora): allow opt-in shared authenticated retrieval

* docs(weknora): clarify shared retrieval identity requirements

* docs(weknora): clarify durable batch authentication limits

* docs(gateway): keep agent guidance within hard size limit

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 17:06:05 +08:00
Hyacinth-of-Security
c738c5ef8b
fix(skills): give a bracketed literal in the userinfo slot no host on every Python (#8017)
* fix(skills): give a bracketed literal in the userinfo slot no host on every Python

`_http_host` answered from `urlsplit(url).hostname`, which decides the
ambiguous authority `http://[::1]@Example.COM/api` differently across
interpreters: 3.12 reads the bracketed part as userinfo and returns the host
`example.com`, 3.13 rejects the authority and `urlsplit` raises, so the helper
returns None. The same URL got two different answers depending on the runtime.

The answer is not cosmetic: `_is_outbound_url` compares it against
`_LOCAL_HTTP_HOSTS`, so whether a URL counts as local or outbound moved with the
interpreter. The test table added with the surrounding refactor (#7950) already
states the intended answer, None, which is why `tests/test_skillscan_native.py`
fails on 3.12 and passes on 3.13.

The bracketed-literal case is now rejected before `hostname` is read, so the
answer no longer depends on that URL-parsing change, and the shape keeps the
treatment the other malformed authorities get: no host, therefore outbound.

The test table also gains a bracketed literal with an IPv6 host and a port
(`http://[2001:DB8::1]@example.com:8443/api`), so a fix that only special-cased
the exact URL from the failure cannot pass.

On 3.12: `tests/test_skillscan_native.py` goes from 1 failed / 387 passed to 388
passed, and the skillscan suite (834 tests) passes. Reverting only the
orchestrator change puts both bracketed cases back to failing.

* fix(skills): reject raw brackets in URL userinfo

---------

Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 16:49:37 +08:00
Willem Jiang
7bbccda575
chore(deps): lock langgraph-cli to stable 0.4.42 (#8045) 2026-11-09 16:35:04 +08:00
Hyeonsang Cho
7bb92cea10
fix(threads): keep the owner row when deleting checkpoints fails (#7992)
* fix(threads): keep the owner row when deleting checkpoints fails

DELETE /api/threads/{id} treated the checkpoint delete as best-effort: a
failure was logged at DEBUG, the threads_meta row was removed anyway and
the request returned success. Read routes allow a thread without a meta
row as an untracked legacy thread, so the surviving checkpoints (title,
messages, goal) became readable by every signed-in user through
GET /threads/{id}, /state, /history and /goal, and the owner could not
retry because DELETE requires the row.

A failed checkpoint delete now logs the exception and returns a generic
500 before the meta row is touched, so the thread stays owned and the
owner can retry. The remaining cleanup steps stay best-effort; their
reads are owner-scoped.

* docs(changelog): reference #7992 for the thread delete checkpoint fix

* fix(threads): require checkpoint deletion before dropping ownership

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 11:53:56 +08:00
哈基米
aa1f376e90
fix(community): build remaining search clients off the event loop (#7933)
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 10:08:59 +08:00
Wenchao An
97746ad725
fix(frontend): open file chooser from attachment button (#8018) 2026-11-09 09:57:11 +08:00
Zeren Wang
0d04322d41
feat(hitl): add human-in-the-loop approval for high-risk tool calls (#7979)
* feat(hitl): add human-in-the-loop approval for high-risk tool calls

* fix(hitl): address approval review findings

* docs(hitl): keep agent guidance under size limit

* fix(hitl): guard approval pause recovery

* docs(hitl): shrink inherited guidance

* fix(hitl): harden approval admission and recovery
2026-11-09 09:52:35 +08:00
alexcastgonz
3b05ab7dc3
fix(agents): remove empty strings from user content (#8038)
* fix(agents): drop empty strings from rebuilt user content

* fix(agents): normalize empty content before sanitizer early returns

* docs(agents): clarify input sanitization provider limits
2026-11-09 09:27:35 +08:00
hdu_zhou
4c00e8d4a9
fix(memory): reject Windows reserved device names in an agent memory scope (#7941)
* fix(memory): reject Windows reserved device names in an agent memory scope

`_management_agent_name_or_501()` validated the `agent_name` query parameter
with `AGENT_NAME_PATTERN` (`^[A-Za-z0-9-]+$`) only, which admits `NUL`, `CON`,
`COM1` and the rest of the Windows reserved device names.

An agent scope is not only a label: DeerMem resolves it to
`{storage}/users/{uid}/agents/{name}/facts` and creates that directory on the
first write. On Windows the path never materialises, so `create_fact` raised
`FileNotFoundError`, which the endpoint's `OSError` branch turned into
`500 Failed to create memory fact`.

Run `windows_incompatible_segment()` at the same boundary -- the check agent
directories, thread IDs, uploads, sandbox writes and skill support files
already go through -- and answer `422`. The `for_creation` flag scopes the rule
to the two routes that create the scope, `POST /memory/facts` and
`POST /memory/import`; read and delete routes stay permissive so a scope that an
older release stored under a reserved name can still be exported and cleared.

Closes #7940

* fix(memory): address review feedback for Windows reserved agent names

Cite PR #7941 in CHANGELOG instead of issue #7940; pin the clear route's permissive behavior for a legacy reserved-name scope with a regression test; cross-reference the shared validate_agent_name() reserved-name check so the two copies cannot drift.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 09:09:00 +08:00
lilei
24884e8120
fix(task-continuity): count distinct archived search sources (#7956)
* fix(task-continuity): count distinct archived search sources

* docs(task-continuity): explain archive search ranking trade-off

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-09 09:07:20 +08:00
dependabot[bot]
3163a96ebd
chore(deps): bump langgraph-cli from 0.4.24 to 0.4.32.dev0 in /backend (#8015)
Bumps [langgraph-cli](https://github.com/langchain-ai/langgraph) from 0.4.24 to 0.4.32.dev0.
- [Release notes](https://github.com/langchain-ai/langgraph/releases)
- [Commits](https://github.com/langchain-ai/langgraph/compare/cli==0.4.24...cli==0.4.32.dev0)

---
updated-dependencies:
- dependency-name: langgraph-cli
  dependency-version: 0.4.32.dev0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 08:04:39 +08:00
Severus
fe05090b96
docs: fix two stale internal anchor fragments (#7983) (#8007) 2026-11-09 08:01:34 +08:00
fuduoduo
2a9e7255a7
fix(channels): keep Gateway calls off the environment proxy (#7877)
* fix(channels): keep Gateway calls off the environment proxy

* fix(channels): keep the aio_sandbox policy import lazy

Importing deerflow.community.aio_sandbox.backend at module scope executes
the package __init__, which eagerly imports AioSandbox, AioSandboxProvider
and both backends. That makes every ChannelManager import load the entire
sandbox provider stack even when a different sandbox is configured.

Mirror app/gateway/health.py::_make_provisioner_client: move the import into
a small _make_gateway_client helper and route both Gateway call sites through
it, so the URL policy stays lazy.

* fix(channels): keep the run client off the environment proxy

langgraph_sdk.get_client() builds httpx.AsyncHTTPTransport(retries=5) on an
httpx.AsyncClient that leaves trust_env at its default True and exposes no
transport/trust_env override, so ChannelManager._get_client() still sent
runs.wait submissions to a loopback Gateway through HTTP_PROXY. That is the hop
#7876 reports: the control-plane calls are fixed, the run submission is not.

Route the SDK client through _make_langgraph_client: loopback and private
Gateways get an explicit trust_env=False httpx.AsyncClient carrying the SDK's
own retries, timeout and User-Agent and handed to LangGraphClient, while
external Gateways keep the stock get_client() path.

Also move the URL policy out of the aio_sandbox package into
deerflow.utils.http_policy (control_plane_trust_env). Keeping the import lazy
did not help on its own: aio_sandbox/__init__ eagerly imports the provider
stack, so importing aio_sandbox.backend still loaded all of it. The neutral
module has no httpx or sandbox dependency, so manager.py and gateway/health.py
import it at module scope, and aio_sandbox.backend re-exports it as
sandbox_http_trust_env for the existing sandbox call sites.

* fix(channels): keep AGENTS.md under budget and align legacy CSRF test with new client

* style(sandbox): remove unused urlparse import

---------

Co-authored-by: jack12178 <jack12178@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 22:10:10 +08:00
Hyeonsang Cho
955524a510
fix(middleware): scan PII emails in linear time (#8006)
* fix(middleware): scan PII emails in linear time

The email detector's pattern restarts at every position inside a
whitespace-free run and consumes the rest of the run before failing to
find '@', so a scan is quadratic in the run length. PiiRedactionMiddleware
re-redacts every user message on every model call, on the event loop, so
one pasted 100 KB hex dump stalled the Gateway for ~6 s per model call.

Scan from run starts only (a local part must reach the end of its run),
and match anchored where the previous match ended so glued addresses
such as 'a@b.cc+c@d.org' keep both matches, exactly as before.

* docs(changelog): reference #8006 for the linear PII email scan

* fix(middleware): group the embedded email pattern and pin every detector's linearity

Wrap the pattern behind the run-start lookbehind in a group so a future
top-level alternative in _EMAIL_PATTERN cannot escape the lookbehind, and
add one whitespace-free payload per other detector to the linearity test
so the AGENTS.md rule is enforced for all of them.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 22:09:02 +08:00
Eilen Shin
aaca3eeaeb
refactor(agent): ground runtime decisions in structured evidence (#7986)
* refactor(agent): ground runtime decisions in structured evidence

Use producer status and structured errors for tool recovery, preserve memory context without lexical confirmation, and remove predetermined receipt and skill conclusions.

Require explicit MCP discovery and authenticated schedule trial actions. Preserve authorization, ownership, isolation, and bounded execution; update documentation and regression coverage.

* test(sandbox): expect read_file initialization error callback

* fix(agent): address evidence review follow-ups

* fix(agent): preserve retry budgets for OpenAI transport errors

* fix(agent): expose tool execution status in model requests

Project validated execution metadata and exact tool text into request-only JSON so status survives provider serialization. Preserve stored results and receipt hashes, cover SDK and graph boundaries, and migrate replay input hashes without changing recorded responses.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 22:00:23 +08:00
dependabot[bot]
f986536492
chore(deps): bump oauthlib from 4.0.0 to 4.0.1 in /backend (#8014)
Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/oauthlib/oauthlib/releases)
- [Changelog](https://github.com/oauthlib/oauthlib/blob/v4.0.1/CHANGELOG.rst)
- [Commits](https://github.com/oauthlib/oauthlib/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: oauthlib
  dependency-version: 4.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 21:56:38 +08:00
Eric
3920169fb6
fix(subagents): reject Windows reserved device names in managed subagent creation (#7959)
* fix(subagents): reject Windows reserved device names in managed subagent creation

* Reject Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
  in normalize_managed_subagent_name when for_creation=True via
  windows_incompatible_segment.
* Validate subagent names on creation in FileManagedSubagentStore.create
  and /api/subagents POST gateway router before attempting disk writes.
* Keep read, update, and delete paths permissive for backward compatibility.
* Add unit tests for store validation and router endpoints.
* Update CHANGELOG.md under Fixed.

* fix(subagents): reject reserved names in SQL managed-subagent create

Apply the same for_creation check used by the file store so both
backends refuse Windows device names. Pin mixed-case Nul in tests and
import the shared normalizer at module level.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 21:55:46 +08:00
Evan
a73a94a6a3
fix(agents): keep HarnessGuard from rewriting the result that ends a loop (#8001)
* fix(agents): keep HarnessGuard from rewriting the result that ends a loop

The alternating-loop check fired when three of the last four tool calls
failed, without checking the current call. After A and B failed in turn,
a successful B therefore received the OSCILLATION DETECTED warning: the
model was told to stop just as the loop ended, and structured output such
as JSON was corrupted by the appended text.

Only a failing call now continues the pattern, so successful results reach
the model unchanged. Failing alternation still warns.

* fix(agents): only warn on an alternating loop when the whole window failed

The oscillation check still counted 3 errors out of the last 4 calls, so a
window that contained a success could warn on the next failure, e.g.
A err, B err, A ok, B err. A success ends the loop, so require all four
calls in the window to have failed, and pin the break shapes with tests.

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-08 19:20:51 +08:00
Evan
d8e085630a
fix(agents): keep todo reminders in Goal turns after an earlier provider stop (#8004)
* fix(agents): keep todo reminders in Goal turns after an earlier provider stop

TodoMiddleware skipped completion reminders whenever runtime.context held
safety_capped or model_length_capped. The worker keeps that key across the
run's hidden Goal continuation turns so the run record reports the first
cap, and an unmet Goal continues after these provider stops (it stands down
only on loop and token caps). After one capped response, every continuation
turn therefore lost its todo reminders and could stop with unfinished items.

Loop and token stops stay run-level and are still read from the context.
Provider safety and length stops now apply to the response they ended,
identified by the safety_termination / model_length_termination marker that
those guards stamp on it. The check still runs before the parent hook, so a
capped response cannot be resumed by the parallel-write retry.

* refactor(agents): import provider-stop markers from the guards that stamp them

TodoMiddleware matched the safety and length markers by string literals that
duplicated the guards' own. Each guard now exports its marker and stop reason
as module constants, used at its stamp and check sites and imported by
TodoMiddleware, so a rename in a guard cannot silently disable suppression.

Comment that completion_reminder_hard_stop_reasons is the inventory surface
and only its run-level entries are read from runtime.context, and run the
worker-level Goal continuation test for the safety guard too, which
overwrites stop_reason instead of keeping the first one.

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-08 19:19:56 +08:00
LuckVd
284e0d537d
IssueHunter candidate 771965045901: fix for issue #7995 (#7996) 2026-11-08 14:06:04 +08:00
aduoge
91f20a782f
fix(github): match repository bindings case-insensitively (#7982)
GitHub owner and repository names are case-insensitive, but the agent
registry indexed and looked up bindings by the raw repo string. A
binding for acme/widget never matched webhooks for Acme/Widget, so the
event was dropped without an error. Lowercase the repo on both sides.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 13:45:59 +08:00
yetuge
5b1bee3d5a
fix(memory): skip text-less turns when formatting the memory prompt (#7985)
* fix(memory): skip text-less turns when formatting the memory prompt

A content-less message rendered as the literal "None", and a block list
without text blocks rendered as its Python repr, including any base64
data URL. Normalize through the same rules as the sibling content-to-text
helpers and skip turns that carry no text.

* test(memory): cover whitespace-only assistant turns

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 13:43:06 +08:00
Hyeonsang Cho
8924c5844a
fix(scheduler): pause a task whose schedule no longer computes (#7993)
* fix(scheduler): pause a task whose schedule no longer computes

Admission validates every schedule, but a stored one can stop computing
later: a zone the installed tz database no longer has, or a cron
expression a newer croniter rejects. The scheduler recomputes next_run_at
inside the shared poll, and that raise escaped:

- dispatch_task recomputed it in its except block after a successful
  launch, so run_once aborted the claimed batch and left the occurrence
  in "launching" with no last_error;
- once the launch lease expired, recover_expired_launch_claims found the
  durable run and raised again for every poll, before claim_due_tasks,
  so no other scheduled task was admitted for the rest of the process.

Runtime cursor writes (launch, launch repair, queue timeout, launch
failure, finalization) now use stored_next_run_at, which returns the
reason instead of raising. A live recurring task is paused with
"schedule can no longer be computed: ..." as its last_error and no next
run; a once task keeps its normal outcome. Resume re-validates the
schedule, so it reports the problem until the schedule is fixed.

run_once also isolates the lease-recovery pass, like the terminal-run
reconciliation next to it, so a failed pass no longer skips admission.

* docs(changelog): reference #7993 for the unschedulable task fix
2026-11-08 13:39:34 +08:00
Harvey DU
3aefc56f5f
fix(gateway): keep batch cancellation running when the scheduler store fails (#7971)
* fix(gateway): keep batch cancellation running when the scheduler store fails

_run_completion_hook awaited scheduled_task_service.handle_run_completion
before batch_continuations.source_finished, so a scheduler store outage
aborted the hook and skipped batch cancellation entirely: a claimed
follow-up of a hard-stopped run stayed in the dispatching state instead
of being cancelled.

The scheduler notification is now best-effort (logged as non-fatal);
batch cancellation always runs.

Fixes #7969.

* fix(gateway): isolate batch cancellation failures and trace completion warnings

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 12:52:05 +08:00
baolong lin
c69e9ef0bc
fix(memory): make doctor validates the selected backend config (#7965)
A stale memory.backend_config (DeerMem-only keys left in place after
switching manager_class to openviking) surfaced only when
MemoryMiddleware constructed the manager mid-conversation, after model
tokens were already spent; make doctor reported Ready because
MemoryConfig accepts backend_config as untyped data.

doctor now runs the backend's own side-effect-free validator
(OpenVikingConfig.from_backend_config) and reports the offending keys
before an agent turn is accepted. Backends without a static validator
are skipped, as is disabled memory.

Fixes #7962

Signed-off-by: Batianxieshen1 <280706568+Batianxieshen1@users.noreply.github.com>
Co-authored-by: Batianxieshen1 <280706568+Batianxieshen1@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 10:25:19 +08:00
Severus
961262a17e
chore(frontend): declare required Node and pnpm versions in package.json (#7952)
* chore(frontend): declare required Node and pnpm versions

* chore(frontend): align pnpm engine floor with pinned version

---------

Co-authored-by: severus0044 <severus0044@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 09:57:59 +08:00
alexcastgonz
b315cf2c9e
fix(telegram): wait for retry_after before retrying a new message (#7990)
* fix(telegram): honor retry_after when retrying a new message

A 429 on sendMessage or sendRichMessage slept 2**attempt and dropped
the reply before Telegram's flood wait expired. Wait the longer of
that backoff and the server hint, and leave other channels unchanged.

Fixes #7989

* fix(telegram): bound new reply flood waits

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 09:51:43 +08:00
Eilen Shin
2a8927c5e1
feat(models): add StepFun provider preset (#7988)
* feat(models): add StepFun provider preset

* fix(models): standardize the StepFun preset label

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 09:49:43 +08:00
LuckVd
1dae5eb363
fix(sandbox): exempt expression division after closers from the host-bash guard (#7448)
* fix(sandbox): exempt expression division after closers from the host-bash guard

A single-segment absolute-path match directly after ')', ']' or '}' is
division in an inline expression (python3 -c, perl, node -e, $((...)) ),
not a host path; #7023 exempted this shape only inside single-quoted awk
programs. Per-closer rules keep unknown-value path builds rejected:
digits only after ')' and '}' ($1/1024 rationale), an identifier after
')' only when the paren closes a name(...) call, any segment after ']'.
Literal tokens ('ls /2') are unchanged.

Fixes #7444

* fix(sandbox): scope division exemptions to parsed expressions

* docs(sandbox): fit inherited guidance within the hard limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 09:49:07 +08:00
一片一片
7d805cd1b8
fix(skills): reject Windows device names when creating a skill (#7948)
* fix(skills): reject Windows device names when creating a skill

A skill name is a `custom/<name>` directory component, but the hyphen-case
grammar admits Windows reserved device names (`nul`, `con`, `com1`, ...).
`skill_manage` create and `.skill` archive install accepted them: on Windows
`mkdir("nul")` reports success without producing a directory and the first
write inside it fails with FileNotFoundError; on Linux the install succeeds
but the tree can never be copied, mounted or exported to a Windows host.

Creation now rejects those names through `windows_incompatible_segment()`, the
check uploads, sandbox writes, skill support files, thread IDs and agent names
already use. Archive extraction also rejects member segments that cannot be
created on every host. Reads and deletes stay permissive so a skill an older
release created on a Linux host can still be inspected and removed.

Fixes #7945

* test(skills): pin the extraction guard and the tool-level create wiring

Address review findings on the skill-name portability change:

- Isolate the extraction guard: an archive with a portable folder and a
  portable frontmatter name but a reserved support-file segment
  (`my-skill/references/nul.md`) now fails with the member-path error, so
  deleting the guard turns this suite red again.
- Pin the `skill_manage` wiring: `create` of a device name raises through the
  tool, while `edit`/`delete` keep reaching the existing "not found" path.
- Reword the `safe_extract_skill_archive` docstring: members before the
  offending one are already written into staging, so what the check
  guarantees is that the install never commits under `custom/`.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 08:54:08 +08:00
Hyeonsang Cho
6c94ecc6cb
fix(community): strip provider API keys before they reach a header (#7981)
* fix(community): strip provider API keys before they reach a header

GroundRoute (from GROUNDROUTE_API_KEY), InfoQuest, Tavily, Exa, Jina,
Firecrawl, fastCRW and Crawl4AI put the configured or environment key into
an HTTP header as read. A key read from a file keeps its trailing newline
(a Kubernetes Secret created with --from-file and mounted through envFrom),
and the transport refuses it with an error that quotes the whole header:
h11's "Illegal header value b'Bearer <key>\n'" or requests' "... in header
value: '<key>\n'". The providers returned that message as the tool result,
so the key reached the model and the checkpointed conversation, and
GroundRoute, InfoQuest, Jina and Crawl4AI also wrote it to the error log.

Resolve each key through provider_credentials.resolve_api_key, as the other
bundled providers already strip theirs. Tavily, Exa and Firecrawl now read
their environment fallback themselves instead of leaving it to the SDK,
which would read the raw value again. A blank configured key now falls back
to the environment, as it already does for GroundRoute and the stripping
providers.

* docs(changelog): reference #7981 for the provider API key fix

* fix(community): keep fastCRW from sending the Firecrawl key

fastCRW builds the Firecrawl SDK client, and the SDK reads
FIRECRAWL_API_KEY whenever api_key is None. Once a blank configured key
resolved to None, a fastCRW config with api_key: "" and no CRW_API_KEY sent
the Firecrawl key to the fastCRW endpoint. With no api_key configured at
all, main already did the same.

Pass "" when fastCRW resolves no key of its own; the SDK falls back only on
None and sends no Authorization header for an empty key. A loopback test
pins the empty, blank and absent cases with FIRECRAWL_API_KEY set.

* fix(community): keep a blank environment key away from the SDKs

Tavily, Exa and Firecrawl resolved their environment fallback themselves,
but passed None when it was blank, and each SDK then re-read the same
variable unstripped. Tavily and Firecrawl now pass "", which the SDKs
treat as a missing key; Exa sends "" as is, so it fails before any
request instead.

The fastCRW comment records that only the Firecrawl SDK's v2 client
honours "", and the fastCRW regression test now covers web_fetch as well
as web_search, so a move to the v1 client would fail it.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 08:44:56 +08:00
Hyeonsang Cho
4e90f5d771
fix(docker): keep local config out of the image build context (#7958)
* fix(docker): keep local config out of the image build context

backend/Dockerfile copies the backend tree wholesale (COPY backend ./backend),
and frontend/Dockerfile does the same for frontend/. .dockerignore excluded
.env files and runtime data, but not the local configuration: config.yaml,
the config.yaml.bak that make config-upgrade writes beside it,
extensions_config.json and legacy mcp_config.json. The config resolvers still
read these from the legacy backend/ location, so on a checkout that keeps them
there every locally built gateway image (dev and prod) carried the model API
keys and MCP credentials in its layers, under the file Compose mounts over at
runtime. frontend/.env.local, the documented place for BETTER_AUTH_SECRET in
local development, was copied into the frontend image the same way, where
next build and next start also read it.

Exclude those files at the root and in backend/, and .env*.local at any depth.
None has tracked content; the build's file list is otherwise unchanged.

* docs(changelog): reference #7958 for the Docker build-context fix

* fix(docker): keep local HTTPS keys and snapshot credentials out of images

frontend/.gitignore also ignores *.pem, the mkcert key pair for local HTTPS,
and COPY frontend ./frontend shipped it into the dev and prod frontend images.
Exclude frontend/*.pem.

The trailing !backend/extensions/sources/** re-inclusion wins over every
earlier rule, so a hand-edited extension snapshot could still carry .env files
or keys into the gateway image. Exclude, after the re-inclusion, exactly the
files the extension manager already refuses to snapshot; a test pins that set
to the manager's own _SENSITIVE_FILENAMES and _SENSITIVE_SUFFIXES.

The test's matcher now translates patterns as Docker does (** across segments,
* within one, character classes, parent-directory matches), so it can model
the mid-path ** rules; its predictions agree with BuildKit on every test path.

The README and changelog now say that a locally built frontend image no longer
inlines NEXT_PUBLIC_* values from frontend/.env.local.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-08 07:56:27 +08:00
Colin Lin
c953ab65f5
fix(subagents): drain abandoned capacity waiters before cancellation (#7900)
* fix(subagents): drain abandoned capacity waiters before cancellation

Continue the unmerged queued-waiter cleanup approach from #7571, with deterministic cancellation and timeout controls.

Co-authored-by: 熊小雨 <1178327328@qq.com>

* test(subagents): gate capacity timeout cleanup deterministically

---------

Co-authored-by: linkangjun <331227811+linkangjun@users.noreply.github.com>
Co-authored-by: 熊小雨 <1178327328@qq.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-07 23:34:41 +08:00
Hoouse
3a85baba9f
fix(bookmarks): avoid splitting surrogate pairs in automatic labels (#7966)
* fix(bookmarks): avoid splitting surrogate pairs in automatic labels

* fix(bookmarks): sanitize unpaired surrogates before save
2026-11-07 23:04:59 +08:00
MubashirAziz1
d1238c6974
fix(tools): Refactor tool filtering into a single assembly pass (#7950)
* Refractor the tools filtering logic.

* Address the feedback.
2026-11-07 22:33:59 +08:00
Willem Jiang
f68abbb831
chore(example): fix the deprecated contextmanager issue (#7974) 2026-11-07 21:44:05 +08:00
golemon
372baf762f
fix(agent): prevent todo reminders from resuming runs after hard stops (#7955)
* fix(agent): stop todo reminders after run hard limits

Respect loop_capped and token_capped before scheduling completion reminders, and include the behavior in the assembly policy fingerprint.

Cover sync and async agent graphs, blocked post-stop tools, preserved Todo state, and continuation from the same thread checkpoint. Validation: 316 targeted tests passed.

* docs(agent): explain todo behavior after hard stops

Document that loop and token hard stops suppress Todo completion reminders while retaining unfinished tasks for later runs.

* docs(changelog): record todo hard-stop fix

Add English and Chinese Unreleased entries referencing issue #7953.

* fix(agent): mark safety stops with partial responses

* fix(agent): honor provider stops before todo retries

* docs(agent): clarify todo behavior after provider stops

* test(agent): isolate todo safety-chain runtime dependencies

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-07 21:43:16 +08:00
yetuge
2bd60d1e6f
fix(subagents): build the chat model off the event loop (#7967)
* fix(subagents): build the chat model off the event loop

`SubagentExecutor._create_agent` is awaited from `_aexecute` on the shared
event loop, and it constructed the chat model inline. Provider construction can
read local credentials -- the Codex provider loads the Codex CLI `auth.json`
while it is built -- so every delegated task did synchronous file IO on the loop
before its first model call, which stalls every concurrent turn sharing that
loop.

Build the model through `run_assembly`, the dedicated assembly pool the other
provider-construction sites already use, and leave the rest of the middleware
and graph assembly where it is. The helper preserves request context, and a
cancelled construction still never starts an inference.

`tests/blocking_io/test_subagent_model_offloop.py` drives the real
`_create_agent` with a real `CodexChatModel` under the strict Blockbuster gate:
on `main` the credential load fails with `BlockingError(os.stat)`, and reverting
the `run_assembly` call reproduces the same failure.

* docs(subagents): name the isolated subagent loop in the off-loop notes

The blocked loop is the persistent isolated subagent loop every concurrent
delegated task shares, not the Gateway request loop; say so in the boundary
audit, the regression docstring, and the assertion message.

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-07 19:46:03 +08:00
Willem Jiang
a99cd299f9
docs(memory): trim inherited guidance below hard limit (#7976) 2026-11-07 19:08:05 +08:00
Totoro
3f9a78e02e
fix(goal,artifacts): verify completion and artifact handoff (#7961)
* fix(goal,artifacts): verify completion and artifact handoff

* fix(goal,artifacts): harden shared artifact handoff

* docs(agents): shrink inherited guidance to fit chain budget

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-11-07 18:21:49 +08:00
MasterZRC
d0007090c2
fix(sandbox): retain ports when restricted cleanup is unconfirmed (#7917)
* fix(sandbox): reconcile ports after restricted creation failures

* fix(sandbox): guard creation ports through concurrent teardown

* fix(sandbox): narrow restricted startup port cleanup

---------

Co-authored-by: Totoro <msy626836554@gmail.com>
2026-11-07 16:07:15 +08:00
Zhiqiang Yan
aadc816f0b
fix(knowledge): retain later evidence that fits the output budget (#7942) 2026-11-07 16:01:47 +08:00
Hyeonsang Cho
d658b13c79
fix(channels): keep Feishu cards within the card size limit (#7798)
* fix(channels): keep Feishu cards within the card size limit

Card text was unbounded and ASCII-escaped, so an answer of about 4,400 CJK
characters made the card request body exceed Feishu's 30 KB limit (230025).
The final patch and its reply fallback were both rejected, the answer was
lost and the running card stayed on its last streamed text.

Cards are now serialized without ASCII escapes and budgeted by the bytes
their text adds to the request body. A streamed update shows what fits; a
longer final answer continues in up to 10 cards replied to the same source
message, split at line breaks, and a retry resumes after the cards already
delivered.

* docs(changelog): reference #7798 for the Feishu card size fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-07 15:52:59 +08:00
VicFei
a3d21e5e4d
feat(skills): expose MiniMax resolution/duration in video-generation (#7912)
* feat(skills): expose MiniMax resolution/duration in video-generation

Hailuo 2.3 accepts explicit resolution (768P/1080P) and duration (6/10s)
in the /v1/video_generation body, but the skill sent neither — and its
SKILL.md told the caller --aspect-ratio is ignored for MiniMax without
offering any alternative, leaving cost/quality/length uncontrollable.
Read MINIMAX_VIDEO_RESOLUTION and MINIMAX_VIDEO_DURATION from the
environment and pass them through when set (unset keeps the API defaults
byte-for-byte); values are passed through unvalidated beyond the integer
check so newer tiers (H3 per-second 4-15s) need no script change. Document
both knobs in SKILL.md with a draft/final example. Fixes #7911.

* fix(skills): address MiniMax video override review

---------

Co-authored-by: vicfei <4058491+vicfei@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-07 15:52:35 +08:00
yetuge
ab168befae
fix(sandbox): build the readiness client off the event loop (#7934)
* fix(sandbox): build the readiness client off the event loop

`wait_for_sandbox_ready_async` exists so sandbox startup waits do not block the
runtime event loop, yet it constructed `httpx.AsyncClient` inline: the
constructor resolves the system trust store synchronously, so the loop stalled
for the whole construction before the first poll was sent (~320 ms in a local
probe on this host).

Move the construction into a worker thread and own it as a task, the way
backend/AGENTS.md now requires for HTTPX clients: a cancellation that lands
while the worker is still inside the constructor waits for it and closes the
client it builds, so the pool is not abandoned. Requests, polling cadence,
timeouts and cleanup stay on the event loop.

The blocking-I/O regression pins both halves. Against the previous code the
trust-store assertion fires on the loop thread; with a plain `asyncio.to_thread`
instead of the owned task, the cancellation test reports the leaked client.

* fix(sandbox): preserve readiness cancellation during cleanup

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-07 15:49:55 +08:00
Zhiqiang Yan
b8e1ca98a0
fix(jsonify): keep HTTPX TLS setup off the event loop (#7932)
* fix(jsonify): keep HTTPX TLS setup off the event loop

* fix(jsonify): retain cleanup diagnostics during repeated cancellation
2026-11-07 14:10:18 +08:00
Tanishq
2a16b26960
fix(compaction): build the manual-compaction summarization models off the event loop (#7806)
compact_thread_context built the summarization middleware inline on the
Gateway event loop, and with it the run-profile model and any separately
configured summary model. When the provider reads local credential files
(the Codex provider reads the Codex CLI auth.json while it is constructed),
each POST /api/threads/{thread_id}/compact did synchronous file IO on the
shared loop, once for every model it built, so other requests and streams
waited for it. The route passes no app_config, so the call also read the
live config on the loop first, which stats and hashes config.yaml on every
call. #7767 listed this site in the utils guidance as not covered.

Build the middleware through run_assembly, as #7484 and #7767 do for the
one-shot helpers, skill moderation and the goal evaluator, and read the live
config in that pool too when no app_config is passed. The lead agent already
builds the same middleware there through run_agent's agent factory call.
Model resolution, the memory-flush policy, the disabled, failed and
pending-work outcomes and the written checkpoint are unchanged, and
cancellation during construction neither starts the summary call nor writes
a checkpoint.

tests/blocking_io/test_compaction_model_offloop.py builds real
CodexChatModels under the strict Blockbuster gate. On main, compaction
reported itself disabled because every model build hit BlockingError
(os.stat), and the /compact handler answered 500 because the gate rejected
its config read first.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:46:14 +08:00
lilei
d8b0d24a84
fix(summarization): preserve native document text during compaction (#7825)
* fix(summarization): preserve native document text during compaction

* fix(summarization): retain metadata for opaque documents

* docs: keep agent guidance within size limits

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:42:13 +08:00
liyinlong
8d7e48e5e7
fix(memory): restore facts from older backups (#7921)
* fix(memory): restore facts from older backups

Use the current snapshot revision for existing facts during backup import so saved contents can be restored after later edits. Preserve manifest and fact preconditions, scope isolation, and caller-owned input data.

* refactor(memory): share import revision preconditions

* docs(memory): fit import guidance within chain budget

* fix(memory): validate backup revisions before restoration

---------

Co-authored-by: liyinlong <173714821+lilong555@users.noreply.github.com>
2026-11-06 21:39:18 +08:00
Hyeonsang Cho
61d76d9a6a
fix(channels): key WeCom group conversations by chatid, not the sender (#7794)
* fix(channels): key WeCom group conversations by chatid, not the sender

Group messages used the sender's userid as chat_id and topic_id, the same
key as the sender's private chat. Wherever no group connection kept them
apart (auth-disabled mode, require_bound_identity: false, legacy group
bindings), a member's private chat and every group they wrote in ran in one
DeerFlow thread. Groups are now keyed by chatid with one thread per member.
A group-scoped connection carries its sender-keyed thread over once; a
legacy binding, which spans every group of the bot, does not.

* docs(changelog): reference #7794 for the WeCom group conversation fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:36:19 +08:00
lilei
7e64330dd1
fix(memory): avoid recursive tool-argument admission (#7928)
* fix(memory): avoid recursive tool-argument admission

* fix(memory): track argument redaction changes without deep equality
2026-11-06 21:24:57 +08:00
yetuge
a41d0fcec1
fix(parsing): keep literal think tags inside JSON string values (#7907)
* fix(parsing): keep literal think tags inside JSON string values

Structured parsers ran strip_think_blocks over the whole response before
json.loads. A model answer that quotes the tag -- "Explain <think>example
</think>" -- lost the quoted text, and an unclosed literal tag inside a
string cut the JSON document mid-string, so suggestions returned None and
goal evaluation failed to parse.

Strip reasoning only outside JSON string literals: leading blocks are still
removed whole (a JSON example inside reasoning is never taken as the
answer), and blocks after the payload starts are removed only outside
quotes. Suggestions and the goal evaluator both use the shared helper.

* fix(parsing): keep think-tag stripping complete outside the JSON payload

The string-aware walker only covered the payload itself: a reasoning block
after prose and before the payload was copied verbatim, so brackets inside
it became the parsers' find/rfind targets, and an unclosed block in
trailing prose was kept where strip_think_blocks used to truncate.

Find the payload start while stepping over think blocks, strip the head with
strip_think_blocks, keep strip/truncate semantics after the top-level
payload closes, and truncate at an unclosed block outside a string. Adds a
CHANGELOG entry for the user-visible parsing change.
2026-11-06 21:19:52 +08:00
Chau Vu
856cde25b1
fix(gateway): strip server-owned metadata from run input todos and goal (#7803 follow-up) (#7904)
* fix(gateway): strip server-owned metadata from run input todos and goal (#7803 follow-up)

Commit 459fc8c introduced shape validation for caller-written todos in
both strip_server_owned_state_metadata and normalize_input, but only
applied _strip_external_metadata_from_message_like in
strip_server_owned_state_metadata.

External callers submitting POST /runs or POST /threads/{id}/runs with
input.todos or input.goal could supply forged server-owned metadata
(such as untrusted_input: False, original_user_content, or artifact
knowledge_sources) directly into the agent graph.

Apply _strip_external_metadata_from_message_like to both normalized
todos and goal in normalize_input when trusted_internal=False.

* fix(gateway): mirror normalized goal and todos into canonical run record input

* fix(gateway): replace non-dict additional_kwargs and cover edge cases

- Ensure non-dict additional_kwargs in message-like entries is replaced with an empty dict instead of surviving the strip
- Remove redundant None-check on normalized_goal in normalize_input
- Add regression tests covering non-dict additional_kwargs replacement and untrusted_input stamping on human-like entries

* test(gateway): format todo input validation cases

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:14:28 +08:00
yi-coding
eab978f04d
fix(skills): publish complete data-analysis cache generations (#7861)
* fix(skills): publish complete data-analysis cache generations

* fix(skills): clean cache builds when manifest preparation fails

* fix(skills): clean cache generations after failed replacement

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:12:41 +08:00
Hoouse
f9e05c24ea
docs(tutorials): add required tool names and groups (#7922)
* docs: complete tool configuration examples in tutorials

Signed-off-by: ZKDplz <2646995705@qq.com>

* test(docs): validate tutorial tool configuration examples

Signed-off-by: ZKDplz <2646995705@qq.com>

* docs: complete tool configuration examples in related guides

---------

Signed-off-by: ZKDplz <2646995705@qq.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 21:10:30 +08:00
baolong lin
07b4e06697
fix(skills): pair a tool result with its own response's calls (#7929)
Tool-call ids are unique per assistant response, not per conversation.
extract_skills built a single id -> path map over the whole message
list, so a later read that reused an id overwrote the earlier one: the
earlier skill silently dropped out of the ledger and its correct
ToolMessage logged "mismatched skill read metadata".

Rebuild the map from each AIMessage's own calls and resolve a
ToolMessage against the nearest preceding response, matching the
pairing rule tool_call_args.pair_tool_call_results already applies.

Fixes #7858

Signed-off-by: Batianxieshen1 <280706568+Batianxieshen1@users.noreply.github.com>
Co-authored-by: Batianxieshen1 <280706568+Batianxieshen1@users.noreply.github.com>
2026-11-06 19:53:32 +08:00
Nan Gao
3067b5a5bd
fix(ui): tidy the extension detail layout and narrow-screen overflow (#7936)
* fix(capabilities): tidy the extension detail layout and narrow-screen overflow

The extension detail view led with a page-level reload button and sat the
back button beside it 3px off: the two flowed inline and aligned by
baseline, which the back button takes from its icon. It also kept a
directory search that filters nothing there, a heading close to the page
title, and status copy that read like body text. On narrow screens the tab
strip widened the whole page, leaving a horizontal scrollbar.

Lead with the back button and move reload to the opposite end, drop the
search from the detail view, and give the detail an icon, package name and
a status notice holding the installation guide. On the directory, reload
now sits beside the hint that explains it. The tab strip scrolls inside
itself and keeps the active tab in view, and reload collapses to an icon
below `sm` with its accessible name intact.

No copy changes.

* fix(capabilities): realign the tab strip on resize without moving the page

Review follow-up. The active tab was only realigned when the tab changed, so
a rotation or window resize could leave it clipped. Realign whenever the strip
resizes, and leave manual swipes alone.

Scroll only the strip. scrollIntoView would also move the page, pulling it back
up when a resize arrives after the user has scrolled past the strip.

Also hide the strip's scrollbar in browsers without scrollbar-width support,
and find the page scroller in the E2E test by computed style rather than by a
class name.
2026-11-06 14:00:04 +08:00
lisry-1
c0b0ac3489
fix(feishu): clear msg.files after receive_file downloads them (#7804)
receive_file downloaded each image/file descriptor and replaced the
[image]/[file] placeholders in msg.text, but left msg.files intact. The
generic URL-based _ingest_inbound_files pass then re-walked the residual
{"image_key":...}/{"file_key":...} descriptors, found no url, and logged a
spurious 'reader returned no data' warning per file.

Clear msg.files after materializing the paths, matching DingTalk (msg.files =
[]) and the Slack/Telegram/Discord providers (which materialize them).

Co-authored-by: lisry-1 <249596828+lisry-1@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 11:27:36 +08:00
hataa
e163b02bea
fix(provisioner): keep POSIX hostPath values POSIX on Windows hosts (#7905)
join_host_path joined POSIX-styled bases with the running OS's native
path module, so a provisioner running on Windows emitted backslash
hostPath values into pod specs — paths a Linux kubelet cannot resolve.
The same leak existed in _host_base_dir_for_extra_mounts,
_is_path_under_base, extra-mount validation, and PVC subPath
computation, all of which used os.path on paths that are POSIX by
contract. Route every non-Windows-styled path through posixpath and
keep the PureWindowsPath branch only for genuinely Windows bases.

Also re-spell the memory guide probe layout with forward slashes so
backend/tests/test_memory_documentation.py compares against the
documented layout instead of the test host's local separator.
2026-11-06 11:19:13 +08:00
Rei
097fa6dca7
fix(frontend): fence a confirmed background-task cancellation against stale polls (#7914)
A background-task detail or list read issued before the user cancels can
resolve after the mutation's success handler and overwrite the confirmed
"cancelled" snapshot with the pre-cancel "working" one, so the expanded card
shows the task as active again (and polling resumes) until a later response
corrects it.

useCancelBackgroundTask now awaits cancelQueries for the thread's
["background-tasks", threadId] prefix before seeding the confirmed detail and
list entry, so a read that started before the cancel is dropped instead of
landing afterwards. The following list invalidation is unchanged, so a mounted
card still refreshes from the server. This is the same ordering contract as the
memory mutations fixed in #6959.

Regression: frontend/tests/unit/core/background-tasks/hooks.dom.test.tsx uses a
real QueryClient with deferred detail and list reads; it ends on "working"
without the fix and keeps "cancelled" with it.

Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
2026-11-06 10:29:57 +08:00
哈基米
ee006e0c86
fix(community): initialize InfoQuest TLS clients off the event loop (#7899)
* fix(community): initialize InfoQuest TLS clients off the event loop

InfoQuest's fetch, web search and image search constructed
httpx.AsyncClient inline in their coroutines, so the default transport
loaded the TLS trust store synchronously on the agent event loop
(~125 ms per call in a local probe). RAGFlow, LightRAG, You.com,
GroundRoute and Webz already construct their clients with
asyncio.to_thread; InfoQuest was the missed sibling.

Move the three constructions to asyncio.to_thread and cover all three
methods in the blocking-I/O TLS test. Requests, cleanup, timeouts and
responses stay on the event loop.

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

* docs: qualify the off-loop HTTPX provider list

The list is not exhaustive; several community clients still construct
their client inline. Use non-exhaustive wording until the follow-up
sweep lands.

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

* docs: correct the off-loop provider list after merging main

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-06 10:26:42 +08:00
hdu_zhou
2feec9cb09
fix(mcp): degrade a blob without mimeType instead of failing the tool call (#7800)
* fix(mcp): degrade a blob without mimeType instead of failing the tool call

A resource link or embedded resource without mimeType made the blob guard
raise, so the whole tool call failed instead of degrading the attachment.
Keep the guard for genuinely unusable payloads and let a missing media type
fall through to the caller's default.

* Modify test for text blocks and mime type validation

Update test to check for text blocks and their mime types.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(mcp): remove MIME assertion from text content block

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-11-05 21:40:32 +08:00
Chau Vu
4a53a36e32
fix(extensions): roll back shared state mutations on budget check failure (#7902)
* fix(extensions): roll back shared state mutations on budget check failure

In deerflow_extension_agent_teams.shared_state, publish() and update()
mutated team['public_state'] in-memory before invoking check_budget().
When check_budget() raised a budget-exceeded ValueError (e.g. 16,000 JSON
bytes or 24,000 shared context bytes), the state mutation was not rolled
back, causing:
1. Permanent state lockout on rejected publish (the oversized entry
   remained in state['entries']).
2. False optimistic concurrency conflicts on update retry (the entry
   revision was prematurely bumped and content altered).

Wrap check_budget() in try-except in both functions:
- On publish failure: pop the appended entry and decrement revision.
- On update failure: restore original entry dict and decrement revision.
- Added regression test covering both rollback scenarios.

* test(extensions): request plugin fixture for isolated regression run

* fix(extensions): add rollback identity guard and pin retry assertions
2026-11-05 16:06:20 +08:00
inchang-ing
9731bcf28a
fix(browserless): construct the fetch and screenshot clients off the event loop (#7913)
* fix(browserless): construct the fetch and screenshot clients off the event loop

* fix(browserless): close the client a cancelled construction still builds

Keeping the constructor off the loop is right, but awaiting
`asyncio.to_thread(httpx.AsyncClient, ...)` directly is not cancellation-safe:
the await can be cancelled while the worker thread is still inside the
constructor. The await is abandoned, the thread runs on and hands back a live
client, and execution never enters the `async with` that would have closed it,
so `is_closed` stays False and the connection pool leaks on that cancellation.

Own the construction as a task instead. When the caller is cancelled, wait for
the task with the shield loop this package already uses for cleanup, close
whatever client it produced, and only then let the cancellation propagate. A
failure while closing is logged rather than raised, so it cannot mask the
cancellation the caller asked for.

The new regression test gates the constructor on a thread event, cancels the
operation while it is parked there, and releases the worker afterwards. Against
the previous commit it fails for both fetch_html_with_status and
capture_screenshot with "the client built after the cancellation was leaked".

---------

Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
2026-11-05 16:04:45 +08:00
Qiu
7f9e0bf611
fix(sandbox): defer fresh AIO skill-policy acquisition (#7782)
* fix(sandbox): defer fresh AIO skill-policy acquisition

Prepare the authorized thread skill view before the model runs without
acquiring an AIO container for a fresh lazy run. Keep existing sandbox
bindings and upload-based providers on the eager rebind/sync path.

Add sync/async regressions for first-tool authorization, strict rebinding,
filtered read-only AIO mounts, and policy-scoped warm reuse.

AI assistance: Codex; the account owner confirmed human review of the
complete seven-file patch and responsibility for the change.

* docs(sandbox): keep lazy-isolation guidance within inherited budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 15:37:13 +08:00
Totoro
593ba49458
feat(subagents): continue requested batch work and schedule explicit prerequisites (#7839)
* feat(subagents): continue native batches and honor item dependencies

* docs: link native batch follow-up changelog entries

* test(migrations): pin isolated history-seed upgrade to 0050

* fix(subagents): preserve follow-up scope, blockers and fair scheduling

* test(memory): freeze capacity evaluation time

* fix(subagents): guard follow-up admission and retain run controls

* fix(subagents): enforce runs:cancel on tool cancellation of follow-up batches

* style(tests): format test_batch_followup_host with ruff

* fix(subagents): drop the admission checkpoint check and keep SDK cancel compatible

The checkpoint check inside run admission waited on the checkpoint saver's
lock while holding the SQLite write lock, which could make another chat's
checkpoint write fail. Remove it; dispatch still checks the conversation
before admission.

A follow-up waiting for an unanswered question now keeps its claim until the
lease expires instead of rereading the conversation every poll. Cancel passes
the caller's run authority only to submitters that take it, so existing SDK
submitters keep the two-argument call. Document that retrying a prerequisite
by hand does not requeue its blocked items, and list 0052 as the migration head.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 15:10:21 +08:00
dengzi
24d93399fb
fix(lark): keep Windows SID lookup working with non-ASCII account names (#7845)
* Keep the Windows SID lookup alive on non-UTF-8 console output

`_resolve_current_user_sid` calls `whoami /user /fo csv /nh` in text mode
without an encoding. `make dev` and `make gateway` start the Gateway with
PYTHONUTF8=1, so on a Chinese Windows host the interpreter decodes as UTF-8
while whoami writes the account name in the console's ANSI code page. The
strict decode raises inside subprocess's reader thread; the exception is not
re-raised, so stdout comes back as None and the credential-tree hardening
walk dies with `AttributeError: 'NoneType' object has no attribute 'strip'`
before any directory is created or hardened.

Pin the encoding and keep an errors policy. The SID is the second CSV field
and is pure ASCII, as are the delimiters, so replacing the undecodable
account name is harmless. #6590 pinned the seven lark-cli/npm captures in
this file; this is the eighth text-mode capture and, unlike those, does not
emit UTF-8, so the errors policy is the part that matters here.

Add a cross-platform regression test next to the #6590 encoding tests.

* docs(lark): correct the SID capture wording

`undecodable` is the spelling used elsewhere in the repository, and the
comment about the console code page reads better as "which does not need to
match". No behaviour change.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 15:09:13 +08:00
lukyturnflow
bef8c9c2e3
fix(skills): keep disabled skills disabled when the extensions config cannot be read (#7705)
* fix(skills): fail closed when the extensions config cannot be read

* test(skills): cover a disabled skill when the extensions config cannot be read

* style(tests): format the fail-closed regression for ruff

* style(tests): fit the docstring and signature inside the 240-column limit

* fix(skills): fail closed in the user-scoped listing when the config cannot be read

* test(skills): cover the user-scoped listing with an unreadable extensions config

* fix(gateway): keep skill read surfaces loud about an unreadable extensions config

Folding the user-scoped degraded mode in (#7704) also stopped the skill
read endpoints from failing on a config that cannot be read: the storage
now answers with an all-disabled listing, which regressed the #7155
contract (a generic 500 with the host path only in the server log) into
a 200 that presents a broken deployment as a legitimately empty one.

Keep both properties: the storage still fails closed for the runtime
gate, and the read surfaces re-validate the config explicitly so the
raised error reaches each handler's generic 500 branch unchanged.
First-run behavior is untouched: no config file resolves to an empty
config without raising, so default-enabled still applies.

* fix(skills): redact config-load failures and keep the read preflight off the event loop

Review follow-ups:

- Log the failure shape instead of the exception text in both degraded
  modes: from_file resolves $VAR placeholders before validation, so its
  message can embed resolved credentials. Reuse _describe_load_failure,
  the loader's own redaction helper.
- Run the gateway read preflight through asyncio.to_thread in the
  capability adapter, matching the scan it precedes.
- Cover the user-scoped degraded branch with a CUSTOM skill that has
  per-user state on disk, so the second read's guard is directly
  regression-tested (verified red with the guard removed).

---------

Co-authored-by: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com>
2026-11-05 15:07:07 +08:00
Wenchao An
b6ed47778d
feat(extensions): add versioned team file handoffs (#7817)
* feat(extensions): add versioned team file handoffs

* fix(extensions): fence team artifact execution and snapshots

* fix(agent-teams): retain resumed keys and bound artifact transfers

* ci: reclaim runner space before sandbox image smoke

* ci: budget sandbox image preparation separately

* fix(extensions): version artifact contracts and document snapshots
2026-11-05 14:50:09 +08:00
Wenchao An
c1f9064e9b
fix(models): route managed OpenAI GPT-6 presets through Responses (#7870)
* fix(models): route managed OpenAI GPT-6 presets through Responses

* test(memory): pin capacity scenarios to a stable clock

* fix(models): honor Flash mode for managed OpenAI GPT-6

* fix(models): reconcile preset effort with explicit reasoning contracts

* test(memory): share the fixed clock with updater capacity tests

* fix(models): normalize GPT-6 reasoning overrides and sampling

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 14:30:42 +08:00
asts
59beff5799
fix(mcp): load personal transport TLS trust off the event loop (#7909) 2026-11-05 14:27:52 +08:00
yetuge
bebbd2cb35
fix(crawl4ai): construct the fetch client off the event loop (#7906)
* fix(crawl4ai): construct the fetch client off the event loop

Crawl4AiClient builds an httpx.AsyncClient per fetch, and AsyncClient
construction loads the TLS trust store synchronously: on the agent event
loop that blocks every other request for the CA bundle read. Construct it
through asyncio.to_thread, the same rule the RAGFlow/LightRAG and
You.com/GroundRoute/Webz clients already follow; the request and cleanup
stay on the loop.

* docs(agents): list Crawl4AI among the off-loop HTTPX clients

#7889 extended the same sentence for its providers; keep the named list in
the same change set as the client, and record the clients that still
construct on the loop so the next migration has a checklist.
2026-11-05 14:24:21 +08:00
Rei
bb7958886d
fix(mcp): fence notification claims on SQLite (#7859)
* fix(mcp): fence notification claims on SQLite

claim_notification_work() selects due generations with FOR UPDATE SKIP LOCKED, which SQLite ignores. Two workers could read the same due generation before either writes, both returned it with different lease tokens, and a stale flush could overwrite the winning owner's token or phase.

On SQLite, claim each candidate with a conditional UPDATE fenced on the event version, notification status, dispatch version and the absent-or-expired notification lease the read saw, so a peer claim or a newer recorded event wins and the losing claim has no side effects. Pin the dispatch version on the empty-snapshot consumption fence too. PostgreSQL keeps its existing row-locking path.

Coverage: a paused claim that loses a race to a second worker returns no row and leaves the winner's owner, token and phase stored.

* fix(mcp): honor deferred notification backoff in SQLite claims

claim_notification_work() fenced the observed event version, phase, dispatch version and lease, but not next_notification_at. A peer can claim a due row, then release the claim (release_notification_lease) or defer a dispatched one (defer_dispatched_notification) without changing phase, moving the next attempt into the future and clearing the lease. A paused claim still matched and returned the notification immediately, bypassing retry/defer backoff.

Fence the claim on the observed next_notification_at value too, and cover the interleaving with a regression test.

---------

Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 14:19:05 +08:00
Harvey DU
b561242ba3
fix(harness): reject Windows reserved device names in thread ID validation (#7892)
* fix(harness): reject Windows reserved device names in thread_id validation

A caller-supplied thread ID of NUL (any casing) passes
validate_thread_id() but Paths.ensure_thread_dirs() then fails on
Windows hosts with FileNotFoundError (WinError 3), because NUL is a
reserved device name in every directory.

Reject the reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
case-insensitively in the shared validator, including the ".ext"
form kept legal by recent Windows versions, so thread IDs stay safe
for every filesystem backend regardless of host OS.

* style(harness): apply ruff format to thread_id validation

* refactor(harness): reuse windows_incompatible_segment for thread ID check

Address review feedback on #7892:

- Drop the private _WINDOWS_RESERVED_DEVICE_NAMES copy and route the
  thread-ID check through the canonical windows_incompatible_segment()
  from deerflow.utils.host_paths, which uploads, sandbox writes and
  skill support files already use. The canonical list also covers
  CONIN$/CONOUT$ and the superscript-digit COM/LPT variants.
- Remove the unreachable stem-split: the thread-ID pattern cannot
  contain a dot, so test whole IDs and pin the reserved-name branch
  with the dedicated 'reserved Windows device name' message; the
  unreachable 'NUL.txt' case is removed from the parametrization.
- Document the default-behavior change (Linux-host threads with
  reserved-name IDs will fail validation after upgrade; deletion is
  unaffected) under ### Fixed in CHANGELOG.md.
2026-11-05 14:13:23 +08:00
huyengiang101086-spec
8331afa759
fix(agents): pair null call IDs with empty result IDs (#7837)
* fix(agents): pair null call IDs with empty result IDs

* fix(agents): treat producer sentinel IDs as absent when pairing

Middleware producers spell a missing provider ID three ways: "" is canonical,
str(tool_call.get("id", "")) yields "None" once the key is present with a
null value, and the artifact/deferred/knowledge middlewares fall back to
"missing_tool_call_id". Only "" reached the pairing pass, so a result carrying
either sentinel was dropped as an orphan and the middleware's guidance was
replaced by the interrupted placeholder — the exact outcome this PR set out to
avoid, just through a different spelling.

Route every absent spelling through one canonicalisation used by the pairing
gate, the candidate comparison and the turn-completeness count.

---------

Co-authored-by: huyengiang101086-spec <270684046+huyengiang101086-spec@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 14:07:15 +08:00
hdu_zhou
9e2cf685b0
fix(memory): treat a content-less deermem message as empty, not None (#7854)
extract_message_text falls back to str(content), so a message whose content is None becomes the truthy literal 'None'. That string then reaches signal detection and the memory-update prompt as if the user had written it. The canonical helper (utils/messages.py:message_content_to_text) and the mem0 copy both return '' for this shape and the canonical one documents exactly this failure mode.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 12:25:51 +08:00
哈基米
713a861e75
fix(channels): ignore non-finite WeChat server longpoll timeouts (#7898)
* fix(channels): ignore non-finite WeChat server longpoll timeouts

The getupdates response's longpolling_timeout_ms was passed through
float() with only TypeError/ValueError handled. A huge JSON integer
raised OverflowError before the message loop and cursor write, so the
same batch was re-polled forever, while Infinity/NaN were stored and
turned the next request timeout into inf/nan.

Apply the same positive-finite rule the channel already uses for
configured timing values: catch OverflowError and reject non-finite
results, keeping the previous valid timeout.

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

* fix(channels): bound WeChat server long-poll hints

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 12:07:31 +08:00
一片一片
459fc8ca43
fix(todos): keep an unusable stored todo list from failing every model call (#7803)
* fix(todos): keep an unusable stored todo list from failing every model call

`todos` is a reducer channel that `POST /threads/{id}/state` and run input can
Overwrite directly, but neither boundary validated its shape. The todo
middleware rendered it with `entry.get(...)` in both `before_model` and
`after_model`, so a non-object entry — or a value that is not a list — raised
AttributeError on every model call, permanently for that thread.

Validate caller-written todos beside `goal` in `_normalize_external_todos`: a
list of objects with string content and a known status, or HTTP 400. The
middleware now reads the channel through `_usable_todos` and ignores entries of
any other stored shape, so checkpoints and direct integrations written before
this boundary still run.

* fix(todos): normalize stored todos in token-usage attribution

TokenUsageMiddleware reads the same stored `todos` channel through
`_build_attribution` whenever the last AIMessage calls write_todos, and
`token_usage.enabled` defaults on, so a value the todo middleware now tolerates
still aborted the step here. Route the stored list through `_normalize_todos`
before reading entries.

Document that a caller-written todo without a status is accepted as pending and
cover that shape at both external boundaries.

* docs: keep backend agent guidance within size budgets

* fix(todos): keep the channel metadata strip on caller-written todos

The dedicated todos branch in strip_server_owned_state_metadata replaced the
generic list branch, which stripped server-owned metadata from every entry. A
caller could therefore persist additional_kwargs.original_user_content,
untrusted_input, or artifact.knowledge_sources on a todo entry. Re-apply the
per-entry strip after normalization, keeping None and empty lists untouched.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 07:28:33 +08:00
Cordis798
6538496dbc
fix(memory): apply Honcho read policies to malformed search metadata (#7890)
Co-authored-by: Cordis798 <284918518+Cordis798@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 07:04:15 +08:00
Xinmin Zeng
728446fe0d
fix(frontend): keep the live run timer on the run's server start (#7888)
A chat that observes an active run again (remount, chat switch, rejoin)
started its Working… timer from the browser's current time, dropping the
time the run had already spent. useThreadStream now exposes the active
run's created_at from the runs list, and MessageList moves its start back
to it. It never moves the start forward, so a run submitted in this view
keeps its local start.

Fixes #7886

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 06:51:55 +08:00
Wenchao An
2145c9ab1f
feat(models): discover provider catalogs and search chat models (#7871)
* feat(models): discover provider catalogs and search chat models

* docs(models): scope catalog guidance to owning directories

* test(guidance): register model catalog scoped instructions

* test(memory): pin capacity scenarios to a stable clock

* fix(models): guard catalog imports and align picker interactions

* test(memory): share the fixed clock with updater capacity tests

* fix(models): clear provider filters removed by catalog refresh

* refactor(models): share connection fields across model forms

* fix(models): keep active provider filters clearable

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 06:43:01 +08:00
yetuge
a46d9221f8
fix(summarization): build the summary model off the Gateway event loop (#7874)
Context compaction runs from abefore_model/aafter_model on the shared loop,
and its async candidate loop constructed the summary provider inline.
create_chat_model can read local CLI credentials while it is constructed
(Codex auth.json, Claude Code credential files), so an automatic compaction
stalled every other request for that read.

Split construction out of _model_for and add _amodel_for, which builds the
provider on the assembly pool; the sync path keeps building inline. Anchor
and failure-cache semantics are unchanged, so a broken candidate still falls
through to the next one, and inference still awaits on the loop.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 21:54:47 +08:00
MasterZRC
2ceda05bf9
fix(sandbox): release ports after definite CLI spawn failures (#7887)
* fix(sandbox): release ports after definite CLI spawn failures

* test(memory): pin hybrid capacity evaluation clock
2026-11-04 21:44:45 +08:00
aduoge
26bf78f2b9
fix(github): apply allow_authors to the webhook sender (#7868)
_author_login returned the issue/PR creator for issues and pull_request
events. On edited/labeled/reopened actions the actor is sender, so an
allow_authors user editing someone else's issue was gated on mention,
while anyone editing an allow_authors user's issue bypassed it. Prefer
sender.login and keep the object author as a fallback.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 21:35:55 +08:00
Cordis798
4c4a4e9858
fix(community): initialize search TLS clients off the event loop (#7889)
Co-authored-by: Cordis798 <284918518+Cordis798@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 20:54:43 +08:00
easT-T
3dd9e8841f
fix(frontend): preserve live subtask steps during history backfill (#7864)
* fix(frontend): preserve live subtask steps during history backfill

* test(deermem): keep updater eviction fixtures fresh

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 20:16:09 +08:00
ZhiLin Zhang
e5969fd8b9
feat(harness): introduce HarnessGuardMiddleware for pre-write syntax validation and anti-doom-loop (#7624)
* feat(harness): introduce HarnessGuardMiddleware for pre-write syntax validation and anti-doom-loop

- Add HarnessGuardMiddleware to intercept tool writes and validate .py (AST) and .json syntax before disk write
- Add anti-doom-loop state machine detecting repeated error signatures (>=2) and A -> B -> A -> B oscillating loops
- Inject targeted remediation hints directly into tool results
- Add DynamicSkillRouter for zero-LLM JIT skill intent activation (<1ms)
- Fully unit-tested with 25 passing test cases

* fix(harness): address review findings on write tools, scoping, and content handling

* fix(harness): offload async validation off-thread, support Command results, and harden parsing

* fix(harness): address reviewer 1 feedback on scoping, assembly count, and non-blocking verification

- Revert .gitignore to exclude out-of-scope graphify-out entry
- Short-circuit syntax validation when block_on_syntax_error is false
- Trim AGENTS.md rule chain to fit hard guidance budget (AG002)
- Fix input_sanitization mock and update subagent middleware count expectation to 23

* fix(harness): preserve chunked write flow, normalize CRLF line endings, and guard error classification

- Preserve append-in-chunks flow: use codeop to distinguish incomplete Python fragments from syntax errors, and handle premature EOF on chunked JSON arrays/objects
- Reuse native _match_line_endings for str_replace to prevent CRLF line ending bypass
- Check existing tool failure status before classifying error text to avoid misclassifying successful tool outputs
- Add 4 regression tests covering chunked writes, CRLF replacement, and successful error-text reads

* fix(harness): scope pre-write syntax validation to str_replace and preserve write_file semantics

* fix(harness): make str_replace syntax check opt-in, allow progressive repair of invalid files, and clean up RFC docs

* chore: align subagent middleware count and satisfy AG002 guidance budget

---------

Co-authored-by: Yakyou7 <Yakyou7@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 20:13:19 +08:00
MubashirAziz1
c373f8491d
docs: clarify MiniMax API host configuration by region (#7833)
* docs: clarify MiniMax API host configuration by region

* docs: clarify MiniMax API host comment

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 10:43:40 +08:00
周龙杰
900a5173b1
fix(github-research): disclose truncated trees (#7815)
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 10:40:59 +08:00
golemon
5371343f69
fix(gateway): continue dispatching queued inputs after cancellation (#7746)
* fix(gateway): continue queued inputs after cancellation

* docs(runtime): clarify queued input cancellation handling

* fix(gateway): clarify cancelled-input error and log race-window skips

* docs(changelog): document queued input cancellation fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 10:39:20 +08:00
leom1919
fcbb14e83f
docs: replace dead pull/5224 links with squash-merge commit permalinks in subagents reference (en/zh) (#7849)
* docs: replace dead pull/5224 links with squash-merge commit permalinks in subagents reference (en/zh)

The "Related changes" list in harness/subagents/reference.mdx cites PR #5224
("tool assembly off the event loop"), but the PR and its issue counterpart were
deleted by repository administrators, so both URLs return 404. The PR itself was
merged: squash commit 5727449 matches the cited description verbatim.

Same approach as #7689 (changelog deleted-PR references): keep the [#5224] text
and entry description, replace the URL with the squash-merge commit permalink.
1 line changed in each of the en and zh pages.

* test(deermem): keep eviction fixtures fresh

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-04 06:28:20 +08:00
yetuge
6f24bc7c36
fix(title): build the title model off the Gateway event loop (#7813)
Thread title generation runs inside the agent graph (aafter_model), and it
built its configured model inline. create_chat_model can read local CLI
credentials while it is constructed (Codex auth.json, Claude Code credential
files), so the first turn of every new thread read them on the shared loop
and other requests waited for it.

Build the provider through run_assembly, the dedicated assembly pool the
one-shot helpers, skill moderation, the goal evaluator and start_run already
use. Inference still awaits on the loop, and the sync path is unchanged.

A Blockbuster regression builds the real Codex provider: on main the gate
rejects the credential read, the broad fallback swallows it, and the local
fallback title replaces the model's title; with the change the provider is
constructed on a worker thread and the model's title is kept.
2026-11-04 04:31:57 +08:00
Nan Gao
1dd6d05627
fix(extensions): hand compaction observers the live task store and name the task in CompactionEvent (#7831)
* feat(extension-api): add task identity fields to CompactionEvent (0.2.10)

CompactionEvent carried the transform, its hashes and its counts, but
nothing that names the task, run or thread the compaction happened in.
An observer that wants to relate a compaction to the run that produced it
had to match hashes across sessions, which is unreliable.

Append three optional fields, task_id, run_id and thread_id (str | None,
default None). Existing keyword and positional construction is unchanged,
so already-released extensions keep working. The host fills them in the
next commit.

Bump deerflow-extension-api to 0.2.10 and the harness's exact pin with it.

* fix(extensions): hand compaction observers the live task store and its identity

The summarization middleware already recovers the live task store from
the runtime to observe its summary system call, and the registry
allocates that store precisely because a compaction observer is
registered. notify_context_compacted() then discarded it and handed every
observer a detached store, with a docstring claiming there was no live
task.

- The lead run worker and the subagent executor now seed every task store
  they allocate with the task's TaskInfo, not only when a lifecycle
  contributor is registered; the lifecycle notification reuses the seeded
  object. A subagent without a parent run_id still gets no TaskInfo.
- compact_state() and acompact_state() pass the store from
  task_store_from_runtime() to _record_compaction().
- notify_context_compacted() takes an optional task_store. A live store
  goes to the observers and fills the event's task_id/run_id/thread_id
  from its TaskInfo; without one (manual /compact runs outside any run)
  observers still receive a detached store and the ids stay None.

For a background subagent the store keeps its public scope id (the
provider tool_call_id) while TaskInfo.task_id stays the execution id, as
lifecycle contributors already see it; the event follows TaskInfo.

* docs(extensions): document compaction event identity and seeded TaskInfo

The extensions guide and the developer manual still said compaction
observers always receive a detached store because no task is live at the
call site. Describe what the host does now: lead runs and subagents with a
parent run seed every task store with TaskInfo, compaction observers get
the live store, and CompactionEvent's task_id/run_id/thread_id come from
it; only manual /compact falls back to a detached store with None ids.
Adds the 0.2.10 row to the contract version table (PR number pending).

* docs(changelog): note compaction observers receive the live task identity

* docs(extensions): list compaction observers among task-store triggers

* docs: reference #7831 for the compaction event identity change

* docs(extensions): backfill contract history rows 0.2.5 to 0.2.9

The version table jumped from 0.2.4 to 0.2.10 although 0.2.5-0.2.9
shipped. Add those rows, attributing names merged without a bump to
the first release that carried them (#5796 in 0.2.5, #6920 and #5645
in 0.2.8, #7729 in 0.2.10). The page intro now says it documents
0.2.4 plus the 0.2.10 CompactionEvent fields, and points to the
history for the rest.
2026-11-03 15:06:52 +08:00
Xiao Hou
14abd9a28e
fix(subagents): terminalize a background cancel that beat the loop (#7788)
* fix(subagents): terminalize a background cancel that beat the loop

request_cancel_background_task() calls Future.cancel() on the future handed
back by _submit_to_isolated_loop_in_context(). That call only wins while the
coroutine has not been picked up yet; when it does win, run_with_timeout never
starts, so no one writes a terminal status and no one opens the cleanup fence.
The registry entry then stayed PENDING forever: task_tool polled its full
budget for a result that could not arrive, and cleanup_background_task()
refused to drop a non-terminal entry.

Publish CANCELLED from the future's own completion callback when the coroutine
body never ran. A cancellation that did reach a running coroutine keeps its
existing owner -- the future reports cancelled() for that case as well, which
is why the callback checks a start marker instead of cancelled() alone.

* fix(subagents): publish a pre-start cancel from the isolated loop

`Future.cancel()` on the future returned by
`_submit_to_isolated_loop_in_context()` fires `forget_future` in the calling
thread, but the loop keeps owning the coroutine until its first step runs. A
cancel that lands in that window read the start marker as clear and published
CANCELLED — opening the cleanup fence — while the worker could still take its
capacity slot.

Hand the decision to the isolated loop and re-check the marker there, which is
the only place serialized against that first step. A loop that is gone cannot
start the coroutine, so the inline fallback keeps the original guarantee.

* test(subagents): accept both terminal owners in the task-created cancel window

Task.__init__ queues the first step inside the task factory, so the step can
run before the deferred pre-start decision that was handed to the loop. When
it does, the coroutine's own CancelledError branch legitimately publishes
"Cancelled by user"; the harness had pinned the pre-start message instead.
Assert the invariants both owners share -- terminal cancelled, fence opened
only after the loop settled the body, cleanup removes the entry -- and let
either error message pass.

* fix(subagents): refuse to start a cancelled execution before taking a slot

A cancel that wins the race against the loop is decided on the loop, one turn
after the submission callback queues the wrapper's first step. That turn is not
a guarantee: the wrapper could still start, create the execution task, and take
a capacity slot after the terminal status and the cleanup fence had already been
published from that decision.

request_cancel_background_task sets cancel_event before it cancels the
future, so the wrapper can treat that event as the startup half of the handshake
and decline to create the execution task. The fence then cannot be outrun by a
worker that acquires capacity afterwards.

* test(subagents): hold the cancel window open with an event barrier

The factory blocked the loop for a fixed 200 ms and then resumed on its own. A
caller delayed longer than that -- a loaded CI worker is enough -- let execution
start before the cancel landed, and `assert not execution_ran.is_set()` failed
even though cancellation was handled correctly.

Block the factory on an event instead and release it from the test's `finally`
after the cancel request and the closed-fence assertions, so the window stays
open exactly as long as the test needs it.

---------

Co-authored-by: gouyu-hou <213296172+gouyu-hou@users.noreply.github.com>
2026-11-02 21:36:59 +08:00
dependabot[bot]
12c304f12c
chore(deps): bump pyasn1 from 0.6.4 to 0.6.5 in /backend (#7796)
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.4 to 0.6.5.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.4...v0.6.5)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 15:56:14 +08:00
easT-T
3b627af8e7
fix(frontend): release rejected run-input attempts before retrying (#7790) 2026-11-02 15:31:05 +08:00
minibeartwo
7c3d2551b6
feat(frontend): add a token usage dashboard for the console APIs (#7636)
* feat(frontend): add token usage dashboard over console APIs

* fix(frontend): address token usage dashboard review feedback

* fix(frontend): route usage runs to their custom agent chats

* docs(frontend): keep agent guidance within the module budget
2026-11-02 15:29:46 +08:00
spud
21175ba46f
fix(runtime): persistently fence stale run event producers (#7619)
* fix(runtime): fence revoked run event producers

* fix(runtime): reduce off-mode producer guard locking

* docs: keep inherited guidance within size budget

* chore(runtime): remove development phase labels

* fix(persistence): normalize libpq SSL options for asyncpg connections

* docs(runtime): clarify producer fencing behavior in off mode

* test(persistence): align migration TLS probes with channel-binding rejection

* fix(runtime): authorize fenced Gateway history seeding

* fix(scheduler): preserve takeover identity in recovery publication

* fix(scheduler): report unconfirmed recovery publication

* fix(runtime): preserve history messages before the first human turn

* fix(runtime): clarify run creation failure diagnostic

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 15:26:42 +08:00
Willem Jiang
01e1ed778b
docs: restore agent guidance size budgets (#7810) 2026-11-02 15:20:54 +08:00
linkangjun
3f86f071f3
fix(middlewares): prevent todo retries after text-only output caps (#7605)
* fix(middlewares): prevent todo retries after text-only output caps

* docs(middlewares): clarify text-only output cap markers

* test(subagents): isolate cleanup test from shared assembly pool

* docs(middlewares): keep cap guidance within chain budget

---------

Co-authored-by: linkangjun <331227811+linkangjun@users.noreply.github.com>
2026-11-02 10:46:59 +08:00
王一帆
d0ec8dcc5d
feat(knowledge): add optional ingestion and status center (#7518)
* feat(knowledge): add optional ingestion and status center

* docs(knowledge): record upstream CI budget fix and regression results

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 10:42:21 +08:00
Cordis798
92325bd126
fix(community): load retrieval TLS trust off the event loop (#7738)
Co-authored-by: Cordis798 <284918518+Cordis798@users.noreply.github.com>
2026-11-02 10:35:15 +08:00
Hyeonsang Cho
2c8892c810
fix(channels): deliver the DingTalk reply when an AI card request fails (#7781)
* fix(channels): deliver the DingTalk reply when an AI card request fails

dingtalk-stream's AICardReplier logs a failed card request and returns
as if it succeeded; its create call returns the card ID it generated
before the request. The channel stored that ID, so neither the running
reply's text fallback nor send()'s sampleMarkdown fallback could run,
every streamed update failed silently, and the final answer never
reached DingTalk.

Send createAndDeliver and the streaming update through the channel's
httpx client with its token, using the bodies the SDK sends, and raise
on HTTP and transport failures so both fallbacks run. The SDK replier,
its per-card map and the token shim it needed are removed.

* docs(changelog): reference #7781 for the DingTalk AI card fallback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 10:34:03 +08:00
Hyeonsang Cho
7fd0c0cb3e
fix(harness): keep a cancelled batch cancelled when retrying a failed item (#7792)
* fix(harness): keep a cancelled batch cancelled when retrying a failed item

retry_item only kept a paused batch as it was, so retrying a failed item
of a cancelled batch reset the batch to queued and the worker claimed and
ran the item. A cancelled batch now rejects the retry (409), re-checked
under the write lock because SQLite ignores FOR UPDATE, and the workspace
no longer offers Retry there.

* docs(changelog): reference #7792 for the cancelled batch retry fix
2026-11-02 10:05:05 +08:00
N
98b72af732
fix(auth): allow demoting disabled administrators (#7769) 2026-11-02 09:26:00 +08:00
Tanishq
bf9b031127
fix(skills,goal): build the skill-moderation and goal-evaluator models off the event loop (#7767)
scan_skill_content and the goal evaluator built their chat model inline on
the Gateway event loop. When the provider reads local credential files (the
Codex provider reads the Codex CLI auth.json while it is constructed), each
scanned skill edit, rollback, install or agent-managed skill write, and the
evaluator construction in each goal run, did synchronous file IO on the
shared loop, so other requests and streams waited for it. #7484 removed the
same stall for the one-shot helpers and listed these two constructors in the
utils guidance as not covered.

Build both providers through run_assembly, as the one-shot helpers do: the
moderation model in scan_skill_content, the evaluator in
evaluate_goal_completion when no model is injected, and the run worker's
cached evaluator factory. A scan without app_config (the in-graph
skill_manage call) also reads the live config in that pool, and its
fail-closed fallback reuses that config instead of reading config.yaml again
on the loop; an unreadable config still fails closed. Verdicts, the
fail-open/fail-closed policy, error propagation and tracing are unchanged,
and cancellation during construction never starts inference.

tests/blocking_io/test_system_model_offloop.py builds a real CodexChatModel
under the strict Blockbuster gate. On main the scan fell back to its
fail-closed "unavailable" block, the evaluator raised BlockingError
(os.stat), and the worker stood the goal down as evaluator_failed.
2026-11-02 09:24:31 +08:00
Daoyuan Li
80902c52b7
fix(channels): explain in-flight goal write rejections (#7741)
* fix(channels): explain in-flight goal write rejections

* test(channels): pin Gateway busy-goal feedback contract

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 09:16:19 +08:00
lisry-1
08f343f5d7
fix(wecom): report False when send_file cannot upload instead of True (#7768)
send_file short-circuited with `return True` for non-final messages, which
reported a successful upload that never happened. Per the Channel.send_file
contract ("Returns True if the upload succeeded, False otherwise"), a
message with no upload path must report False so the attachment lands in the
'failed' feedback the user relies on, instead of being silently dropped.

Drop the non-final short-circuit so the method reaches its normal
connectivity checks (no WS client / no thread frame -> False), matching every
other channel.

Co-authored-by: lisry-1 <249596828+lisry-1@users.noreply.github.com>
2026-11-01 23:59:00 +08:00
Hyeonsang Cho
17b05718e0
fix(agents): store an explicit null description as empty (#7638)
* fix(agents): store an explicit null description as empty

PUT /api/agents/{name} accepted "description": null and wrote it into the
agent's config, but AgentConfig.description is a str. Every later load of
that agent failed validation: the PUT itself, GET and repair PUTs returned
500, the file backend dropped the agent from the list, and the db backend
failed the owner's whole list.

Store an explicit null as the empty default, and have parse_agent_config
read a stored null description (already written by this route, or a bare
YAML key) as empty so affected agents load again.

* docs(changelog): reference #7638 for the agent null description fix

* docs(agents): document that a null description resets it to empty

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-01 23:34:59 +08:00
一片一片
a0ce43d46a
fix(agents): tolerate deeply nested JSON in tool-call normalization (#7742)
Loop detection, clarification and dangling-tool-call repair parse model-provided
tool arguments and options. A JSON string, Python structure or native option
label nested past the interpreter recursion limit raised RecursionError and
aborted the run; these paths now treat it like any other unusable payload
(opaque args, an empty replay object, or a skipped option), and the dict-option
flatten and _stable_tool_key use guarded serialization.

Rebased onto current main; the stale-base docs commit is superseded by main and
the AGENTS note is trimmed to stay inside the guidance chain budget.
2026-11-01 23:32:00 +08:00
Evan
02df37c5de
fix(runtime): charge a steered subagent's usage to the token budget (#7776)
* fix(runtime): charge a steered subagent's usage to the token budget

A task that finishes after a Steer reports through a receipt HumanMessage, so its usage never reached the dispatching AIMessage the token budget reads. Carry the usage over when the receipt is delivered.

* fix(runtime): report deferred usage with no AIMessage to charge

Log the rare case instead of dropping the usage silently, and scan for AIMessages once per delivery.
2026-11-01 23:28:04 +08:00
Xiao Hou
830c8ff6f1
fix(runs): trim event_types filter entries before querying (#7758)
* fix(runs): trim event_types filter entries before querying

* style(runs): compute the trimmed event type once per entry

Addresses the review nit on #7758: use the walrus pattern already present
in this file (lines 657 and 1658) instead of stripping each entry twice.

No behavior change.

---------

Co-authored-by: gouyu-hou <gouyu-hou@users.noreply.github.com>
Co-authored-by: gouyu-hou <213296172+gouyu-hou@users.noreply.github.com>
2026-11-01 23:27:00 +08:00
onhm
51f732304a
test(channels): drop the POSIX-only utime flag that breaks 9 tests on Windows (#7750)
* test(channels): drop the POSIX-only utime flag that breaks 9 tests on Windows

os.utime(..., follow_symlinks=False) is not implemented on Windows, so every test in
tests/test_channel_credential_staging.py that ages a staging file dies in _age before
it exercises anything (NotImplementedError). CI runs backend unit tests on ubuntu only,
so this never surfaced. Every file aged here is a regular file, and the other 15+
os.utime call sites in the suite already omit the flag.

Verified on Windows 11 / Python 3.12: 9 failed, 2 passed before; 11 passed after.

* test(channels): keep the symlink aging the flag was there for

Review follow-up on #7750. Dropping follow_symlinks=False kept Windows green but
quietly lost POSIX coverage: _age(link) in the os.name != "nt" block used to stamp the
symlink's own mtime two days into the past, and without the flag os.utime follows the
link and ages backup.tmp instead. cleanup_stale_credential_staging keeps that symlink
alive through its entry.stat(follow_symlinks=False) + S_ISREG type guard, and with a
fresh mtime the age guard alone would retain it too, so the test no longer proved that
an old staging-pattern symlink is spared by the type check.

Pass the flag through instead of hard-coding it: _age(path, *, follow_symlinks=True)
forwards it, and only the POSIX-only symlink call site asks for follow_symlinks=False.
Windows never passes it, so os.utime still cannot raise NotImplementedError there.

Also corrects the comment: _age also ages a directory, so "every file aged here is a
regular file" overstated the case. os.utime on a directory works on Windows; the flag
only matters for the symlink below.

Verified on Windows 11 / Python 3.12: 11 passed.

Addresses review feedback on #7750.

---------

Co-authored-by: luoyi0000 <luoyi0000@users.noreply.github.com>
2026-11-01 23:25:44 +08:00
Tsai Yuan
980cf3a448
fix(frontend): preserve skill HTTP errors for malformed bodies (#7774) 2026-11-01 23:24:07 +08:00
Evan
be39a8a879
fix(runtime): resume children paused for a withdrawn steer (#7753)
* fix(runtime): resume children paused for a withdrawn steer

wait_tool pauses every child as soon as a steer is pending, but only a
delivered steer sets steering_active, the flag finish_graph needs before it
resumes them. Removing the steer before delivery left the children parked
and the run waiting on them.

Release the children when deliver finds no input and no steer is in
progress. They stay paused during later model turns of a delivered steer.

* fix(runtime): release children paused by a steer withdrawn mid-turn

The steering_active guard skipped the release whenever another steer was
being handled. A child started during that turn is outside its
interrupted_children snapshot, so nothing resumed it after a second steer
was withdrawn and finish_graph waited forever.

Release every child a delivered steer does not hold. Each deliver re-takes
that snapshot, so a paused child outside it can only belong to a withdrawn
steer.

* fix(runtime): keep a replacement reusing a call ID out of the held steer

interrupted_children stores call IDs, and child() can replace a closed gate under the same ID. The replacement then looked held by an earlier delivered steer, so a withdrawn steer left it paused forever in a replaced turn. Drop the ID when its gate is replaced.
2026-11-01 23:18:49 +08:00
Totoro
a17e5694cc
fix(sandbox): stop owned command processes when a run is cancelled (#7772)
* fix(sandbox): stop owned command processes when a run is cancelled

* fix(sandbox): handle incomplete AIO output responses

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-01 23:09:25 +08:00
一片一片
38e6be638b
fix(memory): tolerate unbounded stored fact confidence (#7764)
* fix(memory): tolerate unbounded stored fact confidence

A stored memory fact can carry a JSON integer such as 10**400. float() on it
raises OverflowError in _coerce_source_confidence and in the dedup merge
comparison, aborting the whole update cycle even though both sites already
handle every other unusable confidence. Treat the overflow like a missing or
non-finite value: 0.5 for ranking, 0.0 for the merge comparison.

* fix(memory): harden the remaining fact-confidence coercions

The import normalizer, prompt builder and relevance scorer each coerce a
stored fact confidence with float() and caught only TypeError/ValueError, so
an unbounded JSON integer such as 10**400 raised OverflowError: import failed
before the updater fallbacks ran, and prompt/relevance ranking could abort.
Catch OverflowError and take the existing defaults; document the full set of
coercions.
2026-11-01 15:04:16 +08:00
alexcastgonz
31e7b4ae4f
fix(mcp): keep a tool argument named runtime out of LangGraph's runtime injection (#7677)
* fix(mcp): keep a tool argument named runtime out of LangGraph's runtime injection

The pooled stdio wrapper and the background-submit wrapper took LangGraph's
ToolRuntime in a parameter called `runtime`. ToolNode injects by that name, so a
tool whose own input schema has a `runtime` property had the model's value
replaced by the ToolRuntime and then lost: the server received the other
arguments only, and TaskSubmitRequest.arguments missed it too.

Name the injected parameter `deerflow_runtime` in both wrappers (it is still
detected through its ToolRuntime annotation) and read it under that name in the
personal-MCP ownership guard. Existing tests that call the wrappers pass the new
name; two ToolNode tests cover a `runtime` business argument on each path.

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

* fix(mcp): read the injected runtime under the right name in the personal-tool guard

The personal-tool ownership guard read kwargs["deerflow_runtime"], but only
the pooled stdio wrapper was renamed; personal HTTP/SSE tools still carry
langchain-mcp-adapters' `runtime` parameter, so the guard never saw the
authoritative runtime user for them. Look the parameter name up per tool, and
update the mcp AGENTS.md note on how the runtime is injected.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 12:45:14 +08:00
Hyeonsang Cho
9a49fb2bb3
fix(clarification): keep each question when a provider reuses a call ID (#7762)
* fix(clarification): keep each question when a provider reuses a call ID

The clarification ToolMessage ID, which is also the human-input
request_id, was clarification:{tool_call_id}. Providers that number
calls per response reuse call_0 across turns, so a later question took
the earlier one's ID: add_messages replaced the earlier question in
place and left the new call without a result.

Add a digest of the issuing AIMessage ID. A retry of the same call comes
from the same AIMessage and keeps its ID; without that message in state
the ID keeps its previous form.

* docs(changelog): reference #7762 for the clarification call ID fix
2026-11-01 12:41:34 +08:00
yetuge
35656d6a9f
fix(agents): ignore malformed entries in a serialized MCP routing index (#7751)
* fix(agents): ignore malformed entries in a serialized MCP routing index

`McpRoutingMiddleware._normalize_index` documents that it accepts "arbitrary
serialized routing data, not only the output of the builder", but two shapes
escaped the coercion and raised instead:

- an entry that is not a mapping (`str`, `list`, `None`, a number) reached
  `.get` and raised `AttributeError`;
- a non-finite `priority` raised `OverflowError` from `int()` — JSON accepts
  `Infinity`/`-Infinity` literals, so a hand-edited or foreign index can carry
  one.

The middleware is constructed per run, so either shape fails every model call
in that process instead of the entry being ignored the way an entry with no
keywords already is.

Both now degrade to "not indexable": a non-mapping entry is skipped, and a
priority that cannot be a finite int falls back to 0 (the existing value for
`None`/non-numeric priorities). Valid entries are unchanged.

Tests cover each rejected shape, the control shapes that already degraded, and
the builder-shaped output that must stay identical. Dropping only the mapping
guard fails the non-mapping cases; dropping only `OverflowError` fails the
non-finite cases.

* fix(mcp): handle infinite routing priorities in the builder

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-01 08:08:08 +08:00
一片一片
a9bd03ef57
fix(citations): keep copied markdown references valid (#7743)
* fix(citations): keep copied markdown references valid

formatCitationMarkdownReference pasted the source title and URL into
"[title](url)" verbatim. A title with a stray "[" or "]" ends the link text
early, and a URL with an unbalanced parenthesis ends the destination, so a
copied reference no longer rendered as a link. Escape brackets in the title and
wrap a parenthesized URL in the angle-bracket form.

* fix(citations): only angle-bracket a parenthesized URL

normalizeUrl already percent-encodes "<", ">" and whitespace, so including
them in the trigger could wrap such a URL in an angle-bracket destination that
CommonMark forbids, which is worse than the bare form. Parentheses are the only
reachable delimiter that breaks the bare destination.
2026-10-31 23:00:39 +08:00
Evan
c6a3993695
fix(runtime): keep each parallel task's gate across a steer (#7755)
wait_tool reused the name gate for its pause loop, so every parallel task
returned the last child's result id and its done callback closed that
child's gate. The message reducer then dropped all but one pending result,
and the model was told that a running subagent had been interrupted.

Rename the loop variable so each call keeps its own gate.
2026-10-31 21:40:59 +08:00
lisry-1
e0f5483fdf
fix(goal): fall back gracefully for an unsupported reply locale (#7745)
* fix(goal): fall back gracefully for an unsupported reply locale

goal_reply_text and goal_status_text indexed _TEXT directly with the
caller's locale, so a locale outside NotificationLocale (en-US/zh-CN)
raised KeyError. The /connect handshake (connect_reply_text) normalizes
with supported_locale and falls back to en-US; the /goal replies must do
the same instead of surfacing an internal error to the user.

Normalize the locale in both public entry points. Adds tests pinning the
fallback for an unknown locale and the zh-CN wording for a supported one.

* style(tests): format goal status locale assertion

---------

Co-authored-by: lisry-1 <249596828+lisry-1@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 19:19:42 +08:00
Hyeonsang Cho
eb5d1e1880
fix(projects): keep referenced originals when the trash sweep walks a symlinked tree (#7732)
* fix(projects): keep referenced originals when the trash sweep walks a symlinked tree

The storage reconciliation protects each row's namespace by its resolved
path (project_document_path resolves symlinks) but walked the unresolved
documents directory. With a symlinked .deer-flow (no DEER_FLOW_HOME),
users/, users/{id}/ or projects/, no walked directory matched a protected
namespace, so the startup sweep and the lazy trash-listing sweep unlinked
live document originals older than 24 hours as orphans.

Walk each project's resolved documents directory so both sides compare
the same paths.

* docs(changelog): reference #7732 for the trash sweep symlink fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 19:16:50 +08:00
aduoge
12b2a9429c
fix(channels): surface Feishu upload log_id and missing keys (#7731)
* fix(channels): surface Feishu upload log_id and missing keys

* test(channels): cover Feishu upload key and log_id checks

* test(channels): cover non-str Feishu upload keys
2026-10-31 19:16:29 +08:00
dependabot[bot]
6910c13e1e
chore(deps): bump opentelemetry-api from 1.42.1 to 1.45.1 in /backend (#7752)
Bumps [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) from 1.42.1 to 1.45.1.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.42.1...v1.45.1)

---
updated-dependencies:
- dependency-name: opentelemetry-api
  dependency-version: 1.45.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-31 18:26:29 +08:00
Liu Hao
762b7188ab
fix(conversation): tolerate malformed event metadata (#7721)
* fix(conversation): tolerate malformed event metadata

* fix(gateway): guard malformed event metadata

* fix(gateway): guard malformed message kwargs

* fix(gateway): log malformed transcript metadata

* fix(gateway): ignore malformed message roles
2026-10-31 18:25:31 +08:00
hataa
3027ab6714
fix(persistence): normalize non-finite floats before every JSON column write (#7733)
* fix(persistence): normalize non-finite floats before every JSON column write

The shared ORM engine serialized JSON columns with the lenient json.dumps
default (allow_nan=True), and the sync agent-storage engine set no
json_serializer at all. A non-finite float reaching a JSON column — run
metadata merges caller-supplied values, goal_verdict and
token_usage_by_model carry model/provider numbers, and json.loads('1e400')
yields inf — was emitted as the bare NaN/Infinity token, which PostgreSQL
jsonb rejects as invalid JSON, failing the whole write with DataError.
SQLite silently accepts and round-trips the invalid token, so the failure
was postgres-only and environment-dependent.

Adds dumps_json_safe/json_safe_value to persistence/json_compat.py (same
None-normalization the canonical display serializer applies since #7670,
which explicitly does not cover persisted state) and wires it into both
engines' json_serializer. Regressions pin: nested non-finite floats
normalize everywhere, finite values/bools pass through unchanged, the
async engine kwargs and the sync agent engine (postgres only; SQLite takes
no json_serializer) both route through it.

* fix(persistence): normalize JSON across synchronous engines

* docs: keep frontend guidance within the CI size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 18:12:32 +08:00
jswSpace
bee97022b1
fix(projects): defer trash cleanup when retention config is unavailable (#7683)
* fix(projects): preserve trash when retention config is unavailable

* test(projects): address trash retention review feedback

---------

Co-authored-by: jswSpace <198684035+jswSpace@users.noreply.github.com>
2026-10-31 18:08:11 +08:00
gouyu-hou
0f2f199b3d
fix(console): tolerate malformed usage counters and pricing (#7736)
* fix(console): tolerate malformed usage counters and pricing

* docs(routers): record console counter tolerance in the router contract

* fix(console): reject stored counters beyond the float range

A JSON integer is not bounded by the float range: 10**400 parses as an
exact int, so _token_count passed it through and _token_cost then raised
OverflowError on the division back to float, taking /usage, /stats and
/runs down with a 500 - the same failure mode the rest of this PR
removes for every other junk shape.

Reject counts whose float conversion is not finite inside the existing
try, so they coerce to 0 and the run falls back to its legacy totals.
10**30 stays usable.

---------

Co-authored-by: gouyu-hou <gouyu-hou@users.noreply.github.com>
Co-authored-by: gouyu-hou <213296172+gouyu-hou@users.noreply.github.com>
2026-10-31 18:05:16 +08:00
NanPan
793d1edabc
fix(projects): drain summary shutdown across caller cancellation (#7684) 2026-10-31 17:34:49 +08:00
Totoro
44e372dd7f
feat(runtime): support Queue and Steer during active Web runs (#7729)
* feat(runtime): add Queue and Steer for active Web runs

* style(frontend): format Queue and Steer controls

* fix(runtime): retain user Steer controls and order active inputs

* docs: add Chinese changelog entry for Queue and Steer

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 11:35:36 +08:00
derrickLzw
51f0e2de1c
feat(extensions): scope configured middlewares to lead or subagent (#7661)
* feat(extensions): scope configured middlewares to lead or subagent

* fix(helm): synchronize configured middleware config schema version

* fix(extensions): require explicit configured middleware scope
2026-10-31 08:46:16 +08:00
Lucien
178a9ab458
fix(web): preserve tool-result identity when provider call IDs repeat (#7672)
* fix(web): preserve independent results when tool call IDs repeat

* fix(web): preserve leading results and idempotent result replay
2026-10-31 08:41:07 +08:00
Dragon ze
5d2749b05b
fix(gateway): read the live config off the loop for thread token usage (#7727)
* fix(gateway): read the live config off the loop for thread token usage

build_context_usage resolves the model capacity through deps.get_config,
which revalidates config.yaml and extensions_config.json on every call, so
each /threads/{id}/token-usage request paid that file IO on the shared
Gateway loop.

* docs(changelog): record the thread token-usage config read move

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 08:28:01 +08:00
yinx65588-boop
cdc3e92d6b
fix(streaming): isolate in-memory replay payloads (#7644)
* fix(streaming): isolate in-memory replay payloads

* docs(streaming): explain sentinel identity during replay

* docs(streaming): document replay payload ownership

---------

Co-authored-by: yinx65588-boop <yinx65588-boop@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 08:15:24 +08:00
aduoge
ac21b69cbf
docs(auth): describe IM channel user buckets accurately (#7730) 2026-10-31 07:21:57 +08:00
mika
d50cbf8dc5
fix(browser): isolate egress DNS workers (#6301)
* fix(browser): isolate egress DNS workers

* fix(browser): release resolver slots after loop close

* test(browser): cover resolver isolation on production loop

* docs(browser): scope egress resolver guidance to browser automation

* test: approve browser automation guidance path

---------

Co-authored-by: mikamikasuki <211269698+mikamikasuki@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 22:18:54 +08:00
jswSpace
882433ae50
fix(subagents): recover expired attempts in paused batches (#7686)
* fix(subagents): recover expired attempts in paused batches

* test(subagents): target item reads in lease recovery race

---------

Co-authored-by: jswSpace <198684035+jswSpace@users.noreply.github.com>
2026-10-30 21:50:39 +08:00
Hyeonsang Cho
8062d4ba12
fix(scripts): include nginx logs in dev_multi_instance.sh logs all (#7723)
* fix(scripts): include nginx logs in dev_multi_instance.sh logs all

`logs all` selected only the two Gateway logs, the same as running
`logs` with no argument, so nginx's error and access logs never showed
up although the help lists all next to nginx. `all` now adds the nginx
logs, and the nginx selection also covers nginx.log, where the start
command's own output goes and which no option printed before.

* docs(changelog): reference #7723 for the dev_multi_instance logs fix

* test(scripts): pin dev_multi_instance logs when nginx never started

Cover the layout start_nginx leaves behind when it bails on a missing
binary or a busy port: only gateway-*.log exists, so `logs all` prints
the two Gateway files and `logs nginx` prints nothing, both exiting 0.
2026-10-30 21:48:31 +08:00
caitomato
d8d0830e16
fix(skills): support direct invocation of the skill packager (#6938)
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 21:27:36 +08:00
Fish
2488ba7c27
fix(agents): tolerate deeply nested JSON tool results (#7679)
* fix(agents): tolerate deeply nested JSON tool results

* test(agents): verify the deep JSON parser premise

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>

---------

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 20:29:15 +08:00
llb555
3f1438d4a0
fix(channels): publish Discord and WeChat JSON state atomically (#7595)
* fix(channels): publish Discord and WeChat JSON state atomically

* fix(channels): adopt the shared atomic publisher for both state writers

* test(channels): pin the cleanup-failure direction too

* test(channels): keep a platform-independent guard on the publish call shape

* style(tests): apply ruff format to the channel encoding test
2026-10-30 14:54:41 +08:00
Hyeonsang Cho
b4b2a68ee5
fix(memory): mask backend secrets in the memory config responses (#7665)
* fix(memory): mask backend secrets in the memory config responses

GET /api/memory/config and /api/memory/status returned
memory.backend_config as loaded. Config loading has already resolved
$VAR references by then, so a DeerMem extraction model api_key or a
Honcho api_key reached every caller with memory:read in plaintext,
which with authorization disabled (the default) is every signed-in
user.

MemoryConfigResponse now masks credential-named values (api_key,
*_token, password, authorization, ...) at any depth as "***". The
key is matched on its end, so knobs such as token_counting and
guaranteed_token_budget and the api_key_env variable name stay
readable, and unset values stay null or empty. The loaded config the
backend uses is not changed.

* docs(changelog): reference #7665 for the memory config secret masking fix

* refactor(gateway): share config secret masking between MCP and memory

The memory config masking forked the MCP router's key normalization
and recursive masker. Both now use app/gateway/secret_masking.py, and
each router keeps its own key rule: MCP matches credential terms
anywhere in the key, memory matches them at the end and keeps unset
values. MCP masking is unchanged.

The memory rule now also covers plural and cookie keys (api_keys,
secrets, passwords, cookies). Plural tokens stays out because
max_injection_tokens is a DeerMem knob. A secret-named container is
still masked whole, since its keys can be arbitrary names; the helper
docstring and a test now say so.

* docs(changelog): move the #7665 entry to Unreleased
2026-10-30 14:53:33 +08:00
Winoren
c50cdc5161
fix(uploads): resolve conversion settings in the parser worker (#7642) 2026-10-30 08:50:37 +08:00
RongJie G
9e6ce2f98e
fix(runtime): normalize non-finite floats in canonical serializer (#7670)
serialize_lc_object() passed floats through unchanged, so a non-finite
value (NaN/inf/-inf) survived serialization and broke the two display
paths that share it with opposite JSON policies: the SSE wire
(format_sse / Redis bridge use the lenient default and emit bare
NaN/Infinity tokens the langgraph-sdk/useStream decoder rejects,
breaking the live stream) and the REST surface (Starlette JSONResponse
uses allow_nan=False and raises, turning a thread read into HTTP 500).

Non-finite floats reach the serializer from ordinary runtime data:
json.loads("1e400") yields inf for a tool parsing external JSON, a
provider logprob of -inf for a zero-probability token, or a divide-by-
zero ratio. They round-trip inside Python and checkpoint persistence and
only fail at the JSON boundary.

Normalize non-finite floats to None and recurse into model_dump()/dict()
output so nested values (e.g. a message's response_metadata) are
normalized too. This is a display/wire normalization only; persisted
checkpoint state is never routed through this serializer. Finite floats
are unchanged.

Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
2026-10-30 08:49:30 +08:00
lihongyuan99
9e7448cabd
fix(changelog): define the missing [#7022] reference link (#7710)
Both change logs mention [#7022] in the workflow-turn-cap entry but neither
defines it, so the released page renders the literal text "[#7022]" next to a
working link for the fixing PR. #7022 is an issue, not a pull request, so the
definition targets issues/7022.

Add an offline guard for the whole class: every [#n] reference token needs a
definition, and every definition must aim at the same number in this repository
(commit permalinks, as used for deleted PRs, stay allowed).
2026-10-30 08:40:00 +08:00
SumEn
b86be89f09
fix(threads): preserve concurrent create idempotency in memory mode (#7691)
* fix(threads): preserve concurrent create idempotency in memory mode

* fix(threads): serialize detached run metadata creation

* docs(gateway): clarify metadata admission timeout budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 08:39:03 +08:00
haotianqi666
f7da737118
fix(memory): avoid replaying Honcho checkpoint history (#7687)
* fix(memory): avoid replaying Honcho checkpoint history

* fix(memory): harden Honcho capture lookups

---------

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
2026-10-30 07:33:41 +08:00
LingYi
c28e64fc8f
fix(memory): enforce the token budget when truncating summaries (#7709)
Signed-off-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
Co-authored-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
2026-10-30 07:32:51 +08:00
Lucien
15433dda2a
fix(browser): preserve the browser context after login popups close (#7674)
* fix(browser): preserve context when active popup closes

* fix(browser): focus recovered managed tabs and clarify session guidance
2026-10-30 07:20:02 +08:00
Cordis798
62de7f3425
perf(knowledge): bound concurrent WeKnora document checks (#7707)
Co-authored-by: Cordis798 <284918518+Cordis798@users.noreply.github.com>
2026-10-30 07:08:01 +08:00
dependabot[bot]
8d52d2541d
chore(deps): bump langgraph from 1.2.9 to 1.2.14 in /backend (#7703)
* chore(deps): bump langgraph from 1.2.9 to 1.2.14 in /backend

Bumps [langgraph](https://github.com/langchain-ai/langgraph) from 1.2.9 to 1.2.14.
- [Release notes](https://github.com/langchain-ai/langgraph/releases)
- [Commits](https://github.com/langchain-ai/langgraph/compare/1.2.9...1.2.14)

---
updated-dependencies:
- dependency-name: langgraph
  dependency-version: 1.2.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* test: make goal race saver compatible with LangGraph 1.2.14

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 06:42:38 +08:00
haotianqi666
81e1ab8dcc
fix(telegram): preserve group reply conversation context (#7654)
* fix(telegram): preserve group reply conversation context

* fix(telegram): log mapping outages once per streamed answer

---------

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-30 00:16:45 +08:00
N
4fd2b211cc
fix(artifacts): keep saved revisions ahead of stale reads (#7650)
* fix(artifacts): keep saved revisions ahead of stale reads

* fix(artifacts): abort cancelled content requests

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>

---------

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-29 23:23:08 +08:00
dependabot[bot]
1e24a2115c
chore(deps): bump langchain-anthropic from 1.4.1 to 1.4.6 in /backend (#7702)
Bumps [langchain-anthropic](https://github.com/langchain-ai/langchain) from 1.4.1 to 1.4.6.
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-anthropic==1.4.1...langchain-anthropic==1.4.6)

---
updated-dependencies:
- dependency-name: langchain-anthropic
  dependency-version: 1.4.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 23:20:05 +08:00
dependabot[bot]
477e53386d
chore(deps): bump langgraph-api from 0.10.0 to 0.15.0 in /backend (#7700)
Bumps langgraph-api from 0.10.0 to 0.15.0.

---
updated-dependencies:
- dependency-name: langgraph-api
  dependency-version: 0.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 22:53:21 +08:00
dependabot[bot]
771cf619c1
chore(deps): bump dnspython from 2.8.0 to 2.9.0 in /backend (#7701)
Bumps [dnspython](https://github.com/rthalley/dnspython) from 2.8.0 to 2.9.0.
- [Release notes](https://github.com/rthalley/dnspython/releases)
- [Changelog](https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst)
- [Commits](https://github.com/rthalley/dnspython/compare/v2.8.0...v2.9.0)

---
updated-dependencies:
- dependency-name: dnspython
  dependency-version: 2.9.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 22:52:49 +08:00
Totoro
8d11aeb97b
feat(subagents): configure thinking and reasoning effort for native workers (#7668)
* feat(subagents): add thinking_enabled and reasoning_effort config for sub-agents (#4875)

(cherry picked from commit b1083728b23504cb0f1d1a463f8db70c5ec1c5a7)

* fix: resolve CI failures for subagent thinking config PR

- Fix UnboundLocalError in task_tool.py: initialize parent_thinking_enabled
  and parent_reasoning_effort before the if runtime block
- Remove unused imports from test_subagent_thinking_config.py
  (SimpleNamespace, pytest, resolve_subagent_model_name)
- Fix import sorting in subagents_config.py and test file
- Add reasoning_effort: None to executor test assertion

(cherry picked from commit 187851e307b871937e48bb67ae1b34b8b69bf92d)

* fix: address review comments - remove parent inheritance, supports_thinking clamp, Literal type

- Remove parent_thinking_enabled/parent_reasoning_effort from metadata
  extraction in task_tool.py (backward compatible: subagents only think
  when explicitly configured)
- Add supports_thinking clamp in _create_agent to prevent cross-model
  mismatch errors
- Change SubagentConfig.reasoning_effort to Literal type
- Fix sandbox_state assignment that was accidentally removed
- Fix syntax errors in subagents_config.py (missing closing parens)
- Update all docstrings/comments from "inherit" to "no override"
- Simplify test helper and remove parent inheritance tests

(cherry picked from commit 74f9c659ef4a98bd1756109929ab0748fab82317)

* feat(subagents): adapt reasoning settings to current native workers

* fix(subagents): retain model context on reasoning rejection

* fix(subagents): preserve model-factory reasoning diagnostics

---------

Co-authored-by: Neon549 <neon549@users.noreply.github.com>
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-29 22:26:12 +08:00
LingYi
30aa601f6a
fix(runtime): isolate run admission from unrelated heartbeats (#7664)
Signed-off-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
Co-authored-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
2026-10-29 22:19:10 +08:00
Felix Shi
44b88151be
fix(skills): capture package digests without POSIX-only dir_fd (#7583)
* fix(skills): capture package digests without POSIX-only dir_fd

`capture_package` rejected every non-POSIX host, so `SkillMutationRuntime.state`
fell back to `"unsupported-" + uuid4().hex`. That digest differs on every call,
so `repository.observe()` recorded a mutation for an untouched package:
`mutation_seq` kept climbing and ABA detection could never match.

Walk the tree with `scandir` + `lstat` where `dir_fd`/`O_NOFOLLOW` are missing,
comparing the pre-open `lstat` against the opened descriptor before reading,
and leave the POSIX descriptor walk unchanged.

Windows 11 / Python 3.12: test_skill_asset_versions.py 5 failed -> 23 passed;
reverting this file alone brings the 5 failures back.

* docs(gateway): keep guidance below CI size budget

---------

Co-authored-by: Felix Shi <204130665+shi-felix@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-29 22:08:06 +08:00
Willem Jiang
b3f23326a5
Moved to the 2.2.3 developement (#7697) 2026-10-29 22:02:49 +08:00
leom1919
dedb154e4c
docs(changelog): replace links to deleted PRs with permalinks to their commits (#7689)
* docs(changelog): replace links to deleted PRs with permalinks to their commits

* docs(changelog): fix remaining deleted #6304 reference

---------

Co-authored-by: leom1919 <leom1919@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-29 21:55:52 +08:00
Willem Jiang
6ca83a32cd
chore(release): keep deerflow-harness in the version-source lockstep (#7696)
* chore(release): keep deerflow-harness in the version-source lockstep

bump_version.sh now also rewrites backend/packages/harness/pyproject.toml
(deerflow-harness is a workspace member, so `uv lock` moves its
backend/uv.lock entry too), and verify_versions.sh gates on it in both
tag and mutual-equality modes. RELEASING.md and AGENTS.md describe the
five version sources.

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

* docs: keep version guidance within inherited size budget

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-29 21:11:45 +08:00
NanPan
5c4ba4d2fe
fix(mcp): drain compensation work during service shutdown (#7662)
* fix(mcp): drain compensation work during service shutdown

* test(mcp): make shutdown drain assertion deterministic

* fix(mcp): preserve shutdown drain through caller cancellation

* style(mcp): keep test module spacing consistent

* fix(mcp): report pending compensation tasks on shutdown timeout

* test(mcp): cover compensation observability on poller timeout
2026-10-29 14:40:09 +08:00
dependabot[bot]
76835c1ff2
chore(deps): bump multidict from 6.7.1 to 6.9.1 in /backend (#7660)
Bumps [multidict](https://github.com/aio-libs/multidict) from 6.7.1 to 6.9.1.
- [Release notes](https://github.com/aio-libs/multidict/releases)
- [Changelog](https://github.com/aio-libs/multidict/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/multidict/compare/v6.7.1...v6.9.1)

---
updated-dependencies:
- dependency-name: multidict
  dependency-version: 6.9.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 14:37:26 +08:00
1163 changed files with 99748 additions and 18709 deletions

View File

@ -62,6 +62,9 @@ docker/
skills/ skills/
frontend/.next frontend/.next
frontend/node_modules frontend/node_modules
# Frontend unit tests can import fixtures outside frontend/ (for example, examples/),
# so exclude them from the frontend-only Docker build context.
frontend/tests
backend/.venv backend/.venv
backend/htmlcov backend/htmlcov
backend/.coverage backend/.coverage
@ -73,6 +76,23 @@ backend/.coverage
# root that the build client cannot read. # root that the build client cannot read.
**/.deer-flow/ **/.deer-flow/
backend/sandbox/ backend/sandbox/
# Local configuration holds model API keys, MCP credentials, frontend secrets and
# local HTTPS keys (frontend/*.pem, the frontend .gitignore's mkcert pair).
# Compose and Helm supply it at runtime. The config resolvers still read the legacy
# backend/ copies, and `COPY backend ./backend` / `COPY frontend ./frontend` would
# otherwise bake them into every image layer built from this checkout.
config.yaml
config.yaml.bak
extensions_config.json
mcp_config.json
backend/config.yaml
backend/config.yaml.bak
backend/extensions_config.json
backend/mcp_config.json
**/.env*.local
frontend/*.pem
*.md *.md
!README.md !README.md
!frontend/README.md !frontend/README.md
@ -84,3 +104,17 @@ backend/sandbox/
!backend/extensions/ !backend/extensions/
!backend/extensions/sources/ !backend/extensions/sources/
!backend/extensions/sources/** !backend/extensions/sources/**
# The re-inclusion above wins over every earlier rule, so the files the extension
# manager refuses to snapshot (deerflow/extensions/manager.py) are excluded again
# here. A snapshot the manager wrote never contains them; one edited by hand
# must not carry credentials into the image either.
backend/extensions/sources/**/.env
backend/extensions/sources/**/.env.*
backend/extensions/sources/**/.npmrc
backend/extensions/sources/**/.pypirc
backend/extensions/sources/**/credentials.json
backend/extensions/sources/**/*.key
backend/extensions/sources/**/*.pem
backend/extensions/sources/**/*.p12
backend/extensions/sources/**/*.pfx

View File

@ -30,6 +30,9 @@ INFOQUEST_API_KEY=your-infoquest-api-key
# Sofya API Key (web search and fetch) - https://sofya.co # Sofya API Key (web search and fetch) - https://sofya.co
SOFYA_API_KEY=your-sofya-api-key SOFYA_API_KEY=your-sofya-api-key
# SpecStream API Key (web search and fetch) - https://specstream.ai
SPECSTREAM_API_KEY=your-specstream-api-key
# Unbrowse API Key (web fetch) - https://unbrowse.ai/app # Unbrowse API Key (web fetch) - https://unbrowse.ai/app
UNBROWSE_API_KEY=your-unbrowse-api-key UNBROWSE_API_KEY=your-unbrowse-api-key
@ -72,6 +75,10 @@ UNBROWSE_API_KEY=your-unbrowse-api-key
# DEEPSEEK_API_KEY=your-deepseek-api-key # DEEPSEEK_API_KEY=your-deepseek-api-key
# NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai # NOVITA_API_KEY=your-novita-api-key # OpenAI-compatible, see https://novita.ai
# MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io # MINIMAX_API_KEY=your-minimax-api-key # OpenAI-compatible, see https://platform.minimax.io
# MiniMax API host (choose by region):
# China: https://api.minimaxi.com (default)
# International: https://api.minimax.io
# MINIMAX_API_HOST=https://api.minimax.io
# OPPER_API_KEY=your-opper-api-key # OpenAI-compatible, see https://opper.ai # OPPER_API_KEY=your-opper-api-key # OpenAI-compatible, see https://opper.ai
# OpenAI-compatible Images API (used by the image-generation skill) # OpenAI-compatible Images API (used by the image-generation skill)
# IMAGE_GENERATION_PROVIDER=openai # openai-compatible is also accepted # IMAGE_GENERATION_PROVIDER=openai # openai-compatible is also accepted

View File

@ -57,7 +57,7 @@ jobs:
# its peers. # its peers.
fail-fast: false fail-fast: false
matrix: matrix:
shard: [1, 2, 3, 4] shard: [1, 2, 3, 4, 5, 6]
services: services:
postgres: postgres:
@ -105,7 +105,7 @@ jobs:
working-directory: backend working-directory: backend
run: uv sync --group dev --extra postgres run: uv sync --group dev --extra postgres
- name: Run unit tests of backend (shard ${{ matrix.shard }} of 4) - name: Run unit tests of backend (shard ${{ matrix.shard }} of ${{ strategy.job-total }})
working-directory: backend working-directory: backend
env: env:
# Expose the job's Postgres service to the cross-pod dedupe integration # Expose the job's Postgres service to the cross-pod dedupe integration
@ -114,4 +114,4 @@ jobs:
# exercised in CI. # exercised in CI.
DEERFLOW_TEST_WORKFLOW_POSTGRES: ${{ env.TEST_POSTGRES_URI }} DEERFLOW_TEST_WORKFLOW_POSTGRES: ${{ env.TEST_POSTGRES_URI }}
DEDUPE_TEST_POSTGRES_URL: ${{ env.TEST_POSTGRES_URI }} DEDUPE_TEST_POSTGRES_URL: ${{ env.TEST_POSTGRES_URI }}
run: make test-shard SPLITS=4 GROUP=${{ matrix.shard }} run: make test-shard SPLITS=${{ strategy.job-total }} GROUP=${{ matrix.shard }}

View File

@ -14,6 +14,7 @@ on:
- 'backend/pyproject.toml' - 'backend/pyproject.toml'
- 'backend/uv.lock' - 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**' - 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
pull_request: pull_request:
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@ -28,6 +29,7 @@ on:
- 'backend/pyproject.toml' - 'backend/pyproject.toml'
- 'backend/uv.lock' - 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**' - 'examples/deerflow-extension-bookmarks/**'
- 'examples/deerflow-extension-office-preview/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
concurrency: concurrency:
@ -41,6 +43,15 @@ jobs:
e2e-tests: e2e-tests:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Keep the image version aligned with frontend/pnpm-lock.yaml. Browsers and
# OS libraries are installed together, without per-shard CDN downloads.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
# The host tool cache can contain Python built for a newer OpenSSL than
# this image. uv's standalone Python carries compatible runtime libraries.
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25 timeout-minutes: 25
strategy: strategy:
fail-fast: false fail-fast: false
@ -51,21 +62,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
# The bookmark browser test starts the real Python action/router fixture.
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
with: with:
# Keep aligned with backend/Dockerfile and other backend workflows. # Keep aligned with backend/Dockerfile and other backend workflows.
version: "0.11.1" version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (bookmark plugin gateway) - name: Install backend dependencies (bookmark plugin gateway)
working-directory: backend working-directory: backend
run: uv sync --locked run: |
uv sync --locked
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -82,9 +92,20 @@ jobs:
working-directory: frontend working-directory: frontend
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium # Playwright collects the resource suite before selecting a shard. Its
# synthetic Office fixtures use JSZip from the extension's locked install.
- name: Install Office preview test dependencies
working-directory: examples/deerflow-extension-office-preview
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Verify Playwright Chromium
working-directory: frontend working-directory: frontend
run: npx playwright install chromium --with-deps id: chromium
run: >-
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Run E2E tests - name: Run E2E tests
working-directory: frontend working-directory: frontend
@ -100,7 +121,7 @@ jobs:
- name: Run auth recovery E2E tests - name: Run auth recovery E2E tests
# Keep auth coverage unsharded and run it even if the first shard fails. # Keep auth coverage unsharded and run it even if the first shard fails.
if: ${{ !cancelled() && matrix.shard == 1 }} if: ${{ !cancelled() && matrix.shard == 1 && steps.chromium.outcome == 'success' }}
working-directory: frontend working-directory: frontend
run: >- run: >-
pnpm exec playwright test -c playwright.auth.config.ts pnpm exec playwright test -c playwright.auth.config.ts

View File

@ -71,21 +71,27 @@ jobs:
name: Layer 2 — full-stack render (no API key) name: Layer 2 — full-stack render (no API key)
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Match the locked Playwright runtime so the image supplies its browsers.
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble
options: --ipc=host
env:
UV_PYTHON_PREFERENCE: only-managed
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v7
with: with:
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py # Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
version: "0.11.1" version: "0.11.1"
- name: Install container-compatible Python
run: uv python install 3.12
- name: Install backend dependencies (replay gateway) - name: Install backend dependencies (replay gateway)
working-directory: backend working-directory: backend
run: uv sync --group dev run: |
uv sync --group dev
uv run --no-sync python -c 'import ssl, sqlite3'
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@ -97,9 +103,13 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend working-directory: frontend
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium - name: Verify Playwright Chromium
working-directory: frontend working-directory: frontend
run: npx playwright install chromium --with-deps run: >-
node --input-type=module -e
'import { chromium } from "@playwright/test";
const browser = await chromium.launch();
await browser.close();'
- name: Full-stack replay render (DOM assertions are the gate) - name: Full-stack replay render (DOM assertions are the gate)
working-directory: frontend working-directory: frontend
run: pnpm exec playwright test -c playwright.real-backend.config.ts run: pnpm exec playwright test -c playwright.real-backend.config.ts

View File

@ -29,7 +29,7 @@ permissions:
jobs: jobs:
sandbox-image-smoke: sandbox-image-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 45
env: env:
SANDBOX_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest' }} SANDBOX_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest' }}
FOWNER_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_FOWNER_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:1.11.0' }} FOWNER_SMOKE_IMAGE_REF: ${{ vars.SANDBOX_FOWNER_SMOKE_IMAGE || 'enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:1.11.0' }}
@ -38,7 +38,16 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Free disk space for sandbox images
# This job only needs Python and Docker, not the runner's mobile/.NET/Haskell SDKs.
run: |
df -h /
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /usr/local/.ghcup /opt/ghc
docker image prune --all --force
df -h /
- name: Resolve immutable image references for this run - name: Resolve immutable image references for this run
timeout-minutes: 30
# Pull on the runner's platform, then pass only immutable repo@sha256 # Pull on the runner's platform, then pass only immutable repo@sha256
# references to pytest. This keeps both the baseline and the 1.11.0 # references to pytest. This keeps both the baseline and the 1.11.0
# regression run reproducible even if either source tag moves later. # regression run reproducible even if either source tag moves later.
@ -76,5 +85,6 @@ jobs:
run: uv sync --group dev run: uv sync --group dev
- name: Run the live real-image smoke tests - name: Run the live real-image smoke tests
timeout-minutes: 10
working-directory: backend working-directory: backend
run: uv run pytest -m live tests/test_aio_sandbox_local_backend.py -v run: uv run pytest -m live tests/test_aio_sandbox_local_backend.py -v

View File

@ -55,7 +55,13 @@ jobs:
// ---- area: replaces .github/labeler.yml (path -> area) ---- // ---- area: replaces .github/labeler.yml (path -> area) ----
const AREA_RULES = [ const AREA_RULES = [
['area:frontend', [/^frontend\//]], ['area:frontend', [/^frontend\//]],
['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]], // Cover both backend packages. Keep the harness exclusions aligned
// with the dedicated tree rules below; the offline replay checks this.
['area:backend', [
/^backend\/app\//,
/^backend\/packages\/harness\/(?!deerflow\/(?:agents|subagents|reflection|sandbox|skills|mcp)\/)/,
/^backend\/packages\/extension-api\//,
]],
['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]], ['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]],
['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]], ['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]],
['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]], ['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]],

318
AGENTS.md
View File

@ -1,250 +1,122 @@
# AGENTS.md # DeerFlow
Guidance source; `CLAUDE.md` imports `@AGENTS.md`. DeerFlow is an agent harness: a LangGraph-based backend with thread-isolated
Module guides: sandboxes, memory, subagents, skills and MCP tools, and a Next.js frontend. IM
channels connect through the Gateway. `CLAUDE.md` imports this file; edit
guidance here, not in the shim.
- **[backend/AGENTS.md](backend/AGENTS.md)** — backend depth: harness/app split, agent & ## How guidance is organized
middleware chain, sandbox, MCP, skills, memory, IM channels, persistence/migrations,
config system, test layout.
- **[frontend/AGENTS.md](frontend/AGENTS.md)** — frontend depth: Next.js App Router layout,
thread/streaming data flow, code style, commands.
## What is DeerFlow Rules live in the `AGENTS.md` nearest to the code. Before you edit a directory,
read every `AGENTS.md` from that directory up to this file. Some coding agents do
not load the nested files on their own.
DeerFlow combines LangGraph and Next.js with thread-isolated sandbox execution, | Area | Guide |
memory, subagents and extensible tools. IM channels connect through the Gateway. | --- | --- |
| Backend: harness, Gateway, channels, tests | [backend/AGENTS.md](backend/AGENTS.md) |
| Frontend | [frontend/AGENTS.md](frontend/AGENTS.md) |
| nginx, Compose, provisioner | [docker/AGENTS.md](docker/AGENTS.md) |
| Orchestration scripts, `scripts/pnpm.py` | [scripts/AGENTS.md](scripts/AGENTS.md) |
| Skill review, skill tests | [skills/AGENTS.md](skills/AGENTS.md) |
| Extension examples | `examples/*/AGENTS.md` |
## Service Topology Humans start at [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md) and
[docs/guide/](docs/guide/).
A single `make dev` / Docker stack runs four cooperating services: ## Service topology
| Service | Port | Role | | Service | Port | Role |
| --- | --- | --- | | --- | --- | --- |
| **Nginx** | `2026` | Unified reverse-proxy entry point — open this in the browser | | Nginx | 2026 | The single entry point; open this in the browser |
| **Gateway API** | `8001` | FastAPI REST API + embedded LangGraph-compatible agent runtime | | Gateway | 8001 | FastAPI REST API and the embedded agent runtime |
| **Frontend** | `3000` | Next.js web interface | | Frontend | 3000 | Next.js web interface |
| **Provisioner** | `8002` | Optional — only when sandbox is configured for provisioner/K8s mode | | Provisioner | 8002 | Optional; only for provisioner or Kubernetes sandboxes |
Nginx is the single public entry: it proxies `/api/*` to the Gateway, rewriting Nginx serves the frontend, proxies `/api/*` to the Gateway and rewrites
`/api/langgraph/*` onto the Gateway's native routes, and serves the frontend — see `/api/langgraph/*` to the Gateway's native routes.
[backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail. It compresses
HTML and configured textual assets, deliberately leaving SSE, fonts, images, audio, and
video uncompressed at the proxy layer.
Keep the dedicated `/api/threads/{thread_id}/browser/stream` WebSocket regex before Before changing ingress, port bindings or provisioner mounts, read
the generic threads regex in Docker, local and Helm nginx configs. Upgrade headers [service topology details](docs/agent-service-topology.md).
and the 600s read timeout are pinned by `backend/tests/test_nginx_browser_websocket.py`.
Helm uses nginx's default connect/send timeouts for the in-cluster Gateway.
Both Compose files publish nginx at `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` ## Repository map
(README's **loopback default**); bare `"${PORT}:2026"` binds `0.0.0.0`. Root `PORT`
controls Docker ingress only; local Next.js stays on `3000` so `.env` cannot
redirect `make dev`'s port wait. Nginx's IPv4+IPv6 `default_server` and Gateway's
`0.0.0.0:8001` bind are container-internal; only published nginx is external.
Require explicit bind addresses for new published ports;
`backend/tests/test_compose_default_bind_host.py` pins every service in both files.
Provisioner PVC mode declares `USERDATA_PVC_NAME` once as `user-data`; extra and | Path | Content |
Lark broker credential mounts reuse it with their existing subPaths and read-only | --- | --- |
flags. Keep credential mounts broker-only and keep distinct volumes in hostPath mode. | `backend/packages/harness/` | `deerflow-harness`, import `deerflow.*`: the agent framework |
| `backend/packages/extension-api/` | `deerflow-extension-api`: the public extension contract |
| `backend/app/` | The Gateway and the IM channels, import `app.*` |
| `frontend/` | The Next.js app (pnpm) |
| `docker/`, `deploy/helm/` | Compose files, nginx config, provisioner, Helm chart |
| `skills/public/`, `skills/custom/` | Committed skills and local, gitignored skills |
| `contracts/` | JSON contracts shared across components |
| `examples/` | Installable extension examples |
| `scripts/`, `tests/` | Root orchestration scripts, and tests for public skills |
## Repository Map `config.yaml` and `extensions_config.json` are copied from the example files at
the repository root. Both are gitignored and the Gateway can write them. Do not
commit them.
``` ## Commands
deer-flow/
├── Makefile # Root orchestration: drives the full stack (dev/start/stop, docker, setup)
├── config.example.yaml # Template → copy to config.yaml (gitignored) at repo root
├── extensions_config.example.json # Template → copy to extensions_config.json (gitignored): MCP servers + skills
├── backend/ # Python backend — see backend/AGENTS.md
│ ├── Makefile # Per-module backend commands (dev, gateway, test, lint, migrate-rev)
│ ├── extensions/sources/ # Deployable snapshots of locally installed Python extensions
│ ├── packages/extension-api/ # deerflow-extension-api package (import: deerflow_extension_api.*) — public extension contract
│ ├── packages/harness/ # deerflow-harness package (import: deerflow.*) — agent framework
│ └── app/ # FastAPI Gateway + IM channels (import: app.*)
├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
├── docker/ # docker-compose files, nginx config, provisioner
├── skills/ # Agent skills: public/ (committed), custom/ (gitignored)
│ # Managed integration skill packs are global at .deer-flow/integrations/skills/{provider}/
│ # Integration credentials and enabled state remain per-user
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
├── examples/ # Extension examples: deerflow-extension-{example,bookmarks,agent-teams}
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)
└── docs/ # Cross-cutting docs, plans, and design notes
```
Third-party extensions load from operator-controlled `config.yaml -> plugins:`. `make help` lists the root targets. First setup is `make config`, then
This imports code, so the list stays outside API-writable `extensions_config.json`. `make install`, then `make dev`, which serves the stack on `localhost:2026` and
Extensions contribute middleware, lifecycle observers, Gateway services, FastAPI writes `logs/gateway.log` and `logs/frontend.log`.
routers, and experimental full-stack plugins. Use
`deerflow extensions install/upgrade/list/enable/disable/remove` or root
`make extension-*` wrappers. Restart Gateway after every mutation. Build hooks
and extension code run with Gateway privileges; use only trusted operator sources.
Transactions, accepted source forms, locking, and contribution contracts:
[extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md).
User manual: `frontend/src/content/{en,zh}/harness/extensions/`.
**Extension-first evaluation** — before implementing a feature by editing core code,
evaluate whether it can ship as a packaged extension through the contribution points
above. Stay in core for bug fixes and for changes to the agent loop, memory, context
compaction, or authentication. If existing hooks cannot express the feature, add a
generic hook to the extension contract plus an extension that uses it — do not
hard-code business logic into core. Working examples:
`examples/deerflow-extension-{example,bookmarks,agent-teams}`.
[Workflow guide](examples/deerflow-extension-workflows/AGENTS.md): public
AgentRuns; POSIX, one process/store; approval and session-authorized recovery; uncertain admissions need reconciliation;
no multi-Gateway failover or hard currency budget.
Knowledge management/space policy: `examples/deerflow-extension-knowledge-{management,platform}/`
(scoped `AGENTS.md`, `CONTRACT.md`). Use existing plugin/router/principal contracts;
retrieval stays independent. Space policy intersects administrator/resource/operation
and upstream grants, never grants chat access. SQLite holds plugin-owned space policy.
Provider stages are not completion evidence; reconcile uncertain writes, never retry.
Root runtime configs: copy `config.example.yaml` → `config.yaml` and
`extensions_config.example.json` → `extensions_config.json` (MCP/skills).
Both are gitignored and Gateway-editable; schema/resolution:
[backend/AGENTS.md](backend/AGENTS.md).
Skill quality review note:
- `skills/public/skill-reviewer/` is the built-in read-only reviewer (harness tool
`review_skill_package`, contracts in `contracts/skill_review/`). Model-visible review
data is tag-neutralized; raw payloads stay in tool artifacts. See
[backend/AGENTS.md](backend/AGENTS.md) for the ownership boundaries.
- CI waivers live in `.github/skill-review-waivers.v1.json`, enforced by
`scripts/review_changed_public_skills.py`. Only the manifest from the trusted base
revision can suppress a run, and blocker findings can never be waived.
Scheduled-task note:
- `scheduler.tool_enabled` (default off) offers owner-bound schedule tools only
through Gateway capability admission. Interactive turns create/manage their
schedules; scheduled turns can request stopping only their own schedule.
Per-occurrence goal success does not end a recurring schedule.
- The scheduled-task MVP adds a workspace page at `/workspace/scheduled-tasks` plus a
background scheduler gated by `config.yaml -> scheduler.enabled`.
- Scheduled runs are non-interactive: `ask_clarification` is excluded. `non_interactive`,
`disable_clarification`, and `github_token` are honored only for internally-authenticated
callers; client-supplied copies are dropped.
- Busy occurrences persist as `queued`; `scheduler.queue_timeout_seconds` bounds the wait.
Do not reintroduce skip-on-overlap.
## Commands: Root vs. Module
**Root stack commands** (from repo root):
```bash ```bash
make setup # Interactive setup wizard (recommended for new users) cd backend && make test && make test-blocking-io # offline suites
make doctor # Check configuration and system requirements cd backend && make format # CI runs ruff format --check
make support-bundle # Generate redacted troubleshooting summary, AI issue draft, and optional zip cd backend && python -m pytest tests/path/test_x.py::test_name -q
make config # Generate local config files from the examples cd frontend && pnpm check && pnpm test
make check # Check that required tools are installed cd frontend && pnpm rstest run <pattern>
make install # Install all dependencies (frontend + backend + pre-commit hooks) make check-agent-guidance # size budget of these guides
make extension-install SOURCE=... # Install and enable a trusted Python extension
make extension-upgrade SOURCE=... # Replace an installed extension and keep its config
make extension-list # List configured Python extensions
make extension-enable NAME=... # Enable an installed extension (restart required)
make extension-disable NAME=... # Disable without uninstalling (restart required)
make extension-remove NAME=... # Remove package and config entry (restart required)
make dev # Start all services with hot-reload (Gateway + Frontend + Nginx)
make start # Start all services in production mode (local, optimized); SKIP_FRONTEND_BUILD=1 reuses the last frontend build
make stop # Stop all running services
make up / down # Build/stop the production Docker stack (browser at localhost:2026)
make docker-start / docker-stop / docker-logs # Docker development environment
``` ```
Production uses image Python (`uv run --no-sync`) and `exec` for SIGTERM; ## Rules for every change
Compose sets `init: true` (`backend/tests/test_gateway_sigterm_delivery.py`).
`make up` waits for Gateway `/health`; failures show Compose status and Gateway logs.
Docker logs/restarts resolve `DEER_FLOW_ROOT` from this checkout before Compose, - **Evaluate an extension first.** Before you add a feature to core code, check
as start/stop do. whether it can ship as a packaged extension. Core is the place for bug fixes
and for changes to the agent loop, memory, context compaction and
authentication. When the existing hooks cannot express a feature, add a
generic hook to the extension contract and an extension that uses it; business
logic is not hard-coded into core. Contract and examples:
[extensions guide](backend/packages/harness/deerflow/extensions/AGENTS.md),
`examples/`.
- **Extensions are operator-controlled code.** They load from `plugins:` in
`config.yaml`. The list stays out of the API-writable `extensions_config.json`,
because loading an extension imports code with Gateway privileges. Install
from trusted sources only, and restart the Gateway after every extension
change.
- **Ship tests with every feature and bug fix.**
- **Document behavior next to its topic, in the same change**: user-facing
behavior in `docs/guide/` or `backend/docs/`. `README.md` is an index; change
it only to add or re-point a link.
- **Keep the version fields identical**: `backend/pyproject.toml`,
`backend/packages/harness/pyproject.toml`, `frontend/package.json`, and
`version` and `appVersion` in `deploy/helm/deer-flow/Chart.yaml`.
`scripts/bump_version.sh <ver>` updates them and `backend/uv.lock`, and
`scripts/verify_versions.sh <ver>` checks the result. Tag CI blocks publishing
on drift. See [RELEASING.md](RELEASING.md).
Full list: `make help`. ## What belongs in an `AGENTS.md`
**Module commands** (from each module): An agent reads these files on every task, so each line costs attention. A guide
holds four kinds of content:
```bash 1. Scope: what the directory owns, in one or two sentences.
# Backend (see backend/AGENTS.md for the full set) 2. Verification: the exact commands.
cd backend && make dev # Gateway API with reload (port 8001) 3. Traps: where the code misleads, or where a plausible change breaks something
cd backend && make test # Four parallel shards; excludes live and blocking-I/O tests that a local test run does not show.
cd backend && make test-blocking-io # Strict blocking-I/O suite 4. Decisions and their reasons that the code cannot state, and links to docs.
cd backend && make lint # ruff check
cd backend && make format # ruff format
# Frontend (see frontend/AGENTS.md for the full set) Values, defaults, field lists, function inventories, test names, issue numbers
cd frontend && pnpm dev # Dev server: Webpack by default (override with DEER_FLOW_DEV_BUNDLER=turbo) and history stay in the code, the tests and git. Write full sentences in the
cd frontend && pnpm check # Lint + type check (run before committing) positive form and give the reason. `make check-agent-guidance` enforces the size
cd frontend && pnpm test # Unit tests budget.
```
Host pnpm calls use `scripts/pnpm.py`: native Windows tries `pnpm.cmd` before ### Office preview example
`pnpm`; POSIX reverses the order. Its Corepack fallback applies the same ordering
to `corepack.cmd` and `corepack`. The runner operates from `frontend/` so
Corepack honors its pinned package-manager version.
### Prerequisites before `make dev` `examples/deerflow-extension-office-preview/` packages Office readers through the
optional `artifactPreviews` browser contract. Keep parser dependencies and resources
Run `make config`, then `make install`, then `make dev` on first setup. in the extension; rebuild its manifest and static module graph after UI changes.
`make dev` requires existing config; both runtime configs are API-editable and Test packaged assets with the Office E2E fixtures; rendering must preserve original
gitignored. Never commit them. downloads and state its search limits.
### Run a single test
```bash
# Backend (pytest); run one file or one test function
cd backend && python -m pytest tests/test_compose_default_bind_host.py -q
cd backend && python -m pytest tests/path/to/test.py::test_func -q
# Frontend (rstest)
cd frontend && pnpm rstest run <pattern> # e.g. pnpm rstest run my-component
```
### Logs
- Docker stack: `make docker-logs` (or `docker compose -f docker/... logs -f <svc>`).
- Local `make dev`: Gateway and frontend output goes to
`logs/gateway.log` and `logs/frontend.log` in the repository root.
Run `tail -f logs/gateway.log logs/frontend.log` there to follow both.
## References
- Backend work → **[backend/AGENTS.md](backend/AGENTS.md)**
- Frontend work → **[frontend/AGENTS.md](frontend/AGENTS.md)**
- Setup & install → **[Install.md](Install.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**
- Project overview & usage → **[README.md](README.md)** (translations: `README_zh.md`,
`README_ja.md`, `README_fr.md`, `README_ru.md`, `README_pt.md`, `README_ko.md`)
- Security policy → **[SECURITY.md](SECURITY.md)**
- Changes → **[CHANGELOG.md](CHANGELOG.md)**
- Cutting a release → **[RELEASING.md](RELEASING.md)**
## Cross-Cutting Conventions
Repo-wide conventions:
- Update `README.md` for user-facing changes and the relevant `AGENTS.md` for
development/architecture changes in the same change set.
- **Test-driven development** — features and bug fixes ship with tests. Backend tests live
in `backend/tests/` (TDD is mandatory there; see [backend/AGENTS.md](backend/AGENTS.md));
frontend tests live in `frontend/tests/`.
Skill-creator tests must preserve YAML values/body, restore bytecode settings,
and check invalid metadata through offline CLI subprocesses.
Public skill script regressions live in `tests/skills/` and run in Skill Review CI
independently of package review. Exercise dependency fallbacks with the optional
import unavailable and mock HTTP transport to keep tests offline.
Video, podcast, and chart-visualization CLI failures exit non-zero;
diagnostics on stderr, success URLs on stdout; test offline.
- **Before pushing** — backend `make format`, frontend `pnpm check`.
Backend CI enforces `ruff format --check`.
- **Skill text encoding** — use explicit UTF-8 for textual skill resources.
Read `SKILL.md` with `encoding="utf-8-sig"` to strip a leading BOM; write with
`encoding="utf-8"`. Never rely on the platform locale.
- **Version sources must stay in lockstep** — a release version must match identically in
`backend/pyproject.toml`, `frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml`
(`version` + `appVersion`), and `backend/uv.lock` must record the same version for the root
package (uv stores its PEP 440 form, e.g. `2.1.0rc0`). Pushing a `v*` git tag triggers CI
that runs `scripts/verify_versions.sh` and **blocks all publishing** if any source drifts.
Before bumping a version, run `scripts/bump_version.sh <ver>` (aligns the four fields and
refreshes `backend/uv.lock` at once — it needs `uv` on `PATH`) and
`scripts/verify_versions.sh <ver>` to catch drift early. See [RELEASING.md](RELEASING.md).
- **Don't edit `CLAUDE.md`** — it only contains `@AGENTS.md`. All agent guidance changes
belong here in `AGENTS.md`; `CLAUDE.md` is a thin import shim.

View File

@ -7,9 +7,479 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
This section accumulates work toward the **2.3.0** milestone
([2.3.0](https://github.com/bytedance/deer-flow/milestone/4)).
### Added
- **summarization:** The built-in compaction prompt guides the summarizer to
preserve active user requirements, literal approval phrases, action conditions,
and explicit revisions. Explicit `summarization.summary_prompt` configurations
continue to take precedence. ([#8114](https://github.com/bytedance/deer-flow/pull/8114))
- **sandbox:** Short ranged `read_file` calls on uploaded and workspace files
display source line numbers when the result contains 1–40 lines and the
labeled view fits the output budget. ([#8109](https://github.com/bytedance/deer-flow/pull/8109))
- **projects:** Owner-scoped project memory with automatic-extraction, delete and
move-to-instructions controls. Project facts share DeerMem's extraction call
while remaining isolated from user facts. New shelf documents receive
best-effort summaries by default. ([#8117])
- **hitl:** Optional Ask, Auto and Full tool-permission modes, independent of
reasoning mode. Auto uses a separate tool-free review call with an existing
model profile; uncertain or high-risk operations require a human. Cards show
complete arguments and support approve, reject, edit and exact-operation chat
grants with revocation. Root approvals survive restart through checkpoints;
interactive subagents wait through an owner-scoped channel with cancellation
and expiry. Mandatory deployment rules and account permissions apply in every
mode. Personal/platform MCP connections, policy and arguments bind approvals.
Disabled by default; see the configuration guide. `config_version` 66.
- **knowledge:** The WeKnora retrieval extension offers opt-in shared read-only
access for authenticated users, without maintaining a per-user allowlist.
Exact-user allowlists remain the default; shared access requires matching
host-verified identity and grants no management permissions. ([#7944](https://github.com/bytedance/deer-flow/pull/7944))
- **community:** New SpecStream `web_search` and `web_fetch` provider. Search returns
ranked results with a direct answer when the sources agree on one, and fetch
returns a page or document as markdown. ([#7968](https://github.com/bytedance/deer-flow/pull/7968))
- **subagents:** Durable batch items can wait for explicitly named prerequisites.
An optional, once-only lead-Agent follow-up completes a previously requested
summary or delivery after the batch ends, with persisted admission, cancellation
and authorization checks. Active Goals retain continuation ownership. ([#7839](https://github.com/bytedance/deer-flow/pull/7839))
- **chat:** Running Web chats offer Queue and Steer in the composer. Queue
waits for the next turn; Steer lets the main Agent handle updates while
native subagents pause and resume. Unconfirmed messages can be retried
after a Gateway restart. ([#7729])
- **models:** Administrators can select StepFun in **Settings → Models** and
connect an ordinary StepFun API account with an API key and model ID.
([#7988](https://github.com/bytedance/deer-flow/pull/7988))
### Changed
- **checkpoint:** DeltaChannel deployments now require `langgraph-checkpoint`
4.3.0, SQLite saver 3.1.2, and Postgres saver 3.1.3, excluding versions that
could reorder parallel writes during replay or lose paged ancestor history.
The ordering and Postgres pagination contracts now fail on regression instead
of skipping. ([#8139])
### Fixed
- **gateway:** Creating a thread with checkpoint-owned keys such as `step`,
`parents` or `counters_since_delta_snapshot` in its metadata no longer
leaves the thread broken. Those keys used to land on the thread's first
checkpoint, so a bad `parents` made the thread's history fail with 500 for
good, and a bad `step` or delta counter made every later turn fail. A
`deerflow_agent_name` key was also copied into the next state update as if
the server had set it. The first checkpoint now takes none of these keys
from the request. ([#8019])
- **mcp:** Two enabled MCP servers could bind the same tool name (server `a`
exposing `b_x` and server `a_b` exposing `x` both bind `a_b_x`). Both
declarations reached the model and the provider silently kept only one.
Duplicate bound names are now dropped with a warning naming the surviving
server, in enabled-server declaration order. Note the surviving server is
stable only while its discovery keeps succeeding: if the winner's discovery
fails transiently, the later duplicate becomes served again, so the
implementation behind a bound name can alternate across cache generations
with server health.
([#8135](https://github.com/bytedance/deer-flow/pull/8135))
- **channels:** WeCom no longer drops images between 2 MB and 10 MB. The
adapter capped image uploads at 2 MB — the voice limit in the same protocol —
so a larger chart was skipped before upload and the turn arrived without it.
The cap now matches the platform's 10 MB for images; the 20 MB file limit is
unchanged. ([#8174])
- **threads:** Deleting a conversation on Windows no longer fails with
`Failed to delete local thread data.` when the workspace holds a file whose
path exceeds `MAX_PATH`. The thread directory is now resolved through the
Windows extended-length namespace before removal.
([#8144](https://github.com/bytedance/deer-flow/pull/8144))
- **channels:** A WeCom group command that follows the bot mention now runs as a
command. WeCom keeps the mention inside the message text (`@RobotA /new`), and
the classifier required a bare leading `/`, so `/new` and `/status` sent in a
group became chat turns. Leading mentions are skipped for the command lookup
only; ordinary chat keeps them and `/connect` is unchanged. Mirrors the Feishu
and DingTalk handling. ([#8118])
- **subagents:** Apply tool allowlists and denylists to middleware-declared
tools even when global authorization is disabled, including the default
denial of `task`. Restricted subagents reject non-Sequence declarations
explicitly, and invalid declarations report middleware-specific configuration
guidance. Final graph assembly checks both explicit and middleware tools;
framework-generated discovery helpers retain their filtered catalogs.
([#8136](https://github.com/bytedance/deer-flow/pull/8136))
- **frontend:** **Share** in a sidebar chat's menu now copies a link to the
deployment you are using. On `localhost` and `127.0.0.1`, the default
`make dev` and Docker addresses, it copied a link to the hosted demo
(`deer-flow-v2.vercel.app`) instead. That link never opened the
conversation, and opening it sent the thread id to an outside host.
([#8126])
- **runtime:** In multi-worker mode (`run_ownership.heartbeat_enabled`), a
worker that had lost its run to a peer could put the row back. Its assembly
evidence write was an unconditional full-row upsert, so after a peer
reclaimed the run it reset the row to `running` and erased the takeover
error. The run could then finish as `success` after the peer had already
reported it failed, or be reclaimed a second time. The evidence is now merged
into the row's metadata only while the worker still holds a live lease on an
active run (`RunStore.merge_owned_metadata()`); otherwise the worker stops
the run before executing it. Custom `RunStore` implementations used with
heartbeats must implement that method. ([#8046])
- **subagents:** Concurrent creates for the same managed-subagent name in shared
file storage now preserve the winning definition and return HTTP 409 to the
losing request instead of reporting two successes and overwriting one definition.
Gateway startup also probes hard-link support and warns if the storage volume
cannot support managed subagent creation. ([#8132](https://github.com/bytedance/deer-flow/pull/8132))
- **agents:** A high-risk tool call without a usable id no longer wedges the
thread when `hitl_approval` is enabled. A provider that omits a tool-call id
(`None`) or repeats one within a response made the middleware pause with an
approval payload the Gateway can never validate: every resume answered 422
and every new run stayed 409-blocked, all-reject included. An empty id was
technically resumable but is denied as well, since strict providers reject it
when the conversation is replayed. Such calls are now removed from the model
response with a visible rejection note instead of pausing, and deny paths no
longer build an invalid `ToolMessage` for a null id. A call whose id an
ordinary call also carries is stripped before it can be answered by result,
so the next request cannot advertise the id twice with two results.
A pause stored before
this change stays unresolvable; in-place recovery is tracked in [#8071].
([#8069])
- **runtime:** A HITL rejection, including the all-reject escape after approval is
disabled or the gated tools change during a pause, now reaches the thread's
message history under the resume run instead of existing only in checkpoint
state. ([#8073](https://github.com/bytedance/deer-flow/pull/8073))
- **summarization:** vLLM compaction counts reasoning tokens replayed to the
model when checking the trigger and estimating how much history to keep.
([#8077](https://github.com/bytedance/deer-flow/pull/8077))
- **gateway:** Posting feedback twice for the same run now returns a 409
instead of a 500. `POST /api/threads/{id}/runs/{run_id}/feedback` allows one
feedback row per user and run; a retry, or feedback on another message of the
same run, hit that unique key and the Gateway failed with an unhandled
database error. The 409 points callers at `PUT` on the same route, which
updates the existing feedback. ([#8064])
- **frontend:** Reopening a conversation restores successfully handed-off shared
artifacts in the Artifacts sidebar. ([#8080](https://github.com/bytedance/deer-flow/pull/8080))
- **sandbox:** AIO commands from independent subagents now run concurrently when
they carry per-call environment variables, including Lark CLI commands.
Commands within one subagent remain ordered and client cleanup drains them.
- **sandbox:** BoxLite VMs recovered at startup now run health checks, commands,
and cleanup on the provider's event loop, avoiding sync/async SDK mismatches
that made healthy recovered VMs fail their first command. ([#8072])
- **uploads:** Re-uploading a file whose name collides with a compound archive
suffix no longer inserts the dedupe counter inside the suffix. A second
`report.tar.gz` is now named `report_1.tar.gz` (matching `report_1.tar` and
`report_1.txt`), instead of `report.tar_1.gz`, so the extension stays intact
for file-type detection. ([#8042](https://github.com/bytedance/deer-flow/pull/8042))
- **gateway:** Deleting a thread no longer reports success while its
conversation stays readable. When the checkpoint store failed during
`DELETE /api/threads/{id}`, the route logged it at DEBUG, removed the
thread's metadata row and returned success; any signed-in user could then
read the leftover title and messages through `GET /threads/{id}`, `/state`,
`/history` and `/goal`, and the owner could not retry. The delete now
returns a 500 and keeps the thread, so the owner can delete it again.
([#7992])
- **gateway:** Creating a thread with a caller-supplied `thread_id` that
already belongs to another user now returns a non-disclosing 404
(`Thread not found`) instead of a 500. The duplicate-key recovery path reads
the surviving row back bypassing the owner scope so the cross-owner case
mirrors `ThreadOwnershipConflictError`; a duplicate key with no surviving row
is still surfaced as a 500. ([#8057])
- **models:** Cancelling a Codex-backed run now closes its upstream HTTP/SSE
connection, including while waiting for response headers, and retry waits
are cancellable. Local resources are released; the remote service may still
continue generation. ([#8053](https://github.com/bytedance/deer-flow/pull/8053))
- **middleware:** With PII redaction enabled, a long run of text without
spaces (such as a pasted hex dump) no longer stalls the Gateway. The email
detector was quadratic in the length of such a run, and redaction re-scans
every user message on every model call, so one 100 KB paste blocked every
thread for about 6 s per model call. The scan is now linear, and it redacts
exactly the same addresses as before. ([#8006])
- **agents:** Token budgets count separate accepted model calls even when a
provider reuses message IDs, so repeated IDs no longer bypass the configured
cap. Hard stops preserve already-completed structured-output tool results.
([#8050](https://github.com/bytedance/deer-flow/pull/8050))
- **subagents:** Managed subagent creation now rejects Windows reserved device
names (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`) at the shared
`normalize_managed_subagent_name(..., for_creation=True)` validator and
Gateway `/api/subagents` boundary before attempting filesystem writes,
preventing `OSError` failures on Windows hosts while keeping existing
read and delete operations permissive.
- **scheduler:** A stored `schedule_spec` that is JSON null, a list or a string
no longer aborts the scheduler poll. `stored_next_run_at` caught the value
errors from a dropped zone or a newer cron parser, but a non-object spec
fails on `.get`; the launch-failure handler recomputed it again, so the
`AttributeError` escaped `run_once`'s claimed-task loop. Such a task is now
paused with the reason, like the other unusable schedules. The poll loop also
isolates one task's dispatch failure from the rest of its claimed batch, so a
future per-record raise costs one tick instead of every other claimed task.
([#7998])
- **scheduler:** One scheduled task whose saved schedule can no longer be
computed no longer stops every other task. When a stored timezone or cron
expression stopped working (a zone missing from the time zone database
after an upgrade, a cron expression a newer croniter rejects), recomputing
its next run raised inside the scheduler poll; once its launch lease
expired, every later poll failed before admitting any due task. That task
is now paused with the reason as its last error, and resuming it reports
the problem until the schedule is fixed. ([#7993])
- **models:** A Claude model using a Claude Code OAuth token no longer fails
every request once the request carries `betas`. The OAuth betas
(`oauth-2025-04-20`, …) were only the client's default `anthropic-beta`
header, and the SDK sends a request's `betas` as that header instead, so a
configured `betas` list (even `[]`), per-call `betas`, or a server tool for
which langchain-anthropic adds a beta left them out and Anthropic rejected
the token. The OAuth betas now lead any request `betas` and any
`anthropic-beta` in `extra_headers`.
([#8061](https://github.com/bytedance/deer-flow/pull/8061))
- **community:** A search or fetch API key that ends in a newline no longer
leaks into the conversation. GroundRoute (`GROUNDROUTE_API_KEY`), InfoQuest,
Tavily, Exa, Jina, Firecrawl, fastCRW and Crawl4AI put the key into an HTTP
header as read, so a key from a file (a Kubernetes Secret created with
`--from-file`) made the transport fail with `Illegal header value b'Bearer
<key>\n'`. That message, key included, became the tool result the model and
the conversation history kept, and four of them also logged it. Keys now go
through `provider_credentials.resolve_api_key` like the other providers', and
a blank configured key falls back to the environment for Tavily, Exa,
Firecrawl and fastCRW. fastCRW without a key of its own no longer lets the
Firecrawl SDK send `FIRECRAWL_API_KEY` to the fastCRW endpoint, and a blank
`TAVILY_API_KEY`, `EXA_API_KEY` or `FIRECRAWL_API_KEY` now counts as missing
instead of reaching the SDK as is. ([#7981])
- **community:** Browserless is the last search/fetch provider that read its
token raw. A `BROWSERLESS_TOKEN` or configured `token` from a file kept its
trailing newline and became the auth query parameter, so every `web_fetch`
and `web_capture` request failed authentication while the key was correct.
Both now go through `provider_credentials.resolve_api_key`, and only an
omitted `token` key falls back to the environment: an explicit `""` or
`null` keeps opting a self-hosted capture endpoint out of authentication.
([#8043])
- **docker:** Images built from a checkout no longer contain its local
configuration. `.dockerignore` did not exclude `config.yaml`,
`config.yaml.bak`, `extensions_config.json` or `mcp_config.json`, so a
checkout that keeps them in the legacy `backend/` location baked its model
API keys and MCP credentials into every gateway image layer through
`COPY backend ./backend`. `frontend/.env.local` reached the frontend image the
same way. They are now excluded at the root and in `backend/`, along with
`.env*.local` at any depth, `frontend/*.pem`, and the credential files the
extension manager refuses inside extension snapshots. A locally built
frontend image no longer inlines `NEXT_PUBLIC_*` values from
`frontend/.env.local`; the Docker stack's single nginx origin needs none of
them. Published CI images were not affected; rebuild local images that were
pushed or shared. ([#7958])
- **agents:** Unfinished todos no longer trigger additional model calls or
tool execution after a loop-detection, run token-budget, provider-safety, or
model output-length stop. Todo state is preserved so users can continue in a
later run. ([#7953])
- **runtime:** A thread can accept new runs and mutations after a transient
database failure leaves a completed operation's reservation in place. The
next request for the thread on the same Gateway worker retries the release,
including in single-worker deployments with lease heartbeats disabled.
([#8068](https://github.com/bytedance/deer-flow/pull/8068))
- **channels:** Long Feishu/Lark answers are no longer lost. Card text was
unbounded and ASCII-escaped, so an answer of about 4,400 CJK characters made
the card request exceed Feishu's 30 KB limit (`230025`); the final patch and
its reply fallback were both rejected, and the running card stayed on its
last streamed text or a bare `▉`. Cards now send CJK unescaped and are
budgeted by the bytes they add to the request. A streamed update shows what
fits, and a longer final answer continues in up to 10 cards replied to the
same message, split at line breaks; a retry resumes after the cards already
delivered. ([#7798])
- **channels:** WeCom group messages now have their own conversation. Every
message used the sender's userid as both chat and topic ID, so a member's
private chat and every group they wrote in shared one DeerFlow thread
wherever no group binding kept them apart: in auth-disabled mode, with
`require_bound_identity: false`, and under legacy group bindings. A group
reply could then draw on the member's private chat or another group, and a
reply sent without its frame went to the private chat. Groups are now keyed
by `chatid` with one thread per member, and private chats keep their
threads. A group bound with its own `/connect` keeps its thread; a legacy
group binding, which covers every group of the bot, starts a new one in
each group. ([#7794])
- **memory:** DeerMem rejects backups with duplicate fact IDs before replacing
saved memory, regardless of entry order. ([#8151])
- **memory:** Importing a backup without a `facts` array now returns HTTP 422
and preserves saved memory. An explicit empty array still clears the selected
fact bucket. ([#8131])
- **memory:** An agent-scoped memory write whose `agent_name` is a Windows
reserved device name (`NUL`, `CON`, `COM1`, ...) is now rejected with a `422`
on the two routes that create that scope, `POST /memory/facts` and
`POST /memory/import`. DeerMem resolves the scope to
`{storage}/users/{uid}/agents/{name}/facts` and creates it on the first write;
the character-class check admitted such names, so the write raised
`FileNotFoundError` and the endpoint answered an opaque `500`. The check reuses
`windows_incompatible_segment()` and applies on every platform. Read and delete
routes stay permissive, so a scope with a reserved-name identifier that an
older release stored on a Linux host can still be exported and cleared.
([#7941])
- **skills:** A custom skill name that is a Windows reserved device name
(`nul`, `con`, `com1`, ...) is now rejected when the skill is created —
`skill_manage` `create` and `.skill` archive install — on every platform. The
hyphen-case grammar admitted such names, and the skill name becomes a
directory under `custom/`, so creating one "succeeded" on Windows without
producing a directory and the first write inside it failed with
`FileNotFoundError`; on Linux the install succeeded but could never be
copied, mounted or exported to a Windows host. The archive path also refuses
member path segments that cannot be created on every host, matching support
file writes. Reading and deleting an existing skill stay permissive, so a
skill an older release created on a Linux host can still be inspected and
removed. ([#7945])
- **sandbox:** Fresh lazy AIO runs with an explicit authorized skill view no
longer create or acquire a sandbox before the first sandbox-backed tool
call. Existing sandbox bindings and providers that require skill uploads
retain their eager synchronization behavior. ([#7782])
- **parsing:** Follow-up suggestions and the `/goal` evaluator keep a literal
`<think>` tag that appears inside a JSON string value, such as an answer
quoting the tag, while reasoning around the JSON is still removed. An
unclosed literal tag inside a string no longer truncates the document and
fails parsing, and reasoning outside the payload still strips and truncates
as before. ([#7907])
- **gateway:** A caller-supplied thread ID that is a Windows reserved device
name (`NUL`, `CON`, `COM1`, ...) is now rejected at the shared
`validate_thread_id()` boundary. Such IDs previously passed validation but
failed workspace creation on Windows hosts with `FileNotFoundError`. The
check reuses `windows_incompatible_segment()` and applies on every platform,
so threads with reserved-name IDs created on Linux hosts will fail
validation on read/resume/upload routes after upgrade (deletion still
works). ([#7892])
- **extensions:** Context-compaction observers now learn which task a
compaction happened in. Inside a lead run or subagent they receive that
task's store instead of a detached one, and `CompactionEvent` gains
`task_id`, `run_id` and `thread_id` (default `None`), copied from the
`TaskInfo` the host now seeds into every task store even when no lifecycle
contributor is registered. A manual `/compact` still gets a detached store
and `None` ids. `deerflow-extension-api` is now 0.2.10; existing
extensions keep working. ([#7831])
- **channels:** A DingTalk reply in AI card mode (`card_template_id`) is no
longer lost when a card request fails, for example without card permission
or during a DingTalk outage. dingtalk-stream logs a failed card request and
returns as if it succeeded, so neither the text running reply nor the
`sampleMarkdown` fallback for the final answer ever ran. The channel now
sends card requests itself and falls back when one fails. ([#7781])
- **harness:** Retrying a failed item no longer reopens a cancelled durable
subagent batch. `retry_item` kept only a paused batch as it was, so a failed
item of a cancelled batch put the batch back to `queued`, the worker claimed
and ran the item, and the batch ended `completed`. The retry route now
answers 409 for a cancelled batch, including when the cancel commits while
the retry is in flight on SQLite, and the workspace no longer offers Retry
there. Retrying in a completed, failed, running or paused batch is
unchanged. ([#7792])
- **gateway:** A run that names a missing or invalid custom agent now says so.
Messaging a chat whose custom agent was deleted, or passing a malformed
`agent_name`, returned the 422 detail "knowledge_scope assistant configuration
could not be resolved" even when no knowledge scope was involved. The 422 now
reports `Agent '<name>' not found`, an invalid `agent_name`, or an agent
config that could not be loaded. ([#8087])
- **agents:** `PUT /api/agents/{name}` with `"description": null` now resets the
description to empty. It used to store the null, which the agent config
rejects, so the agent then failed every load: the update, GET and repair
updates returned 500, the file backend dropped it from the agent list, the
database backend failed the owner's whole list, and chats with it failed.
Agents already stored with a null description load again. ([#7638])
- **sandbox:** Cancelled native Runs now stop their owned AIO and POSIX local
bash processes, including ordinary `nohup` children, before releasing the
execution lease. Concurrent work and background services from successful
earlier turns remain running. ([#7772])
- **agents:** A clarification no longer overwrites an earlier question when the
provider reuses a tool-call ID across turns (for example `call_0` on every
response). Its message ID, which is also the human-input `request_id`, named
only the call ID, so the later question replaced the earlier one in place and
the later call was left without a result. The ID now also names the
AIMessage that issued the call; a retry of the same call keeps its ID.
([#7762])
- **projects:** The trash retention sweep no longer deletes live document
originals when the data directory, `users/`, a user's directory or a
user's `projects/` is a symlink. It protected each document by its resolved
path but walked the unresolved tree, so no document matched and every
original older than 24 hours was removed as an orphan, at Gateway startup
and from `GET /api/trash/documents`. ([#7732])
- **chat:** Cancelling a queued input after dispatch starts no longer
prevents subsequent queued inputs from executing. ([#7746])
- **dev:** `scripts/dev_multi_instance.sh logs all` now prints the nginx logs
as well as both Gateway logs; it printed only the Gateway logs, the same as
`logs` with no argument. `logs nginx` and `logs all` also include `nginx.log`,
the nginx start command's own output, which no option printed before.
([#7723])
- **memory:** `GET /api/memory/config` and `/api/memory/status` now mask
credential values in `backend_config` (`api_key`, tokens, passwords,
`Authorization` headers, at any depth) as `***`. They returned the config as
loaded, with `$VAR` references already resolved, so a DeerMem extraction
model key or a Honcho `api_key` reached every caller with `memory:read` in
plaintext, which with authorization disabled (the default) is every
signed-in user. Non-secret knobs, `api_key_env` names and unset values are
unchanged. ([#7665])
- **gateway:** `GET /threads/{thread_id}/token-usage` now resolves the live
config in a worker thread. `build_context_usage` called the accessor inline,
and `get_app_config()` revalidates on every call — it digests the whole
`config.yaml`, then `extensions_config.json` — so a config edit landing just
before a thread view stalled the Gateway event loop that runs, streams and
every other thread share. The response is unchanged. ([#7727])
- **community:** The shared SSRF guard now returns its documented `Error: ...`
rejection for malformed URLs instead of raising `ValueError`.
`validate_public_http_url` and `validate_delegated_backend_url` called
`urlparse()` unguarded, so an authority it rejects, such as the unmatched
IPv6 bracket in `http://[::1/api`, escaped to the caller. That surfaced as an
unhandled 500 from the personal MCP router and as generic exception text in
the crawl4ai and fastCRW `web_fetch` tools. Both validators now reject any
malformed authority before the scheme check and the private-address escape
hatches, including a raw bracket in userinfo, which RFC 3986 forbids and
Python versions disagree on. This is a fail-closed contract fix plus
version-drift hardening: no host reachable through a well-formed URL becomes
blocked. ([#8079])
## [2.2.0] — 2026-10-29
This section accumulates work toward the **2.2.0** milestone This section accumulates work toward the **2.2.0** milestone
([2.2.0](https://github.com/bytedance/deer-flow/milestone/3)). ([2.2.0](https://github.com/bytedance/deer-flow/milestone/3)).
As of 2026-10-28, the milestone includes **1068 merged pull requests**. This release closes that milestone with **1068 merged pull requests**.
### Added ### Added
@ -14678,7 +15148,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5217]: https://github.com/bytedance/deer-flow/pull/5217 [#5217]: https://github.com/bytedance/deer-flow/pull/5217
[#5219]: https://github.com/bytedance/deer-flow/pull/5219 [#5219]: https://github.com/bytedance/deer-flow/pull/5219
[#5221]: https://github.com/bytedance/deer-flow/pull/5221 [#5221]: https://github.com/bytedance/deer-flow/pull/5221
[#5224]: https://github.com/bytedance/deer-flow/pull/5224 [#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
[#5225]: https://github.com/bytedance/deer-flow/pull/5225 [#5225]: https://github.com/bytedance/deer-flow/pull/5225
[#5227]: https://github.com/bytedance/deer-flow/pull/5227 [#5227]: https://github.com/bytedance/deer-flow/pull/5227
[#5228]: https://github.com/bytedance/deer-flow/pull/5228 [#5228]: https://github.com/bytedance/deer-flow/pull/5228
@ -14687,9 +15157,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5236]: https://github.com/bytedance/deer-flow/pull/5236 [#5236]: https://github.com/bytedance/deer-flow/pull/5236
[#5238]: https://github.com/bytedance/deer-flow/pull/5238 [#5238]: https://github.com/bytedance/deer-flow/pull/5238
[#5239]: https://github.com/bytedance/deer-flow/pull/5239 [#5239]: https://github.com/bytedance/deer-flow/pull/5239
[#5244]: https://github.com/bytedance/deer-flow/pull/5244 [#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
[#5245]: https://github.com/bytedance/deer-flow/pull/5245 [#5245]: https://github.com/bytedance/deer-flow/pull/5245
[#5247]: https://github.com/bytedance/deer-flow/pull/5247 [#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
[#5249]: https://github.com/bytedance/deer-flow/pull/5249 [#5249]: https://github.com/bytedance/deer-flow/pull/5249
[#5251]: https://github.com/bytedance/deer-flow/pull/5251 [#5251]: https://github.com/bytedance/deer-flow/pull/5251
[#5254]: https://github.com/bytedance/deer-flow/pull/5254 [#5254]: https://github.com/bytedance/deer-flow/pull/5254
@ -14746,7 +15216,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5367]: https://github.com/bytedance/deer-flow/pull/5367 [#5367]: https://github.com/bytedance/deer-flow/pull/5367
[#5369]: https://github.com/bytedance/deer-flow/pull/5369 [#5369]: https://github.com/bytedance/deer-flow/pull/5369
[#5371]: https://github.com/bytedance/deer-flow/pull/5371 [#5371]: https://github.com/bytedance/deer-flow/pull/5371
[#5373]: https://github.com/bytedance/deer-flow/pull/5373 [#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
[#5374]: https://github.com/bytedance/deer-flow/pull/5374 [#5374]: https://github.com/bytedance/deer-flow/pull/5374
[#5375]: https://github.com/bytedance/deer-flow/pull/5375 [#5375]: https://github.com/bytedance/deer-flow/pull/5375
[#5377]: https://github.com/bytedance/deer-flow/pull/5377 [#5377]: https://github.com/bytedance/deer-flow/pull/5377
@ -14773,7 +15243,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5408]: https://github.com/bytedance/deer-flow/pull/5408 [#5408]: https://github.com/bytedance/deer-flow/pull/5408
[#5410]: https://github.com/bytedance/deer-flow/pull/5410 [#5410]: https://github.com/bytedance/deer-flow/pull/5410
[#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5411]: https://github.com/bytedance/deer-flow/pull/5411
[#5413]: https://github.com/bytedance/deer-flow/pull/5413 [#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
[#5415]: https://github.com/bytedance/deer-flow/pull/5415 [#5415]: https://github.com/bytedance/deer-flow/pull/5415
[#5416]: https://github.com/bytedance/deer-flow/pull/5416 [#5416]: https://github.com/bytedance/deer-flow/pull/5416
[#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5418]: https://github.com/bytedance/deer-flow/pull/5418
@ -14956,8 +15426,8 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5736]: https://github.com/bytedance/deer-flow/pull/5736 [#5736]: https://github.com/bytedance/deer-flow/pull/5736
[#5738]: https://github.com/bytedance/deer-flow/pull/5738 [#5738]: https://github.com/bytedance/deer-flow/pull/5738
[#5739]: https://github.com/bytedance/deer-flow/pull/5739 [#5739]: https://github.com/bytedance/deer-flow/pull/5739
[#5740]: https://github.com/bytedance/deer-flow/pull/5740 [#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
[#5741]: https://github.com/bytedance/deer-flow/pull/5741 [#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
[#5745]: https://github.com/bytedance/deer-flow/pull/5745 [#5745]: https://github.com/bytedance/deer-flow/pull/5745
[#5748]: https://github.com/bytedance/deer-flow/pull/5748 [#5748]: https://github.com/bytedance/deer-flow/pull/5748
[#5750]: https://github.com/bytedance/deer-flow/pull/5750 [#5750]: https://github.com/bytedance/deer-flow/pull/5750
@ -14970,7 +15440,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5760]: https://github.com/bytedance/deer-flow/pull/5760 [#5760]: https://github.com/bytedance/deer-flow/pull/5760
[#5761]: https://github.com/bytedance/deer-flow/pull/5761 [#5761]: https://github.com/bytedance/deer-flow/pull/5761
[#5762]: https://github.com/bytedance/deer-flow/pull/5762 [#5762]: https://github.com/bytedance/deer-flow/pull/5762
[#5763]: https://github.com/bytedance/deer-flow/pull/5763 [#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
[#5767]: https://github.com/bytedance/deer-flow/pull/5767 [#5767]: https://github.com/bytedance/deer-flow/pull/5767
[#5769]: https://github.com/bytedance/deer-flow/pull/5769 [#5769]: https://github.com/bytedance/deer-flow/pull/5769
[#5775]: https://github.com/bytedance/deer-flow/pull/5775 [#5775]: https://github.com/bytedance/deer-flow/pull/5775
@ -15038,7 +15508,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5880]: https://github.com/bytedance/deer-flow/pull/5880 [#5880]: https://github.com/bytedance/deer-flow/pull/5880
[#5881]: https://github.com/bytedance/deer-flow/pull/5881 [#5881]: https://github.com/bytedance/deer-flow/pull/5881
[#5883]: https://github.com/bytedance/deer-flow/pull/5883 [#5883]: https://github.com/bytedance/deer-flow/pull/5883
[#5884]: https://github.com/bytedance/deer-flow/pull/5884 [#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
[#5888]: https://github.com/bytedance/deer-flow/pull/5888 [#5888]: https://github.com/bytedance/deer-flow/pull/5888
[#5890]: https://github.com/bytedance/deer-flow/pull/5890 [#5890]: https://github.com/bytedance/deer-flow/pull/5890
[#5892]: https://github.com/bytedance/deer-flow/pull/5892 [#5892]: https://github.com/bytedance/deer-flow/pull/5892
@ -15100,9 +15570,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5999]: https://github.com/bytedance/deer-flow/pull/5999 [#5999]: https://github.com/bytedance/deer-flow/pull/5999
[#6000]: https://github.com/bytedance/deer-flow/pull/6000 [#6000]: https://github.com/bytedance/deer-flow/pull/6000
[#6008]: https://github.com/bytedance/deer-flow/pull/6008 [#6008]: https://github.com/bytedance/deer-flow/pull/6008
[#6009]: https://github.com/bytedance/deer-flow/pull/6009 [#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
[#6013]: https://github.com/bytedance/deer-flow/pull/6013 [#6013]: https://github.com/bytedance/deer-flow/pull/6013
[#6015]: https://github.com/bytedance/deer-flow/pull/6015 [#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
[#6017]: https://github.com/bytedance/deer-flow/pull/6017 [#6017]: https://github.com/bytedance/deer-flow/pull/6017
[#6018]: https://github.com/bytedance/deer-flow/pull/6018 [#6018]: https://github.com/bytedance/deer-flow/pull/6018
[#6020]: https://github.com/bytedance/deer-flow/pull/6020 [#6020]: https://github.com/bytedance/deer-flow/pull/6020
@ -15126,7 +15596,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6060]: https://github.com/bytedance/deer-flow/pull/6060 [#6060]: https://github.com/bytedance/deer-flow/pull/6060
[#6062]: https://github.com/bytedance/deer-flow/pull/6062 [#6062]: https://github.com/bytedance/deer-flow/pull/6062
[#6063]: https://github.com/bytedance/deer-flow/pull/6063 [#6063]: https://github.com/bytedance/deer-flow/pull/6063
[#6066]: https://github.com/bytedance/deer-flow/pull/6066 [#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
[#6067]: https://github.com/bytedance/deer-flow/pull/6067 [#6067]: https://github.com/bytedance/deer-flow/pull/6067
[#6068]: https://github.com/bytedance/deer-flow/pull/6068 [#6068]: https://github.com/bytedance/deer-flow/pull/6068
[#6069]: https://github.com/bytedance/deer-flow/pull/6069 [#6069]: https://github.com/bytedance/deer-flow/pull/6069
@ -15152,7 +15622,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6105]: https://github.com/bytedance/deer-flow/pull/6105 [#6105]: https://github.com/bytedance/deer-flow/pull/6105
[#6107]: https://github.com/bytedance/deer-flow/pull/6107 [#6107]: https://github.com/bytedance/deer-flow/pull/6107
[#6109]: https://github.com/bytedance/deer-flow/pull/6109 [#6109]: https://github.com/bytedance/deer-flow/pull/6109
[#6112]: https://github.com/bytedance/deer-flow/pull/6112 [#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
[#6113]: https://github.com/bytedance/deer-flow/pull/6113 [#6113]: https://github.com/bytedance/deer-flow/pull/6113
[#6115]: https://github.com/bytedance/deer-flow/pull/6115 [#6115]: https://github.com/bytedance/deer-flow/pull/6115
[#6117]: https://github.com/bytedance/deer-flow/pull/6117 [#6117]: https://github.com/bytedance/deer-flow/pull/6117
@ -15253,7 +15723,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6298]: https://github.com/bytedance/deer-flow/pull/6298 [#6298]: https://github.com/bytedance/deer-flow/pull/6298
[#6299]: https://github.com/bytedance/deer-flow/pull/6299 [#6299]: https://github.com/bytedance/deer-flow/pull/6299
[#6302]: https://github.com/bytedance/deer-flow/pull/6302 [#6302]: https://github.com/bytedance/deer-flow/pull/6302
[#6304]: https://github.com/bytedance/deer-flow/pull/6304 [#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
[#6305]: https://github.com/bytedance/deer-flow/pull/6305 [#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6307]: https://github.com/bytedance/deer-flow/pull/6307
@ -15637,6 +16107,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7017]: https://github.com/bytedance/deer-flow/pull/7017 [#7017]: https://github.com/bytedance/deer-flow/pull/7017
[#7018]: https://github.com/bytedance/deer-flow/pull/7018 [#7018]: https://github.com/bytedance/deer-flow/pull/7018
[#7020]: https://github.com/bytedance/deer-flow/pull/7020 [#7020]: https://github.com/bytedance/deer-flow/pull/7020
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
[#7023]: https://github.com/bytedance/deer-flow/pull/7023 [#7023]: https://github.com/bytedance/deer-flow/pull/7023
[#7024]: https://github.com/bytedance/deer-flow/pull/7024 [#7024]: https://github.com/bytedance/deer-flow/pull/7024
[#7031]: https://github.com/bytedance/deer-flow/pull/7031 [#7031]: https://github.com/bytedance/deer-flow/pull/7031
@ -15909,3 +16380,46 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7643]: https://github.com/bytedance/deer-flow/pull/7643 [#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651 [#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659 [#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732
[#7746]: https://github.com/bytedance/deer-flow/pull/7746
[#7762]: https://github.com/bytedance/deer-flow/pull/7762
[#7772]: https://github.com/bytedance/deer-flow/pull/7772
[#7781]: https://github.com/bytedance/deer-flow/pull/7781
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7892]: https://github.com/bytedance/deer-flow/pull/7892
[#7907]: https://github.com/bytedance/deer-flow/pull/7907
[#7941]: https://github.com/bytedance/deer-flow/pull/7941
[#7945]: https://github.com/bytedance/deer-flow/issues/7945
[#7953]: https://github.com/bytedance/deer-flow/issues/7953
[#7958]: https://github.com/bytedance/deer-flow/pull/7958
[#7981]: https://github.com/bytedance/deer-flow/pull/7981
[#7992]: https://github.com/bytedance/deer-flow/pull/7992
[#7993]: https://github.com/bytedance/deer-flow/pull/7993
[#7998]: https://github.com/bytedance/deer-flow/issues/7998
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8057]: https://github.com/bytedance/deer-flow/pull/8057
[#8064]: https://github.com/bytedance/deer-flow/pull/8064
[#8069]: https://github.com/bytedance/deer-flow/issues/8069
[#8071]: https://github.com/bytedance/deer-flow/issues/8071
[#8072]: https://github.com/bytedance/deer-flow/pull/8072
[#8079]: https://github.com/bytedance/deer-flow/pull/8079
[#8087]: https://github.com/bytedance/deer-flow/pull/8087
[#8117]: https://github.com/bytedance/deer-flow/pull/8117
[#8118]: https://github.com/bytedance/deer-flow/pull/8118
[#8126]: https://github.com/bytedance/deer-flow/pull/8126
[#8131]: https://github.com/bytedance/deer-flow/pull/8131
[#8139]: https://github.com/bytedance/deer-flow/pull/8139
[#8151]: https://github.com/bytedance/deer-flow/pull/8151
[#8174]: https://github.com/bytedance/deer-flow/pull/8174

View File

@ -9,8 +9,200 @@
## [未发布] ## [未发布]
本节累积面向 **2.3.0** 里程碑([2.3.0](https://github.com/bytedance/deer-flow/milestone/4))的工作。
### 新增
- **子智能体:** 后台批次可声明条目前置关系,并可登记一次主 Agent 接续,在批次结束后完成
用户已要求的汇总或交付。接续保留持久化启动记录、取消和授权检查;启用 Goal 时仍由 Goal 负责续跑。([#7839](https://github.com/bytedance/deer-flow/pull/7839))
- **对话:** 网页端在任务运行时,可在输入框选择 Queue(排队执行)或 Steer(调整当前任务)。
Queue 等当前任务结束后再执行下一条消息;Steer 让主 Agent 先处理新要求,原生子 Agent 可暂停后恢复。
Gateway 重启后,尚未确认生效的消息可以手动重试。([#7729])
- **模型:** 管理员可在 **设置 → 模型** 中选择 StepFun(阶跃星辰),
填写 API Key 和模型 ID 即可接入普通 API。([#7988](https://github.com/bytedance/deer-flow/pull/7988))
### 变更
- **Checkpoint:** DeltaChannel 部署现在要求 `langgraph-checkpoint` 4.3.0、
SQLite saver 3.1.2 和 Postgres saver 3.1.3,排除会在重放时重排并行写入或
丢失分页祖先历史的版本。顺序和 Postgres 分页契约在回归时现在会直接失败,
不再跳过。([#8139])
### 修复
- **网关:** 创建对话时在元数据中带上 `step`、`parents`、`counters_since_delta_snapshot`
等由检查点自身维护的键,不再让该对话损坏。此前这些键会写入对话的首个检查点,错误的 `parents`
会让该对话的历史记录永久返回 500,错误的 `step` 或增量计数器会让之后每一轮对话都失败;
`deerflow_agent_name` 还会被当作服务端写入的值复制到下一次状态更新中。现在首个检查点不再从
请求中接收这些键。([#8019])
- **通道:** 企业微信不再丢弃 2–10 MB 的图片。此前适配器把图片上限定为 2 MB(同一协议中语音的限制),
超出即在上传前被跳过,消息到达时缺少图片。现在图片上限与平台的 10 MB 对齐;普通文件 20 MB 的限制不变。([#8174])
- **通道:** WeCom 群聊中 `@机器人 /new` 这类带提及的命令现在按命令执行。WeCom 会把提及保留在
正文里,而命令识别要求裸的前导 `/`,于是群里的 `/new`、`/status` 被当成聊天轮次。现在仅在命令
识别时跳过前导提及;普通聊天保留提及,`/connect` 行为不变。与飞书、钉钉的处理一致。([#8118])
- **前端:** 侧边栏对话菜单中的 **分享** 现在复制指向当前部署的链接。此前在 `localhost` 和
`127.0.0.1`(`make dev` 与 Docker 的默认地址)上,复制的是托管演示站
(`deer-flow-v2.vercel.app`)的链接:该链接无法打开对话,打开时还会把线程 ID 发送到外部主机。
([#8126])
- **运行时:** 多 Worker 模式(`run_ownership.heartbeat_enabled`)下,已被其他 Worker
接管的运行不再被原 Worker 恢复。此前装配证据通过无条件的整行 upsert 写入,在对端接管后会把
行改回 `running` 并清除接管错误;运行可能在对端已报告失败后以 `success` 结束,或被再次接管。
现在仅当该 Worker 仍持有活跃运行的有效租约时,才将证据合并到行元数据
(`RunStore.merge_owned_metadata()`),否则在执行前停止该运行。启用心跳的自定义
`RunStore` 实现必须实现该方法。([#8046])
- **网关:** 同一用户对同一次运行重复提交反馈时,现在返回 409 而不是 500。
`POST /api/threads/{id}/runs/{run_id}/feedback` 每个用户每次运行只允许一条反馈;此前重试,
或对同一次运行的另一条消息提交反馈,会触发该唯一约束,Gateway 因未处理的数据库错误而失败。
409 会提示调用方改用同一路由的 `PUT` 更新已有反馈。([#8064])
- **网关:** 删除线程时不再在对话仍可读取的情况下返回成功。此前
`DELETE /api/threads/{id}` 在检查点存储失败时只记录 DEBUG 日志,仍删除线程元数据行并
返回成功;之后任何已登录用户都能通过 `GET /threads/{id}`、`/state`、`/history` 和
`/goal` 读取残留的标题和消息,所有者也无法重试。现在删除会返回 500 并保留线程,所有者
可以再次删除。([#7992])
- **模型:** 取消使用 Codex 的任务时,会关闭上游 HTTP/SSE 连接,包括等待响应头期间;
重试等待也可取消。本地资源会释放,远端服务仍可能继续生成。([#8053](https://github.com/bytedance/deer-flow/pull/8053))
- **中间件:** 启用 PII 脱敏时,不含空格的长文本(例如粘贴的十六进制转储)不再让 Gateway
卡住。此前邮箱检测器的耗时与这类文本长度呈平方关系,而脱敏会在每次模型调用时重新扫描全部
用户消息,因此一次 100 KB 的粘贴会让每次模型调用阻塞所有线程约 6 秒。现在扫描为线性耗时,
且脱敏的地址与此前完全相同。([#8006])
- **调度器:** 一个已保存的计划无法再计算的定时任务,不再让其他所有任务停止运行。此前
已保存的时区或 cron 表达式失效时(例如升级后时区数据库中缺少该时区,或新版 croniter
不再接受该表达式),在调度器轮询中计算其下一次运行时间会抛出异常;其启动租约过期后,
之后每次轮询都会在接纳到期任务之前失败。现在该任务会被暂停,并把原因记录为最近错误;
在修复计划之前,恢复该任务会提示具体问题。([#7993])
- **模型:** 使用 Claude Code OAuth token 的 Claude 模型,不再在请求带有 `betas` 时每次
都失败。此前 OAuth 所需的 beta(`oauth-2025-04-20` 等)只放在客户端默认的
`anthropic-beta` 请求头里,而 SDK 会用请求自身的 `betas` 替换这个请求头;因此只要配置了
`betas`(即使是 `[]`)、调用时传入 `betas`,或使用了 langchain-anthropic 会为其追加 beta
的服务端工具,OAuth beta 就会丢失,Anthropic 随即拒绝该 token。现在 OAuth beta 会排在
请求 `betas` 以及 `extra_headers` 中 `anthropic-beta` 的最前面。
([#8061](https://github.com/bytedance/deer-flow/pull/8061))
- **社区工具:** 以换行结尾的搜索或抓取 API 密钥不再泄露到对话中。GroundRoute
(`GROUNDROUTE_API_KEY`)、InfoQuest、Tavily、Exa、Jina、Firecrawl、fastCRW 和
Crawl4AI 此前把读取到的密钥原样写入 HTTP 请求头;从文件读取的密钥(例如用
`--from-file` 创建的 Kubernetes Secret)会让传输层报出 `Illegal header value
b'Bearer <key>\n'`,这条包含完整密钥的消息成为模型和对话历史保存的工具结果,
其中四个提供方还会把它写入日志。现在这些密钥与其他提供方一样经过
`provider_credentials.resolve_api_key` 处理;Tavily、Exa、Firecrawl 和 fastCRW
的配置密钥为空时会回退到环境变量。fastCRW 没有自己的密钥时,不再让 Firecrawl SDK
把 `FIRECRAWL_API_KEY` 发送到 fastCRW 端点;`TAVILY_API_KEY`、`EXA_API_KEY` 或
`FIRECRAWL_API_KEY` 只含空白时按未设置处理,不再原样交给 SDK。([#7981])
- **社区工具:** Browserless 是最后一个直接读取原始 token 的搜索/抓取提供方。从文件读取的
`BROWSERLESS_TOKEN` 或配置 `token` 会保留尾部换行,并被原样用作鉴权查询参数,导致
`web_fetch` 和 `web_capture` 在密钥本身正确时也全部鉴权失败。现在两者都经过
`provider_credentials.resolve_api_key` 处理,且只有省略 `token` 键时才回退到环境变量:
显式配置 `""` 或 `null` 仍可让自建 capture 端点跳过鉴权。([#8043])
- **docker:** 从本地 checkout 构建的镜像不再包含本地配置。`.dockerignore`
此前没有排除 `config.yaml`、`config.yaml.bak`、`extensions_config.json` 和
`mcp_config.json`,因此配置放在旧位置 `backend/` 时,`COPY backend ./backend`
会把模型 API 密钥和 MCP 凭据写进每一层 gateway 镜像;`frontend/.env.local`
也以同样方式进入前端镜像。现在根目录和 `backend/` 下的这些文件、任意层级的
`.env*.local`、`frontend/*.pem`,以及扩展管理器拒绝写入扩展快照的凭据文件都会
被排除。本地构建的前端镜像不再内联 `frontend/.env.local` 中的 `NEXT_PUBLIC_*`
值;Docker 栈通过同一个 nginx 源提供前端和 Gateway,不需要这些变量。CI 发布的
镜像不受影响;已推送或分享过的本地镜像请重新构建。([#7958])
- **智能体:** 循环检测、运行 token 预算、模型服务安全限制或输出长度限制触发停止后,
未完成的 Todo 不再触发额外的模型调用或工具执行。Todo 状态仍会保留,
用户可在后续运行中继续处理。([#7953])
- **渠道:** 飞书/Lark 的长回答不再丢失。此前卡片文本没有长度上限,并以 ASCII 转义
序列化,约 4,400 个中日韩字符就会让卡片请求超过飞书 30 KB 的限制(`230025`):
最终更新及其回复兜底都会被拒绝,运行中卡片停留在最后一次流式内容或只剩 `▉`。
现在卡片不再转义中日韩字符,并按其在请求中占用的字节计算预算。流式更新只显示能
放下的部分;更长的最终回答会在同一条消息下以最多 10 张卡片按行续发,重试会从
已送达的卡片之后继续。([#7798])
- **渠道:** 企业微信群消息现在拥有独立的会话。此前每条消息都把发送者的 userid
同时用作会话 ID 和话题 ID,因此在没有群绑定加以区分的情况下(免认证模式、
`require_bound_identity: false`,以及旧版群绑定),成员的私聊和其发过言的所有群
共用同一个 DeerFlow 线程:群内回复可能用到该成员私聊或其他群的内容,失去原始
frame 的回复也会被发到私聊。现在群以 `chatid` 为键、每位成员一个线程,私聊线程
保持不变。通过各自 `/connect` 绑定的群保留原有线程;覆盖该机器人所有群的旧版
群绑定会在每个群中开始新线程。([#7794])
- **沙箱:** 使用显式授权技能视图的新 AIO 运行,在启用惰性初始化时,不再于首次调用
沙箱工具前创建或获取沙箱。已有沙箱绑定和需要上传技能的提供方继续保留原有的即时同步行为。
([#7782])
- **扩展:** 上下文压缩观察者现在能知道压缩发生在哪个任务里。在 Lead 运行或子 Agent
内部,观察者收到的是该任务的存储,而不再是分离存储;`CompactionEvent` 新增
`task_id`、`run_id`、`thread_id`(默认 `None`),取自宿主放进每个任务存储的
`TaskInfo`,即使没有注册生命周期贡献者也会放入。手动 `/compact` 仍收到分离存储,
id 为 `None`。`deerflow-extension-api` 升级到 0.2.10,已有扩展不受影响。([#7831])
- **智能体:** 即使模型提供方复用消息 ID,Token 预算也会分别统计已接受的模型调用,
避免重复 ID 绕过配置的上限;触发硬停止时保留已完成的结构化输出工具结果。
([#8050](https://github.com/bytedance/deer-flow/pull/8050))
- **渠道:** 钉钉 AI 卡片模式(`card_template_id`)下,卡片请求失败(例如缺少卡片权限或钉钉服务异常)时
不再丢失回复。dingtalk-stream 会记录失败的卡片请求后按成功返回,因此文本形式的运行中提示和最终答案的
`sampleMarkdown` 兜底都不会执行。现在渠道自行发送卡片请求,并在失败时回退。([#7781])
- **harness:** 重试失败条目不再重新打开已取消的持久子智能体批次。`retry_item`
此前只保留已暂停批次的状态,因此重试已取消批次中的失败条目会把批次改回
`queued`,worker 随即认领并执行该条目,批次最终变为 `completed`。现在对已取消
批次的重试路由返回 409,包括 SQLite 上取消在重试进行中提交的情况;工作区也不再
为这些条目显示重试按钮。在已完成、失败、运行中或已暂停批次中重试的行为不变。
([#7792])
- **网关:** 运行指定的自定义智能体不存在或名称无效时,现在会明确说明原因。此前向一个
自定义智能体已被删除的对话发消息,或传入格式错误的 `agent_name`,即使与知识库范围无关,
也会返回 422 和 "knowledge_scope assistant configuration could not be resolved"。现在
422 会提示 `Agent '<name>' not found`、`agent_name` 无效,或智能体配置无法加载。([#8087])
- **智能体:** `PUT /api/agents/{name}` 传入 `"description": null` 时现在会把描述重置为
空。此前会把 null 原样保存,而智能体配置不接受该值,导致之后每次加载都失败:更新本身、
GET 和修复性更新都返回 500,文件后端会把它从智能体列表中漏掉,数据库后端会让该用户的
整个列表失败,与它的对话也会失败。已以 null 描述保存的智能体现在可以重新加载。([#7638])
- **智能体:** 当模型提供方在不同轮次复用同一个工具调用 ID(例如每次响应都是 `call_0`)时,
澄清提问不再覆盖之前的问题。此前其消息 ID(同时也是人工输入的 `request_id`)只包含调用 ID,
后一个问题会原地替换前一个问题,后一次调用也因此缺少结果。现在 ID 还会包含发起该调用的
AIMessage;同一调用的重试仍保持原 ID。([#7762])
- **项目:** 当数据目录、`users/`、某个用户目录或用户的 `projects/` 是符号链接时,
回收站保留期清扫不再删除仍在使用的文档原件。此前它按解析后的路径保护每个文档,
却遍历未解析的目录树,导致没有任何文档能匹配上,所有超过 24 小时的原件都被当作
孤立文件删除,Gateway 启动时和 `GET /api/trash/documents` 都会触发。([#7732])
- **对话:** 队列分发开始后,取消某条排队输入不再阻止后续排队输入继续执行。([#7746])
- **开发:** `scripts/dev_multi_instance.sh logs all` 现在除两个 Gateway 日志外也会输出
nginx 日志;此前它只输出 Gateway 日志,与不带参数的 `logs` 相同。`logs nginx` 与
`logs all` 还会包含 `nginx.log`(nginx 启动命令自身的输出),此前没有任何选项会输出它。
([#7723])
- **记忆:** `GET /api/memory/config` 与 `/api/memory/status` 现在会把 `backend_config`
中的凭据值(`api_key`、token、密码、`Authorization` 请求头,任意层级)遮蔽为 `***`。
此前它们按加载结果原样返回配置,而 `$VAR` 引用此时已被解析,因此 DeerMem 提取模型的密钥
或 Honcho 的 `api_key` 会以明文返回给所有拥有 `memory:read` 的调用方;在默认关闭授权时,
这就是所有已登录用户。非机密配置项、`api_key_env` 变量名和未设置的值保持不变。([#7665])
- **网关:** `GET /threads/{thread_id}/token-usage` 现在在工作线程里解析实时配置。
此前 `build_context_usage` 直接内联调用该访问器,而 `get_app_config()` 每次调用都会重新校验
——它先对整个 `config.yaml` 取摘要,再对 `extensions_config.json` 做同样的事——所以在有人打开
会话视图前刚好改了配置,就会让运行、消息流与其他线程共用的网关事件循环整段停顿。响应内容不变。([#7727])
## [2.2.0] — 2026-10-29
本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。 本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。
截至 2026-10-28,该里程碑已合并 **1068 个拉取请求**。 该里程碑随本次发布收尾,共合并 **1068 个拉取请求**。
### 新增 ### 新增
@ -221,6 +413,12 @@
选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后 选择器缺失、是目录或不可读时会在调用 Docker 之前直接失败,而文件删除后
`make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env` `make down` 仍能停止整个栈;不设置或为空的选择器保持可选的仓库根 `.env`
默认值。([#6522]) 默认值。([#6522])
- **HITL:** 新增独立于推理模式的 Ask、Auto、Full 工具权限模式。Auto 通过单独的
无工具模型调用审核具体操作,默认复用现有模型配置;不确定或高风险操作转人工审批。
卡片展示完整参数,支持批准、拒绝、修改参数及可撤销的会话精确授权。主代理审批通过
checkpoint 支持重启恢复;交互式子代理通过属主限定的通道等待,并处理取消和过期。
所有模式均保留部署规则和账户权限,个人/平台 MCP 连接、参数和策略均参与授权绑定。
默认关闭;`config_version` 66。
- **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置 - **MCP:** 按未变更的服务器缓存完成包装的工具,仅在绑定对账后重新发现缺失或配置
发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享 发生变化的服务器。将成功但未发现工具的结果与发现失败分开保存,并保留代际、共享
@ -11449,7 +11647,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5217]: https://github.com/bytedance/deer-flow/pull/5217 [#5217]: https://github.com/bytedance/deer-flow/pull/5217
[#5219]: https://github.com/bytedance/deer-flow/pull/5219 [#5219]: https://github.com/bytedance/deer-flow/pull/5219
[#5221]: https://github.com/bytedance/deer-flow/pull/5221 [#5221]: https://github.com/bytedance/deer-flow/pull/5221
[#5224]: https://github.com/bytedance/deer-flow/pull/5224 [#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
[#5225]: https://github.com/bytedance/deer-flow/pull/5225 [#5225]: https://github.com/bytedance/deer-flow/pull/5225
[#5227]: https://github.com/bytedance/deer-flow/pull/5227 [#5227]: https://github.com/bytedance/deer-flow/pull/5227
[#5228]: https://github.com/bytedance/deer-flow/pull/5228 [#5228]: https://github.com/bytedance/deer-flow/pull/5228
@ -11458,9 +11656,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5236]: https://github.com/bytedance/deer-flow/pull/5236 [#5236]: https://github.com/bytedance/deer-flow/pull/5236
[#5238]: https://github.com/bytedance/deer-flow/pull/5238 [#5238]: https://github.com/bytedance/deer-flow/pull/5238
[#5239]: https://github.com/bytedance/deer-flow/pull/5239 [#5239]: https://github.com/bytedance/deer-flow/pull/5239
[#5244]: https://github.com/bytedance/deer-flow/pull/5244 [#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
[#5245]: https://github.com/bytedance/deer-flow/pull/5245 [#5245]: https://github.com/bytedance/deer-flow/pull/5245
[#5247]: https://github.com/bytedance/deer-flow/pull/5247 [#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
[#5249]: https://github.com/bytedance/deer-flow/pull/5249 [#5249]: https://github.com/bytedance/deer-flow/pull/5249
[#5251]: https://github.com/bytedance/deer-flow/pull/5251 [#5251]: https://github.com/bytedance/deer-flow/pull/5251
[#5254]: https://github.com/bytedance/deer-flow/pull/5254 [#5254]: https://github.com/bytedance/deer-flow/pull/5254
@ -11517,7 +11715,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5367]: https://github.com/bytedance/deer-flow/pull/5367 [#5367]: https://github.com/bytedance/deer-flow/pull/5367
[#5369]: https://github.com/bytedance/deer-flow/pull/5369 [#5369]: https://github.com/bytedance/deer-flow/pull/5369
[#5371]: https://github.com/bytedance/deer-flow/pull/5371 [#5371]: https://github.com/bytedance/deer-flow/pull/5371
[#5373]: https://github.com/bytedance/deer-flow/pull/5373 [#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
[#5374]: https://github.com/bytedance/deer-flow/pull/5374 [#5374]: https://github.com/bytedance/deer-flow/pull/5374
[#5375]: https://github.com/bytedance/deer-flow/pull/5375 [#5375]: https://github.com/bytedance/deer-flow/pull/5375
[#5377]: https://github.com/bytedance/deer-flow/pull/5377 [#5377]: https://github.com/bytedance/deer-flow/pull/5377
@ -11544,7 +11742,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5408]: https://github.com/bytedance/deer-flow/pull/5408 [#5408]: https://github.com/bytedance/deer-flow/pull/5408
[#5410]: https://github.com/bytedance/deer-flow/pull/5410 [#5410]: https://github.com/bytedance/deer-flow/pull/5410
[#5411]: https://github.com/bytedance/deer-flow/pull/5411 [#5411]: https://github.com/bytedance/deer-flow/pull/5411
[#5413]: https://github.com/bytedance/deer-flow/pull/5413 [#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
[#5415]: https://github.com/bytedance/deer-flow/pull/5415 [#5415]: https://github.com/bytedance/deer-flow/pull/5415
[#5416]: https://github.com/bytedance/deer-flow/pull/5416 [#5416]: https://github.com/bytedance/deer-flow/pull/5416
[#5418]: https://github.com/bytedance/deer-flow/pull/5418 [#5418]: https://github.com/bytedance/deer-flow/pull/5418
@ -11727,8 +11925,8 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5736]: https://github.com/bytedance/deer-flow/pull/5736 [#5736]: https://github.com/bytedance/deer-flow/pull/5736
[#5738]: https://github.com/bytedance/deer-flow/pull/5738 [#5738]: https://github.com/bytedance/deer-flow/pull/5738
[#5739]: https://github.com/bytedance/deer-flow/pull/5739 [#5739]: https://github.com/bytedance/deer-flow/pull/5739
[#5740]: https://github.com/bytedance/deer-flow/pull/5740 [#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
[#5741]: https://github.com/bytedance/deer-flow/pull/5741 [#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
[#5745]: https://github.com/bytedance/deer-flow/pull/5745 [#5745]: https://github.com/bytedance/deer-flow/pull/5745
[#5748]: https://github.com/bytedance/deer-flow/pull/5748 [#5748]: https://github.com/bytedance/deer-flow/pull/5748
[#5750]: https://github.com/bytedance/deer-flow/pull/5750 [#5750]: https://github.com/bytedance/deer-flow/pull/5750
@ -11741,7 +11939,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5760]: https://github.com/bytedance/deer-flow/pull/5760 [#5760]: https://github.com/bytedance/deer-flow/pull/5760
[#5761]: https://github.com/bytedance/deer-flow/pull/5761 [#5761]: https://github.com/bytedance/deer-flow/pull/5761
[#5762]: https://github.com/bytedance/deer-flow/pull/5762 [#5762]: https://github.com/bytedance/deer-flow/pull/5762
[#5763]: https://github.com/bytedance/deer-flow/pull/5763 [#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
[#5767]: https://github.com/bytedance/deer-flow/pull/5767 [#5767]: https://github.com/bytedance/deer-flow/pull/5767
[#5769]: https://github.com/bytedance/deer-flow/pull/5769 [#5769]: https://github.com/bytedance/deer-flow/pull/5769
[#5775]: https://github.com/bytedance/deer-flow/pull/5775 [#5775]: https://github.com/bytedance/deer-flow/pull/5775
@ -11809,7 +12007,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5880]: https://github.com/bytedance/deer-flow/pull/5880 [#5880]: https://github.com/bytedance/deer-flow/pull/5880
[#5881]: https://github.com/bytedance/deer-flow/pull/5881 [#5881]: https://github.com/bytedance/deer-flow/pull/5881
[#5883]: https://github.com/bytedance/deer-flow/pull/5883 [#5883]: https://github.com/bytedance/deer-flow/pull/5883
[#5884]: https://github.com/bytedance/deer-flow/pull/5884 [#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
[#5888]: https://github.com/bytedance/deer-flow/pull/5888 [#5888]: https://github.com/bytedance/deer-flow/pull/5888
[#5890]: https://github.com/bytedance/deer-flow/pull/5890 [#5890]: https://github.com/bytedance/deer-flow/pull/5890
[#5892]: https://github.com/bytedance/deer-flow/pull/5892 [#5892]: https://github.com/bytedance/deer-flow/pull/5892
@ -11871,9 +12069,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5999]: https://github.com/bytedance/deer-flow/pull/5999 [#5999]: https://github.com/bytedance/deer-flow/pull/5999
[#6000]: https://github.com/bytedance/deer-flow/pull/6000 [#6000]: https://github.com/bytedance/deer-flow/pull/6000
[#6008]: https://github.com/bytedance/deer-flow/pull/6008 [#6008]: https://github.com/bytedance/deer-flow/pull/6008
[#6009]: https://github.com/bytedance/deer-flow/pull/6009 [#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
[#6013]: https://github.com/bytedance/deer-flow/pull/6013 [#6013]: https://github.com/bytedance/deer-flow/pull/6013
[#6015]: https://github.com/bytedance/deer-flow/pull/6015 [#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
[#6017]: https://github.com/bytedance/deer-flow/pull/6017 [#6017]: https://github.com/bytedance/deer-flow/pull/6017
[#6018]: https://github.com/bytedance/deer-flow/pull/6018 [#6018]: https://github.com/bytedance/deer-flow/pull/6018
[#6020]: https://github.com/bytedance/deer-flow/pull/6020 [#6020]: https://github.com/bytedance/deer-flow/pull/6020
@ -11897,7 +12095,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6060]: https://github.com/bytedance/deer-flow/pull/6060 [#6060]: https://github.com/bytedance/deer-flow/pull/6060
[#6062]: https://github.com/bytedance/deer-flow/pull/6062 [#6062]: https://github.com/bytedance/deer-flow/pull/6062
[#6063]: https://github.com/bytedance/deer-flow/pull/6063 [#6063]: https://github.com/bytedance/deer-flow/pull/6063
[#6066]: https://github.com/bytedance/deer-flow/pull/6066 [#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
[#6067]: https://github.com/bytedance/deer-flow/pull/6067 [#6067]: https://github.com/bytedance/deer-flow/pull/6067
[#6068]: https://github.com/bytedance/deer-flow/pull/6068 [#6068]: https://github.com/bytedance/deer-flow/pull/6068
[#6069]: https://github.com/bytedance/deer-flow/pull/6069 [#6069]: https://github.com/bytedance/deer-flow/pull/6069
@ -11923,7 +12121,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6105]: https://github.com/bytedance/deer-flow/pull/6105 [#6105]: https://github.com/bytedance/deer-flow/pull/6105
[#6107]: https://github.com/bytedance/deer-flow/pull/6107 [#6107]: https://github.com/bytedance/deer-flow/pull/6107
[#6109]: https://github.com/bytedance/deer-flow/pull/6109 [#6109]: https://github.com/bytedance/deer-flow/pull/6109
[#6112]: https://github.com/bytedance/deer-flow/pull/6112 [#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
[#6113]: https://github.com/bytedance/deer-flow/pull/6113 [#6113]: https://github.com/bytedance/deer-flow/pull/6113
[#6115]: https://github.com/bytedance/deer-flow/pull/6115 [#6115]: https://github.com/bytedance/deer-flow/pull/6115
[#6117]: https://github.com/bytedance/deer-flow/pull/6117 [#6117]: https://github.com/bytedance/deer-flow/pull/6117
@ -12024,7 +12222,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6298]: https://github.com/bytedance/deer-flow/pull/6298 [#6298]: https://github.com/bytedance/deer-flow/pull/6298
[#6299]: https://github.com/bytedance/deer-flow/pull/6299 [#6299]: https://github.com/bytedance/deer-flow/pull/6299
[#6302]: https://github.com/bytedance/deer-flow/pull/6302 [#6302]: https://github.com/bytedance/deer-flow/pull/6302
[#6304]: https://github.com/bytedance/deer-flow/pull/6304 [#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
[#6305]: https://github.com/bytedance/deer-flow/pull/6305 [#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6307]: https://github.com/bytedance/deer-flow/pull/6307
@ -12408,6 +12606,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7017]: https://github.com/bytedance/deer-flow/pull/7017 [#7017]: https://github.com/bytedance/deer-flow/pull/7017
[#7018]: https://github.com/bytedance/deer-flow/pull/7018 [#7018]: https://github.com/bytedance/deer-flow/pull/7018
[#7020]: https://github.com/bytedance/deer-flow/pull/7020 [#7020]: https://github.com/bytedance/deer-flow/pull/7020
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
[#7023]: https://github.com/bytedance/deer-flow/pull/7023 [#7023]: https://github.com/bytedance/deer-flow/pull/7023
[#7024]: https://github.com/bytedance/deer-flow/pull/7024 [#7024]: https://github.com/bytedance/deer-flow/pull/7024
[#7031]: https://github.com/bytedance/deer-flow/pull/7031 [#7031]: https://github.com/bytedance/deer-flow/pull/7031
@ -12680,3 +12879,33 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7643]: https://github.com/bytedance/deer-flow/pull/7643 [#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651 [#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659 [#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732
[#7746]: https://github.com/bytedance/deer-flow/pull/7746
[#7762]: https://github.com/bytedance/deer-flow/pull/7762
[#7772]: https://github.com/bytedance/deer-flow/pull/7772
[#7781]: https://github.com/bytedance/deer-flow/pull/7781
[#7792]: https://github.com/bytedance/deer-flow/pull/7792
[#7794]: https://github.com/bytedance/deer-flow/pull/7794
[#7798]: https://github.com/bytedance/deer-flow/pull/7798
[#7831]: https://github.com/bytedance/deer-flow/pull/7831
[#7782]: https://github.com/bytedance/deer-flow/pull/7782
[#7953]: https://github.com/bytedance/deer-flow/issues/7953
[#7958]: https://github.com/bytedance/deer-flow/pull/7958
[#7981]: https://github.com/bytedance/deer-flow/pull/7981
[#7992]: https://github.com/bytedance/deer-flow/pull/7992
[#7993]: https://github.com/bytedance/deer-flow/pull/7993
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8064]: https://github.com/bytedance/deer-flow/pull/8064
[#8087]: https://github.com/bytedance/deer-flow/pull/8087
[#8118]: https://github.com/bytedance/deer-flow/pull/8118
[#8126]: https://github.com/bytedance/deer-flow/pull/8126
[#8139]: https://github.com/bytedance/deer-flow/pull/8139
[#8174]: https://github.com/bytedance/deer-flow/pull/8174

View File

@ -383,6 +383,41 @@ Every pull request triggers the following CI workflows:
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml) - **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change) - **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
The browser E2E and full-stack replay jobs use the official Playwright image,
which includes Chromium and its system libraries. When upgrading the locked
Playwright version, update the image tags in both browser workflows to match.
Each job launches Chromium before running tests; auth recovery tests run only
after that browser readiness check succeeds.
These containers use uv-managed Python instead of the host's Python tool cache,
so the interpreter's OpenSSL libraries match its runtime. Backend dependency
setup also verifies SSL and SQLite imports before launching fixture gateways.
### Test and diagnostics notes
Backend `make test` runs four duration-balanced shards concurrently and reports
failure if any shard fails. Use `make test TEST_JOBS=1` to run the shards sequentially.
It excludes live external-API and blocking-I/O coverage.
Run `cd backend && make test-blocking-io` for strict blocking-I/O checks.
Maintainers can run the real `DeerFlowClient` suite with `cd backend && make test-live`.
This command requires a valid root `config.yaml` and API credentials.
It can incur API costs and create local sandboxes, artifacts, or files.
Direct pytest runs additionally require `DEER_FLOW_RUN_LIVE_TESTS=1`.
Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`.
Backend blocking-IO diagnostics are available from the repository root with
`make detect-blocking-io`: it statically scans backend business code for
blocking IO that may run on the backend event loop, prints a concise summary,
and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`.
The JSON includes compact review records with `priority`, `location`,
`blocking_call`, `event_loop_exposure`, `reason`, and `code`.
Gateway artifact serving now forces active web content types (`text/html` and XML documents such as `.xml`, `.xhtml`, and `.svg`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
Frontend route asset budgets can be checked with `cd frontend && pnpm
perf:check`. The command measures `/login` from a normal production build, then
performs a production static-demo build for the fixture-backed workspace routes.
It measures the unique JavaScript and CSS referenced by representative routes
and writes the detailed result to `.next/performance-results.json`.
## Code Style ## Code Style
- **Backend (Python)**: We use `ruff` for linting and formatting. Run `make format` before committing. - **Backend (Python)**: We use `ruff` for linting and formatting. Run `make format` before committing.

4014
README.md

File diff suppressed because one or more lines are too long

View File

@ -1195,7 +1195,7 @@ Agent settings에서 **Tool access**는 설정된 모든 도구 그룹을 상속
비전을 지원하는 에이전트의 경우 `view_image`와 그 뒤에 이어지는 모델 컨텍스트 이미지 읽기에도 `sandbox:execute`가 필요합니다. 도구 이름만 허용해서는 이미지 파일 접근 권한이 부여되지 않으며, 샌드박스 실행이 거부된 역할은 권한이 변경된 후 이전에 기록된 이미지 메타데이터를 다시 읽을 수 없습니다. 외부 실행 입력과 스레드 상태 업데이트로는 `viewed_images`나 `thread_data`를 설정할 수 없습니다. 저장된 이미지를 호스트 사본에서 읽을 때는 해당 경로가 현재 사용자의 스레드와 기록된 가상 이미지 경로로 해석되어야 합니다. 비전을 지원하는 에이전트의 경우 `view_image`와 그 뒤에 이어지는 모델 컨텍스트 이미지 읽기에도 `sandbox:execute`가 필요합니다. 도구 이름만 허용해서는 이미지 파일 접근 권한이 부여되지 않으며, 샌드박스 실행이 거부된 역할은 권한이 변경된 후 이전에 기록된 이미지 메타데이터를 다시 읽을 수 없습니다. 외부 실행 입력과 스레드 상태 업데이트로는 `viewed_images`나 `thread_data`를 설정할 수 없습니다. 저장된 이미지를 호스트 사본에서 읽을 때는 해당 경로가 현재 사용자의 스레드와 기록된 가상 이미지 경로로 해석되어야 합니다.
각 모델 요청에는 최근에 본 이미지가 최대 4개, base64 이미지 데이터가 최대 28 MiB까지 포함됩니다. 이 제한으로 제외된 이미지는 생략된 것으로 보고되며, 에이전트는 `view_image`를 다시 호출해 이미지를 컨텍스트로 다시 가져올 수 있습니다. 각 모델 요청에는 최근에 본 이미지가 최대 4개, base64 이미지 데이터가 최대 28 MiB까지 포함됩니다. 이 제한으로 제외된 이미지는 생략된 것으로 보고되며, 에이전트는 `view_image`를 다시 호출해 이미지를 컨텍스트로 다시 가져올 수 있습니다.
고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 설정된 같은 목록을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 리드 에이전트 전용/서브에이전트 전용 미들웨어 목록을 따로 지정하는 기능은 아직 지원되지 않습니다. 고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs, apply_to}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 각 체인에 대해 선택된 설정 항목을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 객체의 선택적 `apply_to`는 `both`(기본값), `lead`, `subagent`를 지원하며, 문자열 항목과 이 필드를 생략한 객체는 계속 두 파이프라인 모두에 적용됩니다. 해당 체인에서 제외된 항목은 임포트하거나 인스턴스화하지 않습니다.
패키지화되고 설정 가능한 런타임 통합에는 DeerFlow의 확장 관리자를 사용하세요. 확장 관리자는 Python 패키지 요구 사항, 공개 HTTPS Git URL, 또는 로컬 디렉터리를 받아 백엔드 전용 `extensions` 의존성 그룹에 패키지를 설치하고, `backend/uv.lock`을 업데이트하며, 시작 시에만 읽는 `config.yaml`의 최상위 `plugins:` 목록에 활성화된 항목을 추가합니다. 패키지화되고 설정 가능한 런타임 통합에는 DeerFlow의 확장 관리자를 사용하세요. 확장 관리자는 Python 패키지 요구 사항, 공개 HTTPS Git URL, 또는 로컬 디렉터리를 받아 백엔드 전용 `extensions` 의존성 그룹에 패키지를 설치하고, `backend/uv.lock`을 업데이트하며, 시작 시에만 읽는 `config.yaml`의 최상위 `plugins:` 목록에 활성화된 항목을 추가합니다.
@ -1842,7 +1842,7 @@ WeChat은 바인딩 후 현재 대화 컨텍스트를 안전하게 저장하기
양식이 그룹 알림 지원 여부를 확인하는 동안에는 로딩 안내를 표시하고 저장된 선택을 유지합니다. 양식이 그룹 알림 지원 여부를 확인하는 동안에는 로딩 안내를 표시하고 저장된 선택을 유지합니다.
그룹은 자동으로 선택되지 않습니다. 선택을 해제하면 새 그룹 알림이 중지되고, 선택을 변경하면 이후 알림에 반영되며, 이미 큐에 들어간 알림은 원래 대상을 유지합니다. 연결이 해제된 바인딩은 대기 중인 알림을 절대 받지 않습니다. 첫 버전에서는 작업 양식 또는 REST API로 설정하며, 대화용 일정 도구의 입력은 기존과 같습니다. [그룹 전달 가이드](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications)를 참고하세요. 그룹은 자동으로 선택되지 않습니다. 선택을 해제하면 새 그룹 알림이 중지되고, 선택을 변경하면 이후 알림에 반영되며, 이미 큐에 들어간 알림은 원래 대상을 유지합니다. 연결이 해제된 바인딩은 대기 중인 알림을 절대 받지 않습니다. 첫 버전에서는 작업 양식 또는 REST API로 설정하며, 대화용 일정 도구의 입력은 기존과 같습니다. [그룹 전달 가이드](backend/docs/IM_CHANNEL_CONNECTIONS.md#group-notifications)를 참고하세요.
### 수명 주기, 안전 상한, 중지 조건 ### 수명 주기, 안전 상한, 중지 조건

View File

@ -109,7 +109,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和 管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和
**模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、 **模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、
Moonshot/Kimi、智谱/Z.AI 和 MiniMax 的端点、认证与适配器,地域和 Coding Plan Moonshot/Kimi、智谱/Z.AI、MiniMax 和阶跃星辰(StepFun)的端点、认证与适配器,地域和 Coding Plan
使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。 使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。
新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。 新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。
@ -712,6 +712,18 @@ DeerFlow 2.0 不再是一个需要你自己拼装的 framework。它是一个开
## 核心特性 ## 核心特性
### Token 用量统计
点击侧边栏的 **Token 用量**(`/workspace/usage`),查看近 7/30 天的用量、
每日输入/输出 Token、各模型的缓存命中输入 Token,以及支持失败筛选和分页的
运行记录。运行记录包含所有日期,不受图表所选周期限制。
此功能需要 `runs:read` 权限和 SQLite 或 PostgreSQL 数据库,静态演示不提供统计。
数据来自已持久化的服务商用量;运行过程中或服务商未返回用量时可能不完整,不能
作为服务商账单。缓存命中 Token 已包含在输入 Token 中。每日分桶使用浏览器当前
UTC 偏移,并按运行的创建日期归属。页面复用现有 `/api/console` 接口,保留服务端
价格配置及可选的费用、币种字段。
### Skills 与 Tools ### Skills 与 Tools
Skills 是 DeerFlow 能做“几乎任何事”的关键。 Skills 是 DeerFlow 能做“几乎任何事”的关键。
@ -842,6 +854,11 @@ Web UI 会在输入框上方展示当前激活的 goal,说明自动续跑为
### Sub-Agents ### Sub-Agents
显式后台 `batch_task` 可用 `depends_on` 声明同批条目的前置关系,前置工作完成后即可继续,
无需等待无关任务。用户已要求后续汇总或交付时,可用可选的 `continuation` 登记一次主 Agent 接续;
启用 Goal 时仍由 Goal 负责续跑。执行、取消、授权和恢复范围见
[批次依赖与完成接续说明](backend/docs/BATCH_CONTINUATION.md)。
Sub-agent 是一种执行优化,而不是遇到复杂任务时的默认选择。 Sub-agent 是一种执行优化,而不是遇到复杂任务时的默认选择。
lead agent 只会在委派具有明确净收益时动态拉起 sub-agents,例如真正缩短耗时的并行工作、专业能力收益或上下文隔离收益。存在跨 Agent 依赖或重叠副作用的工作不会并行分派;当专业能力或上下文隔离收益明显占优时,一条有界的顺序任务链仍可交给一个 sub-agent 完成。lead agent 会使用能取得收益的最少 sub-agents,并在每一批完成后重新评估,而不会仅仅因为任务规模大或步骤多就继续拆分。每个 sub-agent 都有自己独立的上下文、工具和终止条件,返回结构化结果后由 lead agent 验证并汇总成完整输出。 lead agent 只会在委派具有明确净收益时动态拉起 sub-agents,例如真正缩短耗时的并行工作、专业能力收益或上下文隔离收益。存在跨 Agent 依赖或重叠副作用的工作不会并行分派;当专业能力或上下文隔离收益明显占优时,一条有界的顺序任务链仍可交给一个 sub-agent 完成。lead agent 会使用能取得收益的最少 sub-agents,并在每一批完成后重新评估,而不会仅仅因为任务规模大或步骤多就继续拆分。每个 sub-agent 都有自己独立的上下文、工具和终止条件,返回结构化结果后由 lead agent 验证并汇总成完整输出。

View File

@ -8,11 +8,12 @@ gates the release on them agreeing with the tag.
## Version sources ## Version sources
A release version must appear, identically, in four places: A release version must appear, identically, in five places:
| File | Field | | File | Field |
| -------------------------------------- | -------------------- | | -------------------------------------- | -------------------- |
| `backend/pyproject.toml` | `version = "X.Y.Z"` | | `backend/pyproject.toml` | `version = "X.Y.Z"` |
| `backend/packages/harness/pyproject.toml` | `version = "X.Y.Z"` |
| `frontend/package.json` | `"version": "X.Y.Z"` | | `frontend/package.json` | `"version": "X.Y.Z"` |
| `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` | | `deploy/helm/deer-flow/Chart.yaml` | `version: X.Y.Z` |
| `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`| | `deploy/helm/deer-flow/Chart.yaml` | `appVersion: "X.Y.Z"`|
@ -32,7 +33,7 @@ distinguishes it from a release.
## Helper scripts ## Helper scripts
- `scripts/bump_version.sh <version>` — set all four fields at once, refresh - `scripts/bump_version.sh <version>` — set all five fields at once, refresh
`backend/uv.lock`, then self-verify. Tolerates a leading `v` (e.g. `v2.1.0`). `backend/uv.lock`, then self-verify. Tolerates a leading `v` (e.g. `v2.1.0`).
Needs `uv` on `PATH` (`backend/uv.lock` pins the root package version too, and Needs `uv` on `PATH` (`backend/uv.lock` pins the root package version too, and
lint CI runs `uv lock --check`); the script fails before editing anything when lint CI runs `uv lock --check`); the script fails before editing anything when
@ -77,7 +78,7 @@ distinguishes it from a release.
### Release candidates ### Release candidates
Release-candidate tags must include the same prerelease suffix in all four Release-candidate tags must include the same prerelease suffix in all five
version fields. For example, before tagging `v2.1.0-rc0`, run version fields. For example, before tagging `v2.1.0-rc0`, run
`bash scripts/bump_version.sh 2.1.0-rc0` and run `bash scripts/bump_version.sh 2.1.0-rc0` and run
`bash scripts/verify_versions.sh 2.1.0-rc0` from the repository root. Commit `bash scripts/verify_versions.sh 2.1.0-rc0` from the repository root. Commit
@ -150,7 +151,7 @@ Both features stay opt-in: the provisioner ignores them until
Both publishing workflows call `.github/workflows/verify-versions.yml` as their Both publishing workflows call `.github/workflows/verify-versions.yml` as their
first job. It runs `scripts/verify_versions.sh` against the tag (minus the first job. It runs `scripts/verify_versions.sh` against the tag (minus the
`v`). If any of the four version sources doesn't match the tag, or `v`). If any of the five version sources doesn't match the tag, or
`backend/uv.lock` is stale, the verify job fails and **all** publish jobs are `backend/uv.lock` is stale, the verify job fails and **all** publish jobs are
skipped — no images, no chart. skipped — no images, no chart.

View File

@ -1,327 +1,61 @@
# AGENTS.md # Backend (`backend/`)
## Project Overview The Python backend: the publishable harness in `packages/harness/deerflow/`, the
public extension contract in `packages/extension-api/`, and the unpublished
Gateway and IM channels in `app/`. In every deployment the agent runtime runs
inside the Gateway. Design docs are in [docs/](docs/); start with
[ARCHITECTURE.md](docs/ARCHITECTURE.md) and [API.md](docs/API.md).
The LangGraph backend provides sandbox execution, persistent memory, subagent delegation, and extensible tools, isolated per thread. ## Guides by area
Service roles and ports: [root service topology](../AGENTS.md#service-topology). Read the guide for an area before you change it.
**Runtime**: | Area | Guide |
- `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers. | --- | --- |
- Gateway streams `write_file` and `str_replace` argument deltas in bounded batches for multi-mode `messages-tuple` consumers; single-mode message consumers retain the original per-chunk contract. Non-message frames flush pending batches, and `values` remains an optional complete-state snapshot rather than a prerequisite for batching. | Run streaming, checkpoints, run ownership | [runtime](packages/harness/deerflow/runtime/AGENTS.md) |
- `stream_subgraphs` preserves subgraph SSE namespaces (`values|<ns>`, LangGraph Platform style); bare `values` would overwrite the SDK thread view (#4399). Delegated subagents inherit the parent's checkpoint namespace. Root-only consumers (file-tool chunk batcher, subagent event persistence, LLM error-fallback detection) ignore namespaced frames. The web frontend uses root `task_*` events without subgraph streaming. | Repositories, leases, scheduled tasks, schema changes | [persistence](packages/harness/deerflow/persistence/AGENTS.md), [migrations](packages/harness/deerflow/persistence/migrations/AGENTS.md) |
- Background subagent identity is deliberately split: the provider `tool_call_id` remains the correlation key for `ToolMessage`, `task_*` SSE events, persisted lifecycle events, frontend cards, and the public `ExtensionData.scope_id` contract (stored as `SubagentResult.external_task_id`), while `SubagentExecutor.execute_async()` generates a full server-side `execution_id` for `SubagentResult.task_id`, the process-wide registry, polling, cancellation, timeout handling, and cleanup. Provider IDs are not globally unique across parent runs, so they must never become registry ownership keys; scheduler closures retain their own `SubagentResult` rather than resolving ownership again through the mutable registry. Terminal subagent token usage travels in the current run's `ToolMessage.additional_kwargs` and is attributed from message state, never through a process-global provider-ID cache. | `config.yaml`, `extensions_config.json` | [config](packages/harness/deerflow/config/AGENTS.md) |
- Scheduled runs use Gateway's normal run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch: `scheduler.recursion_limit` defaults to 1000 (web UI), capped by `max_recursion_limit`; YAML changes apply next run without restart. IM enqueue checks recipients before locking preferences in the outcome transaction. | Lead agent, prompt, thread state, middleware chain | [agents](packages/harness/deerflow/agents/AGENTS.md), [middlewares](packages/harness/deerflow/agents/middlewares/AGENTS.md) |
- Run-history `status` filters are occurrence states, not task states. `ScheduledTaskRunStatus` in `persistence/scheduled_tasks/model.py` is the shared API/repository vocabulary and must match the active and terminal occurrence-status sets. Keep owner lookup before reading history, and apply SQL task/status predicates before pagination; omitted status preserves the existing response. | Memory, subagents, skills, tools | [memory](packages/harness/deerflow/agents/memory/AGENTS.md), [subagents](packages/harness/deerflow/subagents/AGENTS.md), [skills](packages/harness/deerflow/skills/AGENTS.md), [tools](packages/harness/deerflow/tools/AGENTS.md) |
- The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows. | Sandbox, uploads, MCP, models, extensions | [sandbox](packages/harness/deerflow/sandbox/AGENTS.md), [uploads](packages/harness/deerflow/uploads/AGENTS.md), [mcp](packages/harness/deerflow/mcp/AGENTS.md), [models](packages/harness/deerflow/models/AGENTS.md), [extensions](packages/harness/deerflow/extensions/AGENTS.md) |
- The multi-process startup gate also fires on `deployment.multi_instance: true`; contract in `docs/CONFIGURATION.md`. | Trace ID, embedded client, other root modules | [harness package](packages/harness/deerflow/AGENTS.md) |
- Long-running MCP work uses a separate durable task runtime (`McpTaskService` + `mcp_tasks`, lease-based recovery) rather than keeping remote task IDs or status polling inside the Agent loop; only submit remains Agent-visible, the database is the source of truth, and `ThreadState` receives only a bounded current-thread projection. Full contract — including notification retries, dead-lettering, and the cancel endpoint's worker-stopped 503 (leases, cancellation fencing, delivery idempotency, management-tool exposure): [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md). | Search, fetch and sandbox providers | [community](packages/harness/deerflow/community/AGENTS.md), then the provider's own guide |
- Scheduled-task dispatch permits one active occurrence per task via `uq_scheduled_task_run_active` (`task_id WHERE status IN ('queued','launching','running')`). Durable `queued` rows survive restarts; only lease-fenced `launching` may call Gateway launch; `running` references the durable run. Stable admission idempotency keys reuse that run after recovery. Reused-thread `ConflictError` returns `launching` to `queued`; other launch errors become `failed`. Atomic queue claims enforce `max_concurrent_runs` and the per-owner cap, excluding waiting rows; the budget count and its UPDATE are separate statements, so writers must serialize before the count (Postgres advisory lock; SQLite `BEGIN IMMEDIATE`, whose deferred transaction otherwise reserves the writer only at the UPDATE) or claims on distinct rows overshoot the cap. Repeated triggers coalesce; same-thread FIFO blocks behind older active rows. Queue admission, PATCH/resume, pause and delete lock the parent before the occurrence, freezing active task definitions. Pause/delete atomically cancel `queued` work but reject `launching`/`running`; PATCH/resume reject all active states. Only queued conflicts offer pause cancellation. Manual triggers may queue/run while paused. Manual-trial run origins reuse the admitted `scheduled_for`, so an idempotent retry submits the same request instead of colliding with the first attempt. | Gateway, routers, IM channels | [gateway](app/gateway/AGENTS.md), [routers](app/gateway/routers/AGENTS.md), [channels](app/channels/AGENTS.md) |
- Scheduled-task lifecycle, recovery and queue timeout: [persistence guide](packages/harness/deerflow/persistence/AGENTS.md#scheduled-task-lifecycle). | Tests, benchmarks | [tests](tests/AGENTS.md), [benchmarks](scripts/benchmark/AGENTS.md) |
- `POST /api/scheduled-tasks/preview-cron` requires authenticated `threads:read`. Bounded cron previews call the shared scheduler calculator in `asyncio.to_thread`, preserving its DST semantics. Capture the optional aware reference once; return UTC and offset-bearing local occurrences without acquiring task/thread/run stores or dispatching work. This advisory API does not reserve execution.
- Gateway MCP/skill updates write `extensions_config.json` at runtime. Production Compose mounts it read-write (`config.yaml` stays `:ro`); Helm seeds a writable home-volume copy. Hold both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock` during read-modify-write to prevent lost updates across workers. `atomic_write_extensions_config` uses temp-file replacement; only mount-point `EBUSY` permits a non-atomic in-place overwrite (crashes can truncate). Warn about this fallback once per target; propagate other errors. `get_extensions_config()` revalidates reads by file signature; see [config caching](packages/harness/deerflow/config/AGENTS.md). Tests: `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, `tests/test_helm_extensions_config_writable.py`.
- MCP cache reset scope and discovery fencing: [MCP guide](packages/harness/deerflow/mcp/AGENTS.md).
**Project structure:** The [root repository map](../AGENTS.md#repository-map) ## Verify
indexes the stack. Backend code lives in `app/` (Gateway and channels),
`packages/harness/deerflow/` (agent runtime), and `packages/extension-api/`
(public extension contracts). Tests, detailed docs, and standalone benchmarks
live in `tests/`, `docs/`, and `scripts/benchmark/` respectively. Module-local
`AGENTS.md` files describe their implementation boundaries.
## Important Development Guidelines
### Documentation Update Policy
Every code change must keep docs accurate and current: update `README.md` for
user-facing behavior and the relevant `AGENTS.md` for development changes.
`CLAUDE.md` imports `AGENTS.md`; do not edit the shim.
Image result URL policy: [image search guide](packages/harness/deerflow/community/image_search/AGENTS.md).
### Backend Benchmarks
`scripts/benchmark/context_snapshot/`: explicit `run-live` needs provider env
vars; `summarize` and pytest are offline. See its README for the protocol.
Benchmarks in `scripts/benchmark/` must be standalone and reproducible. Import
production functions; never duplicate them or introduce an alternative runtime.
Sandbox benchmarks reuse production lifecycle. Reject negative/NaN timing values
before provider creation, export no secrets, and fail on final cleanup errors.
See the sandbox benchmark README for supported options and tests.
- Pin every external dataset by immutable revision and SHA-256. Callers provide
the local dataset path; evaluation commands must not silently download data.
- Never commit upstream dataset text, credentials, complete provider requests,
or response headers. Committed manifests may contain stable IDs and source
locators. Synthetic cases must identify themselves as synthetic.
- Read provider credentials and endpoints from named environment variables.
Version model IDs, inference parameters, prompts, retry rules, clocks, and
random seeds in the evaluation config.
- Public raw results may contain case IDs, policy decisions, model hypotheses,
grades, and non-secret response metadata. Keep dataset questions, reference
answers, memory content, and full provider payloads in ignored local run
directories.
- Use fixed clocks and deterministic ordering for offline selection. Results
must record the config, manifest, prompt, dataset, and git revisions used.
`scripts/benchmark/deermem_eviction/` evaluates the production
`select_facts_for_capacity()` implementation used by DeerMem. It compares only
the historical `confidence` policy and PR #4789's opt-in `hybrid-v1`; do not add
another eviction strategy to this evaluation. Run its offline checks from
`backend/`:
```bash ```bash
PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate-contracts make test # offline suite in parallel shards
PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction validate --dataset "$LONGMEMEVAL_ORACLE_PATH" make test-blocking-io # strict blocking-I/O suite
PYTHONPATH=. uv run python -m scripts.benchmark.deermem_eviction run-policy \ make lint
--dataset "$LONGMEMEVAL_ORACLE_PATH" \ make format
--output-dir /tmp/deermem-eviction-policy-run make test-live # calls real APIs; needs DEER_FLOW_RUN_LIVE_TESTS=1
PYTHONPATH=. uv run pytest tests/test_bench_deermem_eviction_*.py -q
``` ```
The offline test suite must not require network access, provider credentials, Run `make test` and `make test-blocking-io` before and after a change. Every
or the LongMemEval dataset. Small LongMemEval-shaped fixtures must be synthetic feature and bug fix ships with a test in `tests/`. Live tests stay opt-in, so the
and generated by tests. default suite needs no network and no API key.
`scripts/benchmark/concurrency/` measures multi-process contention on the ## Rules
`users` table (N separate OS processes, not asyncio tasks) for SQLite vs
Postgres -- the scenario `CONFIGURATION.md` requires Postgres for. `worker.py`
connects directly via SQLAlchemy (skipping the ~8.5s Alembic bootstrap the
orchestrator already ran once) and mirrors the app's per-connection SQLite
PRAGMAs; `run_concurrency_bench.py` seeds a disposable per-run Postgres schema,
synchronises workers on a READY/GO barrier before timing, and exits non-zero on
any crash, short op count, or `errors > 0`. Postgres runs need a throwaway
database via `--pg-url`; nothing here touches `public`. Run from `backend/`:
```bash - **The harness never imports `app`.** `app.*` imports `deerflow.*`, and not the
uv run python scripts/benchmark/concurrency/run_concurrency_bench.py \ reverse, because the harness is published on its own.
--backend sqlite --workers 2,4,8,16 --ops-per-worker 50 --read-ratio 0.7 `tests/test_harness_boundary.py` enforces this in CI.
uv run pytest tests/test_bench_concurrency.py tests/test_bench_worker.py -q - **Start the Gateway with `make dev` or `make gateway`.** `make dev` excludes
``` `DEER_FLOW_HOME` and `backend/sandbox` from the reload watcher. A bare
`uvicorn --reload` restarts the Gateway during a run, because agent tasks write
## Commands Python files below `DEER_FLOW_HOME`.
- **From the repository root, run backend tools with
Root orchestration and extension commands live in the `uv run --no-sync --project backend`.** `--project` keeps the caller's working
[root guide](../AGENTS.md#commands-root-vs-module); run them from the repo root. directory, so relative config paths still resolve. `--no-sync` keeps installed
`make detect-thread-boundaries` inventories backend executor, thread and event-loop extras such as the PostgreSQL drivers.
boundaries. - **Run Python through `uv run` inside `backend/`**, without `--no-sync`, so the
environment matches the lock.
**Backend directory** (for backend development only): - **Guard POSIX-only test markers with `os.name`.** Windows collects the same
```bash files.
make install # Install backend dependencies - **Tool approvals need addressable calls.** Strip missing, empty and duplicate
make dev # Gateway API, reload (port 8001) tool-call IDs before creating a pause or a tool result. See the
make gateway # Gateway API only (port 8001) [run interaction policy](docs/RUN_INTERACTION_POLICY.md#hitl-tool-approval).
make test # four offline workers; TEST_JOBS=1 for serial - **Add a schema change with `make migrate-rev MSG="..."`** and follow the
make test-live # live tests (real APIs) migrations guide.
make test-blocking-io # strict Blockbuster gate on tests/blocking_io/
make test-shard SPLITS=4 GROUP=2 # one duration-aware shard
make test-shard-durations # refresh baseline
make lint # ruff lint
make format # ruff format
make migrate-rev MSG="..." # Autogenerate a new alembic revision (see Schema Migrations section)
```
The backend `make dev` target pre-creates and excludes `DEER_FLOW_HOME`
(default: `backend/.deer-flow`) and `backend/sandbox` from Uvicorn's reload
watcher. Do not replace it with a bare `uvicorn --reload`: agent tasks write
Python and other runtime files below `DEER_FLOW_HOME`, which would otherwise
restart the Gateway during an active run.
Configuration checks and operator migration scripts reuse the installed backend
environment with `uv run --no-sync --project backend` from the repository root.
`--project` preserves the caller's working directory and relative runtime/config
selectors; `--no-sync` retains installed extras such as PostgreSQL drivers.
Read the nearest scoped `AGENTS.md` for subsystem ownership.
## Architecture
Read-only knowledge: [contract](docs/KNOWLEDGE.md) covers independent retrieval
tools, scope intersection and extension-owned providers.
### Harness / App Split
The backend is split into two layers with a strict dependency direction:
- **Harness** (`packages/harness/deerflow/`): Publishable agent framework package (`deerflow-harness`). Import prefix: `deerflow.*`. Contains agent orchestration, tools, sandbox, models, MCP, skills, config — everything needed to build and run agents.
- **App** (`app/`): Unpublished application code. Import prefix: `app.*`. Contains the FastAPI Gateway API and IM channel integrations (Feishu, Slack, Telegram, DingTalk). Feishu option clarifications can render as interactive button cards behind `channels.feishu.card_interaction_enabled` (default off); see `app/channels/AGENTS.md`.
Channel credential staging (`app/channels/credential_staging.py`) is swept on worker-thread state loads, including SQL legacy import. Only owned regular files older than 24h are removed; leave anonymous temporary files alone and keep WeChat constructors IO-free.
**Dependency rule**: App imports deerflow, but deerflow never imports app. This boundary is enforced by `tests/test_harness_boundary.py` which runs in CI.
**Import conventions**:
```python
# Harness internal
from deerflow.agents import make_lead_agent
from deerflow.models import create_chat_model
# App internal
from app.gateway.app import app
from app.channels.service import start_channel_service
# App → Harness (allowed)
from deerflow.config import get_app_config
# Harness → App (FORBIDDEN — enforced by test_harness_boundary.py)
# from app.gateway.routers.uploads import ... # ← will fail CI
```
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
roots expose heavyweight graph/executor entrypoints lazily. The
`deerflow.agents:make_lead_agent` LangGraph Server entrypoint is a concrete thin
module-level function because the server resolves graph factories directly from
the module dictionary; the wrapper keeps the lead-agent and skill-cache imports
inside the function so importing the package remains lightweight. Internal
modules that only need lightweight types, config, or registries should import
the concrete submodule instead of adding eager package-root imports that pull in
the tool graph or subagent executor during state/schema imports.
`ThreadMetaStore.search()` keeps JSON filter semantics identical across memory,
SQLite, and PostgreSQL: missing differs from null, bool differs from int, and
float filters accept integer or real JSON numbers through `json_value_matches`.
### Gateway Run-Context Trust Boundary
Gate server-owned run context on both client feeds (`body.context` and
`body.config`): `merge_run_context_overrides` admits it only for `internal=True`,
while `strip_internal_context_keys` scrubs both destinations. A validated
`context.locale` (`en-US` / `zh-CN`) is a runtime-only language preference for
replies and memory writes; it never persists into checkpoint `configurable`,
and an explicit invalid/null top-level value clears a legacy preference. Treat
`disable_clarification` like `non_interactive`. Before run/state writes,
`_normalize_input_messages` rejects canonical external system/developer roles;
only `AUTH_SOURCE_INTERNAL` run input may retain them.
`contracts/ui_locales.v1.json` pins the UI/Gateway locale allowlists in tests;
DeerMem's portable map stays separate. Scheduled/MCP notification runs omit
locale; persisted owner locale is used for scheduled IM notices only.
`start_run` assigns IDs to ID-less messages before recording input or invoking
the graph; keyed retries keep those IDs stable, while the pre-stamp input
remains the idempotency identity. The record and checkpoint share IDs so
frontend reconnect can deduplicate the user's message.
## Development Workflow
### Test-Driven Development (TDD) — MANDATORY
**Every new feature or bug fix MUST be accompanied by unit tests. No exceptions.**
- Write tests in `backend/tests/` following the existing naming convention `test_<feature>.py`
- Run both offline targets before and after your change: `make test` and `make test-blocking-io`
- Tests must pass before a feature is considered complete
- For lightweight config/utility modules, prefer pure unit tests with no external dependencies
- If a module causes circular import issues in tests, add a `sys.modules` mock in `tests/conftest.py` (see existing example for `deerflow.subagents.executor`)
Keep live tests opt-in via `DEER_FLOW_RUN_LIVE_TESTS=1`; guard POSIX-only
markers with `os.name` for Windows collection.
Jina logging tests use dummy keys; the Jina redirect regression runs offline via `httpx.MockTransport` and keeps POST across temporary redirects; InfoQuest connect/read timeout is 30s, separate from crawl timeouts (`tests/test_jina_client.py`, `tests/test_infoquest_http_timeout.py`).
### Running the Full Application
Run `make dev` from the repo root to start all services at `http://localhost:2026`.
**All startup modes:**
| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** |
|---|---|---|---|---|
| **Dev** | `./scripts/serve.sh --dev`<br/>`make dev` | `./scripts/serve.sh --dev --daemon`<br/>`make dev-daemon` | `./scripts/docker.sh start`<br/>`make docker-start` | — |
| **Prod** | `./scripts/serve.sh --prod`<br/>`make start` | `./scripts/serve.sh --prod --daemon`<br/>`make start-daemon` | — | `./scripts/deploy.sh`<br/>`make up` |
| Action | Local | Docker Dev | Docker Prod |
|---|---|---|---|
| **Stop** | `./scripts/serve.sh --stop`<br/>`make stop` | `./scripts/docker.sh stop`<br/>`make docker-stop` | `./scripts/deploy.sh down`<br/>`make down` |
| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — |
### Running Backend Services Separately
From the **backend** directory:
```bash
# Gateway API
make gateway
```
Direct access (without nginx):
- Gateway: `http://localhost:8001`
### Frontend Configuration
The frontend uses environment variables to connect to backend services:
- `NEXT_PUBLIC_LANGGRAPH_BASE_URL` - Defaults to `/api/langgraph` (through nginx)
- `NEXT_PUBLIC_BACKEND_BASE_URL` - Defaults to empty string (through nginx)
When using `make dev` from root, the frontend automatically connects through nginx.
## Key Features
### Web Search Recency
Optional `web_search` `time_range=day|week|month|year` mappings:
DDG → `d|w|m|y`; Brave → `pd|pw|pm|py`; Tavily/SearXNG → unchanged;
Sofya → unchanged in `freshness`; Serper → `qdr:d|w|m|y` in `tbs`.
Omission preserves request shape; Serper null also omits `tbs`. Image search is unchanged.
DDGS 9.14.1 recency uses only enabled Brave/DuckDuckGo/Yahoo engines honoring
`timelimit`: `auto`/`all` selects this set; drop incompatible engines and fall back
to this set if empty. Re-check on DDGS upgrades.
### Tavily Fetch
Title fallback: result URL, then request URL.
### File Upload
Outlines use ATX syntax (1–6 hashes, space/tab separator, ≤3 leading spaces), strip closing hashes and skip fenced code; closing markers use a linear suffix scan — never unanchored whitespace regex on unbounded headings.
- Endpoint: `POST /api/threads/{thread_id}/uploads`; PDF/PPT/Excel/Word via `markitdown`, one conversion worker per request; directories rejected, keeping uploads all-or-nothing; duplicate filenames in one request get `_N` suffixes.
- Files live in thread-isolated `users/{user_id}/threads/{thread_id}/user-data/uploads` (IM channels thread the owner via the `user_id=` kwarg — see IM Channels → Owner-scoped file storage; HTTP/embedded callers resolve `get_effective_user_id()`); per-thread `upload-companions/` holds source mtime/ctime.
- Gateway HTTP uploads stage `.upload-*.part` files hidden from upload listings, agent context, and sandbox listings/searches; publication is atomic after size validation. Startup sweep keeps lone `.part` files under 24h (possibly in flight on another replica); `st_nlink > 1` means a published alias, removed at any age so the multi-link check can still replace the file.
- Upload/list/delete handlers offload filesystem work through `deerflow.utils.file_io.run_file_io` (ContextVar-preserving); non-mounted sandbox uploads use `SandboxProvider.acquire_async()` and offload `read_bytes()` + `sandbox.update_file()` together. Mounted uploads skip acquire/sync; AIO remote/provisioner requires accurate `sandbox.thread_data_mounts: true`, omission keeps backend auto-detection.
- `UploadsMiddleware` caps outline titles at 200 characters and previews at 2000 including markers. Titles use `original_user_content`; attachment-only titles use a sanitized, bounded filename or count.
See [docs/FILE_UPLOAD.md](docs/FILE_UPLOAD.md) for details.
### Plan Mode
`config.configurable.is_plan_mode=True` enables TodoList `write_todos` for
multi-step tasks: one `in_progress` task, real-time updates. See
[usage](docs/plan_mode_usage.md).
### Run Interaction Policy
Interaction-sensitive changes must follow [policy](docs/RUN_INTERACTION_POLICY.md).
### Context Summarization
Automatic conversation summarization when approaching token limits, configured
under the `summarization` key in `config.yaml` (triggers: tokens, messages, or
fraction of max input); recent messages are kept while older ones are summarized.
- Manual compaction uses `POST /api/threads/{id}/compact`, reuses the same `DeerFlowSummarizationMiddleware`, writes a new checkpoint with updated `messages`/`summary_text`, and bumps only those channel versions. The route uses the shared `reserve_checkpoint_write()` boundary (also used by manual state updates); its short-lived `checkpoint_write` operation shares the durable active-thread uniqueness constraint with run admission, preventing worker-local and cross-worker checkpoint-write races.
- Cache only first-candidate no-ops; never suppress primary retries with fallback results. See [reuse and telemetry](docs/summarization.md#reuse-and-telemetry) for cache and counter contracts.
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
For models with `supports_vision: true`: `ViewImageMiddleware` processes images and adds `view_image_tool`. Images reach the model as base64 in a hidden message carrying a reserved ID prefix plus a server-owned metadata marker (Gateway strips the marker from untrusted input; the middleware requires both to recognize its own message). The injection happens inside `wrap_model_call`, so payloads never enter graph state — checkpoints keep only lightweight `viewed_images` metadata, and client-chosen IDs survive; the middleware also sweeps its own message out of every request before rebuilding it, so a payload stranded in an older checkpoint by an interrupted run stops being resent.
## Code Style
- Uses `ruff` for linting and formatting
- Line length: 240 characters
- Python 3.12+ with type hints
- Double quotes, space indentation
## Documentation
See `docs/` directory for detailed documentation:
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
- [API.md](docs/API.md) - API reference
- [SETUP.md](docs/SETUP.md) - Setup guide
- [FILE_UPLOAD.md](docs/FILE_UPLOAD.md) - File upload feature
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
- [summarization.md](docs/summarization.md) - Context summarization
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList

View File

@ -54,6 +54,10 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
Middlewares execute in strict order, each handling a specific concern: Middlewares execute in strict order, each handling a specific concern:
Starting a new graph invocation resets tool-progress tracking. Results and model
retries still finishing from an earlier invocation cannot block its tools or
restore obsolete progress hints, including same-run goal continuations.
| # | Middleware | Purpose | | # | Middleware | Purpose |
|---|-----------|---------| |---|-----------|---------|
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) | | 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
@ -74,6 +78,12 @@ receive a transient hint on the next model request.
### Sandbox System ### Sandbox System
With lazy initialization, a fresh AIO run prepares its authorized Agent skill
view without acquiring a container until a sandbox-backed tool needs one.
Existing sandbox state still requires eager policy rebinding; upload-based
providers still acquire and synchronize skills eagerly. Explicit sandbox
prewarming remains a separate opt-in behavior.
Per-thread isolated execution with virtual path translation: Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir` - **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
@ -105,6 +115,10 @@ the same item or undo a pause/cancellation observed after their initial read.
### Memory System ### Memory System
If the local retrieval index is busy, read-only, or temporarily unavailable,
DeerMem falls back to substring search without deleting it. Automatic index
recreation is reserved for SQLite-confirmed corruption or a non-database file.
LLM-powered persistent context retention across conversations: LLM-powered persistent context retention across conversations:
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences - **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
@ -156,8 +170,8 @@ to a boundary (SQLite) or causing the search to fail (PostgreSQL).
| `POST /api/skills/install` | Install skill from `.skill` archive | | `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET /api/memory` | Retrieve memory data | | `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload | | `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration | | `GET /api/memory/config` | Memory configuration; credential values in `backend_config` are masked |
| `GET /api/memory/status` | Combined config + data | | `GET /api/memory/status` | Combined config (masked the same way) + data |
| `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity | | `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity |
| `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) | | `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) |
| `GET /api/threads/{id}/uploads/list` | List uploaded files | | `GET /api/threads/{id}/uploads/list` | List uploaded files |

View File

@ -126,8 +126,8 @@ FastAPI 应用程序,为前端集成提供 REST 接口:
| `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 | | `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 |
| `GET /api/memory` | 获取记忆数据 | | `GET /api/memory` | 获取记忆数据 |
| `POST /api/memory/reload` | 强制重新加载记忆 | | `POST /api/memory/reload` | 强制重新加载记忆 |
| `GET /api/memory/config` | 获取记忆配置 | | `GET /api/memory/config` | 获取记忆配置;`backend_config` 中的凭据值会被遮蔽 |
| `GET /api/memory/status` | 获取组合后的配置和数据状态 | | `GET /api/memory/status` | 获取组合后的配置(同样遮蔽凭据)和数据状态 |
| `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 | | `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 |
| `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) | | `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) |
| `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 | | `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 |

File diff suppressed because one or more lines are too long

View File

@ -250,7 +250,12 @@ class Channel(ABC):
log_prefix: str | None = None, log_prefix: str | None = None,
operation_name: str = "send", operation_name: str = "send",
) -> T: ) -> T:
"""Run an outbound send operation with the shared channel retry policy.""" """Run an outbound send operation with the shared channel retry policy.
The wait between attempts is ``_outbound_retry_delay``. The default
is ``2**attempt``; a provider that is told how long to wait overrides
that without changing the attempt bound or the other channels.
"""
prefix = log_prefix or f"[{self.name}]" prefix = log_prefix or f"[{self.name}]"
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(max_retries): for attempt in range(max_retries):
@ -259,9 +264,9 @@ class Channel(ABC):
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = 2**attempt delay = self._outbound_retry_delay(attempt, exc)
logger.warning( logger.warning(
"%s %s failed (attempt %d/%d), retrying in %ds: %s", "%s %s failed (attempt %d/%d), retrying in %ss: %s",
prefix, prefix,
operation_name, operation_name,
attempt + 1, attempt + 1,
@ -276,6 +281,16 @@ class Channel(ABC):
raise RuntimeError(f"{self.name} {operation_name} failed without an exception from any attempt") raise RuntimeError(f"{self.name} {operation_name} failed without an exception from any attempt")
raise last_exc raise last_exc
def _outbound_retry_delay(self, attempt: int, exc: Exception) -> float:
"""Seconds to wait before the next attempt.
``exc`` is the failure just caught. The shared policy ignores it and
sleeps ``2**attempt`` (1s, then 2s, then 4s, ...). A provider that
receives a server wait hint overrides this and may return a longer
delay; it must not shorten the local backoff.
"""
return 2**attempt
def _log_future_error(self, fut: Any, name: str, msg_id: Any) -> None: def _log_future_error(self, fut: Any, name: str, msg_id: Any) -> None:
"""Callback for concurrent futures scheduled from channel worker threads.""" """Callback for concurrent futures scheduled from channel worker threads."""
try: try:

View File

@ -10,6 +10,7 @@ import re
import stat import stat
import threading import threading
import time import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -202,7 +203,6 @@ class DingTalkChannel(Channel):
self._stream_lock = threading.Lock() self._stream_lock = threading.Lock()
self._incoming_messages: dict[str, Any] = {} self._incoming_messages: dict[str, Any] = {}
self._incoming_messages_lock = threading.Lock() self._incoming_messages_lock = threading.Lock()
self._card_repliers: dict[str, Any] = {}
# Serialize inbound-file writes into the uploads directory to avoid # Serialize inbound-file writes into the uploads directory to avoid
# racing writers clobbering one another (mirrors FeishuChannel). # racing writers clobbering one another (mirrors FeishuChannel).
self._file_write_lock = threading.Lock() self._file_write_lock = threading.Lock()
@ -263,7 +263,6 @@ class DingTalkChannel(Channel):
self._dingtalk_client = None self._dingtalk_client = None
with self._incoming_messages_lock: with self._incoming_messages_lock:
self._incoming_messages.clear() self._incoming_messages.clear()
self._card_repliers.clear()
self._card_track_ids.clear() self._card_track_ids.clear()
if self._thread: if self._thread:
# The thread exits once its loop has stopped; keep the join off the # The thread exits once its loop has stopped; keep the join off the
@ -351,12 +350,10 @@ class DingTalkChannel(Channel):
logger.warning("[DingTalk] card stream failed, falling back to sampleMarkdown") logger.warning("[DingTalk] card stream failed, falling back to sampleMarkdown")
if msg.is_final: if msg.is_final:
self._card_track_ids.pop(source_key, None) self._card_track_ids.pop(source_key, None)
self._card_repliers.pop(out_track_id, None)
await self._send_markdown_fallback(robot_code, conversation_type, sender_staff_id, conversation_id, msg.text) await self._send_markdown_fallback(robot_code, conversation_type, sender_staff_id, conversation_id, msg.text)
return return
if msg.is_final: if msg.is_final:
self._card_track_ids.pop(source_key, None) self._card_track_ids.pop(source_key, None)
self._card_repliers.pop(out_track_id, None)
return return
async def send_markdown() -> None: async def send_markdown() -> None:
@ -441,6 +438,24 @@ class DingTalkChannel(Channel):
}, },
) )
response.raise_for_status() response.raise_for_status()
data = response.json()
process_key = data.get("processQueryKey") if isinstance(data, dict) else None
if not isinstance(process_key, str) or not process_key.strip():
logger.warning(
"[DingTalk] file send response missing processQueryKey for %s; treating as not delivered",
attachment.filename,
)
return False
if conversation_type != _CONVERSATION_TYPE_GROUP:
target = sender_staff_id
for field in ("invalidStaffIdList", "flowControlledStaffIdList"):
failed = data.get(field, [])
if not isinstance(failed, list):
logger.warning("[DingTalk] unexpected %s in file send response for %s", field, attachment.filename)
return False
if target in failed:
logger.warning("[DingTalk] file not delivered for %s (%s)", attachment.filename, field)
return False
logger.info("[DingTalk] file sent: %s", attachment.filename) logger.info("[DingTalk] file sent: %s", attachment.filename)
return True return True
@ -1244,30 +1259,42 @@ class DingTalkChannel(Channel):
logger.warning("[DingTalk] SDK client or chatbot_message unavailable, skipping AI card") logger.warning("[DingTalk] SDK client or chatbot_message unavailable, skipping AI card")
return None return None
out_track_id = uuid.uuid4().hex
try: try:
from dingtalk_stream.card_replier import AICardReplier await self._card_request("POST", "/v1.0/card/instances/createAndDeliver", self._card_create_body(out_track_id, chatbot_message, initial_text))
except ImportError:
logger.warning("[DingTalk] dingtalk-stream card_replier not available")
return None
try:
token_client = _CardTokenClient(self._dingtalk_client.credential)
token_client.access_token = await self._get_access_token()
replier = AICardReplier(token_client, chatbot_message)
card_instance_id = await replier.async_create_and_deliver_card(
card_template_id=self._card_template_id,
card_data={"content": initial_text},
)
if not card_instance_id:
return None
self._card_repliers[card_instance_id] = replier
logger.info("[DingTalk] AI card created: outTrackId=%s", card_instance_id)
return card_instance_id
except Exception: except Exception:
logger.exception("[DingTalk] failed to create AI card") logger.exception("[DingTalk] failed to create AI card")
return None return None
logger.info("[DingTalk] AI card created: outTrackId=%s", out_track_id)
return out_track_id
def _card_create_body(self, out_track_id: str, chatbot_message: Any, initial_text: str) -> dict[str, Any]:
"""The ``createAndDeliver`` body dingtalk-stream's ``AICardReplier`` sends."""
body: dict[str, Any] = {
"cardTemplateId": self._card_template_id,
"outTrackId": out_track_id,
"cardData": {"cardParamMap": {"content": initial_text}},
"callbackType": "STREAM",
"imGroupOpenSpaceModel": {"supportForward": True},
"imRobotOpenSpaceModel": {"supportForward": True},
}
hosting_context = getattr(chatbot_message, "hosting_context", None)
conversation_type = str(chatbot_message.conversation_type)
if conversation_type == _CONVERSATION_TYPE_GROUP:
body["openSpaceId"] = f"dtv1.card//IM_GROUP.{chatbot_message.conversation_id}"
deliver_model: dict[str, Any] = {"robotCode": self._client_id}
body["imGroupOpenDeliverModel"] = deliver_model
elif conversation_type == _CONVERSATION_TYPE_P2P:
body["openSpaceId"] = f"dtv1.card//IM_ROBOT.{chatbot_message.sender_staff_id}"
deliver_model = {"spaceType": "IM_ROBOT"}
body["imRobotOpenDeliverModel"] = deliver_model
else:
return body
if hosting_context is not None:
deliver_model["extension"] = {"hostingRepliedContext": json.dumps({"userId": hosting_context.user_id})}
return body
async def _stream_update_card( async def _stream_update_card(
self, self,
out_track_id: str, out_track_id: str,
@ -1276,20 +1303,35 @@ class DingTalkChannel(Channel):
is_finalize: bool = False, is_finalize: bool = False,
is_error: bool = False, is_error: bool = False,
) -> None: ) -> None:
replier = self._card_repliers.get(out_track_id) await self._card_request(
if not replier: "PUT",
raise RuntimeError(f"No AICardReplier found for track ID {out_track_id}") "/v1.0/card/streaming",
{
replier.dingtalk_client.access_token = await self._get_access_token() "outTrackId": out_track_id,
await replier.async_streaming( "guid": str(uuid.uuid4()),
card_instance_id=out_track_id, "key": "content",
content_key="content", "content": content,
content_value=content, "isFull": True,
append=False, "isFinalize": is_finalize,
finished=is_finalize, "isError": is_error,
failed=is_error, },
) )
async def _card_request(self, method: str, path: str, body: dict[str, Any]) -> None:
"""Send an AI card request with the channel token; raise if it fails.
dingtalk-stream's ``AICardReplier`` logs a failed card request and
returns as if it succeeded; its create call even returns the card ID.
Neither the running reply's text fallback nor the sampleMarkdown
fallback in ``send`` could then run, so the reply was lost.
"""
token = await self._get_access_token()
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
response = await client.request(method, f"{DINGTALK_API_BASE}{path}", headers=self._api_headers(token), json=body)
if response.is_error:
logger.warning("[DingTalk] card request %s %s returned %s: %s", method, path, response.status_code, response.text[:500])
response.raise_for_status()
# -- media upload -------------------------------------------------------- # -- media upload --------------------------------------------------------
async def _upload_media(self, file_path: str | Path, media_type: str) -> str | None: async def _upload_media(self, file_path: str | Path, media_type: str) -> str | None:
@ -1318,26 +1360,6 @@ class DingTalkChannel(Channel):
return None return None
class _CardTokenClient:
"""The client ``AICardReplier`` sees: it serves the channel's access token.
The replier's async methods call ``dingtalk_client.get_access_token()``
inline, and dingtalk-stream's version refreshes through ``requests.post``
with no timeout, so on the Gateway loop every refresh (about every two
hours) blocked the loop for the whole request, indefinitely if DingTalk
stalled. The channel sets ``access_token`` from its own bounded
``_get_access_token()`` before every replier call, so the replier never
fetches a token itself.
"""
def __init__(self, credential: Any) -> None:
self.credential = credential
self.access_token = ""
def get_access_token(self) -> str:
return self.access_token
class _DingTalkMessageHandler: class _DingTalkMessageHandler:
"""Callback handler registered with dingtalk-stream.""" """Callback handler registered with dingtalk-stream."""

View File

@ -28,6 +28,7 @@ from app.channels.message_bus import (
OutboundMessage, OutboundMessage,
ResolvedAttachment, ResolvedAttachment,
) )
from deerflow.utils.atomic_write import publish_bytes_atomically
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -275,7 +276,10 @@ class DiscordChannel(Channel):
try: try:
snapshot = dict(self._active_threads) snapshot = dict(self._active_threads)
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True) self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
self._thread_store_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8", newline="") # Publish through the shared helper: it fsyncs the staged bytes
# before the rename (so a machine crash cannot leave an empty
# mapping behind) and re-applies an existing file's mode.
publish_bytes_atomically(self._thread_store_path, json.dumps(snapshot, indent=2).encode("utf-8"))
except Exception: except Exception:
logger.exception("[Discord] failed to persist thread mappings") logger.exception("[Discord] failed to persist thread mappings")

View File

@ -55,6 +55,9 @@ PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
FEISHU_STOP_TIMEOUT_SECONDS = 5 FEISHU_STOP_TIMEOUT_SECONDS = 5
FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2 FEISHU_WS_CLOSE_TIMEOUT_SECONDS = 2
_PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id" _PENDING_CARD_METADATA_KEY = "_feishu_pending_card_id"
# Number of attachments from a post's top-level ``files`` array.
_POST_FILES_COUNT_METADATA_KEY = "_feishu_post_files_count"
_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY = "_feishu_defer_post_file_placeholders"
# Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop, # Channel-store deadlines. ``_on_message`` runs on lark-oapi's private event loop,
# which also carries the SDK's websocket reads, pongs and its 120s ping loop: # which also carries the SDK's websocket reads, pongs and its 120s ping loop:
# every second the bridge blocks that loop is a second the connection cannot # every second the bridge blocks that loop is a second the connection cannot
@ -107,6 +110,7 @@ class _ParsedMessage:
chat_type: str | None chat_type: str | None
text: str text: str
files: list[dict[str, Any]] files: list[dict[str, Any]]
post_file_count: int = 0
msg_type: InboundMessageType = InboundMessageType.CHAT msg_type: InboundMessageType = InboundMessageType.CHAT
@ -144,12 +148,110 @@ class _RoutingAttempt:
FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75 FEISHU_INBOUND_BATCH_WINDOW_SECONDS = 0.75
FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000 FEISHU_MAX_INBOUND_FILE_BYTES = 20_000_000
SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview" SOURCE_PREVIEW_METADATA_KEY = "feishu_source_preview"
# Feishu rejects a card request body over 30 KB (230025), and style tags make
# the stored message larger than the body sent. Card text is budgeted by the
# bytes it adds to that body, leaving room for the other fields and that growth.
FEISHU_CARD_MAX_BYTES = 24 * 1024
# A long final answer continues in further cards, up to this many per reply.
FEISHU_MAX_CARDS_PER_REPLY = 10
_CARD_TRUNCATION_MARKER = "\n\n... (truncated)"
_CARD_CONTINUES_MARKER = "\n\n…"
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
# Characters JSON escapes, measured after the second escape: ``"`` is ``\\\"``.
_ESCAPED_CARD_CHAR_BYTES = {'"': 4, "\\": 4, "\b": 3, "\f": 3, "\n": 3, "\r": 3, "\t": 3}
def _card_char_bytes(char: str) -> int:
escaped = _ESCAPED_CARD_CHAR_BYTES.get(char)
if escaped is not None:
return escaped
code = ord(char)
if code < 0x20:
return 7 # \u00XX, whose backslash is escaped again
if code < 0x80:
return 1
if code < 0x800:
return 2
if code < 0x10000:
return 3 # a lone surrogate is sent as U+FFFD
return 4
def _card_text_bytes(text: str) -> int:
"""Bytes *text* adds to a card request body.
The text is escaped twice: once inside the card JSON, and again as the
request body's ``content`` string. JSON escapes each character on its own,
so the cost of a text is the sum of its characters' costs.
"""
return sum(map(_card_char_bytes, text))
def _card_text_budget() -> int:
return FEISHU_CARD_MAX_BYTES - _card_text_bytes(FeishuChannel._build_card_content(""))
def _fitting_prefix_end(text: str, start: int, budget: int) -> tuple[int, int]:
"""Return the end of the longest prefix of ``text[start:]`` within *budget*, and its last newline."""
used = 0
end = start
last_newline = -1
while end < len(text):
cost = _card_char_bytes(text[end])
if used + cost > budget:
break
used += cost
if text[end] == "\n":
last_newline = end
end += 1
return end, last_newline
def _clip_card_text(text: str, marker: str = _CARD_TRUNCATION_MARKER) -> str:
"""Clip *text* to one card, ending it with *marker* when anything was cut."""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return text
end, _ = _fitting_prefix_end(text, 0, budget - _card_text_bytes(marker))
return text[:end] + marker
def _split_card_text(text: str) -> list[str]:
"""Split *text* into cards, preferring line boundaries so markdown survives.
Each newline stays on the tail of its card, so the cards join back to
the original text. Past ``FEISHU_MAX_CARDS_PER_REPLY`` the last card is
clipped instead.
"""
budget = _card_text_budget()
if _card_text_bytes(text) <= budget:
return [text]
cards: list[str] = []
start = 0
while start < len(text):
if len(cards) == FEISHU_MAX_CARDS_PER_REPLY - 1:
logger.warning("[Feishu] reply of %d characters exceeds %d cards, clipping the last one", len(text), FEISHU_MAX_CARDS_PER_REPLY)
cards.append(_clip_card_text(text[start:]))
break
end, last_newline = _fitting_prefix_end(text, start, budget)
if end < len(text) and last_newline >= start:
end = last_newline + 1
end = max(end, start + 1) # always advance, even past an oversized character
cards.append(text[start:end])
start = end
return cards
def _is_feishu_command(text: str) -> bool: def _is_feishu_command(text: str) -> bool:
return is_known_channel_command(text) return is_known_channel_command(text)
def _append_post_file_placeholders(text: str, count: int) -> str:
paragraphs = [text] if text else []
paragraphs.extend(["[file]"] * count)
return "\n\n".join(paragraphs)
class FeishuChannel(Channel): class FeishuChannel(Channel):
"""Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client. """Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client.
@ -549,8 +651,10 @@ class FeishuChannel(Channel):
len(msg.text), len(msg.text),
) )
# Cards already delivered by an earlier attempt are not sent again.
delivered: list[str | None] = []
await self._send_with_retry( await self._send_with_retry(
lambda: self._send_card_message(msg), lambda: self._send_card_message(msg, delivered=delivered),
max_retries=_max_retries, max_retries=_max_retries,
log_prefix="[Feishu]", log_prefix="[Feishu]",
) )
@ -668,9 +772,7 @@ class FeishuChannel(Channel):
async def _upload_image(self, path) -> str: async def _upload_image(self, path) -> str:
"""Upload an image to Feishu and return the image_key.""" """Upload an image to Feishu and return the image_key."""
response = await asyncio.to_thread(self._upload_image_sync, path) response = await asyncio.to_thread(self._upload_image_sync, path)
if not response.success(): return self._uploaded_key(response, "image")
raise RuntimeError(f"Feishu image upload failed: code={response.code}, msg={response.msg}")
return response.data.image_key
def _upload_file_sync(self, path, filename: str, file_type: str): def _upload_file_sync(self, path, filename: str, file_type: str):
with open(str(path), "rb") as f: with open(str(path), "rb") as f:
@ -692,9 +794,17 @@ class FeishuChannel(Channel):
file_type = "stream" file_type = "stream"
response = await asyncio.to_thread(self._upload_file_sync, path, filename, file_type) response = await asyncio.to_thread(self._upload_file_sync, path, filename, file_type)
return self._uploaded_key(response, "file")
@staticmethod
def _uploaded_key(response, kind: str) -> str:
"""Return the ``image_key``/``file_key`` of an upload response or raise with its log_id."""
if not response.success(): if not response.success():
raise RuntimeError(f"Feishu file upload failed: code={response.code}, msg={response.msg}") raise RuntimeError(f"Feishu {kind} upload failed: code={response.code}, msg={response.msg}, log_id={response.get_log_id()}")
return response.data.file_key key = getattr(getattr(response, "data", None), f"{kind}_key", None)
if not isinstance(key, str) or not key:
raise RuntimeError(f"Feishu {kind} upload returned no {kind}_key: log_id={response.get_log_id()}")
return key
async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage: async def receive_file(self, msg: InboundMessage, thread_id: str, *, user_id: str | None = None) -> InboundMessage:
"""Download a Feishu file into the thread uploads directory. """Download a Feishu file into the thread uploads directory.
@ -709,6 +819,10 @@ class FeishuChannel(Channel):
logger.warning("[Feishu] received message with no files: %s", msg) logger.warning("[Feishu] received message with no files: %s", msg)
return msg return msg
text = msg.text text = msg.text
post_file_count = msg.metadata.pop(_POST_FILES_COUNT_METADATA_KEY, 0)
defer_post_file_placeholders = msg.metadata.pop(_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY, False)
if not isinstance(post_file_count, int) or post_file_count < 0:
post_file_count = 0
search_from = 0 search_from = 0
def replace_next_placeholder(placeholder: str, replacement: str) -> None: def replace_next_placeholder(placeholder: str, replacement: str) -> None:
@ -719,14 +833,40 @@ class FeishuChannel(Channel):
text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}" text = f"{text[:idx]}{replacement}{text[idx + len(placeholder) :]}"
search_from = idx + len(replacement) search_from = idx + len(replacement)
downloaded: list[tuple[str, str]] = []
for file in files: for file in files:
if file.get("image_key"): if file.get("image_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["image_key"], "image", thread_id, user_id=user_id)
replace_next_placeholder("[image]", virtual_path) downloaded.append(("[image]", virtual_path))
elif file.get("file_key"): elif file.get("file_key"):
virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id) virtual_path = await self._receive_single_file(msg.thread_ts, file["file_key"], "file", thread_id, user_id=user_id)
replace_next_placeholder("[file]", virtual_path) downloaded.append(("[file]", virtual_path))
# Top-level post attachments append their ``[file]`` placeholders
# after the parsed body, so their slots are the final
# ``post_file_count`` ones. Fill them from the end first: a literal
# ``[file]`` typed by the user must not consume a top-level
# attachment's path. Inline rich-text attachments keep the historical
# left-to-right matching below; that path is deliberately unchanged.
post_file_count = min(post_file_count, len(downloaded))
if defer_post_file_placeholders is True:
# A command such as /goal has now entered the chat path. Add the
# generated slots to the chat text after its arguments were parsed.
text = _append_post_file_placeholders(text, post_file_count)
suffix_end = len(text)
for placeholder, virtual_path in reversed(downloaded[len(downloaded) - post_file_count :] if post_file_count else []):
idx = text.rfind(placeholder, 0, suffix_end)
if idx < 0:
continue
text = f"{text[:idx]}{virtual_path}{text[idx + len(placeholder) :]}"
suffix_end = idx
for placeholder, virtual_path in downloaded[: len(downloaded) - post_file_count]:
replace_next_placeholder(placeholder, virtual_path)
msg.text = text msg.text = text
# Clear the descriptors so the generic URL-based _ingest_inbound_files
# pass does not try to re-fetch files that were downloaded by key.
msg.files = []
return msg return msg
async def _receive_single_file( async def _receive_single_file(
@ -858,9 +998,11 @@ class FeishuChannel(Channel):
""" """
card = { card = {
"config": {"wide_screen_mode": True, "update_multi": True}, "config": {"wide_screen_mode": True, "update_multi": True},
"elements": [{"tag": "markdown", "content": text}], # Unpaired surrogates cannot be encoded as UTF-8.
"elements": [{"tag": "markdown", "content": _LONE_SURROGATE.sub("\ufffd", text)}],
} }
return json.dumps(card) # ASCII escapes would make each CJK character 6 bytes instead of 3.
return json.dumps(card, ensure_ascii=False)
# -- reaction helpers -------------------------------------------------- # -- reaction helpers --------------------------------------------------
@ -940,7 +1082,7 @@ class FeishuChannel(Channel):
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> str | None: ) -> str | None:
"""Create the running card and cache its message ID when available.""" """Create the running card and cache its message ID when available."""
running_card_id = await self._reply_card(source_message_id, self._compose_card_text(text, metadata)) running_card_id = await self._reply_card(source_message_id, _clip_card_text(self._compose_card_text(text, metadata), _CARD_CONTINUES_MARKER))
if running_card_id: if running_card_id:
self._running_card_ids[source_message_id] = running_card_id self._running_card_ids[source_message_id] = running_card_id
logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card created: source=%s card=%s", source_message_id, running_card_id)
@ -1054,8 +1196,14 @@ class FeishuChannel(Channel):
self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id) self._remember_pending_clarification(msg, card_message_id, topic_id=topic_id)
await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id) await self._remember_thread_mapping(msg, msg.thread_ts, card_message_id)
async def _send_card_message(self, msg: OutboundMessage) -> None: async def _send_card_message(self, msg: OutboundMessage, *, delivered: list[str | None] | None = None) -> None:
"""Send or update the Feishu card tied to the current request.""" """Send or update the Feishu card tied to the current request.
A final answer over one card's budget continues in further cards replied
to the same source message; a streamed update shows only what fits.
*delivered* collects the IDs of the final answer's cards across retries.
"""
delivered = [] if delivered is None else delivered
source_message_id = msg.thread_ts source_message_id = msg.thread_ts
# Option clarifications render as an interactive button card on the # Option clarifications render as an interactive button card on the
# final outbound; everything else keeps the plain markdown card. # final outbound; everything else keeps the plain markdown card.
@ -1063,11 +1211,45 @@ class FeishuChannel(Channel):
card_topic_id = await self._bind_card_thread(msg) if card_request else None card_topic_id = await self._bind_card_thread(msg) if card_request else None
if card_topic_id is None: if card_topic_id is None:
card_request = None card_request = None
card_text = build_button_card(card_request) if card_request else self._compose_card_text(msg.text, msg.metadata)
# Button cards arrive as serialized card JSON and bypass the markdown # Button cards arrive as serialized card JSON and bypass the markdown
# wrapper; the plain path keeps its exact historical call signature. # wrapper; the plain path keeps its exact historical call signature.
raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {} raw_card_kw: dict[str, bool] = {"raw_card_content": True} if card_request else {}
if source_message_id: if source_message_id:
if card_request:
cards = [build_button_card(card_request)]
elif msg.is_final:
cards = _split_card_text(self._compose_card_text(msg.text, msg.metadata))
else:
cards = [_clip_card_text(self._compose_card_text(msg.text, msg.metadata), _CARD_CONTINUES_MARKER)]
if not delivered:
await self._send_first_card(msg, cards[0], card_request=card_request, card_topic_id=card_topic_id, delivered=delivered, raw_card_kw=raw_card_kw)
if msg.is_final:
for card_text in cards[len(delivered) :]:
delivered.append(await self._reply_card(source_message_id, card_text))
if len(delivered) > 1:
# A reply to any of the cards continues this thread.
await self._remember_thread_mapping(msg, *delivered[1:])
self._running_card_ids.pop(source_message_id, None)
self._remembered_thread_aliases.pop(source_message_id, None)
await self._add_reaction(source_message_id, "DONE")
return
for card_text in _split_card_text(msg.text)[len(delivered) :]:
await self._create_card(msg.chat_id, card_text)
delivered.append(None)
async def _send_first_card(
self,
msg: OutboundMessage,
card_text: str,
*,
card_request: ClarificationCardRequest | None,
card_topic_id: str | None,
delivered: list[str | None],
raw_card_kw: dict[str, bool],
) -> None:
"""Put *card_text* on the running card, or reply with it; record it in *delivered* when final."""
source_message_id = msg.thread_ts
running_card_id = self._running_card_ids.get(source_message_id) running_card_id = self._running_card_ids.get(source_message_id)
awaited_running_card_task = False awaited_running_card_task = False
@ -1088,8 +1270,11 @@ class FeishuChannel(Channel):
running_card_id, running_card_id,
) )
fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw) fallback_card_id = await self._reply_card(source_message_id, card_text, **raw_card_kw)
delivered.append(fallback_card_id)
await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, fallback_card_id, topic_id=card_topic_id)
else: else:
if msg.is_final:
delivered.append(running_card_id)
await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, running_card_id, topic_id=card_topic_id)
logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id) logger.info("[Feishu] running card updated: source=%s card=%s", source_message_id, running_card_id)
elif msg.is_final: elif msg.is_final:
@ -1098,6 +1283,7 @@ class FeishuChannel(Channel):
card_text, card_text,
**raw_card_kw, **raw_card_kw,
) )
delivered.append(final_card_id)
await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id) await self._remember_card_delivery(msg, card_request, final_card_id, topic_id=card_topic_id)
elif awaited_running_card_task: elif awaited_running_card_task:
logger.warning( logger.warning(
@ -1112,14 +1298,6 @@ class FeishuChannel(Channel):
) )
await self._remember_thread_mapping(msg, source_message_id, created_card_id) await self._remember_thread_mapping(msg, source_message_id, created_card_id)
if msg.is_final:
self._running_card_ids.pop(source_message_id, None)
self._remembered_thread_aliases.pop(source_message_id, None)
await self._add_reaction(source_message_id, "DONE")
return
await self._create_card(msg.chat_id, msg.text)
# -- internal ---------------------------------------------------------- # -- internal ----------------------------------------------------------
async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None: async def _remember_thread_mapping(self, msg: OutboundMessage, *topic_ids: str | None) -> None:
@ -1609,6 +1787,10 @@ class FeishuChannel(Channel):
} }
if source_preview: if source_preview:
metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview metadata[SOURCE_PREVIEW_METADATA_KEY] = source_preview
if parsed.post_file_count:
metadata[_POST_FILES_COUNT_METADATA_KEY] = parsed.post_file_count
if parsed.msg_type == InboundMessageType.COMMAND:
metadata[_DEFER_POST_FILE_PLACEHOLDERS_METADATA_KEY] = True
if routing.clarification_card_id: if routing.clarification_card_id:
metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id metadata[_PENDING_CARD_METADATA_KEY] = routing.clarification_card_id
@ -1947,6 +2129,26 @@ class FeishuChannel(Channel):
text = "" text = ""
text = text.strip() text = text.strip()
# Feishu desktop attaches files to a message as a post whose rich-text
# body holds the caption and whose top-level ``files`` array holds the
# attachments. The body may be empty. Keep these attachments last so
# ``receive_file`` can match their placeholders from the end. Skip
# folders and attachments already represented by a rich-text element.
attachments = content.get("files") if isinstance(content, dict) else None
if not isinstance(attachments, list):
attachments = []
post_file_count = 0
for attachment in attachments:
if not isinstance(attachment, dict) or attachment.get("is_folder"):
continue
file_key = attachment.get("file_key")
if not isinstance(file_key, str) or not file_key:
continue
if any(existing.get("file_key") == file_key for existing in files_list):
continue
files_list.append({"file_key": file_key})
post_file_count += 1
logger.info( logger.info(
"[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d", "[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, parent_id=%s, thread_id=%s, chat_type=%s, sender=%s, text_len=%d",
chat_id, chat_id,
@ -1972,6 +2174,7 @@ class FeishuChannel(Channel):
chat_type=chat_type, chat_type=chat_type,
text=text, text=text,
files=files_list, files=files_list,
post_file_count=post_file_count,
) )
def _on_message(self, event) -> None: def _on_message(self, event) -> None:
@ -2014,6 +2217,8 @@ class FeishuChannel(Channel):
if _is_feishu_command(command_text): if _is_feishu_command(command_text):
parsed.msg_type = InboundMessageType.COMMAND parsed.msg_type = InboundMessageType.COMMAND
parsed.text = command_text parsed.text = command_text
elif parsed.post_file_count:
parsed.text = _append_post_file_placeholders(text, parsed.post_file_count)
# topic_id determines which LangGraph thread the message maps to. # topic_id determines which LangGraph thread the message maps to.
# P2P chats: topic_id=None so all messages share one thread (like Telegram DMs). # P2P chats: topic_id=None so all messages share one thread (like Telegram DMs).

View File

@ -16,6 +16,7 @@ from __future__ import annotations
import math import math
from collections.abc import Mapping from collections.abc import Mapping
from app.channels.reply_locale import supported_locale
from app.scheduler.notification_text import goal_reason_text from app.scheduler.notification_text import goal_reason_text
from deerflow.persistence.scheduled_task_runs.finalization import CHECK_FAILURE_CODES from deerflow.persistence.scheduled_task_runs.finalization import CHECK_FAILURE_CODES
from deerflow.runtime.goal import is_active_goal from deerflow.runtime.goal import is_active_goal
@ -38,6 +39,7 @@ _TEXT: dict[str, dict[str, str]] = {
"fetch_failed": "Failed to fetch goal information.", "fetch_failed": "Failed to fetch goal information.",
"clear_failed": "Failed to clear goal.", "clear_failed": "Failed to clear goal.",
"set_failed": "Failed to set goal.", "set_failed": "Failed to set goal.",
"busy": "A task is still running. Wait for it to finish before changing the goal.",
}, },
"zh-CN": { "zh-CN": {
"goal": "目标:{objective}", "goal": "目标:{objective}",
@ -56,6 +58,7 @@ _TEXT: dict[str, dict[str, str]] = {
"fetch_failed": "无法获取目标信息。", "fetch_failed": "无法获取目标信息。",
"clear_failed": "无法清除目标。", "clear_failed": "无法清除目标。",
"set_failed": "无法设置目标。", "set_failed": "无法设置目标。",
"busy": "当前任务仍在运行,请等待结束后再修改目标。",
}, },
} }
@ -65,6 +68,7 @@ _WAITING_KEYS = {"needs_user_input": "waiting_for_you", "external_wait": "waitin
def goal_reply_text(key: str, locale: str) -> str: def goal_reply_text(key: str, locale: str) -> str:
"""A fixed ``/goal`` reply (``none``, ``cleared``, ``*_failed``) in ``locale``.""" """A fixed ``/goal`` reply (``none``, ``cleared``, ``*_failed``) in ``locale``."""
locale = supported_locale(locale)
return _TEXT[locale][key] return _TEXT[locale][key]
@ -102,6 +106,7 @@ def goal_status_text(goal: object, outcome: object, *, running: bool, locale: st
``running`` says whether the thread has a run pending or in progress. ``running`` says whether the thread has a run pending or in progress.
""" """
locale = supported_locale(locale)
text = _TEXT[locale] text = _TEXT[locale]
if is_active_goal_state(goal): if is_active_goal_state(goal):
lines = [text["goal"].format(objective=goal["objective"])] lines = [text["goal"].format(objective=goal["objective"])]

View File

@ -69,6 +69,7 @@ from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.trace_context import ensure_trace_context from deerflow.trace_context import ensure_trace_context
from deerflow.uploads.manager import apply_upload_sandbox_permits from deerflow.uploads.manager import apply_upload_sandbox_permits
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
from deerflow.utils.http_policy import control_plane_trust_env
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -301,6 +302,59 @@ _WECOM_MEDIA_COS_HOST_RE = re.compile(r"^ww-aibot-img-(?P<appid>\d+)\.cos\.[a-z0
_METADATA_DROP_KEYS = frozenset({"raw_message", "ref_msg"}) _METADATA_DROP_KEYS = frozenset({"raw_message", "ref_msg"})
def _make_gateway_client(gateway_url: str) -> httpx.AsyncClient:
"""Build the short-lived client for Gateway control-plane calls.
A loopback Gateway must bypass ``HTTP_PROXY``, while an external one keeps
the environment proxy. The policy itself lives in
:mod:`deerflow.utils.http_policy` so this module never imports the sandbox
provider stack.
"""
return httpx.AsyncClient(trust_env=control_plane_trust_env(gateway_url))
# Mirrors the client ``langgraph_sdk.get_client()`` builds, so the loopback path
# below differs from it only in ``trust_env``.
_LANGGRAPH_SDK_RETRIES = 5
_LANGGRAPH_SDK_TIMEOUT = httpx.Timeout(connect=5, read=300, write=300, pool=5)
def _make_langgraph_client(base_url: str, headers: Mapping[str, str]) -> Any:
"""Build the ``langgraph_sdk`` async client used for channel run submission.
``get_client()`` always builds ``httpx.AsyncHTTPTransport(retries=5)`` on an
``httpx.AsyncClient`` that keeps ``trust_env`` at its default ``True`` and
exposes neither ``transport`` nor ``trust_env``, so a loopback Gateway would
still be sent through ``HTTP_PROXY``: control-plane calls are fixed but
``runs.wait`` keeps hitting the proxy-generated error of #7876. Loopback and
private Gateways therefore get an explicit ``trust_env=False`` client handed
to the SDK; external Gateways keep the stock ``get_client()`` path.
"""
from langgraph_sdk import __version__ as sdk_version
from langgraph_sdk import get_client
if control_plane_trust_env(base_url):
return get_client(url=base_url, headers=dict(headers))
try: # langgraph-sdk keeps the async client in a private package since 0.3
from langgraph_sdk._async import LangGraphClient
except ImportError: # older releases expose it directly on ``langgraph_sdk.client``
from langgraph_sdk.client import LangGraphClient
# ``get_client`` also folds an ``LANGGRAPH_API_KEY`` env var into the SDK
# headers; a control-plane Gateway authenticates with the internal auth
# headers above, so only those are forwarded here.
return LangGraphClient(
httpx.AsyncClient(
base_url=base_url,
transport=httpx.AsyncHTTPTransport(retries=_LANGGRAPH_SDK_RETRIES),
timeout=_LANGGRAPH_SDK_TIMEOUT,
headers={"User-Agent": f"langgraph-sdk-py/{sdk_version}", **headers},
trust_env=False,
)
)
def _slim_metadata(meta: dict[str, Any]) -> dict[str, Any]: def _slim_metadata(meta: dict[str, Any]) -> dict[str, Any]:
"""Return a shallow copy of *meta* with known-large keys removed.""" """Return a shallow copy of *meta* with known-large keys removed."""
return {k: v for k, v in meta.items() if k not in _METADATA_DROP_KEYS} return {k: v for k, v in meta.items() if k not in _METADATA_DROP_KEYS}
@ -599,7 +653,7 @@ def _is_run_status_result(result: Any) -> bool:
return isinstance(result, dict) and "messages" not in result and isinstance(result.get("status"), str) return isinstance(result, dict) and "messages" not in result and isinstance(result.get("status"), str)
def _turn_of_message(values: Any, message_id: str) -> dict[str, Any] | None: def _turn_of_message(values: Any, message_id: str, *, thread_id: str | None = None) -> dict[str, Any] | None:
"""Thread *values* cut after the turn that answered human message *message_id*. """Thread *values* cut after the turn that answered human message *message_id*.
The cut ends before the next visible human message, so the response, artifact The cut ends before the next visible human message, so the response, artifact
@ -611,7 +665,7 @@ def _turn_of_message(values: Any, message_id: str) -> dict[str, Any] | None:
for index, message in enumerate(messages): for index, message in enumerate(messages):
if isinstance(message, dict) and message.get("type") == "human" and message.get("id") == message_id: if isinstance(message, dict) and message.get("type") == "human" and message.get("id") == message_id:
end = next( end = next(
(later for later in range(index + 1, len(messages)) if isinstance(messages[later], dict) and messages[later].get("type") == "human" and not _is_hidden_human_control_message(messages[later])), (later for later in range(index + 1, len(messages)) if isinstance(messages[later], dict) and messages[later].get("type") == "human" and not _is_hidden_human_control_message(messages[later], thread_id=thread_id)),
len(messages), len(messages),
) )
return {**values, "messages": messages[:end]} return {**values, "messages": messages[:end]}
@ -810,7 +864,7 @@ def _apply_explicit_model_choice(
carrier["model_name"] = model_name carrier["model_name"] = model_name
def _extract_response_text(result: dict | list) -> str: def _extract_response_text(result: dict | list, *, thread_id: str | None = None) -> str:
"""Extract the last AI message text from a LangGraph runs.wait result. """Extract the last AI message text from a LangGraph runs.wait result.
``runs.wait`` returns the final state dict which contains a ``messages`` ``runs.wait`` returns the final state dict which contains a ``messages``
@ -837,7 +891,7 @@ def _extract_response_text(result: dict | list) -> str:
# Stop at the last human message — anything before it is a previous turn # Stop at the last human message — anything before it is a previous turn
if msg_type == "human": if msg_type == "human":
if _is_hidden_human_control_message(msg): if _is_hidden_human_control_message(msg, thread_id=thread_id):
continue continue
break break
@ -1146,7 +1200,7 @@ def _accumulate_stream_text(
return buffers[message_id], message_id return buffers[message_id], message_id
def _extract_artifacts(result: dict | list) -> list[str]: def _extract_artifacts(result: dict | list, *, thread_id: str | None = None) -> list[str]:
"""Extract artifact paths from the last AI response cycle only. """Extract artifact paths from the last AI response cycle only.
Instead of reading the full accumulated ``artifacts`` state (which contains Instead of reading the full accumulated ``artifacts`` state (which contains
@ -1167,7 +1221,7 @@ def _extract_artifacts(result: dict | list) -> list[str]:
continue continue
# Stop at the last human message — anything before it is a previous turn # Stop at the last human message — anything before it is a previous turn
if msg.get("type") == "human": if msg.get("type") == "human":
if _is_hidden_human_control_message(msg): if _is_hidden_human_control_message(msg, thread_id=thread_id):
continue continue
break break
# Look for AI messages with present_files tool calls # Look for AI messages with present_files tool calls
@ -1181,13 +1235,20 @@ def _extract_artifacts(result: dict | list) -> list[str]:
return artifacts return artifacts
def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool: def _is_hidden_human_control_message(msg: Mapping[str, Any], *, thread_id: str | None = None) -> bool:
"""Distinguish internal control messages from real hidden user answers.""" """Distinguish internal control messages from real hidden user answers."""
if msg.get("type") != "human": if msg.get("type") != "human":
return False return False
additional_kwargs = msg.get("additional_kwargs") additional_kwargs = msg.get("additional_kwargs")
if not isinstance(additional_kwargs, Mapping): if not isinstance(additional_kwargs, Mapping):
if additional_kwargs is not None:
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
thread_id or msg.get("thread_id", "<unknown>"),
msg.get("seq", "<unknown>"),
type(additional_kwargs).__name__,
)
return False return False
return additional_kwargs.get("hide_from_ui") is True and read_human_input_response(additional_kwargs) is None return additional_kwargs.get("hide_from_ui") is True and read_human_input_response(additional_kwargs) is None
@ -2311,11 +2372,9 @@ class ChannelManager:
def _get_client(self): def _get_client(self):
"""Return the ``langgraph_sdk`` async client, creating it on first use.""" """Return the ``langgraph_sdk`` async client, creating it on first use."""
if self._client is None: if self._client is None:
from langgraph_sdk import get_client self._client = _make_langgraph_client(
self._langgraph_url,
self._client = get_client( {**create_internal_auth_headers(), **self._csrf_headers()},
url=self._langgraph_url,
headers={**create_internal_auth_headers(), **self._csrf_headers()},
) )
return self._client return self._client
@ -3462,9 +3521,9 @@ class ChannelManager:
return return
result = await self._admitted_turn(client, msg, thread_id) or result result = await self._admitted_turn(client, msg, thread_id) or result
response_text = _extract_response_text(result) response_text = _extract_response_text(result, thread_id=thread_id)
pending_clarification = _has_current_turn_clarification(result) pending_clarification = _has_current_turn_clarification(result)
artifacts = _extract_artifacts(result) artifacts = _extract_artifacts(result, thread_id=thread_id)
logger.info( logger.info(
"[Manager] agent response received: thread_id=%s, response_len=%d, artifacts=%d", "[Manager] agent response received: thread_id=%s, response_len=%d, artifacts=%d",
@ -3517,7 +3576,7 @@ class ChannelManager:
except Exception: except Exception:
logger.warning("[Manager] failed to read thread state for an admitted run: thread_id=%s", thread_id, exc_info=True) logger.warning("[Manager] failed to read thread state for an admitted run: thread_id=%s", thread_id, exc_info=True)
return None return None
return _turn_of_message(state.get("values") if isinstance(state, dict) else None, message_id) return _turn_of_message(state.get("values") if isinstance(state, dict) else None, message_id, thread_id=thread_id)
async def _handle_streaming_chat( async def _handle_streaming_chat(
self, self,
@ -3594,7 +3653,7 @@ class ChannelManager:
# Clarification text is only in the values snapshot; # Clarification text is only in the values snapshot;
# publish it so the user sees the question mid-stream. # publish it so the user sees the question mid-stream.
if _has_current_turn_clarification(data): if _has_current_turn_clarification(data):
clarification_text = _extract_response_text(data) clarification_text = _extract_response_text(data, thread_id=thread_id)
if clarification_text and clarification_text != latest_text: if clarification_text and clarification_text != latest_text:
latest_text = clarification_text latest_text = clarification_text
@ -3644,9 +3703,9 @@ class ChannelManager:
if replay_gap and last_values is None and not latest_text and stream_error is None: if replay_gap and last_values is None and not latest_text and stream_error is None:
last_values = await self._admitted_turn(client, msg, thread_id) last_values = await self._admitted_turn(client, msg, thread_id)
result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]} result = last_values if last_values is not None else {"messages": [{"type": "ai", "content": latest_text}]}
response_text = _extract_response_text(result) response_text = _extract_response_text(result, thread_id=thread_id)
pending_clarification = _has_current_turn_clarification(result) pending_clarification = _has_current_turn_clarification(result)
artifacts = _extract_artifacts(result) artifacts = _extract_artifacts(result, thread_id=thread_id)
# Reuse the storage owner resolved by _handle_chat so artifact delivery # Reuse the storage owner resolved by _handle_chat so artifact delivery
# matches the upload bucket and we avoid re-running _safe_user_id_for_run # matches the upload bucket and we avoid re-running _safe_user_id_for_run
# (and its possible filesystem touch) on the streaming-error path. # (and its possible filesystem touch) on the streaming-error path.
@ -4024,6 +4083,23 @@ class ChannelManager:
self._remember_thread_model(thread_id, name) self._remember_thread_model(thread_id, name)
return f"Model '{name}' selected. It applies to this conversation from the next message." return f"Model '{name}' selected. It applies to this conversation from the next message."
@staticmethod
def _is_goal_in_flight_error(error: Exception) -> bool:
"""Recognize only the Gateway's known goal-write conflict, not arbitrary 409s."""
if not isinstance(error, httpx.HTTPStatusError) or error.response.status_code != 409:
return False
detail = {
"PUT": "Thread has a run in flight. Set the goal after the run finishes.",
"DELETE": "Thread has a run in flight. Clear the goal after the run finishes.",
}.get(error.request.method)
if detail is None:
return False
try:
body = error.response.json()
except ValueError:
return False
return isinstance(body, Mapping) and body.get("detail") == detail
async def _goal_request( async def _goal_request(
self, self,
method: str, method: str,
@ -4032,7 +4108,7 @@ class ChannelManager:
headers: dict[str, str], headers: dict[str, str],
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
async with httpx.AsyncClient() as http: async with _make_gateway_client(self._gateway_url) as http:
request = getattr(http, method.lower()) request = getattr(http, method.lower())
kwargs: dict[str, Any] = {"timeout": 10, "headers": {**headers, **self._csrf_headers()}} kwargs: dict[str, Any] = {"timeout": 10, "headers": {**headers, **self._csrf_headers()}}
if json is not None: if json is not None:
@ -4097,7 +4173,9 @@ class ChannelManager:
return await self._goal_reply(msg, "cleared") return await self._goal_reply(msg, "cleared")
try: try:
await self._goal_request("delete", thread_id, headers=headers) await self._goal_request("delete", thread_id, headers=headers)
except Exception: except Exception as exc:
if self._is_goal_in_flight_error(exc):
return await self._goal_reply(msg, "busy")
logger.exception("Failed to clear goal through gateway") logger.exception("Failed to clear goal through gateway")
return await self._goal_reply(msg, "clear_failed") return await self._goal_reply(msg, "clear_failed")
return await self._goal_reply(msg, "cleared") return await self._goal_reply(msg, "cleared")
@ -4107,7 +4185,9 @@ class ChannelManager:
try: try:
await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective}) await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective})
except Exception: except Exception as exc:
if self._is_goal_in_flight_error(exc):
return await self._goal_reply(msg, "busy")
logger.exception("Failed to set goal through gateway") logger.exception("Failed to set goal through gateway")
return await self._goal_reply(msg, "set_failed") return await self._goal_reply(msg, "set_failed")
@ -4124,7 +4204,7 @@ class ChannelManager:
replies should catch and degrade (see ``_fetch_gateway``). replies should catch and degrade (see ``_fetch_gateway``).
""" """
headers = _owner_headers(msg) if msg is not None else None headers = _owner_headers(msg) if msg is not None else None
async with httpx.AsyncClient() as http: async with _make_gateway_client(self._gateway_url) as http:
resp = await http.get( resp = await http.get(
f"{self._gateway_url}{path}", f"{self._gateway_url}{path}",
timeout=10, timeout=10,

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math
import re import re
import threading import threading
import time import time
@ -34,6 +35,9 @@ TELEGRAM_MAX_INBOUND_FILE_BYTES = 20_000_000
# Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget. # Keep Telegram cleanup inside the Gateway's five-second shutdown-hook budget.
TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0 TELEGRAM_SHUTDOWN_TIMEOUT_SECONDS = 4.0
TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0 TELEGRAM_BRIDGE_DRAIN_TIMEOUT_SECONDS = 1.0
# Outbound listeners await sends inline. Bound each flood wait while still
# allowing a full group rate-limit window.
TELEGRAM_MAX_OUTBOUND_RETRY_DELAY_SECONDS = 60.0
STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0 STREAM_EDIT_MIN_INTERVAL_SECONDS = 1.0
# Groups (negative chat_id) are capped at 20 messages/minute by Telegram, # Groups (negative chat_id) are capped at 20 messages/minute by Telegram,
# so stream edits there must pace well below the private-chat 1 msg/s guideline. # so stream edits there must pace well below the private-chat 1 msg/s guideline.
@ -323,14 +327,20 @@ class TelegramChannel(Channel):
if not msg.is_final: if not msg.is_final:
await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts)) await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts))
state = self._stream_messages.get(key)
if state is not None:
binding = (msg.thread_id, msg.connection_id, msg.owner_user_id, msg.thread_ts, state["message_id"])
if state.get("thread_binding") != binding and await self._remember_reply_thread(msg, state["message_id"], stream_state=state):
state["thread_binding"] = binding
return return
state = self._stream_messages.pop(key, None) state = self._stream_messages.pop(key, None)
if state is not None: if state is not None:
await self._remember_reply_thread(msg, state["message_id"], stream_state=state)
if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text): if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text):
self._last_bot_message[msg.chat_id] = state["message_id"] self._last_bot_message[msg.chat_id] = state["message_id"]
return return
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text) await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text, msg)
return return
if self._can_send_rich(msg.text): if self._can_send_rich(msg.text):
@ -340,10 +350,51 @@ class TelegramChannel(Channel):
logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc) logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc)
message_id = None message_id = None
if message_id is not None: if message_id is not None:
await self._remember_reply_thread(msg, message_id)
return return
for chunk in self._split_message(msg.text): for chunk in self._split_message(msg.text):
await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries) message_id = await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
await self._remember_reply_thread(msg, message_id)
async def _remember_reply_thread(self, msg: OutboundMessage, message_id: int | None, *, stream_state: dict[str, Any] | None = None) -> bool:
"""Keep group replies on their source thread, without undoing a later /new."""
if not msg.thread_id or message_id is None or int(msg.chat_id) >= 0:
return True
store = self.config.get("channel_store")
if msg.connection_id:
# Bound conversations must never fall back to the shared legacy store.
if self._connection_repo is None or not msg.owner_user_id:
return False
elif store is None:
return False
topic_ids = {str(message_id)}
if msg.thread_ts:
topic_ids.add(msg.thread_ts)
try:
for topic_id in topic_ids:
if msg.connection_id:
await self._connection_repo.claim_thread_id(
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
provider=self.name,
external_conversation_id=msg.chat_id,
external_topic_id=topic_id,
thread_id=msg.thread_id,
)
else:
await store.claim_thread_id(self.name, msg.chat_id, msg.thread_id, topic_id=topic_id)
except Exception:
# Delivery has already succeeded. Retry the binding on the next update,
# not the Telegram send, which could duplicate a visible reply.
if stream_state is not None and stream_state.get("thread_binding_error_logged"):
logger.debug("[Telegram] reply thread mapping retry failed in chat=%s", msg.chat_id)
else:
logger.exception("[Telegram] failed to remember reply thread mapping in chat=%s", msg.chat_id)
if stream_state is not None:
stream_state["thread_binding_error_logged"] = True
return False
return True
async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None: async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None:
"""Edit the in-flight streamed message with accumulated text. """Edit the in-flight streamed message with accumulated text.
@ -409,7 +460,7 @@ class TelegramChannel(Channel):
state["last_edit_at"] = _monotonic() state["last_edit_at"] = _monotonic()
state["last_text"] = display state["last_text"] = display
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str) -> None: async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str, msg: OutboundMessage) -> None:
"""Apply the final text: edit the streamed message, splitting overflow into follow-ups.""" """Apply the final text: edit the streamed message, splitting overflow into follow-ups."""
bot = self._application.bot bot = self._application.bot
chunks = self._split_message(text or "") chunks = self._split_message(text or "")
@ -423,10 +474,12 @@ class TelegramChannel(Channel):
else: else:
# Edit could not be applied (e.g. message deleted) — deliver the # Edit could not be applied (e.g. message deleted) — deliver the
# first chunk as a fresh message with the standard retry policy. # first chunk as a fresh message with the standard retry policy.
await self._send_new_message(chat_id, chat_key, chunks[0]) message_id = await self._send_new_message(chat_id, chat_key, chunks[0])
await self._remember_reply_thread(msg, message_id, stream_state=state)
for chunk in chunks[1:]: for chunk in chunks[1:]:
await self._send_new_message(chat_id, chat_key, chunk) message_id = await self._send_new_message(chat_id, chat_key, chunk)
await self._remember_reply_thread(msg, message_id, stream_state=state)
async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool: async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool:
"""Edit with one rate-limit retry. Returns False if the edit could not be applied.""" """Edit with one rate-limit retry. Returns False if the edit could not be applied."""
@ -545,13 +598,24 @@ class TelegramChannel(Channel):
try: try:
input_file = await asyncio.to_thread(_load_telegram_input_file, attachment.actual_path, attachment.filename) input_file = await asyncio.to_thread(_load_telegram_input_file, attachment.actual_path, attachment.filename)
from telegram.error import BadRequest
if attachment.is_image and attachment.size <= 10 * 1024 * 1024: if attachment.is_image and attachment.size <= 10 * 1024 * 1024:
kwargs: dict[str, Any] = {"chat_id": chat_id, "photo": input_file} kwargs: dict[str, Any] = {"chat_id": chat_id, "photo": input_file}
if reply_to: if reply_to:
kwargs["reply_to_message_id"] = reply_to kwargs["reply_to_message_id"] = reply_to
if chat_id > 0: if chat_id > 0:
kwargs["allow_sending_without_reply"] = True kwargs["allow_sending_without_reply"] = True
try:
sent = await bot.send_photo(**kwargs) sent = await bot.send_photo(**kwargs)
except BadRequest as exc:
if str(exc).casefold() not in {"photo_invalid_dimensions", "image_process_failed"}:
raise
logger.info("[Telegram] photo rejected (%s); delivering as document: %s", exc, attachment.filename)
# A rejected photo can still be delivered unchanged as a file.
# Keep the reply target and reuse the already loaded bytes.
kwargs["document"] = kwargs.pop("photo")
sent = await bot.send_document(**kwargs)
else: else:
kwargs = {"chat_id": chat_id, "document": input_file} kwargs = {"chat_id": chat_id, "document": input_file}
if reply_to: if reply_to:
@ -561,6 +625,7 @@ class TelegramChannel(Channel):
sent = await bot.send_document(**kwargs) sent = await bot.send_document(**kwargs)
self._last_bot_message[msg.chat_id] = sent.message_id self._last_bot_message[msg.chat_id] = sent.message_id
await self._remember_reply_thread(msg, sent.message_id)
logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id) logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id)
return True return True
except Exception: except Exception:
@ -917,6 +982,24 @@ class TelegramChannel(Channel):
"last_text": last_text, "last_text": last_text,
} }
def _outbound_retry_delay(self, attempt: int, exc: Exception) -> float:
"""Extend the shared backoff for new plain or Rich final-reply sends.
Only sends through ``_send_with_retry`` use this policy. The running
acknowledgment and intermediate stream updates are best effort.
Final edits have their own retry path.
"""
delay = super()._outbound_retry_delay(attempt, exc)
if not self._is_retry_after(exc):
return delay
try:
hint = self._retry_after_seconds(exc)
except (TypeError, ValueError, OverflowError):
return delay
if not math.isfinite(hint):
return delay
return max(delay, min(hint, TELEGRAM_MAX_OUTBOUND_RETRY_DELAY_SECONDS))
@staticmethod @staticmethod
def _is_retry_after(exc: Exception) -> bool: def _is_retry_after(exc: Exception) -> bool:
return getattr(exc, "retry_after", None) is not None return getattr(exc, "retry_after", None) is not None
@ -958,7 +1041,7 @@ class TelegramChannel(Channel):
return chunks return chunks
async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None: async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None:
"""Send a 'Working on it...' reply and register it as the stream target.""" """Send a best-effort acknowledgment before dispatching inbound work."""
if not self._application: if not self._application:
return return
try: try:

View File

@ -32,6 +32,7 @@ from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity from app.channels.connection_identity import attach_connection_identity
from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix
from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files
from deerflow.utils.atomic_write import publish_bytes_atomically
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1083,15 +1084,8 @@ class WechatChannel(Channel):
def _update_longpoll_timeout(self, data: Mapping[str, Any]) -> None: def _update_longpoll_timeout(self, data: Mapping[str, Any]) -> None:
if not self._respect_server_longpoll_timeout: if not self._respect_server_longpoll_timeout:
return return
raw_timeout = data.get("longpolling_timeout_ms") timeout_ms = self._coerce_float(data.get("longpolling_timeout_ms"), 0.0)
if raw_timeout is None: if 1_000 <= timeout_ms <= 300_000:
return
try:
timeout_ms = float(raw_timeout)
except (TypeError, ValueError):
return
if timeout_ms <= 0:
return
self._server_longpoll_timeout_seconds = timeout_ms / 1000.0 self._server_longpoll_timeout_seconds = timeout_ms / 1000.0
def _base_info(self) -> dict[str, str]: def _base_info(self) -> dict[str, str]:
@ -1707,7 +1701,10 @@ class WechatChannel(Channel):
return return
try: try:
self._cursor_path.parent.mkdir(parents=True, exist_ok=True) self._cursor_path.parent.mkdir(parents=True, exist_ok=True)
self._cursor_path.write_text(json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2), encoding="utf-8", newline="") # Publish through the shared helper: it fsyncs the staged bytes
# before the rename (so a machine crash cannot leave a truncated
# cursor behind) and re-applies an existing file's mode.
publish_bytes_atomically(self._cursor_path, json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2).encode("utf-8"))
except OSError: except OSError:
logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path) logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path)

View File

@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast from typing import Any, cast
from app.channels.base import Channel, ChannelUnavailable, attachment_delivery_text from app.channels.base import Channel, ChannelUnavailable, attachment_delivery_text
from app.channels.commands import is_known_channel_command from app.channels.commands import is_known_channel_command, strip_leading_mentions
from app.channels.connection_identity import attach_connection_identity from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import ( from app.channels.message_bus import (
InboundMessage, InboundMessage,
@ -40,6 +40,10 @@ else:
_WECOM_ACK_REJECTION_PREFIX = "Reply ack error" _WECOM_ACK_REJECTION_PREFIX = "Reply ack error"
def _group_workspace_id(aibotid: str | None, chatid: str) -> str:
return f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
def _file_md5(path: str) -> str: def _file_md5(path: str) -> str:
md5_hasher = hashlib.md5() md5_hasher = hashlib.md5()
with open(path, "rb") as file_obj: with open(path, "rb") as file_obj:
@ -434,8 +438,6 @@ class WeComChannel(Channel):
logger.exception("[WeCom] failed to send attachment delivery feedback") logger.exception("[WeCom] failed to send attachment delivery feedback")
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if not msg.is_final:
return True
if not self._ws_client: if not self._ws_client:
return False return False
if not msg.thread_ts: if not msg.thread_ts:
@ -445,7 +447,11 @@ class WeComChannel(Channel):
return False return False
media_type = "image" if attachment.is_image else "file" media_type = "image" if attachment.is_image else "file"
size_limit = 2 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024 # aibot media upload limits: image 10 MB, voice 2 MB, video 10 MB,
# file 20 MB (https://developer.work.weixin.qq.com/document/path/101917).
# The image cap used to be 2 MB — the voice limit — which dropped every
# 2-10 MB image before it was uploaded.
size_limit = 10 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024
if attachment.size > size_limit: if attachment.size > size_limit:
logger.warning( logger.warning(
"[WeCom] %s too large (%d bytes), skipping: %s", "[WeCom] %s too large (%d bytes), skipping: %s",
@ -581,7 +587,22 @@ class WeComChannel(Channel):
if handled: if handled:
return return
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT # WeCom group chats deliver "@bot /new" with the mention left in the text
# (Slack/Discord strip their own bot mention upstream). Skip a leading
# mention only for the command path so ordinary chat keeps any @mentions
# intact for the agent; the stripped form also flows into the inbound so
# ChannelManager._handle_command parses the bare command. Mirrors
# DingTalkChannel / FeishuChannel.
#
# This drops *any* leading mention, not only the bot's: WeCom text
# mentions carry display names, so the frame's `aibotid` cannot be
# matched against them and "@alice /status" is indistinguishable from
# "@bot /status". Feishu and DingTalk accept the same tradeoff;
# test_member_mention_before_a_command_also_classifies pins it.
command_text = strip_leading_mentions(text)
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(command_text) else InboundMessageType.CHAT
if inbound_type is InboundMessageType.COMMAND:
text = command_text
inbound_metadata: dict[str, Any] = { inbound_metadata: dict[str, Any] = {
"aibotid": body.get("aibotid"), "aibotid": body.get("aibotid"),
"chattype": body.get("chattype"), "chattype": body.get("chattype"),
@ -589,8 +610,12 @@ class WeComChannel(Channel):
} }
if body.get("chatid") is not None: if body.get("chatid") is not None:
inbound_metadata["chatid"] = body.get("chatid") inbound_metadata["chatid"] = body.get("chatid")
# A group's conversation is the group, a private chat's the sender.
# Keyed by sender alone, a member's private chat and every group they
# wrote in shared one thread wherever no group connection kept them apart.
group_chatid = str(body.get("chatid") or "").strip() if body.get("chattype") == "group" else ""
inbound = self._make_inbound( inbound = self._make_inbound(
chat_id=user_id, # keep user's conversation in memory chat_id=group_chatid or user_id,
user_id=user_id, user_id=user_id,
text=text, text=text,
msg_type=inbound_type, msg_type=inbound_type,
@ -598,7 +623,7 @@ class WeComChannel(Channel):
files=files or [], files=files or [],
metadata=inbound_metadata, metadata=inbound_metadata,
) )
inbound.topic_id = user_id # keep the same thread inbound.topic_id = user_id # one thread per member in each conversation
stream_id = generate_req_id("stream") stream_id = generate_req_id("stream")
self._ws_frames[msg_id] = frame self._ws_frames[msg_id] = frame
@ -616,6 +641,8 @@ class WeComChannel(Channel):
pass pass
inbound = await self._attach_connection_identity(inbound) inbound = await self._attach_connection_identity(inbound)
if group_chatid:
await self._carry_over_sender_keyed_group_thread(inbound, group_chatid)
self._commit_reserved_inbound(reservation, inbound) self._commit_reserved_inbound(reservation, inbound)
finally: finally:
reservation.release() reservation.release()
@ -627,7 +654,7 @@ class WeComChannel(Channel):
if chattype == "group": if chattype == "group":
if not chatid or self._connection_repo is None: if not chatid or self._connection_repo is None:
return inbound return inbound
workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}" workspace_id = _group_workspace_id(aibotid, chatid)
inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False) inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False)
if inbound.connection_id: if inbound.connection_id:
return inbound return inbound
@ -649,6 +676,35 @@ class WeComChannel(Channel):
fallback_without_workspace=True, fallback_without_workspace=True,
) )
async def _carry_over_sender_keyed_group_thread(self, inbound: InboundMessage, chatid: str) -> None:
"""Rebind a group connection's thread from the sender key it used to have.
Group messages were once keyed like private ones (``userid``, ``userid``).
A group connection belongs to one group, so its thread under that key is
this group's and moves to the group key once. A legacy group binding
spans every group of the bot; its old thread is left behind rather than
continued in whichever group writes first.
"""
repo = self._connection_repo
if repo is None or not inbound.connection_id or not inbound.owner_user_id:
return
aibotid = str(inbound.metadata.get("aibotid") or "").strip() or None
if inbound.workspace_id != _group_workspace_id(aibotid, chatid):
return
if await repo.get_thread_id(inbound.connection_id, inbound.chat_id, inbound.topic_id) is not None:
return
previous = await repo.get_thread_id(inbound.connection_id, inbound.user_id, inbound.user_id)
if previous is None:
return
await repo.claim_thread_id(
connection_id=inbound.connection_id,
owner_user_id=inbound.owner_user_id,
provider=self.name,
external_conversation_id=inbound.chat_id,
external_topic_id=inbound.topic_id,
thread_id=previous,
)
async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool: async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool:
if self._connection_repo is None or not code: if self._connection_repo is None or not code:
return False return False
@ -671,7 +727,7 @@ class WeComChannel(Channel):
if not chatid: if not chatid:
await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"]))
return True return True
workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}" workspace_id = _group_workspace_id(aibotid, chatid)
raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip() raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip()
workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None
metadata: dict[str, Any] = { metadata: dict[str, Any] = {

View File

@ -1,264 +1,93 @@
### Gateway API (`app/gateway/`) # Gateway API (`app/gateway/`)
Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-null goals: valid text, int counters/limits >=0, no Overwrite; keep sparse data. The FastAPI application on port 8001. It serves the REST API, embeds the
LangGraph-compatible agent runtime, and owns auth, CSRF, run admission and thread
lifecycle. Route-level rules are in [routers/AGENTS.md](routers/AGENTS.md). The
endpoint list is the OpenAPI schema at `/docs`; [API.md](../../docs/API.md)
describes it for humans.
Studio retains sanitized creation metadata. ## Verify
Capability Center's `business` adapter validates bundled credentials and creates ```bash
an MCP connection. The MCP API permits only its isolated launcher and credential cd backend
environment keys; reject arbitrary commands and manifest-policy bypasses. Keep python -m pytest tests -q -k "gateway or router or auth or run_manager"
credentials out of tool schemas and preserve admin checks, masked atomic edits, make test-blocking-io # request handlers share one event loop
and MCP cache reloads. ```
Memory shutdown resolves hot-reloaded config/backend, flushes and closes in one Read these contracts before you change the matching area:
`await_drained` operation. Resolve config off-loop inside the best-effort handler [thread lifecycle](../../docs/THREAD_LIFECYCLE.md) (branch, regenerate, edit
so malformed edits cannot abort teardown. Cancellation waits for owned workers. replay, archive), [run event stream](../../docs/RUN_EVENT_STREAM.md),
The host sets no close timeout: `close()` must be quick or internally bounded; [run interaction policy](../../docs/RUN_INTERACTION_POLICY.md),
budget it, flush timeout and other hooks within pod grace. Drain extension [auth design](../../docs/AUTH_DESIGN.md), [knowledge](../../docs/KNOWLEDGE.md).
`stop_services()` across cancellation before later resources unwind; each
service `stop()` has a 30-second `asyncio.timeout`.
Routers with persistent writes drain through ## Trust boundary
`app.gateway.persistent_writes.run_drained_write`.
`lifespan` first holds `hold_sandbox_shutdown_ownership()`. After - **Server-owned run context.** A client can send run context in `body.context`
`langgraph_runtime` drains runs and extensions, close MCP (bounded, isolated), and in `body.config`. Admit server-owned keys only from internally authenticated
then drain `shutdown_sandbox_provider()` off-loop in `finally`, even on callers and strip them from both places for everyone else. When you add a
cancel. Never allocate for cleanup; log failures, stay retryable. server-owned key, add it to that strip list in the same change.
- **Server-owned state.** Reject thread state fields that only the server may write
(sandbox, thread data, viewed images, goal outcome) and reserved metadata keys
when they come from a client. External run input cannot carry system or developer
messages.
- **Owner scoping.** Run-history helpers default to the request user and fail
closed when there is no user context. Pass `user_id=None` only in migration or
admin code that needs an unscoped read, and say why at the call site.
- **Thread IDs become paths.** Validate every caller-provided thread ID with
`deerflow.utils.thread_id` before it reaches persistence or the filesystem.
Legacy IDs stay readable and deletable, but they are never interpolated into a
host path.
- **Shared decisions.** Route permissions, model use and skill visibility are each
decided in one helper in `authz.py`. HTTP routes, decorator-only auth and the
Live Browser WebSocket all call it. A new entry point calls the same helper so
that it cannot drift from the others.
- **Tokens and cookies.** A request with an invalid Bearer token gets 401 and never
falls back to the session cookie; the CSRF middleware skips Bearer requests and
relies on this. Personal access tokens work only on allowlisted routes.
- **Proxy headers.** "Is this localhost" decisions use the direct `Host` header.
Forwarded headers are client-controlled unless the outer proxy replaces them.
- **Untrusted remote text.** MCP notifications and other remote events reach the
model framed as untrusted data, outside the trusted instruction.
Retention holds durable thread admission and the checkpoint lock before ## Runs and threads
checkpoint reads. Inspect root head/parent and leaf writes only; protect selectors,
require equal channel versions and invalidate caches before deletion. No scans,
stats or blob GC; failures stay non-fatal.
`conversation_access.py` grants the opt-in read-only tool only for explicit - **One writer per thread.** A run, a checkpoint write, an artifact write, a branch
`conversation_references` with effective `runs:read`; never infer grants from and a delete are all thread operations that share one durable uniqueness
messages or checkpoints. Pass the callback through `RunContext`, not serialized constraint. Reserve through `RunManager.reserve_thread_operation()` or
config, and let the worker inject and clear it. `conversation_reader.py` shares `services.reserve_checkpoint_write()`. A separate lock would let two workers
HTTP transcript visibility/pagination without a Request; callers own authorization. write the same thread.
Exclude non-text/internal content. `RunCreateRequest` lifts SDK-compatible - **Several workers.** In-memory run records exist only on the worker that owns the
`context.conversation_references` into its top-level field before validation, run. Cancel, takeover and orphan recovery are compare-and-set operations on the
then removes it from context; supplying both is 422. This shares bounds/error run store; use the store's claim and cancel primitives so that a lease renewal
locations and keeps `merge_run_context_overrides` clear. Admission and between your read and your write is not overwritten. Multi-worker deployments
`/api/features` use `conversation_references_enabled()` as the sole configured need `run_events.backend: db`.
predicate. - **Idempotency keys.** Scope an external `Idempotency-Key` to its owner and thread
before it reaches `RunManager`; the manager's index is process-wide.
- **`/wait` on a reused run** returns the durable run status and error. The latest
thread checkpoint can belong to a different run.
- **Stream gaps.** Stream bridges keep a bounded buffer. A client that asks for
events older than the buffer receives a `gap` event and the run continues.
- **`Content-Location` must stay CORS-exposed.** Run-creating routes return the run
ID in that header and the LangGraph SDK reads it from there only.
- **Run event contract.** Producer code, `deerflow/constants.py`, the event
catalog, `contracts/run_event_stream_contract.json` and
`docs/RUN_EVENT_STREAM.md` change together.
- **Thread membership in a project** is written by thread creation, branching and
the explicit move route. Run admission reads it and never changes it.
FastAPI listens on 8001. `GET /health` is liveness; `GET /health/ready` concurrently ## Lifecycle
probes startup snapshots of ORM, checkpointer/Store and stream bridge
(`StreamBridge.ping()`) under one deadline and per-kind gates. Unreachable or
unresolved gating backends return 503; `memory` reports `not_configured`;
provisioner `/health` is report-only. See `health.py`. `GATEWAY_ENABLE_DOCS=false`
disables `/docs`, `/redoc` and `/openapi.json`.
`build_run_config()` resolves the default LangGraph super-step budget from the - **Probe file-backed managed subagent storage once during startup, off the
hot-reloaded top-level `recursion_limit` setting. A valid request-level value event loop.** Probe failures warn operators before their first create request;
takes precedence; invalid values fall back to that default, and atomic create stays fail-closed rather than falling back to an overwrite.
`max_recursion_limit` caps both sources. - **Shutdown order matters.** `lifespan` drains runs and extensions first, then
closes MCP, then shuts the sandbox provider down off the event loop. Cleanup code
Durable MCP notifications use internal Agent runs. Keep their trusted delivery instruction outside the user-input boundary, and frame serialized remote events as untrusted before model invocation. Strict thread existence/ownership admission dead-letters events whose task outlives its deleted chat instead of recreating the thread. `start_run` marks run-manager admission conflicts with `BusyThreadConflict`; the notification launcher retries only that typed 409 and treats other 409 responses as permanent. keeps running after cancellation, allocates nothing new, and logs failures
instead of raising.
CORS defaults to same-origin behind nginx on 2026. Split-origin and port-forwarded - **Persistent writes in routers** go through
browser clients must set exact `GATEWAY_CORS_ORIGINS`, shared by `CORSMiddleware` `app.gateway.persistent_writes.run_drained_write` so that a client disconnect
and `CSRFMiddleware`. Expose `Content-Location` through `CORS_EXPOSED_HEADERS` cannot leave a half-written file.
(`csrf_middleware.py`): run-creating routes put the run ID there, and the LangGraph - **LangGraph Studio.** `langgraph_studio.py` is loaded by LangGraph's file loader
SDK uses only that header for run metadata. Without exposure, JS cannot read it, before the runtime starts and without a `sys.modules` entry. Keep its
breaking `useStream.onCreated` and thread-gated actions. annotations eager, and identify Studio callers by the upstream `StudioUser`
type, because the identity string can be reused by any client.
Browser sessions belong to `app.gateway.auth.session_cookie`. Login's `remember_me`
flag never stores passwords. `SessionCookiePolicy` persists `HttpOnly access_token`
only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit
operator opt-in; public HTTP otherwise uses session cookies. Session creators set
final `max_age` on `request.state`; CSRF mirrors it across login, password changes
and OIDC callbacks. A small `HttpOnly` preference cookie preserves the choice.
Logout clears auth cookies and suppresses CSRF re-issue.
OIDC redirects retain existing endpoint query bytes (RFC 6749 section 3.1).
Malformed IdP bodies raise `OIDCError` (502/`sso_failed`).
`tests/test_oidc_auth.py` pins query, state-cookie, nonce and PKCE agreement.
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs/projects routes (including `POST /api/threads/{id}/move`); every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).
Thread→project membership is written by thread creation (`POST /api/threads` with
a validated `project_id`), branch creation (the new row inherits the source
thread's project; an archived/deleted project degrades the branch to unassigned
instead of failing), and explicit moves (`POST /api/threads/{id}/move`); run
admission never modifies membership; it only pins the resolved project context
read-only into the run context. The server-reserved `deerflow_project_id`
metadata key is a read-only exposure of the `threads_meta.project_id` column and
is stripped from client writes.
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
Standalone local LangGraph Studio is recognized only through the upstream
`Auth.types.StudioUser` principal type, never by its reusable identity string.
The type is resolved once at import; an older SDK without it degrades to normal
owner scoping.
For that principal's assistant reads/searches, `langgraph_auth.add_owner_filter`
selects genuine server-registered assistants plus assistants owned by Studio;
all other resources remain owner-scoped. Assistant create/update handlers make
both `user_id` and `created_by=user` server-owned, because LangGraph gives
`created_by=system` privileged ownership semantics during run creation. The
custom application module in `langgraph_studio.py` is imported before the
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
genuine system assistant IDs from the CLI-provided graph registry, removes
their persisted active/version rows so graph registration recreates them, and
demotes every other legacy `created_by=system` marker in both active assistants
and version history. This must happen before runtime 0.30.0 loads and purges
system-marked rows; a user application lifespan is too late. LangGraph executes
this file-backed custom app without first registering its module in
`sys.modules`; keep its annotations eager so dataclass processing remains
compatible with that loader, and preserve the direct file-loader regression
test. An empty graph registry or absent persistence file is a no-op, while
persistence parse/write errors fail startup closed. The harness requires
in-memory runtime 0.30.0 or newer, and a persisted store containing no
expected registered assistant row emits a drift warning so changes to
LangGraph's internal persistence contract are observable. With current
create/update writes and all legacy versions sanitized, ordinary
owner-scoped assistant version selection remains enabled.
**Routers**:
| Router | Endpoints |
|--------|-----------|
| **Managed Models** (`/api/managed-models`) | Admin-only GET/PUT with revisions; streaming POST `/test` (DeepSeek thinking off). Clients/storage off-loop; YAML names reserved; keys private. |
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, separate batch repository/worker states so history stays readable without a worker, `conversation_references` (`read_conversation` configured, per-run reference cap), knowledge scope selection, `scheduled_tasks`, and `thread_activity` |
| **Thread Activity** (`/api/thread-activity`) | `GET /?cursor=` - per-user feed over `runs.change_seq`: no cursor seeds; lists threads of the caller's server-originated runs, passes interactive rows; `read_version`. `POST /api/threads/{id}/read` and search `unread` use `thread_read_markers`. SQL only |
| **Knowledge** (`/api/knowledge`) | Authenticated, capability-driven read-only retrieval catalog and per-assistant `/capabilities`; provider adapters own resource authorization and mapping. Unsupported filters fail admission/execution without widening scope; management stays outside core |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user: `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly (no new `RunStore` methods); SQL only (503 on `database.backend: memory`). Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). Mixed currencies null cost/currency fields. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. POST /cache/reset. Validate expanded, save raw; reload/reset; invalid -> 400. |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
| **Skills** (`/api/skills`) | `GET /` - list; `GET /{name}` - inspect; `PUT /{name}` - toggle; `POST /install` - install a thread-local .skill archive; `POST /install/upload` - admin-only multipart, authorized before parsing and capped at a 100 MiB file plus 1 MiB framing; `POST /reload` - refresh, then publish the shared skills reset marker |
| **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_probed` / `sandbox_runtime_ready` (whether `lark-cli` will be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload/convert PDF/PPT/Excel/Word; upload/shelf names share `normalize_filename` before staging; non-mounted sync lease ends when its last holder exits; `GET /list`; `DELETE /{filename}` - regular files, symlinks 404, keeps converted `.md` (#5672) |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed: only a branch from the **latest** turn best-effort copies the workspace (`workspace_clone_mode="current_thread_best_effort"`); older turns skip it (`"skipped_historical_turn"`), so no later-timeline file leaks in. `sandbox`/`thread_data` channels are not copied (parent `sandbox_id` binds paths/release); the branch lazily acquires its own sandbox. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows get one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn at every persisted human message, including an allowlisted hidden `ask_clarification` reply): `run_id` is a turn identity, and regenerating an inherited answer supersedes its whole `run_id` in `GET /messages/page`, so one shared seed id deleted all inherited history (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - summarize older active context, deriving memory policy and bucket from the state-producing checkpoint rather than request `agent_name`, and block while a run is in flight; 409 unless idle; unexpected failures return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | GET streams byte ranges; HTML/XML/XSL/`+xml` (also `.skill` members) and `?download=true` force attachments. PUT atomically replaces existing UTF-8 outputs on matching SHA-256; active runs conflict. Non-mounted sync uses a request lease and drains remote/local commit-or-rollback across cancellation before releasing reservations/leases. `resolve_outputs_confined_path` normalizes `..` and confines host paths against traversal/symlinks. Preserve POSIX ownership/mode where supported; Windows keeps temp-file permissions. Digest cache: path/dev/ino/ctime_ns/mtime_ns/size; identity invalidates same-size/mtime replacements. Large regular files use stat-prefixed ETags; If-Range requires matching ETags. Windows ctime is creation time. Keep cache reuse, size limits and worker hashing. |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, treating a protocol-valid hidden `human_input_response` as confirmed replay input while rejecting other hidden/control humans, preserving that response's hidden/correlation metadata, and carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream`, `/wait` - stateless runs requiring `runs:create`; optional body `thread_id` is owner-checked. Scheduled-task create/update/resume/trigger also require `threads:write` plus `runs:create`. `GET /{rid}/messages`, `/feedback` - run messages/feedback |
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503, keeping the delivery recorded as failed for manual/API/scripted redelivery (GitHub does not automatically retry any failed delivery, 5xx included); permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
| **GitHub Event-Driven Agents** | Custom agents can declare a `github:` block in their `config.yaml` to bind to repos and event triggers. Webhook fan-out publishes one `InboundMessage` per matching binding to the channel bus; `GitHubChannel` routes those messages through `ChannelManager`. The response `dispatch` summarizes matched/fired/skipped agents. |
Edit replay and messages-input clarification may replace knowledge scope with a
validated client snapshot. Regenerate, null-input and `Command` resumes recover
scope from the genuine source user turn, skip hidden server context, and ignore
client scope. Treat `input.messages: null` as empty to allow promptless
conversation-reference runs.
Thread identifiers use the shared `deerflow.utils.thread_id` contract
`^[A-Za-z0-9_-]{1,64}$`. Caller-provided opaque IDs remain supported; UUIDs
are generated only for `None`, while explicit empty strings fail validation.
Gateway creation and state-producing request boundaries, embedded-client
entry points, filesystem/upload/event-store consumers, scheduled launches,
and the standalone Provisioner enforce the same contract before persistence
or workspace initialization. Route-addressable legacy IDs remain accepted by
pure reads and cleanup/control endpoints; deleting one best-effort removes
metadata and checkpoints but skips local filesystem cleanup, so the raw value
is never interpolated into a host path. New runs, workspace/sandbox
operations, and other state-producing mutations remain blocked.
**Message feed seq** (#4666): streaming `values` frames, `GET
/threads/{id}/state`, and `POST /threads/{id}/history` stamp serialized
messages with `additional_kwargs.deerflow_seq` so clients can place
checkpoint-kept messages against the paged feed; the REST reads resolve the
store via `threads.py::_optional_run_event_store` (a feed-less deployment
still reads threads), and `services.py::normalize_input` strips the
server-owned key from client input (#4380). Mechanism and identity rule:
`packages/harness/deerflow/runtime/AGENTS.md`.
**Workspace change review**: `packages/harness/deerflow/workspace_changes/`
captures a pre-run and post-run snapshot of the thread-owned `workspace` and
`outputs` directories. `runtime/runs/worker.py` performs the filesystem scan via
`asyncio.to_thread` and writes a `workspace_changes` event with category
`workspace` when changes exist. Uploads are intentionally excluded. Text diffs
are size-limited; binary, large, and sensitive-looking paths are persisted as
metadata only. Internal process-feedback directories never count as changes:
the scanner's `EXCLUDED_DIR_NAMES` drops `BROWSER_FRAMES_DIRNAME` (transient
browser screenshots) and `TOOL_RESULTS_DIRNAME` (the tool-output budget
middleware's default externalization subdir, `constants.py` is the shared
source of truth for both writers and the scanner), and the worker threads the
configured `tool_output.storage_subdir` through the snapshot capture as an
extra excluded dir name so custom storage locations stay excluded too.
**Run delivery receipts**: the worker derives delivery requirements from the
run's workspace snapshots rather than a client request option (files
created/modified under `/mnt/user-data/outputs`, minus internal
process-feedback exclusions) and idempotently persists a run-scoped
`run.delivery` receipt before the terminal run status; missing or
unverifiable `present_files` coverage downgrades the run to error, while runs
without changed outputs keep ordinary chat behavior. Journal mechanics
(callback attribution, receipt idempotency and retries, orphan recovery):
`packages/harness/deerflow/runtime/AGENTS.md`. Multi-worker deployments
require `run_events.backend: db` for shared, ordered delivery events; the
startup gate rejects memory/JSONL event stores when `GATEWAY_WORKERS > 1`
or `deployment.multi_instance: true`.
**RunManager / RunStore contract**:
- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded. A placeholder must still accept the stock SDK's own default: `langgraph_sdk` drops only `None` from its run payload, so `stream_resumable=False` reaches every request and means "non-resumable", which is what DeerFlow serves — rejecting it 422'd every IM channel run (#4466). `test_run_request_validation.py::test_gateway_accepts_langgraph_sdk_default_payload` pins the real SDK payload; channel tests mock the SDK and miss this drift.
- `RunManager.get()` is async; direct callers must `await` it.
- The history batch helpers `list_successful_regenerate_sources()`, `list_edit_regenerate_runs()`, and `get_many_by_thread()` default to `user_id=AUTO`: they resolve the request user and fail closed when no user context exists. Migration/admin callers that intentionally need an unscoped read must pass `user_id=None` explicitly.
- Edit-and-rerun visibility is derived from edit replay runs (`metadata.replay_kind="edit"` plus `regenerate_from_run_id`) by `RunManager.list_edit_replay_visibility()`: the newest attempt for each source run is authoritative. Pending/running/success attempts hide the original source run; failed, timed-out, or interrupted attempts hide only the failed attempt so the original conversation reappears.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.
- Thread metadata status switches to `running` only after `RunManager.try_start()` succeeds. Pending-cancelled runs therefore skip the old `running` projection, while clients may observe the prior thread status during the short worker-startup window.
- `cancel()` returns a :class:`~deerflow.runtime.CancelOutcome` enum: `cancelled` (local cancel), `requested` (the non-owning worker durably recorded the first cancellation action for the live owner), `taken_over` (non-owning worker claimed the run because the owner's lease expired — marks it as `error`), `lease_valid_elsewhere` (legacy/custom store lacks the durable request primitive — caller retains the safe 409 + `Retry-After` fallback), `not_active_locally` (heartbeat disabled, preserving the old 409 path), `not_cancellable` (terminal state), or `unknown` (not found in memory or store). `create_or_reject(..., multitask_strategy="interrupt"|"rollback")` persists interrupted status through `RunStore.update_status()`, matching normal `set_status()` transitions.
- Interrupt/rollback admission registers the replacement before its best-effort persistence of locally interrupted predecessors. If the admitting caller is cancelled during that post-registration await, `RunManager` drains a shielded replacement cleanup before propagating `CancelledError`, including across repeated cancellation. The cleanup normally persists `interrupted`; if that best-effort transition fails, it retries the active-to-`interrupted` store transition strictly and verifies the result with the replacement's captured owner identity. A concurrent peer terminal transition wins and is synchronized back into the local record rather than being overwritten or deleted.
- Store-only hydrated runs are readable history. In multi-worker mode with heartbeat enabled, cancel on a store-only run records `runs.cancel_action` / `cancel_requested_at` while the owner's lease is live; the first action wins even if a retry later lands on the owner. `RunStore.request_cancel()` and owner completion through `finalize_if_not_cancelled()` are competing active-row CAS operations, so an accepted cancel cannot be overwritten by a later success. `RunStore.renew_lease()` renews and observes the request atomically in the SQL implementation. The owner then executes the normal process-local interrupt/rollback and terminal stream path without transferring the lease. An expired owner is taken over with the `orphan_recovered` stop reason so cross-worker consumers can synthesize END. `wait=true` and cancel-then-stream use the shared bridge to observe owner finalization; a non-standard process-local bridge returns accepted 202 instead of subscribing to an unreachable stream. In single-worker mode (heartbeat off), store-only runs still return 409.
- A local worker's `RunRecord.lease_expires_at` is the last durably confirmed ownership deadline. `_renew_leases()` bounds each renewal attempt by that deadline: transient store exceptions remain retryable while it is valid, but an exception or blocked call that reaches expiry sets the process-local `ownership_lost` fence, raises `abort_event`, and cancels the run task. Successful renewals collect durable cancellation actions; after all local renewals have been attempted, heartbeat only signals the corresponding process-local tasks, leaving status writes and rollback cleanup to the worker finalization path. Fenced workers do not perform subsequent journal/delivery-receipt, progress/completion/status, checkpoint/thread-metadata, or `on_run_completed` writes; the peer recovery path owns the terminal receipt. `RunStore.update_run_completion()` also refuses to replace a different terminal status, closing the peer-takeover/late-finalization race. `grace_seconds` delays peer reclamation for clock skew but is not extra execution time for an owner that can no longer confirm its lease. Already-committed remote tool side effects remain outside this local cancellation boundary.
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- Run admission and independent writes are first-class thread operations. `runs.operation_kind` distinguishes `run` from `checkpoint_write`, `artifact_write`, `artifact_archive`, `branch`, and `delete`; every active kind shares the durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- Branch/state-update checkpoints copy only the source checkpoint's persisted `deerflow_agent_name`, never request metadata; missing or malformed bindings stay unbound so compaction fails closed.
- `DELETE /api/threads/{id}` holds a durable `delete` reservation for the whole cleanup and removes thread filesystem data (off-loop, drained on cancel), checkpoints, owner-scoped historical runs, run events, feedback and thread metadata best-effort. Historical-run cleanup removes only `operation_kind="run"` rows, so internal thread-operation rows — including the reservation protecting that request — stay durable until `RunManager.reserve_thread_operation()` exits. This cleans persisted rows; preventing an already-admitted write from re-creating state for a deleted thread is a separate thread-incarnation contract, not part of this cleanup.
- Creator SSE and `/wait` apply `on_disconnect` only to confirmed request disconnect or cancellation; bridge failures propagate without cancelling the run. `/wait` consumes bridge END; errors return SDK `__error__`, never stale state.
- Memory and Redis `StreamBridge` implementations retain only `stream_bridge.queue_maxsize` data events. A syntactically valid `Last-Event-ID` older than the retained watermark, or a live subscriber that falls behind it, yields `StreamGap` before any partial replay. `sse_consumer` maps that control item to an id-less SSE `gap` payload (`stream_replay_gap`) and intentionally leaves the run active; internal `/wait` consumers resume from its latest retained ID because they only need terminal completion. Redis checks bounds plus the non-blocking read in one transaction, using blocking `XREAD` only as a wake-up before repeating the atomic snapshot. For a no-cursor subscriber that established a wait on an empty stream, the first wake response remains provisional until that next snapshot verifies its tail is still retained; this closes the pre-first-delivery trimming window without changing malformed-cursor live tailing. The correctness tradeoff is one three-command snapshot pipeline per poll plus the blocking wake round trip while idle. Malformed cursor behavior remains backend-specific. Memory treats a syntactically numeric cursor below its watermark conservatively as a gap even when the evicted timestamp can no longer be verified; unknown ids at or above the watermark retain the legacy replay-from-earliest policy.
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on data and first-END writes) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. Terminal `orphan_recovered` and `scheduled_task_orphan_recovered` rows permit heartbeat fallback because their producer is gone; ordinary terminal rows still await END. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.
- Thread-scoped create, stream and wait share an optional owner/thread-scoped `Idempotency-Key` (internal callers: owner-only, cross-thread reuse 409); never pass the raw external key to `RunManager`'s process-wide index. Stores resolve an existing key before `interrupt`/`rollback`, so a retry reuses rather than interrupts its run. Reuse requires matching input, assistant and conversation references; keyed resume stores a versioned strict typed-JSON SHA-256 (`true`, `1` and `1.0` differ) in the private `runs.idempotency_request_json` column, never public `kwargs`; ordinary input needs no extra identity. New readers accept the exact old-writer resume identity shape from `kwargs`, every response strips it; identity-less legacy resume fails closed. Validate strict resume JSON with or without a key; persist the identity only for keyed resume admission. Missing headers stay non-idempotent; stateless `/api/runs/*` is outside this contract (it creates a thread before admission).
- For a rolling upgrade, route every keyed resume submission/retry on create/stream/wait only to upgraded workers; otherwise pause that traffic or replace old workers first. Old workers ignore the private identity and can reuse `approve` for `deny` with null input. Old-reader schema compatibility does not protect admission. Upgraded workers reject identity-less legacy resumes with 409. Rollout details: `README.md`, `backend/docs/API.md`.
- Reused `/wait` returns durable status/error, never the latest thread checkpoint; `task is None` is not completion, and store-backed fields must refresh after waiting. Capture reuse before awaiting because the cached record's `idempotency_reused` flag is sticky. A creating `/runs/stream` retry whose terminal stream is gone opts into `gap`/`stream_replay_gap` via `emit_gap_on_missing_stream`, while observer joins and default/stateless consumers emit `end`; never infer this from disconnect policy or the sticky flag. A reused running `store_only` record on a process-local bridge returns 409 without `Retry-After`.
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching. In `delta` checkpoint mode the worker rewrites that fork into a linear head write before the graph starts (see "A delta-mode run cannot fork" under Checkpoint Channel Modes), because delta state for a fork replays the abandoned sibling's writes.
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when the turn did not end on an unanswered Human Input Card (`ask_clarification` or the sandbox network prompt; the question is still open, so the goal stands down with `blocker: "needs_user_input"` without calling the evaluator), a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the run has not hit its token-budget hard stop (`stop_reason == "token_capped"`; the goal stands down with `stand_down_reason: "token_capped"`), the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
- Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations.
Scheduled-task PATCH: omitted `assistant_id` preserves the pin; explicit null resets to `lead_agent`.
Two origin keys; never read one for the other. `deerflow_scheduled_origin` marks the scheduled prompt message (`scheduled_goal_notes_contract.json`); `deerflow_origin` says who started a run/created a thread (`thread_origin_contract.json`), set only via `request.state.run_origin` or internal `body.metadata`.
**Thread lifecycle**: Before changing branching, regeneration, edit replay, or
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.
MCP create/replace/state writes require unique installation IDs. DELETE validates
schema but permits residual legacy ID collisions. Explicit selections exclude
ambiguous IDs until repaired.
Capability skill discovery (`/api/capabilities/installations/skills`) reuses
`routers/skills.py::_filter_visible_skills` after its off-thread storage read.
It must preserve the ordinary skill listing's caller visibility and provider
failure policy rather than exposing the unfiltered user-scoped catalog.
Cap edit reads at `MAX_EDITABLE_ARTIFACT_BYTES + 1` despite stale `lstat`.
### Route and skill authorization
`authz.authorize_model_use` shares the `model:use` decision between model details
and follow-up suggestions, including the factory's first configured model when
the caller omits `model_name`. Suggestions check admission before their
best-effort LLM error handler: explicit denies return 403 without invoking the
model, while provider failures honor `authorization.fail_closed`.
`authz.py::resolve_route_permissions()` is shared by HTTP, decorator-only auth, and Live Browser WebSocket admission. It evaluates full `resource:action` targets; provider errors follow `authorization.fail_closed`, while disabled authorization preserves legacy permissions. Owner/admin checks stay separate. Live requires `threads:write` with `is_internal=False` after login/Origin checks; denial closes 4403 and setup errors close 4501. Admission is connection-scoped. On disconnect, Live drains owned `stop_screencast()` cleanup before releasing the session lease. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.
Batch workers pin `app.state.extensions`; never persist snapshots.
Personal MCP is owner-only; platform MCP shared.
### Web model provider presets
Managed model routes expose provider metadata; routing stays server-owned. Align validation, probing, storage, keys, and revisions.

View File

@ -923,11 +923,18 @@ async def _gateway_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if subagent_batches_config.enabled and batch_repo is None: if subagent_batches_config.enabled and batch_repo is None:
raise RuntimeError("subagent_batches.enabled requires database.backend sqlite or postgres") raise RuntimeError("subagent_batches.enabled requires database.backend sqlite or postgres")
if batch_repo is not None: if batch_repo is not None:
from app.subagent_batches.continuation import BatchContinuations
continuations = BatchContinuations(app)
app.state.batch_continuations = continuations
batch_service = SubagentBatchService( batch_service = SubagentBatchService(
repository=batch_repo, repository=batch_repo,
config=subagent_batches_config, config=subagent_batches_config,
runtime_config=subagent_runtime_config, runtime_config=subagent_runtime_config,
extensions=getattr(app.state, "extensions", None), extensions=getattr(app.state, "extensions", None),
prepare_continuation=continuations.prepare,
dispatch_continuation=continuations.dispatch,
cancel_continuation=continuations.cancel,
) )
app.state.subagent_batch_service = batch_service app.state.subagent_batch_service = batch_service
if subagent_batches_config.enabled: if subagent_batches_config.enabled:

View File

@ -77,6 +77,9 @@ _PAT_ROUTE_RULES: tuple[tuple[frozenset[str], re.Pattern[str]], ...] = (
(frozenset({"GET", "PATCH", "DELETE"}), re.compile(r"^/api/projects/[^/]+$")), (frozenset({"GET", "PATCH", "DELETE"}), re.compile(r"^/api/projects/[^/]+$")),
(frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/(archive|restore)$")), (frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/(archive|restore)$")),
(frozenset({"GET"}), re.compile(r"^/api/projects/[^/]+/threads$")), (frozenset({"GET"}), re.compile(r"^/api/projects/[^/]+/threads$")),
(frozenset({"GET", "PATCH"}), re.compile(r"^/api/projects/[^/]+/memory$")),
(frozenset({"DELETE"}), re.compile(r"^/api/projects/[^/]+/memory/[^/]+$")),
(frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/memory/[^/]+/promote$")),
# Project document shelf (Phase 2 Slice B): enumerated per implemented # Project document shelf (Phase 2 Slice B): enumerated per implemented
# route — list/upload on the collection, content and trash on the item. # route — list/upload on the collection, content and trash on the item.
(frozenset({"GET", "POST"}), re.compile(r"^/api/projects/[^/]+/documents$")), (frozenset({"GET", "POST"}), re.compile(r"^/api/projects/[^/]+/documents$")),

View File

@ -6,7 +6,7 @@ from app.gateway.auth.models import User
class LastAdminRemainsError(RuntimeError): class LastAdminRemainsError(RuntimeError):
"""Raised when a role write would demote the last remaining admin. """Raised when a role write would demote the last remaining active admin.
Bootstrap lockout protection: raised inside the same serialized Bootstrap lockout protection: raised inside the same serialized
transaction that would perform the write, so the check-then-act window transaction that would perform the write, so the check-then-act window
@ -123,8 +123,9 @@ class UserRepository(ABC):
Raises: Raises:
UserNotFoundError: the target row does not exist. UserNotFoundError: the target row does not exist.
LastAdminRemainsError: the write would demote the only remaining LastAdminRemainsError: the write would demote the only remaining
admin (the count and the write share one serialized active admin (the count and the write share one serialized
transaction — two concurrent demotions cannot both pass). transaction — two concurrent demotions cannot both pass).
An already disabled admin may be assigned a different role.
""" """
raise NotImplementedError raise NotImplementedError

View File

@ -316,11 +316,12 @@ class SQLiteUserRepository(UserRepository):
row = await session.get(UserRow, user_id) row = await session.get(UserRow, user_id)
if row is None: if row is None:
raise UserNotFoundError(f"User {user_id} no longer exists") raise UserNotFoundError(f"User {user_id} no longer exists")
if row.system_role == "admin" and system_role != "admin": if row.system_role == "admin" and not row.disabled and system_role != "admin":
# Count ACTIVE admins only: a disabled admin cannot # Count ACTIVE admins only: a disabled admin cannot
# authenticate, so counting them would let "disable B, then # authenticate, so counting them would let "disable B, then
# demote self" (or a combined role+disable request) strand # demote self" (or a combined role+disable request) strand
# the deployment with zero usable management credentials. # the deployment with zero usable management credentials.
# Demoting an already disabled admin removes no active admin.
active_admins = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin", UserRow.disabled.is_(False))) active_admins = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin", UserRow.disabled.is_(False)))
if active_admins is None or active_admins <= 1: if active_admins is None or active_admins <= 1:
raise LastAdminRemainsError("cannot demote the last remaining admin") raise LastAdminRemainsError("cannot demote the last remaining admin")

View File

@ -29,7 +29,7 @@ from app.gateway.auth_disabled import (
from app.gateway.authz import AuthContext, resolve_route_permissions from app.gateway.authz import AuthContext, resolve_route_permissions
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from app.gateway.request_path import get_request_route_path from app.gateway.request_path import get_request_route_path
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import bind_authenticated_user, reset_current_user, set_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -207,6 +207,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
}, },
) )
# Browser extension pages are bound to the account that loaded their
# descriptor. Cookies can change in another tab before React observes it.
# This is a stale-view fence, not an authorization grant; CLI callers may
# omit it and all existing route/resource checks still apply.
expected_viewer = request.headers.get("x-deerflow-plugin-viewer")
if get_request_route_path(request).startswith("/api/extensions/") and expected_viewer is not None and expected_viewer != str(user.id):
return JSONResponse(status_code=409, content={"detail": "Account changed; reload this extension view."})
# Stamp both request.state.user (for the contextvar pattern) # Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is # and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire # None" branch short-circuits instead of running the entire
@ -226,6 +234,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
request.state.auth = AuthContext(user=user, permissions=permissions) request.state.auth = AuthContext(user=user, permissions=permissions)
token = set_current_user(user) token = set_current_user(user)
try: try:
verified_user_id = str(user.id) if auth_source in {AUTH_SOURCE_SESSION, AUTH_SOURCE_PAT} else None
with bind_authenticated_user(verified_user_id):
return await call_next(request) return await call_next(request)
finally: finally:
reset_current_user(token) reset_current_user(token)

View File

@ -194,6 +194,11 @@ class LarkAdapter:
class SkillAdapter: class SkillAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]: async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
# The listing reports the enabled flag, which the storage layer fails
# closed on an unreadable extensions config; keep this read surface
# loud about the broken config instead of serving the degraded list.
# Blocking filesystem IO: always off the event loop, like the scan below.
await asyncio.to_thread(skills._assert_extensions_config_readable)
response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False)) response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False))
response = await skills._filter_visible_skills(context.request, context.config, response) response = await skills._filter_visible_skills(context.request, context.config, response)
return [ return [

View File

@ -6,6 +6,26 @@ from collections.abc import Sequence
from typing import Any from typing import Any
def task_interrupts_by_id(snapshot: Any) -> dict[str, list[Any]]:
"""Group non-empty task interrupts of one snapshot by task id."""
grouped: dict[str, list[Any]] = {}
for task in getattr(snapshot, "tasks", None) or ():
interrupts = list(getattr(task, "interrupts", ()) or ())
if interrupts:
grouped[task.id] = interrupts
return grouped
def is_hitl_approval_payload(value: Any) -> bool:
"""Whether an interrupt payload is a HITL tool-approval request."""
return isinstance(value, dict) and value.get("kind") == "hitl_approval"
def hitl_approval_payloads(grouped: dict[str, list[Any]]) -> list[dict[str, Any]]:
"""Flatten approval payloads from task_interrupts_by_id output."""
return [interrupt.value for interrupts in grouped.values() for interrupt in interrupts if is_hitl_approval_payload(interrupt.value)]
class CheckpointLineageError(RuntimeError): class CheckpointLineageError(RuntimeError):
"""Raised when a requested checkpoint ancestor cannot be resolved safely.""" """Raised when a requested checkpoint ancestor cannot be resolved safely."""

View File

@ -66,7 +66,10 @@ def build_context_usage_payload(*, token_count: int, max_context_tokens: int | N
async def build_context_usage(request: Request, thread_id: str, run_store: Any, user_id: str | None = None) -> dict[str, Any] | None: async def build_context_usage(request: Request, thread_id: str, run_store: Any, user_id: str | None = None) -> dict[str, Any] | None:
"""Return approximate usage for the latest materialized thread checkpoint.""" """Return approximate usage for the latest materialized thread checkpoint."""
try: try:
app_config = get_config() # ``get_config`` revalidates ``config.yaml`` and
# ``extensions_config.json`` on every call (stat + full-file hash), so
# reading it here would put that IO on the loop every thread view pays.
app_config = await asyncio.to_thread(get_config)
except HTTPException: except HTTPException:
return None return None

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable, Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -57,16 +57,39 @@ def _source_id(reference: str, request_url: str) -> str:
return validate_thread_id(match.group(1)) return validate_thread_id(match.group(1))
def _visible_text(row: dict) -> tuple[str, str] | None: def _visible_text(row: dict, *, thread_id: str | None = None) -> tuple[str, str] | None:
message = row.get("content") message = row.get("content")
if not isinstance(message, dict): if not isinstance(message, Mapping):
return None return None
role = message.get("type") or message.get("role") role = message.get("type") or message.get("role")
if not isinstance(role, str):
return None
role = {"human": "user", "ai": "assistant"}.get(role, role) role = {"human": "user", "ai": "assistant"}.get(role, role)
extra = message.get("additional_kwargs") or {} extra = message.get("additional_kwargs")
if extra is None:
extra = {}
elif not isinstance(extra, Mapping):
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(extra).__name__,
)
extra = {}
if role not in {"user", "assistant"} or extra.get("hide_from_ui") or message.get("name") == "summary": if role not in {"user", "assistant"} or extra.get("hide_from_ui") or message.get("name") == "summary":
return None return None
if str((row.get("metadata") or {}).get("caller", "")).startswith(("middleware:", "subagent:")): metadata = row.get("metadata")
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed conversation metadata for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
if str(metadata.get("caller", "")).startswith(("middleware:", "subagent:")):
return None return None
content = message.get("content") content = message.get("content")
if role == "user" and isinstance(extra.get("original_user_content"), str): if role == "user" and isinstance(extra.get("original_user_content"), str):
@ -228,7 +251,7 @@ def _prepare_owner_bound_conversation_reader(
found: dict[int, tuple[str, str, int]] = {} found: dict[int, tuple[str, str, int]] = {}
def include_target(row: dict) -> bool: def include_target(row: dict) -> bool:
parsed = _visible_text(row) if row["seq"] == message_seq else None parsed = _visible_text(row, thread_id=thread_id) if row["seq"] == message_seq else None
if parsed is None: if parsed is None:
return False return False
role, text = parsed role, text = parsed
@ -273,7 +296,7 @@ def _prepare_owner_bound_conversation_reader(
parsed_text: dict[int, tuple[str, str]] = {} parsed_text: dict[int, tuple[str, str]] = {}
def include_message(row: dict) -> bool: def include_message(row: dict) -> bool:
parsed = _visible_text(row) parsed = _visible_text(row, thread_id=thread_id)
if parsed is None: if parsed is None:
return False return False
role, text = parsed role, text = parsed

View File

@ -8,7 +8,7 @@ access from a model argument or an ambient HTTP request.
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
@ -70,8 +70,19 @@ def _message_type(message: Any) -> str | None:
return str(value) if value else None return str(value) if value else None
def _is_thread_history_hidden_message_row(row: dict[str, Any]) -> bool: def _is_thread_history_hidden_message_row(row: dict[str, Any], *, thread_id: str | None = None) -> bool:
caller = str((row.get("metadata") or {}).get("caller", "")) metadata = row.get("metadata")
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed conversation metadata for thread %s at row seq %s: expected a mapping, got %s",
thread_id or "<unknown>",
row.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
caller = str(metadata.get("caller", ""))
return caller.startswith("middleware:") or (caller.startswith("subagent:") and _message_type(row.get("content")) == "ai") return caller.startswith("middleware:") or (caller.startswith("subagent:") and _message_type(row.get("content")) == "ai")
@ -110,7 +121,7 @@ async def scan_visible_thread_messages(
if before_seq is not None and row["seq"] >= before_seq: if before_seq is not None and row["seq"] >= before_seq:
reached_before_bound = True reached_before_bound = True
break break
if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)): if (not include_middleware and _is_thread_history_hidden_message_row(row, thread_id=thread_id)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)):
continue continue
visible.append(row) visible.append(row)
if len(visible) == needed: if len(visible) == needed:
@ -137,7 +148,7 @@ async def scan_visible_thread_messages(
break break
_validate_message_scan_rows(raw, thread_id=thread_id, scan_before=scan_before, scan_after=None) _validate_message_scan_rows(raw, thread_id=thread_id, scan_before=scan_before, scan_after=None)
for row in reversed(raw): for row in reversed(raw):
if (not include_middleware and _is_thread_history_hidden_message_row(row)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)): if (not include_middleware and _is_thread_history_hidden_message_row(row, thread_id=thread_id)) or row.get("run_id") in hidden_run_ids or (message_filter is not None and not message_filter(row)):
continue continue
visible_desc.append(row) visible_desc.append(row)
if len(visible_desc) == needed: if len(visible_desc) == needed:

View File

@ -20,6 +20,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import tempfile
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path from pathlib import Path
@ -384,6 +385,27 @@ def _validate_agent_storage(config: AppConfig) -> None:
) )
def _probe_managed_subagent_file_storage() -> None:
"""Warn early if this volume cannot support create-only publication."""
from deerflow.config.paths import get_paths
root = get_paths().managed_subagents_dir
try:
root.mkdir(parents=True, exist_ok=True)
# A private directory keeps probe names out of the JSON catalog and
# lets independent Gateway workers probe without touching each other.
with tempfile.TemporaryDirectory(prefix=".hardlink-probe-", dir=root) as scratch:
source = Path(scratch) / "source"
source.touch()
os.link(source, Path(scratch) / "linked")
except OSError as exc:
logger.warning(
"Managed subagent hard-link probe failed for %s: %s. Managed subagent creation may fail on this volume. Use a volume with working hard links or set agent_storage.backend='db' with SQLite/Postgres.",
root,
exc,
)
def _validate_login_throttle_storage(config: AppConfig) -> None: def _validate_login_throttle_storage(config: AppConfig) -> None:
"""Warn when a multi-process deployment counts login failures per process. """Warn when a multi-process deployment counts login failures per process.
@ -788,6 +810,8 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Reject agent_storage.backend='db' on a non-durable database, and warn on # Reject agent_storage.backend='db' on a non-durable database, and warn on
# node-divergent file storage under multi-worker Postgres. # node-divergent file storage under multi-worker Postgres.
_validate_agent_storage(startup_config) _validate_agent_storage(startup_config)
if getattr(getattr(startup_config, "agent_storage", None), "backend", None) == "file":
await asyncio.to_thread(_probe_managed_subagent_file_storage)
# Warn when login lockouts stay per-process under several Gateway processes. # Warn when login lockouts stay per-process under several Gateway processes.
_validate_login_throttle_storage(startup_config) _validate_login_throttle_storage(startup_config)
# IM chat-to-thread bindings need no companion warning: ``resolve_channel_store`` # IM chat-to-thread bindings need no companion warning: ``resolve_channel_store``
@ -865,6 +889,9 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Initialize repositories — one get_session_factory() call for all. # Initialize repositories — one get_session_factory() call for all.
sf = get_session_factory() sf = get_session_factory()
from deerflow.approvals.store import ApprovalStore
app.state.approval_store = ApprovalStore(sf)
# The login throttle store is resolved once per process from the startup # The login throttle store is resolved once per process from the startup
# snapshot and the engine above (auth.local.throttle_storage is # snapshot and the engine above (auth.local.throttle_storage is
@ -898,6 +925,15 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
run_events_config = getattr(config, "run_events", None) run_events_config = getattr(config, "run_events", None)
app.state.run_events_config = run_events_config app.state.run_events_config = run_events_config
app.state.run_event_store = make_run_event_store(run_events_config) app.state.run_event_store = make_run_event_store(run_events_config)
if sf is not None:
from deerflow.persistence.run.producer import validate_producer_fencing
# An activated database also forbids a memory/JSONL downgrade.
await validate_producer_fencing(
sf,
mode=getattr(run_events_config, "producer_fencing", "off"),
cutover_id=getattr(run_events_config, "producer_cutover_id", None),
)
from deerflow.runtime.events.terminal import RecoveryTerminalPublisher from deerflow.runtime.events.terminal import RecoveryTerminalPublisher
app.state.recovery_terminal_publisher = RecoveryTerminalPublisher(app.state.run_event_store, app.state.run_store) app.state.recovery_terminal_publisher = RecoveryTerminalPublisher(app.state.run_event_store, app.state.run_store)
@ -1236,13 +1272,20 @@ def _selected_checkpoint_ids(config: dict[str, Any]) -> frozenset[str]:
return frozenset(selected) return frozenset(selected)
def _run_completion_hook(*, scheduled_task_service: Any | None): def _run_completion_hook(*, scheduled_task_service: Any | None, batch_continuations: Any | None = None):
"""Notify the scheduler after ordinary run finalization.""" """Notify the scheduler after ordinary run finalization."""
async def handle(record: Any) -> None: async def handle(record: Any) -> None:
# Scheduler persistence is best-effort: a store outage must not skip the
# batch cancellation below, or claimed follow-ups of hard-stopped runs
# stay in `dispatching` forever (#7969).
try:
if scheduled_task_service is not None: if scheduled_task_service is not None:
await scheduled_task_service.handle_run_completion(record) await scheduled_task_service.handle_run_completion(record)
except Exception:
logger.warning("Scheduled task completion hook failed for run %s (non-fatal)", record.run_id, exc_info=True)
if batch_continuations is not None:
await batch_continuations.source_finished(record)
return handle return handle
@ -1272,11 +1315,13 @@ def get_run_context(request: Request) -> RunContext:
checkpoint_snapshot_frequency=getattr(request.app.state, "checkpoint_snapshot_frequency", None), checkpoint_snapshot_frequency=getattr(request.app.state, "checkpoint_snapshot_frequency", None),
thread_store=get_thread_store(request), thread_store=get_thread_store(request),
mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None), mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None),
approval_store=getattr(request.app.state, "approval_store", None),
app_config=get_config(), app_config=get_config(),
extensions=getattr(request.app.state, "extensions", None), extensions=getattr(request.app.state, "extensions", None),
on_run_admitted=_run_admission_hook(checkpointer=checkpointer), on_run_admitted=_run_admission_hook(checkpointer=checkpointer),
on_run_completed=_run_completion_hook( on_run_completed=_run_completion_hook(
scheduled_task_service=scheduled_task_service, scheduled_task_service=scheduled_task_service,
batch_continuations=getattr(request.app.state, "batch_continuations", None),
), ),
) )

View File

@ -18,7 +18,7 @@ from pydantic import TypeAdapter, ValidationError
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_SESSION, get_auth_disabled_user, is_auth_disabled from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_SESSION, get_auth_disabled_user, is_auth_disabled
from app.gateway.authz import AuthContext, resolve_route_permissions from app.gateway.authz import AuthContext, resolve_route_permissions
from deerflow.runtime.run_origin import make_origin from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, make_origin
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.thread_id import ThreadId from deerflow.utils.thread_id import ThreadId
@ -73,15 +73,32 @@ class GatewayAgentRunsHost:
return None return None
return _BoundAgentRuns(self, str(user.id), user.token_version, source, frozenset(auth.permissions)) return _BoundAgentRuns(self, str(user.id), user.token_version, source, frozenset(auth.permissions))
def capture_batch_authority(self, runs, *, user_id: str) -> dict:
"""Capture only a native batch's existing host grant; never browser input."""
if not isinstance(runs, _BoundAgentRuns) or runs._host is not self or runs._user_id != user_id or not self.active:
raise AgentRunError(403, "Batch follow-up requires its originating Agent run authority")
return {"token_version": runs._token_version, "source": runs._source, "permissions": sorted(runs._permissions)}
async def restore_batch_authority(self, *, user_id: str, grant: dict):
"""Restore a persisted native batch grant with its original permission ceiling."""
source, version, permissions = grant.get("source"), grant.get("token_version"), grant.get("permissions")
if source not in (AUTH_SOURCE_SESSION, AUTH_SOURCE_AUTH_DISABLED) or type(version) is not int or not isinstance(permissions, list) or any(not isinstance(value, str) for value in permissions):
raise AgentRunError(403, "Invalid batch follow-up authority")
user = get_auth_disabled_user() if source == AUTH_SOURCE_AUTH_DISABLED and is_auth_disabled() else await self.load_user(user_id) if source == AUTH_SOURCE_SESSION and self.load_user is not None else None
if user is None or str(user.id) != user_id or user.token_version != version or user.needs_setup or getattr(user, "disabled", False):
raise AgentRunError(403, "Batch follow-up authorization revoked")
return _BoundAgentRuns(self, user_id, version, source, frozenset(permissions), origin=make_origin("subagent_batch"))
class _BoundAgentRuns: class _BoundAgentRuns:
def __init__(self, host, user_id: str, token_version: int, source: str, permissions: frozenset[str], plugin_namespace: str | None = None): def __init__(self, host, user_id: str, token_version: int, source: str, permissions: frozenset[str], plugin_namespace: str | None = None, origin: dict | None = None):
self._host = host self._host = host
self._user_id = user_id self._user_id = user_id
self._token_version = token_version self._token_version = token_version
self._source = source self._source = source
self._permissions = permissions self._permissions = permissions
self._plugin_namespace = plugin_namespace self._plugin_namespace = plugin_namespace
self._origin = origin
def for_plugin(self, namespace: str): def for_plugin(self, namespace: str):
if not isinstance(namespace, str) or not namespace or len(namespace) > 96 or ":" in namespace: if not isinstance(namespace, str) or not namespace or len(namespace) > 96 or ":" in namespace:
@ -113,7 +130,7 @@ class _BoundAgentRuns:
request.state.auth = AuthContext(user, list(permissions)) request.state.auth = AuthContext(user, list(permissions))
# Host-set, never HTTP input: threads and runs created through this # Host-set, never HTTP input: threads and runs created through this
# handle are marked as extension work (activity feed, unread state). # handle are marked as extension work (activity feed, unread state).
request.state.run_origin = make_origin("extension", namespace=self._plugin_namespace) request.state.run_origin = self._origin or make_origin("extension", namespace=self._plugin_namespace)
token = set_current_user(user) token = set_current_user(user)
try: try:
if thread_id is not None: if thread_id is not None:
@ -146,13 +163,56 @@ class _BoundAgentRuns:
raise AgentRunError(409, "Host assembly evidence unavailable") raise AgentRunError(409, "Host assembly evidence unavailable")
return copy.deepcopy(evidence) return copy.deepcopy(evidence)
async def read_artifact(self, *, thread_id, path): async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key):
from app.gateway.extension_artifacts import read_output_snapshot from app.gateway.deps import get_run_manager
from app.gateway.extension_artifacts import validate_capture_support
from app.gateway.routers.thread_runs import _scope_http_run_idempotency_key
from deerflow.utils.file_io import await_drained
if self._plugin_namespace is None or not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 200:
raise AgentRunError(422, "Artifact registration requires a plugin-scoped execution key")
async with self._request("runs:read", thread_id) as request:
run = await get_run_manager(request).get(run_id, user_id=self._user_id)
key = _scope_http_run_idempotency_key(request, thread_id, f"extension:{self._plugin_namespace}:{idempotency_key}")
origin = (run.metadata or {}).get(DEERFLOW_ORIGIN_KEY) if run is not None else None
if run is None or run.thread_id != thread_id or run.status != "running" or run.idempotency_key != key or origin != make_origin("extension", namespace=self._plugin_namespace):
raise AgentRunError(422, "Register files during the executing team run")
async with self._request("threads:read", thread_id) as request:
await await_drained(validate_capture_support(request, self._user_id, thread_id))
async def read_artifact(self, *, thread_id, path, expected_checkpoint_id=None):
from app.gateway.extension_artifacts import read_snapshot
from app.gateway.routers.artifacts import reserve_artifact_write from app.gateway.routers.artifacts import reserve_artifact_write
from deerflow.runtime.runs.manager import ConflictError
from deerflow.utils.file_io import await_drained
async with self._request("threads:read", thread_id) as request: async with self._request("threads:read", thread_id) as request:
try:
async with reserve_artifact_write(request, thread_id, user_id=self._user_id): async with reserve_artifact_write(request, thread_id, user_id=self._user_id):
return await asyncio.to_thread(read_output_snapshot, self._user_id, thread_id, path) if expected_checkpoint_id is not None:
if not isinstance(expected_checkpoint_id, str) or not expected_checkpoint_id:
raise AgentRunError(422, "Invalid producing checkpoint")
state = await self.get_state(thread_id=thread_id)
if state.get("checkpoint_id") != expected_checkpoint_id:
raise AgentRunError(412, "Producing conversation changed before artifact capture")
return await await_drained(read_snapshot(request, self._user_id, thread_id, path))
except ConflictError as exc:
raise AgentRunError(409, "Producing conversation is busy") from exc
async def import_artifact(self, *, thread_id, filename, content):
from app.gateway.extension_artifacts import import_snapshot
from app.gateway.routers.artifacts import reserve_artifact_write
from deerflow.runtime.runs.manager import ConflictError
from deerflow.utils.file_io import await_drained
if self._plugin_namespace is None:
raise AgentRunError(422, "Artifact import requires a plugin-scoped capability")
async with self._request("threads:write", thread_id) as request:
try:
async with reserve_artifact_write(request, thread_id, user_id=self._user_id):
return await await_drained(import_snapshot(request, self._user_id, thread_id, self._plugin_namespace, filename, content))
except ConflictError as exc:
raise AgentRunError(409, "Recipient conversation is busy") from exc
async def configuration_revision(self, *, assistant_ids: list[str], durable: bool = False) -> str: async def configuration_revision(self, *, assistant_ids: list[str], durable: bool = False) -> str:
from deerflow.config.agents_config import load_agent_config, load_agent_soul from deerflow.config.agents_config import load_agent_config, load_agent_soul
@ -198,7 +258,8 @@ class _BoundAgentRuns:
response = await create_thread(ThreadCreateRequest(assistant_id=assistant_id, thread_id=thread_id, metadata=_json_object(metadata) or {}), request) response = await create_thread(ThreadCreateRequest(assistant_id=assistant_id, thread_id=thread_id, metadata=_json_object(metadata) or {}), request)
return response.thread_id return response.thread_id
async def _start(self, *, thread_id, input=None, resume=None, context=None, idempotency_key=None, execution_fence=None): async def _start(self, *, thread_id, input=None, resume=None, context=None, idempotency_key=None, execution_fence=None, artifact_imports=None):
from app.gateway.extension_artifacts import validate_import_receipts
from app.gateway.routers.thread_runs import create_run from app.gateway.routers.thread_runs import create_run
from app.gateway.run_models import RunCreateRequest from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id from app.gateway.services import resolve_thread_assistant_id
@ -209,9 +270,16 @@ class _BoundAgentRuns:
run_input = _json_object(input) run_input = _json_object(input)
run_context = _json_object(context) run_context = _json_object(context)
command = _json_object({"resume": resume}) if resume is not None else None command = _json_object({"resume": resume}) if resume is not None else None
imports = _json_object({"items": artifact_imports})["items"] if artifact_imports is not None else None
if imports is not None:
if self._plugin_namespace is None or resume is not None:
raise AgentRunError(422, "Artifact admission requires a plugin-scoped new run")
validate_import_receipts(imports)
if run_context is not None and any(key in run_context for key in ("agent_name", "is_bootstrap")): if run_context is not None and any(key in run_context for key in ("agent_name", "is_bootstrap")):
raise AgentRunError(422, "Agent identity is bound to the thread") raise AgentRunError(422, "Agent identity is bound to the thread")
async with self._request("runs:create", thread_id) as request: async with self._request("runs:create", thread_id) as request:
if imports:
request.state.extension_artifact_imports = imports
if execution_fence is not None: if execution_fence is not None:
if not isinstance(execution_fence, ExecutionFence) or self._plugin_namespace is None or execution_fence.namespace != self._plugin_namespace: if not isinstance(execution_fence, ExecutionFence) or self._plugin_namespace is None or execution_fence.namespace != self._plugin_namespace:
raise AgentRunError(422, "Execution fence must belong to this plugin") raise AgentRunError(422, "Execution fence must belong to this plugin")
@ -225,8 +293,30 @@ class _BoundAgentRuns:
response = await create_run(thread_id, body, request, idempotency_key=f"extension:{self._plugin_namespace or ''}:{idempotency_key}" if idempotency_key is not None else None) response = await create_run(thread_id, body, request, idempotency_key=f"extension:{self._plugin_namespace or ''}:{idempotency_key}" if idempotency_key is not None else None)
return _view(response) return _view(response)
async def start(self, *, thread_id, input, context=None, idempotency_key=None, execution_fence=None) -> AgentRun: async def start(self, *, thread_id, input, context=None, idempotency_key=None, execution_fence=None, artifact_imports=None) -> AgentRun:
return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence) return await self._start(thread_id=thread_id, input=input, context=context, idempotency_key=idempotency_key, execution_fence=execution_fence, artifact_imports=artifact_imports)
async def start_batch_followup(self, *, thread_id, batch_id, input, context, config, execution_fence, goal_wakeup=None):
"""Native single follow-up; the existing SQL admission fences cancellation."""
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id, start_run
from deerflow.subagents.batch_runtime import BATCH_CONTINUATION_NAMESPACE, batch_continuation_key
if self._origin != make_origin("subagent_batch") or execution_fence.namespace != BATCH_CONTINUATION_NAMESPACE or execution_fence.resource_id != batch_id:
raise AgentRunError(403, "Invalid native batch continuation")
async with self._request("runs:create", thread_id) as request:
request.state.extension_execution_fence = execution_fence
assistant_id = await resolve_thread_assistant_id(request, thread_id, fail_closed=True)
body = RunCreateRequest(
assistant_id=assistant_id,
input=_json_object(input),
context=_json_object(context),
config=_json_object(config),
metadata={"batch_goal_wakeup": _json_object(goal_wakeup)} if goal_wakeup is not None else None,
on_disconnect="continue",
)
record = await start_run(body, thread_id, request, idempotency_key=batch_continuation_key(batch_id), require_existing_thread=True)
return _view(record)
async def resume(self, *, thread_id, resume, idempotency_key=None) -> AgentRun: async def resume(self, *, thread_id, resume, idempotency_key=None) -> AgentRun:
if resume is None: if resume is None:
@ -245,6 +335,40 @@ class _BoundAgentRuns:
async with self._request("threads:read", thread_id) as request: async with self._request("threads:read", thread_id) as request:
return (await get_thread_state(thread_id, request)).model_dump(mode="json") return (await get_thread_state(thread_id, request)).model_dump(mode="json")
async def send_input(self, *, thread_id, run_id, text, mode, request_id, context=None):
from app.gateway.routers.thread_runs import create_run_input
from app.gateway.run_inputs import RunInputRequest
async with self._request("runs:create", thread_id) as request:
body = RunInputRequest(request_id=request_id, text=text, mode=mode, context=_json_object(context) or {})
body = body.model_copy(update={"request_id": hashlib.sha256(f"{self._plugin_namespace or 'host'}:{body.request_id}".encode()).hexdigest()})
return await create_run_input(thread_id, run_id, body, request)
async def dispatch_input(self, *, thread_id, run_id, input_id):
from app.gateway.run_inputs import dispatch_one_input
async with self._request("runs:create", thread_id) as request:
return await dispatch_one_input(thread_id, run_id, input_id, request)
async def list_inputs(self, *, thread_id):
from app.gateway.routers.thread_runs import get_thread_inputs
async with self._request("threads:read", thread_id) as request:
return await get_thread_inputs(thread_id, request)
async def authorize_control(self, *, thread_id, run_id, clear_goal=False):
from app.gateway.deps import get_run_manager
async with self._request("runs:cancel", thread_id) as request:
run = await get_run_manager(request).get(run_id, user_id=self._user_id)
if run is None or run.thread_id != thread_id:
raise AgentRunError(404, "Run unavailable")
if run.status not in ("pending", "running"):
raise AgentRunError(409, "Run already stopped")
if clear_goal:
async with self._request("threads:write", thread_id):
pass
async def cancel(self, *, thread_id, run_id, execution_fence=None) -> None: async def cancel(self, *, thread_id, run_id, execution_fence=None) -> None:
from app.gateway.routers.thread_runs import cancel_run from app.gateway.routers.thread_runs import cancel_run

View File

@ -1,24 +1,243 @@
"""Bounded output capture without symlink traversal or mutable-path rereads.""" """Bounded output capture without symlink traversal or mutable-path rereads."""
import hashlib
import logging
import os import os
import re
import stat import stat
import uuid
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any
from deerflow_extension_api import AgentRunError from deerflow_extension_api import AgentRunError
from fastapi import Request
from app.gateway.authz import try_acquire_sandbox_for_request
from app.gateway.path_utils import normalize_outputs_virtual_path from app.gateway.path_utils import normalize_outputs_virtual_path
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
MAX_BYTES = 2 * 1024 * 1024 MAX_BYTES = 2 * 1024 * 1024
logger = logging.getLogger(__name__)
def read_output_snapshot(user_id, thread_id, path): def validate_import_receipts(values: object) -> None:
"""Reject malformed receipts or more than four bounded imports with 422."""
if not isinstance(values, list) or len(values) > 4:
raise AgentRunError(422, "Choose at most four imported artifacts")
for value in values:
if not isinstance(value, dict) or set(value) != {"filename", "path", "sha256", "size"}:
raise AgentRunError(422, "Invalid artifact import receipt")
filename = value["filename"]
if not isinstance(filename, str) or not filename or filename in (".", "..") or any(c in filename for c in "/\\") or any(ord(c) < 32 for c in filename):
raise AgentRunError(422, "Invalid artifact import filename")
try:
valid = len(filename.encode()) <= 255
except UnicodeError:
valid = False
if (
not valid
or value["path"] != "/mnt/user-data/uploads/" + filename
or not isinstance(value["sha256"], str)
or re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is None
or type(value["size"]) is not int
or not 0 <= value["size"] <= MAX_BYTES
):
raise AgentRunError(422, "Invalid artifact import receipt")
@asynccontextmanager
async def snapshot_sandbox(request: Request, user_id: str, thread_id: str) -> AsyncIterator[Sandbox | None]:
"""Yield a remote sandbox lease, or None for host-mounted thread data.
Permission denial raises 403 and an unavailable sandbox raises 503. Release
drains on cancellation; exact snapshots never fall back after a denied lease.
"""
from app.gateway.routers.uploads import _uses_thread_data_mounts
from deerflow.config.app_config import get_app_config
from deerflow.utils.file_io import await_drained, run_file_io
provider = get_sandbox_provider()
lease = None
try:
if not _uses_thread_data_mounts(provider):
lease = await try_acquire_sandbox_for_request(request, provider, thread_id, user_id=user_id, app_config=await run_file_io(get_app_config), owner_prefix="gateway:artifact-snapshot", release_on_last=True)
if lease.denied:
raise AgentRunError(403, "Sandbox permission denied for artifact snapshots")
if lease.sandbox is None:
raise AgentRunError(503, "Sandbox unavailable for artifact snapshots")
yield lease.sandbox if lease is not None else None
finally:
if lease is not None:
await await_drained(lease.release())
async def validate_capture_support(request: Request, user_id: str, thread_id: str) -> None:
"""Require POSIX capture and probe remote support before registration.
Raise 422 for unsupported capture, retaining lease authorization failures.
"""
from deerflow.sandbox.artifact_snapshot import validate_snapshot_support
from deerflow.utils.file_io import run_file_io
if os.name != "posix":
raise AgentRunError(422, "Artifact capture requires POSIX")
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
if sandbox is not None:
await run_file_io(validate_snapshot_support, sandbox)
def validate_import(filename: object, content: object) -> None:
"""Reject unsafe filenames, non-POSIX hosts or content over 2 MiB with 422."""
if os.name != "posix" or not isinstance(filename, str) or not filename or filename in (".", "..") or any(c in filename for c in "/\\") or any(ord(c) < 32 for c in filename):
raise AgentRunError(422, "Use a plain artifact filename on POSIX")
try:
valid = len(filename.encode()) <= 160
except UnicodeError:
valid = False
if not valid or not isinstance(content, bytes) or len(content) > MAX_BYTES:
raise AgentRunError(422, "Artifact filename is too long or content exceeds 2 MiB")
def write_import_snapshot(user_id: str, thread_id: str, namespace: str, filename: str, content: bytes) -> dict[str, Any]:
"""Publish a confined content-addressed upload and return its import receipt.
Reuse only identical regular files; retain existing edits and raise 422 for
unsafe or changed destinations. Run filesystem work off the event loop.
"""
validate_import(filename, content)
digest = hashlib.sha256(content).hexdigest()
identity = hashlib.sha256(namespace.encode() + b"\0" + filename.encode() + b"\0" + content).hexdigest()[:32]
destination = f"artifact-{identity}-{filename}"
paths = get_paths()
root = paths.base_dir.resolve()
relative = paths.sandbox_uploads_dir(thread_id, user_id=user_id).relative_to(paths.base_dir)
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
temporary = ".upload-" + uuid.uuid4().hex + ".part"
created = False
try:
for part in relative.parts:
try:
os.mkdir(part, mode=0o755, dir_fd=directory)
except FileExistsError:
pass
next_dir = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = next_dir
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
created = True
with os.fdopen(fd, "wb") as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
os.fchmod(target.fileno(), 0o644)
try:
os.link(temporary, destination, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
except FileExistsError:
pass
os.unlink(temporary, dir_fd=directory)
created = False
fd = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
with os.fdopen(fd, "rb") as existing:
before = os.fstat(existing.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size != len(content):
raise AgentRunError(422, "Imported artifact was changed; existing file retained")
data = existing.read(MAX_BYTES + 1)
after = os.fstat(existing.fileno())
if data != content or (before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns):
raise AgentRunError(422, "Imported artifact was changed; existing file retained")
return {"filename": destination, "path": "/mnt/user-data/uploads/" + destination, "sha256": digest, "size": len(content)}
except OSError as exc:
raise AgentRunError(422, "Artifact import destination unavailable or unsafe") from exc
finally:
try:
if created:
os.unlink(temporary, dir_fd=directory)
except OSError:
logger.warning("Failed to clean up an abandoned artifact import staging file", exc_info=True)
finally:
os.close(directory)
async def import_snapshot(request: Request, user_id: str, thread_id: str, namespace: str, filename: str, content: bytes) -> dict[str, Any]:
"""Return a receipt after publishing identical host and remote uploads.
Retain existing edits; unsafe/changed snapshots raise 422 and lease failures
keep their authorization/availability status. Cancellation drains file I/O.
"""
from deerflow.sandbox.artifact_snapshot import publish_snapshot
from deerflow.utils.file_io import run_file_io
validate_import(filename, content)
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
result = await run_file_io(write_import_snapshot, user_id, thread_id, namespace, filename, content)
if sandbox is not None:
await run_file_io(publish_snapshot, sandbox, result["filename"], content)
return result
async def read_snapshot(request: Request, user_id: str, thread_id: str, path: str) -> bytes:
"""Capture bounded output bytes through host storage or an authorized lease.
Non-output paths raise HTTP 400; unsafe files raise 422 and host mutation
during capture raises 409. Remote snapshot and lease errors retain their
status. Cancellation drains I/O.
"""
from deerflow.sandbox.artifact_snapshot import read_snapshot as read_remote
from deerflow.utils.file_io import run_file_io
path = normalize_outputs_virtual_path(path)
async with snapshot_sandbox(request, user_id, thread_id) as sandbox:
if sandbox is not None:
return await run_file_io(read_remote, sandbox, path.removeprefix("/mnt/user-data/outputs/"))
return await run_file_io(read_output_snapshot, user_id, thread_id, path)
async def verify_imports(request: Request, user_id: str, thread_id: str, receipts: list[dict[str, Any]], *, lifetime: AsyncExitStack | None = None) -> None:
"""Verify receipt bytes after new admission and before Agent construction.
A supplied lifetime retains the remote lease through execution; otherwise it
ends here. Missing, changed or inaccessible copies raise an artifact-specific
422. Cancellation drains file I/O before the enclosing lease is released.
"""
from deerflow.sandbox.artifact_snapshot import verify_snapshot
from deerflow.utils.file_io import run_file_io
try:
async with AsyncExitStack() as temporary:
# A run retains its remote client through execution: releasing it
# after preflight could let the provider recycle the verified VM.
sandbox = await (lifetime or temporary).enter_async_context(snapshot_sandbox(request, user_id, thread_id))
for receipt in receipts:
data = await run_file_io(_read_host_snapshot, user_id, thread_id, receipt["filename"], "uploads")
if len(data) != receipt["size"] or hashlib.sha256(data).hexdigest() != receipt["sha256"]:
raise AgentRunError(422, "Imported copy changed")
if sandbox is not None:
await run_file_io(verify_snapshot, sandbox, receipt["filename"], receipt["sha256"], receipt["size"])
except (AgentRunError, OSError) as exc:
raise AgentRunError(422, "Attached artifact copy is unavailable or changed; inspect the team artifacts and send a new request") from exc
def read_output_snapshot(user_id: str, thread_id: str, path: str) -> bytes:
"""Read bounded host output bytes without following links.
Non-output paths raise HTTP 400; unsafe files raise 422 and mutation during
capture raises 409. Run filesystem work off the event loop.
"""
if os.name != "posix" or not isinstance(path, str): if os.name != "posix" or not isinstance(path, str):
raise AgentRunError(422, "Output capture requires POSIX and an output path") raise AgentRunError(422, "Output capture requires POSIX and an output path")
path = normalize_outputs_virtual_path(path) path = normalize_outputs_virtual_path(path)
return _read_host_snapshot(user_id, thread_id, path.removeprefix("/mnt/user-data/outputs/"), "outputs")
def _read_host_snapshot(user_id: str, thread_id: str, path: str, area: str) -> bytes:
paths = get_paths() paths = get_paths()
root = paths.base_dir.resolve() root = paths.base_dir.resolve()
relative = paths.sandbox_outputs_dir(thread_id, user_id=user_id).relative_to(paths.base_dir) base = paths.sandbox_outputs_dir(thread_id, user_id=user_id) if area == "outputs" else paths.sandbox_uploads_dir(thread_id, user_id=user_id)
parts = (*relative.parts, *path.removeprefix("/mnt/user-data/outputs/").split("/")) relative = base.relative_to(paths.base_dir)
parts = (*relative.parts, *path.split("/"))
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY) directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
try: try:
for part in parts[:-1]: for part in parts[:-1]:
@ -28,7 +247,7 @@ def read_output_snapshot(user_id, thread_id, path):
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
with os.fdopen(fd, "rb") as source: with os.fdopen(fd, "rb") as source:
before = os.fstat(source.fileno()) before = os.fstat(source.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES: if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_BYTES or (area == "uploads" and before.st_nlink != 1):
raise AgentRunError(422, "Artifact must be a regular file of at most 2 MiB") raise AgentRunError(422, "Artifact must be a regular file of at most 2 MiB")
content = source.read(MAX_BYTES + 1) content = source.read(MAX_BYTES + 1)
after = os.fstat(source.fileno()) after = os.fstat(source.fileno())

View File

@ -95,7 +95,7 @@ def _build_index(agents: list[tuple[str, AgentConfig]]) -> _Registry:
# it is here, so this branch is unreachable. Keep the # it is here, so this branch is unreachable. Keep the
# guard for type-checker happiness. # guard for type-checker happiness.
continue continue
index.setdefault((binding.repo, event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved)) index.setdefault((binding.repo.lower(), event), []).append(GitHubAgentMatch(user_id=user_id, agent=cfg, trigger=resolved))
return index return index
@ -145,4 +145,4 @@ def lookup_agents(
and the pre-resolved trigger config for this specific event, so the and the pre-resolved trigger config for this specific event, so the
caller does not need to walk the agent's ``bindings`` again. caller does not need to walk the agent's ``bindings`` again.
""" """
return registry.get((repo, event), []) return registry.get((repo.lower(), event), [])

View File

@ -81,6 +81,11 @@ def _comment_body(event: str, payload: dict[str, Any]) -> str:
def _author_login(event: str, payload: dict[str, Any]) -> str | None: def _author_login(event: str, payload: dict[str, Any]) -> str | None:
"""Login of the human who triggered the event, for ``allow_authors``.""" """Login of the human who triggered the event, for ``allow_authors``."""
# Object authors need not be the actor on edits, labels, or dismissals.
# Keep the object fallback for payloads without a sender identity.
sender_login = (payload.get("sender") or {}).get("login")
if isinstance(sender_login, str):
return sender_login
if event in ("issue_comment", "pull_request_review_comment"): if event in ("issue_comment", "pull_request_review_comment"):
login = (payload.get("comment") or {}).get("user", {}).get("login") login = (payload.get("comment") or {}).get("user", {}).get("login")
elif event == "pull_request": elif event == "pull_request":

View File

@ -51,6 +51,7 @@ from sqlalchemy import text
from deerflow.persistence.engine import get_engine from deerflow.persistence.engine import get_engine
from deerflow.utils.file_io import await_drained from deerflow.utils.file_io import await_drained
from deerflow.utils.http_policy import control_plane_trust_env
if TYPE_CHECKING: if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
@ -313,10 +314,7 @@ def _make_provisioner_client(provisioner_url: str) -> httpx.AsyncClient:
``NO_PROXY``, so without this a private provisioner behind a proxy-only ``NO_PROXY``, so without this a private provisioner behind a proxy-only
egress would be reported unreachable on every probe. egress would be reported unreachable on every probe.
""" """
# Lazy: importing the aio_sandbox package pulls in the whole provider stack. return httpx.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS, trust_env=control_plane_trust_env(provisioner_url))
from deerflow.community.aio_sandbox.backend import sandbox_http_trust_env
return httpx.AsyncClient(timeout=_PROBE_TIMEOUT_SECONDS, trust_env=sandbox_http_trust_env(provisioner_url))
async def _probe_provisioner(provisioner_url: str | None) -> str: async def _probe_provisioner(provisioner_url: str | None) -> str:

View File

@ -0,0 +1,74 @@
"""Host-authorized history copies under durable thread-operation admission."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import Request
from app.gateway.deps import get_run_manager, get_thread_store
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from deerflow.persistence.run.sql import RunRepository
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
from deerflow.runtime.events.producer import RunEventProducerRejected, history_seed_authority, producer_scope
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import ThreadOperationKind
@dataclass(frozen=True)
class HistorySeedReservation:
record: RunRecord
thread: dict | None
@asynccontextmanager
async def reserve_history_seed(request: Request, thread_id: str) -> AsyncIterator[HistorySeedReservation | None]:
"""Keep snapshot reads and seed writes exclusive with delete and admission."""
events = request.app.state.run_event_store
manager = getattr(request.app.state, "run_manager", None)
runs = getattr(manager, "_store", None)
threads = getattr(request.app.state, "thread_store", None)
if not isinstance(events, DbRunEventStore):
yield None
return
if not isinstance(runs, RunRepository) or not isinstance(threads, ThreadMetaRepository):
if events._producer_fencing == "strict":
raise RunEventProducerRejected("History seeding requires shared SQL lifecycle stores")
yield None
return
if runs._sf.kw.get("bind") is not events._sf.kw.get("bind") or threads._sf.kw.get("bind") is not events._sf.kw.get("bind"):
raise RunEventProducerRejected("History seeding requires shared SQL lifecycle stores")
async with get_run_manager(request).reserve_thread_operation(
thread_id,
kind=ThreadOperationKind.checkpoint_write,
user_id=get_trusted_internal_owner_user_id(request),
) as reservation:
yield HistorySeedReservation(reservation, await threads.get(thread_id, user_id=None))
async def write_history_seed(
request: Request,
events: list[dict],
*,
reservation: HistorySeedReservation | None,
seed_kind: str,
thread: dict | None = None,
assistant_id: str | None = None,
) -> None:
store = request.app.state.run_event_store
if reservation is None:
await store.put_batch(events)
return
if thread is None:
# Only the checkpoint migration may materialize missing legacy metadata.
# The caller read that checkpoint while holding this same reservation.
if seed_kind != "checkpoint":
raise RunEventProducerRejected("Branch seeding requires its original thread identity")
threads = get_thread_store(request)
thread = reservation.thread
if thread is None:
thread = await threads.create(reservation.record.thread_id, assistant_id=assistant_id, user_id=reservation.record.user_id)
authority = history_seed_authority(reservation.record, thread, seed_kind=seed_kind)
with producer_scope(authority):
await store.put_batch(events)

View File

@ -0,0 +1,351 @@
"""Server-side validation for HITL tool-approval resume and admission.
All checks live here (called from ``services.start_run``) so every entry point
— HTTP routes, extension host, internal launchers — shares one choke point.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException
from app.gateway.checkpoint_lineage import hitl_approval_payloads
from deerflow.approvals.protocol import combine_approval_payloads
from deerflow.runtime.checkpoint_history import recent_checkpoint_history
logger = logging.getLogger(__name__)
class ApprovalRequestIds(dict):
"""Identity map plus the validated LangGraph interrupt-ID resume map."""
resume_map: dict | None = None
if TYPE_CHECKING:
from deerflow.runtime.runs.manager import ApprovalAnswerHandle
def _error(status: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status, detail={"code": code, "message": message})
def _responses_of(command: Any) -> list[Any] | None:
if not isinstance(command, Mapping):
return None
resume = command.get("resume")
if not isinstance(resume, Mapping):
return None
responses = resume.get("responses")
return responses if isinstance(responses, list) else None
def _all_reject(responses: list[Any] | None) -> bool:
return bool(responses) and all(isinstance(r, Mapping) and r.get("option_id") == "reject" for r in responses)
async def has_any_pending_interrupt(accessor: Any, read_config: dict[str, Any]) -> bool:
"""Whether a recent checkpoint has an unconsumed interrupt write.
Used for generic (non-hitl) resume admission where snapshot.next may be
empty after a head-moving write. The newest resume/interrupt marker wins,
so an answered pause cannot be resurrected by its older interrupt write.
"""
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return False
if any(len(write) >= 2 and write[1] == "__interrupt__" for write in writes):
return True
return False
async def _pending_interrupts_from_checkpoint_history(checkpointer: Any, read_config: dict[str, Any]) -> dict[str, list[Any]]:
"""Read pending interrupts without materializing a graph state snapshot."""
async for checkpoint in recent_checkpoint_history(checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return {}
interrupt_writes = [write for write in writes if len(write) >= 3 and write[1] == "__interrupt__"]
if not interrupt_writes:
continue
grouped: dict[str, list[Any]] = {}
for write in interrupt_writes:
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
grouped.setdefault(str(write[0]), []).extend(interrupts)
return grouped
return {}
async def has_waiting_for_input_run(request: Any, thread_id: str) -> bool:
"""Whether any run in the thread is paused for input (any interrupt kind)."""
from app.gateway.deps import get_run_store
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, AttributeError) or exc.status_code == 503:
return False
raise
runs = await run_store.list_by_thread(thread_id, limit=5)
return any(run.get("status") == "waiting_for_input" for run in runs)
async def mark_approval_answered(request: Any, thread_id: str, *, expected_request_ids: dict[str, str] | None = None) -> ApprovalAnswerHandle | None:
"""Mark the validated paused run as answered and return its recovery handle."""
from app.gateway.deps import get_run_manager, get_run_store
try:
run_manager = get_run_manager(request)
except (HTTPException, AttributeError) as exc:
if isinstance(exc, HTTPException) and exc.status_code != 503:
raise
# Lightweight compositions may provide only a store. Keep their
# persistence on RunManager's retry and in-memory mutation path too.
from deerflow.runtime import RunManager
try:
run_manager = RunManager(store=get_run_store(request))
except (HTTPException, AttributeError) as store_exc:
if isinstance(store_exc, AttributeError) or store_exc.status_code == 503:
return None
raise
return await run_manager.mark_approval_answered(thread_id, expected_request_ids=expected_request_ids)
async def _interrupt_from_checkpoint_history(accessor: Any, read_config: dict[str, Any], payload: dict[str, Any]) -> tuple[str, Any] | None:
"""Recover the original LangGraph task identity after the head has moved."""
if accessor is None:
return None
if getattr(accessor, "checkpointer", None) is None:
return None
async for checkpoint in recent_checkpoint_history(accessor.checkpointer, read_config):
writes = list(getattr(checkpoint, "pending_writes", ()) or ())
if any(len(write) >= 2 and write[1] == "__resume__" for write in writes):
return None
for write in writes:
if len(write) < 3 or write[1] != "__interrupt__":
continue
interrupts = write[2] if isinstance(write[2], (list, tuple)) else (write[2],)
for pending_interrupt in interrupts:
if getattr(pending_interrupt, "value", None) == payload:
return str(write[0]), pending_interrupt
return None
async def find_pending_interrupts(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
snapshot: Any = None,
) -> dict[str, list[Any]]:
"""Find pending interrupts from an existing snapshot or raw checkpoints.
The live snapshot catches a pause before the worker has finalized the run;
raw pending writes cover head-moving writes and restricted mutation graphs;
the durable run record remains the final fallback.
"""
from langgraph.types import Interrupt
from app.gateway.checkpoint_lineage import task_interrupts_by_id
from app.gateway.deps import get_checkpointer, get_run_store
if snapshot is None and accessor is not None and read_config is not None:
snapshot = await accessor.aget(read_config)
if snapshot is not None:
grouped = task_interrupts_by_id(snapshot)
if grouped:
return grouped
try:
run_store = get_run_store(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
runs = []
else:
runs = await run_store.list_by_thread(thread_id, limit=10)
# A current loop snapshot is authoritative for ordinary polling. Update
# checkpoints can have moved the head past an unconsumed interrupt, and
# checkpoint-only compositions have no snapshot: retain recovery for both.
if (
snapshot is not None
and (getattr(snapshot, "metadata", None) or {}).get("source") == "loop"
and (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
and not (read_config or {}).get("configurable", {}).get("checkpoint_id")
and not any(run.get("status") == "waiting_for_input" for run in runs)
):
return {}
checkpointer = getattr(accessor, "checkpointer", None)
if checkpointer is None:
try:
checkpointer = get_checkpointer(request)
except (HTTPException, AttributeError) as exc:
if not isinstance(exc, AttributeError) and exc.status_code != 503:
raise
if checkpointer is not None:
grouped = await _pending_interrupts_from_checkpoint_history(
checkpointer,
read_config or {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}},
)
if grouped:
return grouped
for run in runs:
if run.get("status") != "waiting_for_input":
continue
payload = (run.get("metadata") or {}).get("hitl_approval")
if payload:
recovered = await _interrupt_from_checkpoint_history(accessor, read_config, payload) if read_config is not None else None
if recovered is not None:
task_id, pending_interrupt = recovered
return {task_id: [pending_interrupt]}
task_id = run.get("run_id") or "approval"
return {task_id: [Interrupt(value=payload, id=payload.get("request_id", task_id))]}
return {}
def _approval_request_ids(payload: dict[str, Any]) -> dict[str, str]:
"""Extract and validate the action identity binding for one HITL pause."""
expected_ids = payload.get("pending_tool_call_ids")
if not isinstance(expected_ids, list) or any(not isinstance(call_id, str) for call_id in expected_ids) or len(set(expected_ids)) != len(expected_ids):
raise _error(422, "invalid_approval_response", "pending approval has invalid tool_call_ids")
expected_set = set(expected_ids)
actions = payload.get("actions")
if not isinstance(actions, list):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids: dict[str, str] = {}
for action in actions:
if not isinstance(action, Mapping):
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
call_id = action.get("tool_call_id")
request_id = action.get("request_id")
if not isinstance(call_id, str) or call_id not in expected_set or call_id in expected_request_ids or not isinstance(request_id, str) or not request_id:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
expected_request_ids[call_id] = request_id
if set(expected_request_ids) != expected_set:
raise _error(422, "invalid_approval_response", "pending approval has invalid actions")
return expected_request_ids
async def current_approval_request_ids(
request: Any,
thread_id: str,
*,
accessor: Any,
read_config: dict[str, Any],
) -> dict[str, str] | None:
"""Read the current live pause's identity for checkpoint-only recovery."""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
payloads = hitl_approval_payloads(pending)
return _approval_request_ids(combine_approval_payloads(payloads)) if payloads else None
async def validate_resume_request(*, request: Any, thread_id: str, accessor: Any, read_config: dict[str, Any], enabled: bool, body: Any) -> dict[str, str] | None:
"""Validate a command.resume against the current pause, or raise 409/422.
Static breakpoints (next non-empty, no approval payload) pass through
unchanged. All-reject remains the escape hatch for the validated pause when
the feature is disabled or the payload was lost; attachment later verifies
that checkpoint-only recovery still targets that same pause.
"""
snapshot = await accessor.aget(read_config)
responses = _responses_of(getattr(body, "command", None))
all_reject = _all_reject(responses)
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config, snapshot=snapshot)
payloads = hitl_approval_payloads(pending)
has_waiting_run = await has_waiting_for_input_run(request, thread_id)
has_pending_interrupt = await has_any_pending_interrupt(accessor, read_config)
head_checkpoint_id = (getattr(snapshot, "config", None) or {}).get("configurable", {}).get("checkpoint_id")
if not pending and not has_waiting_run and not (getattr(snapshot, "next", None) or ()) and not has_pending_interrupt:
raise _error(409, "no_pending_approval", "No resumable pause exists for this thread")
if not payloads:
return None
if responses is None:
raise _error(422, "invalid_approval_response", "resume must carry a 'responses' list for hitl_approval")
expected_request_ids = ApprovalRequestIds(_approval_request_ids(combine_approval_payloads(payloads)))
action_by_request = {action["request_id"]: action for payload in payloads for action in payload["actions"]}
expected_ids = list(expected_request_ids)
expected_set = set(expected_ids)
if len(responses) != len(expected_ids):
raise _error(422, "invalid_approval_response", f"expected {len(expected_ids)} responses, got {len(responses)}")
seen_ids: set[str] = set()
for response in responses:
if not isinstance(response, Mapping):
raise _error(422, "invalid_approval_response", "each response must be an object")
response_id = response.get("tool_call_id")
if not isinstance(response_id, str) or response_id not in expected_set:
raise _error(422, "invalid_approval_response", "unknown tool_call_id")
if response.get("request_id") != expected_request_ids[response_id]:
raise _error(422, "invalid_approval_response", "stale or mismatched request_id")
if response_id in seen_ids:
raise _error(422, "invalid_approval_response", "duplicate tool_call_id")
seen_ids.add(response_id)
action = action_by_request[response["request_id"]]
supported = ("approve", "reject", "edit", "approve_thread") if all(payload.get("version") == 2 for payload in payloads) else ("approve", "reject")
if response.get("option_id") not in supported:
raise _error(422, "invalid_approval_response", f"unsupported option_id; expected one of {', '.join(repr(option) for option in supported)}")
if response.get("option_id") == "approve_thread" and not action.get("grant_allowed"):
raise _error(422, "invalid_approval_response", "This operation does not permit a thread grant")
if response.get("option_id") == "edit":
from jsonschema import Draft202012Validator
from deerflow.approvals.policy import canonical_json
args, schema = response.get("args"), action.get("args_schema")
try:
limit = min(payload.get("max_payload_bytes", 512 * 1024) for payload in payloads)
if not isinstance(args, dict) or not isinstance(schema, dict) or len(canonical_json(args).encode()) > limit:
raise ValueError("Invalid arguments")
Draft202012Validator(schema).validate(args)
if isinstance(schema.get("properties"), dict) and set(args) - set(schema["properties"]):
raise ValueError("Unknown or injected arguments")
except Exception as exc:
raise _error(422, "invalid_approval_arguments", "Edited arguments do not match the tool schema") from exc
if all(payload.get("version") == 2 for payload in payloads):
expected_request_ids.resume_map = {
item.id: {"responses": [response for response in responses if response["request_id"] in {action["request_id"] for action in item.value["actions"]}]}
for interrupts in pending.values()
for item in interrupts
if isinstance(getattr(item, "value", None), dict) and item.value.get("kind") == "hitl_approval"
}
if all_reject:
return expected_request_ids
if not enabled:
raise _error(409, "hitl_approval_disabled", "hitl_approval is currently disabled; resume with all-reject to clear the pause")
requested_checkpoint_id = getattr(body, "checkpoint_id", None)
body_checkpoint = getattr(body, "checkpoint", None)
if requested_checkpoint_id is None and isinstance(body_checkpoint, Mapping):
requested_checkpoint_id = body_checkpoint.get("checkpoint_id")
if requested_checkpoint_id and head_checkpoint_id and str(requested_checkpoint_id) != str(head_checkpoint_id):
raise _error(422, "checkpoint_not_head", "A resume must target the current head checkpoint, never an older pause checkpoint")
return expected_request_ids
async def reject_new_run_when_approval_pending(
request: Any,
thread_id: str,
*,
accessor: Any = None,
read_config: dict[str, Any] | None = None,
) -> None:
"""Reject a brand-new (non-resume) run while an approval is pending.
Without this guard a fresh input silently discards the pending approval
(measured behavior of LangGraph). Internal launchers catch this specific
error code to implement skip-and-retry semantics.
"""
pending = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=read_config)
if hitl_approval_payloads(pending):
raise _error(409, "approval_pending", "Thread has a pending tool approval; resume or reject it before starting a new run")

View File

@ -0,0 +1,78 @@
"""Bounded, read-only provider catalog retrieval. Never follow credentialed redirects."""
import asyncio
import json
import httpx
from deerflow.config.managed_model_providers import get_managed_model_provider
from deerflow.config.managed_models import ManagedModel
MAX_MODELS = 2000
MAX_BYTES = 2 * 1024 * 1024
class DiscoveryError(ValueError):
def __init__(self, code: str):
self.code = code
super().__init__(code)
async def fetch_model_catalog(profile: ManagedModel) -> tuple[list[str], bool]:
provider = get_managed_model_provider(profile.provider)
endpoint = provider.endpoint or profile.base_url
key = profile.api_key.get_secret_value() if profile.api_key else None
native = provider.protocol == "anthropic-messages"
url = endpoint.rstrip("/") + ("/v1/models" if native else "/models")
headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
if native:
headers.update({"x-api-key": key or "", "anthropic-version": "2023-06-01"})
elif key:
headers["Authorization"] = f"Bearer {key}"
# HTTPX constructs TLS trust synchronously; keep that work off the event loop.
client = await asyncio.to_thread(httpx.AsyncClient, timeout=15, follow_redirects=False)
names: set[str] = set()
cursor = None
size = 0
try:
async with asyncio.timeout(20), client:
for _ in range(20):
params = {"limit": 100, **({"after_id": cursor} if cursor else {})} if native else None
async with client.stream("GET", url, headers=headers, params=params) as response:
if response.status_code in (401, 403):
raise DiscoveryError("authentication_failed")
if response.status_code in (404, 405):
raise DiscoveryError("discovery_unsupported")
if response.status_code != 200:
raise DiscoveryError("discovery_failed")
# Reject compression before HTTPX can allocate a decoded
# chunk larger than our response budget.
if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
raise DiscoveryError("discovery_failed")
chunks = []
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_BYTES:
raise DiscoveryError("response_limit")
chunks.append(chunk)
data = json.loads(b"".join(chunks))
if not isinstance(data, dict) or not isinstance(data.get("data"), list):
raise DiscoveryError("discovery_failed")
for item in data["data"]:
name = item.get("id") if isinstance(item, dict) else None
if not isinstance(name, str) or not name or len(name) > 200 or name != name.strip() or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in name):
continue
names.add(name)
if len(names) >= MAX_MODELS:
return sorted(names), True
if not native or not data.get("has_more"):
return sorted(names), bool(data.get("has_more"))
next_cursor = data.get("last_id")
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor or len(next_cursor) > 200:
raise DiscoveryError("discovery_failed")
cursor = next_cursor
return sorted(names), True
except DiscoveryError:
raise
except Exception:
raise DiscoveryError("discovery_failed") from None

View File

@ -1,100 +1,52 @@
# Router contracts # Gateway routers (`app/gateway/routers/`)
`GET /api/plugins/diagnostics` uses `require_admin_user` before reading the One module per API area. The parent [Gateway guide](../AGENTS.md) covers trust
canonical runtime diagnostic snapshot. Return bounded entry-point identifiers, boundaries and run admission; this file covers what every route handler has to do.
known levels and fixed summaries; raw messages stay in logs. Never clear the
sink or expand PAT permissions.
Branch metadata inherits valid `assistant_id` values verbatim. Validate with ## Verify
`custom_agent_name` and clear invalid legacy values to null in the new row;
leave the source record unchanged.
Console usage attributes legacy rows without a per-model usage map to ```bash
`model_name`, including their recorded `total_input_tokens`. Accumulate those cd backend
inputs with newer usage buckets for the same model; do not infer cache hits. python -m pytest tests -q -k "<area>_router" # e.g. threads_router, console_router
Regression coverage lives in `tests/test_console_router.py`. make test-blocking-io # after adding any file, config or network read
```
Shared scheduled-task update validation preserves an unchanged future `once` ## Rules for a route handler
instant on a live task inside `scheduler.min_once_delay_seconds`; new/changed
instants and terminal reactivation still enforce the minimum delay.
Scheduled-task admission rejects nonexistent naive `run_at` and `end_at` - **Authorize first.** Check the session, role or permission before the handler
wall times through the shared Gateway datetime resolver. Keep this check out reads credentials, parses an upload or makes an outbound request. An
of runtime `next_run_at`: legacy tasks retain their accepted execution time, unauthorized caller must not be able to trigger that work.
including unchanged live once definitions. Explicit offsets retain their instant; - **Fixed text in 5xx responses.** Exceptions from storage, config loading and
ambiguous naive times keep the existing fold choice. subprocesses contain host paths. Log the exception on the server and return a
fixed detail string.
The MCP task list accepts optional `TaskStatus` and `active_only` query filters. - **No blocking calls on the event loop.** `get_app_config()` stats and hashes the
Pass both to the repository before its limit; preserve `threads:read`, owner config files on every call, and storage backends do file I/O. Call both through
checks, current-thread incarnation scoping, and the unfiltered array response. `asyncio.to_thread`. The blocking-I/O suite fails the build otherwise.
- **Writes survive disconnects.** Wrap a persistent write in
Custom Agent packages are strict, versioned definition-only JSON documents. `app.gateway.persistent_writes.run_drained_write`. A cancelled request then
Export explicitly projects the caller's portable `AgentConfig` fields and SOUL; still waits for the worker that owns the write, and no half-written state stays
exclude `github` installation bindings, memory contents, conversations and behind.
credentials. Import may rename locally but must remain owner-scoped and - **Catalog responses are projections.** A listing endpoint returns names and
create-only: collisions return 409, never overwrite or upsert. Ordinary creation display fields. Provider paths, credentials and raw tool or server configuration
and import share `_persist_new_agent`, which uses `run_drained_write` so a stay on the server.
cancelled request still drains the owned persistence worker. - **Provenance comes from the session.** Values that say who or what triggered an
action (for example the plugin `ActionContext.interaction`) are set from the
`GET /api/skills`, `/api/skills/custom` and `/api/skills/{name}` serve every authenticated request only. Ignore the same field in a request body.
signed-in user, so their unexpected-failure 500s return a fixed detail. Load - **Unreadable configuration is not default configuration.** When a policy cannot
errors (e.g. a malformed `extensions_config.json`) carry host paths and stay in be read, skip the optional work (cleanup, sweep) and keep serving. Applying a
the server log. default policy can delete data the operator wanted to keep.
- **Imports create, they do not overwrite.** Agent and skill imports are scoped to
Thread upload, upload delete and shelf attach-to-thread 500s name the file but the caller and return 409 on a name collision. Exports leave out credentials,
not the cause: staging and unlink `OSError`s carry host paths and stay in the memory, conversations and installation bindings.
server log. - **Memory backup imports** replace the selected facts and require an explicit
`facts` array. Omitting it returns 422. An empty array clears that bucket.
Skill mutation operation lists return `next_cursor: null` when `has_more` is false, including empty pages after a supplied `after_id`. The next cursor is the final returned operation only when a further page exists. - **Outbound requests from a route** (model discovery, connection tests) have a
time limit and a size limit, do not follow redirects, and return redacted
`GET /api/agent-tool-groups` projects only configured group names for the provider errors.
custom-Agent settings allowlist. Never expose tool provider paths, credentials, - **Locks that several Gateway processes share** are file locks beside the data,
or raw tool configuration through this catalog. taken off the event loop. A process mutex alone does not order writes between
workers.
Custom Agent update guards apply legacy shared-file checks only when the - **Path segments from clients.** A name that becomes a directory (agent name,
resolved store is `FileAgentStore`, including the `update_agent` tool. DB skill name, file name) is validated when it is created, including Windows
imports retain source files for rollback; those files cannot veto SQL updates. reserved device names. Read and delete routes stay permissive so that data
Keep store resolution and filesystem checks off-loop in the HTTP route. stored by an older release can still be exported and removed.
Agent PUT uses `preserve_non_managed_fields(..., new_tool_groups=...)` to clear
an inherited knowledge default when changed groups remove knowledge access.
Explicit scope/null updates still win; unchanged groups are not a migration.
Agent/profile and deployment/personal MCP mutation 500s plus the
scheduled-task `invalid_assistant` 422 use fixed text. Log exceptions
server-side; they can carry host paths.
Committed MCP reconciliation failures are logged once by
`fail_mcp_reconciliation`, with only the underlying exception type.
`_run_drained_mcp_apply` passes `_McpCommittedReconciliationError` as an expected
error to `run_drained_write` to avoid a duplicate diagnostic. It still drains
retired-pool cleanup outside the config locks and reports the fixed-detail 500.
Custom-skill edit, delete and rollback hold the owning root's process mutex and
`.custom.mutation.lock` sidecar across predecessor read, storage mutation and
history append. Peer Gateway processes sharing that root must use this same
critical section to preserve history ordering. Keep the sidecar distinct from
the nested projection lock, retain it across requests, and acquire/release both
off-loop inside the drained worker. Unrelated users retain independent locks;
external filesystem writers do not participate in this history guarantee.
Plugin actions stamp `ActionContext.interaction=session` only from authenticated
session provenance. Never accept it from payloads or propagate it to model tools;
extensions use it to distinguish owner review from model/tool output.
Skill archive upload copies (`_copy_uploaded_skill_archive`) report the original
size-limit or read error when temporary cleanup or its warning log fails.
Successful copies still return the uploaded bytes unchanged. The upload route's
`finally` discards that archive through the same helper before closing the
multipart form. Form close is suppressed, so a close, cleanup, or warning
failure cannot turn a successful install into a 500, replace an install
error, or skip the discard. Coverage: `tests/test_skill_archive_upload_cleanup.py`.
State updates synchronize titles into thread metadata only for the root checkpoint
namespace. Nonroot writes preserve the conversation title, branch-title sequence
and activity timestamp. `tests/test_threads_router.py` checks metadata/search
isolation and root title synchronization in full and delta modes.
Unauthenticated auth routes (`/register`, `/setup-status`, `/providers`,
`/oauth/{provider}`, `/callback/{provider}`, the `/login/local` throttle) read
`get_app_config()` through `asyncio.to_thread`: each read stats and hashes the
config files. Coverage: `tests/blocking_io/test_auth_router_config_reads.py`.

View File

@ -96,7 +96,7 @@ class AgentUpdateRequest(BaseModel):
"""Request body for updating a custom agent.""" """Request body for updating a custom agent."""
display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it") display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it")
description: str | None = Field(default=None, description="Updated description") description: str | None = Field(default=None, description="Updated description; null resets it to empty")
model: str | None = Field(default=None, description="Updated model override") model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)") mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
@ -601,7 +601,9 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) ->
if config_changed: if config_changed:
updated = { updated = {
"name": agent_cfg.name, "name": agent_cfg.name,
"description": body.description if "description" in fields_set else agent_cfg.description, # Explicit null resets to the AgentConfig default; storing None
# would make every later load of this agent fail validation.
"description": (body.description or "") if "description" in fields_set else agent_cfg.description,
} }
if "display_name" in fields_set: if "display_name" in fields_set:
updated["display_name"] = body.display_name or None updated["display_name"] = body.display_name or None

View File

@ -4,17 +4,21 @@ import hashlib
import logging import logging
import mimetypes import mimetypes
import os import os
import secrets
import stat import stat
import tempfile import tempfile
import threading
import zipfile import zipfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import BinaryIO
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from starlette.datastructures import MutableHeaders
from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_run_manager from app.gateway.deps import get_run_manager
@ -41,6 +45,8 @@ _SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024 MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024
MAX_VIEWED_IMAGE_BLOB_BYTES = 20 * 1024 * 1024 MAX_VIEWED_IMAGE_BLOB_BYTES = 20 * 1024 * 1024
_ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-" _ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-"
_ARTIFACT_COPY_BEFORE_STREAM = os.name == "nt"
_artifact_digest_source = threading.local()
class ArtifactUpdateRequest(BaseModel): class ArtifactUpdateRequest(BaseModel):
@ -334,7 +340,7 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
return ("inline_file", mime_type) return ("inline_file", mime_type)
def _sha256_of_file(path: Path) -> str: def _sha256_of_file(path: Path, *, pinned: tuple[BinaryIO, os.stat_result] | None = None) -> str:
"""Return the hex SHA-256 digest of *path* without loading it whole. """Return the hex SHA-256 digest of *path* without loading it whole.
Computing the digest on the Gateway lets the browser skip its own Computing the digest on the Gateway lets the browser skip its own
@ -346,45 +352,217 @@ def _sha256_of_file(path: Path) -> str:
requests a browser issues while scrubbing/paginating a preview do not each requests a browser issues while scrubbing/paginating a preview do not each
re-hash a potentially huge artifact from scratch (raised in PR review). re-hash a potentially huge artifact from scratch (raised in PR review).
""" """
stat = path.stat() file_stat = path.stat() if pinned is None else pinned[1]
# Sandbox syncs and other writers can atomically replace a same-size file # Sandbox syncs and other writers can atomically replace a same-size file
# while preserving its mtime. Identity separates those generations; ctime # while preserving its mtime. Identity separates those generations; ctime
# also invalidates in-place writes where the filesystem exposes change time. # also invalidates in-place writes where the filesystem exposes change time.
return _sha256_of_file_cached( key = (
str(path), str(path),
stat.st_dev, file_stat.st_dev,
stat.st_ino, file_stat.st_ino,
stat.st_ctime_ns, file_stat.st_ctime_ns,
stat.st_mtime_ns, file_stat.st_mtime_ns,
stat.st_size, file_stat.st_size,
) )
if pinned is None:
return _sha256_of_file_cached(*key)
previous = getattr(_artifact_digest_source, "pinned", None)
_artifact_digest_source.pinned = (key, pinned[0])
try:
return _sha256_of_file_cached(*key)
finally:
_artifact_digest_source.pinned = previous
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def _sha256_of_file_cached(path: str, device: int, inode: int, ctime_ns: int, mtime_ns: int, size: int) -> str: def _sha256_of_file_cached(path: str, device: int, inode: int, ctime_ns: int, mtime_ns: int, size: int) -> str:
"""Cached SHA-256 of *path*; metadata arguments separate file generations.""" """Cached SHA-256 of *path*; metadata arguments separate file generations."""
digest = hashlib.sha256() pinned = getattr(_artifact_digest_source, "pinned", None)
# A nested lookup for a different file must hash its own bytes.
if pinned is None or pinned[0] != (path, device, inode, ctime_ns, mtime_ns, size):
with open(path, "rb") as handle: with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""): return hashlib.file_digest(handle, "sha256").hexdigest()
digest.update(chunk)
return digest.hexdigest() handle = pinned[1]
previous_offset = handle.tell()
try:
handle.seek(0)
return hashlib.file_digest(handle, "sha256").hexdigest()
finally:
handle.seek(previous_offset)
def _artifact_etag(path: Path) -> str: def _artifact_etag(path: Path) -> str:
"""Keep content revisions for editable files and metadata-only tags for large files.""" """Keep content revisions for editable files and metadata tags for large files."""
file_stat = path.stat() file_stat = path.stat()
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES: if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
return f'"{_sha256_of_file(path)}"' # Populate the shared cache only with metadata from the hashed handle.
with path.open("rb") as handle:
return _artifact_etag_from_handle(path, handle, os.fstat(handle.fileno()))
return _artifact_stat_etag(file_stat)
def _artifact_stat_etag(file_stat: os.stat_result) -> str:
# Do not read oversized artifacts just to validate a small byte range. # Do not read oversized artifacts just to validate a small byte range.
# The prefix keeps this opaque validator distinct from an editable SHA-256. # The prefix keeps this opaque validator distinct from an editable SHA-256.
identity = f"{file_stat.st_dev}:{file_stat.st_ino}:{file_stat.st_ctime_ns}:{file_stat.st_mtime_ns}:{file_stat.st_size}" identity = f"{file_stat.st_dev}:{file_stat.st_ino}:{file_stat.st_ctime_ns}:{file_stat.st_mtime_ns}:{file_stat.st_size}"
return f'"stat-{hashlib.sha256(identity.encode("ascii")).hexdigest()}"' return f'"stat-{hashlib.sha256(identity.encode("ascii")).hexdigest()}"'
def _artifact_etag_from_handle(path: Path, handle: BinaryIO, file_stat: os.stat_result) -> str:
"""Build the validator from the same file generation the response will read."""
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
digest = _sha256_of_file(path, pinned=(handle, file_stat))
return f'"{digest}"'
return _artifact_stat_etag(file_stat)
def _prepare_artifact_stream(path: Path) -> tuple[BinaryIO, os.stat_result, str]:
"""Pin one file generation before response headers authorize a byte range."""
handle = path.open("rb")
try:
file_stat = os.fstat(handle.fileno())
if not stat.S_ISREG(file_stat.st_mode):
raise RuntimeError(f"File at path {path} is not a file.")
return handle, file_stat, _artifact_etag_from_handle(path, handle, file_stat)
except BaseException:
_close_artifact_handle(handle)
raise
def _copy_artifact_ranges(handle: BinaryIO, ranges: list[tuple[int, int]]) -> BinaryIO:
"""Pack the selected ranges into a private temporary file in send order."""
snapshot = tempfile.TemporaryFile(mode="w+b")
try:
for start, end in ranges:
handle.seek(start)
remaining = end - start
while remaining:
chunk = handle.read(min(1 << 20, remaining))
if not chunk:
raise OSError("Artifact was truncated while preparing the response")
snapshot.write(chunk)
remaining -= len(chunk)
snapshot.seek(0)
return snapshot
except BaseException:
_close_artifact_handle(snapshot)
raise
def _close_artifact_handle(handle: BinaryIO) -> None:
try:
handle.close()
except OSError:
logger.warning("Could not close artifact response handle", exc_info=True)
class _ArtifactFileResponse(FileResponse): class _ArtifactFileResponse(FileResponse):
"""Require an ETag to authorize conditional ranges of replaceable artifacts.""" """Require an ETag to authorize conditional ranges of replaceable artifacts."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._artifact_handle: BinaryIO | None = None
async def __call__(self, scope, receive, send) -> None:
async def prepare() -> None:
self._artifact_handle, self.stat_result, etag = await asyncio.to_thread(_prepare_artifact_stream, Path(self.path))
self.headers["etag"] = etag
self.set_stat_headers(self.stat_result)
try:
await await_drained(prepare())
await super().__call__(scope, receive, send)
finally:
if self._artifact_handle is not None:
handle, self._artifact_handle = self._artifact_handle, None
await await_drained(asyncio.to_thread(_close_artifact_handle, handle))
async def _snapshot_windows_body(self, ranges: list[tuple[int, int]], send_header_only: bool) -> bool:
if not _ARTIFACT_COPY_BEFORE_STREAM:
return False
source = self._artifact_handle
assert source is not None
async def snapshot() -> None:
# Windows replacement needs the public handle closed. A private
# copy also leaves the artifact's link count unchanged for ZIP.
self._artifact_handle = None if send_header_only else await asyncio.to_thread(_copy_artifact_ranges, source, ranges)
await asyncio.to_thread(_close_artifact_handle, source)
await await_drained(snapshot())
return True
async def _handle_simple(self, send, send_header_only: bool, send_pathsend: bool) -> None:
assert self.stat_result is not None
await self._snapshot_windows_body([(0, self.stat_result.st_size)], send_header_only)
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
await await_drained(asyncio.to_thread(handle.seek, 0))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, self.chunk_size))
more_body = len(chunk) == self.chunk_size
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_single_range(self, send, start: int, end: int, file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body([(start, end)], send_header_only)
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}"
headers["content-length"] = str(end - start)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
start += len(chunk)
more_body = len(chunk) == self.chunk_size and start < end
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_multiple_ranges(self, send, ranges: list[tuple[int, int]], file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body(ranges, send_header_only)
boundary = secrets.token_hex(13)
content_length, header_generator = self.generate_multipart(ranges, boundary, file_size, self.headers["content-type"])
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-type"] = f"multipart/byteranges; boundary={boundary}"
headers["content-length"] = str(content_length)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
for start, end in ranges:
await send({"type": "http.response.body", "body": header_generator(start, end), "more_body": True})
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
while start < end:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
if not chunk:
raise OSError("Artifact was truncated while streaming a byte range")
start += len(chunk)
await send({"type": "http.response.body", "body": chunk, "more_body": True})
await send({"type": "http.response.body", "body": b"\r\n", "more_body": True})
await send({"type": "http.response.body", "body": f"--{boundary}--".encode("latin-1"), "more_body": False})
def _should_use_range(self, http_if_range: str) -> bool: def _should_use_range(self, http_if_range: str) -> bool:
# Sandbox syncs can preserve mtimes across replacements, so Last-Modified # Sandbox syncs can preserve mtimes across replacements, so Last-Modified
# is not a strong validator. Date-form If-Range must return the full file. # is not a strong validator. Date-form If-Range must return the full file.

View File

@ -13,6 +13,7 @@ which persists no run history to report on.
import asyncio import asyncio
import logging import logging
import math
from datetime import UTC, datetime, time, timedelta from datetime import UTC, datetime, time, timedelta
from typing import NamedTuple from typing import NamedTuple
@ -181,6 +182,14 @@ def _build_pricing_map() -> dict[str, _ModelPricing]:
except (TypeError, ValueError): except (TypeError, ValueError):
logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name) logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name)
continue continue
# ``float("nan")`` / ``float(".inf")`` parse successfully, and NaN then
# survives every ``<=`` comparison below and every ``round()``, ending up
# in the JSON body where Starlette rejects it (allow_nan=False) -- a 500
# for the whole usage report. Treat non-finite prices as malformed, the
# same way the rest of the config layer does (allow_inf_nan=False).
if any(not math.isfinite(price) for price in (input_price, output_price, cache_hit_price) if price is not None):
logger.warning("console: ignoring non-finite pricing on model %s", model_cfg.name)
continue
if input_price <= 0 and output_price <= 0: if input_price <= 0 and output_price <= 0:
continue continue
model_currency = str(raw.get("currency") or "USD").strip().upper() or "USD" model_currency = str(raw.get("currency") or "USD").strip().upper() or "USD"
@ -228,8 +237,36 @@ def _token_cost(input_tokens: int, output_tokens: int, price: _ModelPricing, cac
return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million
def _token_count(value: object) -> int:
"""Coerce one stored token counter to a non-negative ``int``.
``token_usage_by_model`` is a JSON column written from provider-reported
usage, so a bucket value is not guaranteed to be a number: providers have
shipped counters as strings ("n/a", "1.0"), and rows written by older
build/import paths may hold anything JSON can express. The console already
skips buckets that are not dicts and pricing that is not numeric; coercing
counters the same way keeps one junk bucket from failing the whole report
with a 500. Booleans are rejected because ``int(True) == 1`` would invent a
token, and negatives cannot be spend.
A JSON integer is not bounded by the float range: ``10**400`` parses as an
exact ``int``, and ``_token_cost`` then divides it by a million, which
raises ``OverflowError`` on the way back to a float. Such a value is junk
in exactly the same way as ``"n/a"``, so it is rejected here too.
"""
if isinstance(value, bool):
return 0
try:
count = int(value) # type: ignore[arg-type]
if count < 0 or not math.isfinite(float(count)):
return 0
except (TypeError, ValueError, OverflowError):
return 0
return count
def _usage_is_missing(usage: dict) -> bool: def _usage_is_missing(usage: dict) -> bool:
return int(usage.get("missing_usage_calls") or 0) > 0 return _token_count(usage.get("missing_usage_calls")) > 0
def _has_missing_usage(token_usage_by_model: object) -> bool: def _has_missing_usage(token_usage_by_model: object) -> bool:
@ -263,11 +300,11 @@ def _run_cost(
price = _lookup_pricing(pricing, model) price = _lookup_pricing(pricing, model)
if price is None: if price is None:
continue continue
input_tokens = int(usage.get("input_tokens") or 0) input_tokens = _token_count(usage.get("input_tokens"))
output_tokens = int(usage.get("output_tokens") or 0) output_tokens = _token_count(usage.get("output_tokens"))
if input_tokens == 0 and output_tokens == 0: if input_tokens == 0 and output_tokens == 0:
continue continue
cost += _token_cost(input_tokens, output_tokens, price, int(usage.get("cache_read_tokens") or 0)) cost += _token_cost(input_tokens, output_tokens, price, _token_count(usage.get("cache_read_tokens")))
priced = True priced = True
if priced: if priced:
return cost return cost
@ -511,9 +548,9 @@ async def console_usage(
entry.runs += 1 entry.runs += 1
if not isinstance(usage, dict): if not isinstance(usage, dict):
continue continue
entry.tokens += int(usage.get("total_tokens", 0) or 0) entry.tokens += _token_count(usage.get("total_tokens"))
entry.input_tokens += int(usage.get("input_tokens") or 0) entry.input_tokens += _token_count(usage.get("input_tokens"))
entry.cache_read_tokens += int(usage.get("cache_read_tokens") or 0) entry.cache_read_tokens += _token_count(usage.get("cache_read_tokens"))
price = _lookup_pricing(pricing, model) price = _lookup_pricing(pricing, model)
if _usage_is_missing(usage): if _usage_is_missing(usage):
missing_models.add(model.lower()) missing_models.add(model.lower())
@ -522,7 +559,7 @@ async def console_usage(
# equal rates on an unrelated model are not aliases. # equal rates on an unrelated model are not aliases.
missing_model_prices.add(id(price)) missing_model_prices.add(id(price))
if price is not None: if price is not None:
model_cost = _token_cost(int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0), price, int(usage.get("cache_read_tokens") or 0)) model_cost = _token_cost(_token_count(usage.get("input_tokens")), _token_count(usage.get("output_tokens")), price, _token_count(usage.get("cache_read_tokens")))
entry.cost = round((entry.cost or 0.0) + model_cost, 6) entry.cost = round((entry.cost or 0.0) + model_cost, 6)
elif row.model_name and run_tokens > 0: elif row.model_name and run_tokens > 0:
# Legacy rows predating token_usage_by_model: fall back to the run's model. # Legacy rows predating token_usage_by_model: fall back to the run's model.

View File

@ -7,6 +7,8 @@ request, while startup-scoped capabilities report the runtime that actually
started. started.
""" """
from typing import Literal
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -82,6 +84,12 @@ class ThreadActivityFeature(BaseModel):
available: bool = Field(..., description="Whether GET /api/thread-activity, POST /api/threads/{thread_id}/read and the thread search `unread` field work (SQL persistence)") available: bool = Field(..., description="Whether GET /api/thread-activity, POST /api/threads/{thread_id}/read and the thread search `unread` field work (SQL persistence)")
class ToolApprovalFeature(BaseModel):
enabled: bool = False
default_mode: Literal["ask", "auto", "full"] = "ask"
allowed_modes: list[Literal["ask", "auto", "full"]] = Field(default_factory=list)
class FeaturesResponse(BaseModel): class FeaturesResponse(BaseModel):
"""Frontend-facing feature availability flags.""" """Frontend-facing feature availability flags."""
@ -93,6 +101,8 @@ class FeaturesResponse(BaseModel):
knowledge_base: KnowledgeBaseFeature knowledge_base: KnowledgeBaseFeature
scheduled_tasks: ScheduledTasksFeature scheduled_tasks: ScheduledTasksFeature
thread_activity: ThreadActivityFeature thread_activity: ThreadActivityFeature
run_inputs: bool = False
tool_approval: ToolApprovalFeature = Field(default_factory=ToolApprovalFeature)
@router.get( @router.get(
@ -104,8 +114,11 @@ class FeaturesResponse(BaseModel):
async def list_features(request: Request, config: AppConfig = Depends(get_config)) -> FeaturesResponse: async def list_features(request: Request, config: AppConfig = Depends(get_config)) -> FeaturesResponse:
"""Return availability of optional frontend features.""" """Return availability of optional frontend features."""
browser = browser_capability(config) browser = browser_capability(config)
approval = getattr(config, "hitl_approval", None)
subagent_batch_worker_running = bool(getattr(request.app.state, "subagent_batches_available", False)) subagent_batch_worker_running = bool(getattr(request.app.state, "subagent_batches_available", False))
return FeaturesResponse( return FeaturesResponse(
tool_approval=ToolApprovalFeature(enabled=approval.enabled, default_mode=approval.default_mode, allowed_modes=approval.allowed_modes) if approval is not None else ToolApprovalFeature(),
run_inputs=getattr(getattr(request.app.state, "run_manager", None), "input_store", None) is not None,
agents_api=AgentsApiFeature(enabled=config.agents_api.enabled), agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
browser_control=BrowserControlFeature(enabled=browser.available), browser_control=BrowserControlFeature(enabled=browser.available),
# MCP task bindings and the submitter are startup-scoped. Report the # MCP task bindings and the submitter are startup-scoped. Report the

View File

@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store
from deerflow.persistence.feedback import FeedbackAlreadyExistsError
from deerflow.utils.thread_id import ThreadId from deerflow.utils.thread_id import ThreadId
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -133,6 +134,7 @@ async def create_feedback(
raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}") raise HTTPException(status_code=404, detail=f"Run {run_id} not found in thread {thread_id}")
feedback_repo = get_feedback_repo(request) feedback_repo = get_feedback_repo(request)
try:
return await feedback_repo.create( return await feedback_repo.create(
run_id=run_id, run_id=run_id,
thread_id=thread_id, thread_id=thread_id,
@ -141,6 +143,11 @@ async def create_feedback(
message_id=body.message_id, message_id=body.message_id,
comment=body.comment, comment=body.comment,
) )
except FeedbackAlreadyExistsError:
raise HTTPException(
status_code=409,
detail="Feedback already exists for this run; use PUT to update it",
) from None
@router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse]) @router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse])

View File

@ -17,7 +17,7 @@ from deerflow.config.agents_config import load_agent_config
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.knowledge import KnowledgeRequestContext, RetrievalCatalogPage from deerflow.knowledge import KnowledgeRequestContext, RetrievalCatalogPage
from deerflow.knowledge.providers import get_knowledge_capabilities, resolve_knowledge_provider from deerflow.knowledge.providers import get_knowledge_capabilities, resolve_knowledge_provider
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_authenticated_user_id, get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
@ -94,7 +94,8 @@ async def list_retrieval_catalog_datasets(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
) -> dict[str, Any]: ) -> dict[str, Any]:
provider = await _scope_catalog(config, agent_name, request, "catalog") provider = await _scope_catalog(config, agent_name, request, "catalog")
return await _catalog_result(provider.catalog(context=KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name), page=page, page_size=page_size, search=search)) context = KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name, authenticated_user_id=get_authenticated_user_id())
return await _catalog_result(provider.catalog(context=context, page=page, page_size=page_size, search=search))
@router.get("/retrieval-catalog/datasets/{dataset_id:path}/documents") @router.get("/retrieval-catalog/datasets/{dataset_id:path}/documents")
@ -109,4 +110,5 @@ async def list_retrieval_catalog_documents(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
) -> dict[str, Any]: ) -> dict[str, Any]:
provider = await _scope_catalog(config, agent_name, request, "documents") provider = await _scope_catalog(config, agent_name, request, "documents")
return await _catalog_result(provider.documents(dataset_id, context=KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name), page=page, page_size=page_size, search=search)) context = KnowledgeRequestContext(user_id=get_effective_user_id(), agent_name=agent_name, authenticated_user_id=get_authenticated_user_id())
return await _catalog_result(provider.documents(dataset_id, context=context, page=page, page_size=page_size, search=search))

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict, model_validator from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
from app.gateway.deps import require_admin_user from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
@ -180,3 +180,100 @@ async def test_model(request: Request, body: SaveModelRequest):
except Exception as exc: except Exception as exc:
# Provider exception strings may contain keys, URLs or response bodies. # Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"} return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"}
class ModelConnection(BaseModel):
"""A transient connection draft; credentials never enter a browser query cache."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
provider: str = "openai"
base_url: str | None = None
api_key: SecretStr | None = None
saved_model_name: str | None = Field(default=None, max_length=100)
expected_revision: str | None = Field(default=None, max_length=100)
@model_validator(mode="after")
def valid_connection(self):
ManagedModel(provider=self.provider, base_url=self.base_url, api_key=self.api_key, model="catalog-check")
if bool(self.saved_model_name) != bool(self.expected_revision):
raise ValueError("Saved connection requires its revision")
return self
class ImportModelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
connection: ModelConnection
model: str = Field(min_length=1, max_length=200)
@field_validator("model")
@classmethod
def valid_id(cls, value):
if value != value.strip():
raise ValueError("Invalid model ID")
return ManagedModel.valid_model_id(value)
def _connection_profile(body: ModelConnection, model: str = "catalog-check") -> ManagedModel:
previous = None
if body.saved_model_name:
previous = next((item for item in ManagedModelStore().list() if item.name == body.saved_model_name), None)
if previous is None or previous.revision != body.expected_revision:
raise HTTPException(409, "Connection changed; reload before continuing")
profile = ManagedModel(provider=body.provider, base_url=body.base_url, api_key=body.api_key, model=model)
profile = profile.model_copy(update={"api_key": retained_api_key(profile, previous)})
if profile.provider != "openai-compatible" and not (profile.api_key and profile.api_key.get_secret_value()):
raise HTTPException(422, {"code": "missing_api_key", "message": "Enter the connection API key"})
return profile
def _same_connection(left: ManagedModel, right: ManagedModel) -> bool:
return (left.provider, left.base_url, left.api_key) == (right.provider, right.base_url, right.api_key)
def _existing_import(profile: ManagedModel) -> ManagedModel | None:
return next((item for item in ManagedModelStore().list() if item.model == profile.model and _same_connection(item, profile)), None)
@router.post("/discover")
async def discover_models(request: Request, body: ModelConnection):
from app.gateway.model_discovery import DiscoveryError, fetch_model_catalog
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body)
ids, truncated = await fetch_model_catalog(profile)
records = await asyncio.to_thread(lambda: ManagedModelStore().list())
configured = {item.model for item in records if _same_connection(item, profile)}
return {"models": [{"id": mid, "configured": mid in configured} for mid in ids], "truncated": truncated}
except DiscoveryError as exc:
raise HTTPException(502, {"code": exc.code, "message": "Could not retrieve the model catalog"}) from None
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None
def _save_import(profile: ManagedModel, connection: ModelConnection):
config = get_app_config()
reserved = {item.name for item in config.models if item.name not in config._managed_model_names}
source = (connection.saved_model_name, connection.expected_revision) if connection.saved_model_name and connection.expected_revision else None
try:
return ManagedModelStore().save(profile, expected_revision=None, reserved_names=reserved, deduplicate=True, expected_source=source).public()
except FileExistsError:
raise HTTPException(409, "Connection changed; reload before importing") from None
@router.post("/import")
async def import_model(request: Request, body: ImportModelRequest):
"""One bounded, retry-safe item. The UI sequences items and reports partial success."""
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body.connection, body.model)
existing = await asyncio.to_thread(_existing_import, profile)
if existing:
return {"status": "existing", "model": existing.public()}
probe = await test_model(request, SaveModelRequest(config=profile))
if not probe["ok"]:
return {"status": "failed", "message": probe["message"]}
saved = await run_drained_write("Import managed model", _save_import, (HTTPException,), profile, body.connection)
return {"status": "saved", "model": saved}
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None

View File

@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
from app.gateway.deps import require_admin_user from app.gateway.deps import require_admin_user
from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
from app.gateway.secret_masking import MASKED_VALUE, mask_secret_values, normalize_config_key
from deerflow.config.extensions_config import ( from deerflow.config.extensions_config import (
ExtensionsConfig, ExtensionsConfig,
McpRoutingConfig, McpRoutingConfig,
@ -552,29 +553,19 @@ class McpCacheResetResponse(BaseModel):
message: str = Field(description="Human-readable reset status") message: str = Field(description="Human-readable reset status")
_MASKED_VALUE = "***" _MASKED_VALUE = MASKED_VALUE
_SENSITIVE_EXTRA_KEY_RE = re.compile( _SENSITIVE_EXTRA_KEY_RE = re.compile(
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)", r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
re.IGNORECASE, re.IGNORECASE,
) )
def _normalize_config_key(key: str) -> str:
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
def _is_sensitive_extra_key(key: str) -> bool: def _is_sensitive_extra_key(key: str) -> bool:
return bool(_SENSITIVE_EXTRA_KEY_RE.search(_normalize_config_key(key))) return bool(_SENSITIVE_EXTRA_KEY_RE.search(normalize_config_key(key)))
def _mask_sensitive_extra_value(value: Any) -> Any: def _mask_sensitive_extra_value(value: Any) -> Any:
if isinstance(value, dict): return mask_secret_values(value, _is_sensitive_extra_key)
return {key: _MASKED_VALUE if _is_sensitive_extra_key(str(key)) else _mask_sensitive_extra_value(nested) for key, nested in value.items()}
if isinstance(value, list):
return [_mask_sensitive_extra_value(item) for item in value]
return value
def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool: def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool:

View File

@ -1,6 +1,7 @@
"""Memory API router for retrieving and managing global memory data.""" """Memory API router for retrieving and managing global memory data."""
import asyncio import asyncio
import re
from typing import Any, Literal from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@ -9,11 +10,13 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.persistent_writes import run_drained_write from app.gateway.persistent_writes import run_drained_write
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager from app.gateway.secret_masking import mask_secret_values, normalize_config_key
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryDuplicateFactIdError, MemoryManager, get_memory_manager
from deerflow.config.agents_config import AGENT_NAME_PATTERN from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.memory_config import get_memory_config from deerflow.config.memory_config import get_memory_config
from deerflow.config.paths import make_safe_user_id from deerflow.config.paths import make_safe_user_id
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.host_paths import windows_incompatible_segment
router = APIRouter(prefix="/api", tags=["memory"]) router = APIRouter(prefix="/api", tags=["memory"])
@ -117,6 +120,12 @@ class MemoryResponse(BaseModel):
facts: list[Fact] = Field(default_factory=list) facts: list[Fact] = Field(default_factory=list)
class MemoryImportRequest(MemoryResponse):
"""Replacement imports must explicitly supply the facts to restore."""
facts: list[Fact] = Field(description="Facts to restore. An empty list explicitly clears the selected fact bucket.")
def _map_memory_fact_value_error(exc: ValueError) -> HTTPException: def _map_memory_fact_value_error(exc: ValueError) -> HTTPException:
"""Convert updater validation errors into stable API responses.""" """Convert updater validation errors into stable API responses."""
if exc.args and exc.args[0] == "confidence": if exc.args and exc.args[0] == "confidence":
@ -159,6 +168,8 @@ def _unsupported_501(manager: object, label: str) -> HTTPException:
def _management_agent_name_or_501( def _management_agent_name_or_501(
manager: MemoryManager, manager: MemoryManager,
agent_name: str | None, agent_name: str | None,
*,
for_creation: bool = False,
) -> str | None: ) -> str | None:
"""Validate an optional agent-scoped management target. """Validate an optional agent-scoped management target.
@ -168,6 +179,16 @@ def _management_agent_name_or_501(
into a mutation of the wrong bucket. Preserve the caller's spelling here: into a mutation of the wrong bucket. Preserve the caller's spelling here:
remote backends such as Mem0 use the agent name as a case-sensitive storage remote backends such as Mem0 use the agent name as a case-sensitive storage
identity, while DeerMem performs its own lowercase canonicalization. identity, while DeerMem performs its own lowercase canonicalization.
``for_creation`` also refuses names that cannot become a host path
component. DeerMem resolves an agent scope to
``{storage}/users/{uid}/agents/{name}/facts`` and creates it on the first
write; the character class above admits Windows reserved device names
(``NUL``, ``CON``, ``COM1``, ...), so that write raised
``FileNotFoundError`` and the endpoint's ``OSError`` branch answered
``500``. Only the two endpoints that create the scope set this: reads and
deletes stay permissive so a scope an older release stored can still be
exported and cleared.
""" """
if agent_name is None: if agent_name is None:
return None return None
@ -176,6 +197,20 @@ def _management_agent_name_or_501(
status_code=422, status_code=422,
detail=(f"Invalid agent name '{agent_name}'. Must match {AGENT_NAME_PATTERN.pattern} (letters, digits, and hyphens only)."), detail=(f"Invalid agent name '{agent_name}'. Must match {AGENT_NAME_PATTERN.pattern} (letters, digits, and hyphens only)."),
) )
if for_creation:
# Reuse the canonical Windows-name check that agent directories,
# thread IDs, uploads, sandbox writes and skill support files use.
# Sibling PR #7939 applies the same rule inside the shared
# ``validate_agent_name()`` (``deerflow.config.agents_config``); this
# router inlines ``AGENT_NAME_PATTERN`` and preserves caller spelling for
# case-sensitive backends rather than delegating to it, so keep the two
# copies in sync if the reserved set ever changes.
reason = windows_incompatible_segment(agent_name)
if reason is not None:
raise HTTPException(
status_code=422,
detail=f"Invalid agent name '{agent_name}'. Contains a {reason}.",
)
if getattr(manager, "supports_agent_scoped_management", False) is not True: if getattr(manager, "supports_agent_scoped_management", False) is not True:
raise HTTPException( raise HTTPException(
status_code=501, status_code=501,
@ -234,6 +269,18 @@ class FactPatchRequest(BaseModel):
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)") confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
# Anchored at the end of the key: DeerMem knobs such as ``token_counting`` and
# ``guaranteed_token_budget`` are not secrets, and ``api_key_env`` names an
# environment variable rather than holding the key. This is a denylist: plural
# ``tokens`` stays out because ``max_injection_tokens`` is a knob, and bare
# ``key``, ``auth`` or ``session`` are too generic to mask.
_SECRET_CONFIG_KEY_RE = re.compile(r"(^|_)(api_?keys?|access_keys?|secret_keys?|private_keys?|auth_keys?|secrets?|token|passwords?|passwds?|credentials?|authorization|bearer|cookies?)$")
def _is_secret_config_key(key: str) -> bool:
return bool(_SECRET_CONFIG_KEY_RE.search(normalize_config_key(key)))
class MemoryConfigResponse(BaseModel): class MemoryConfigResponse(BaseModel):
"""Response model for memory configuration.""" """Response model for memory configuration."""
@ -242,7 +289,13 @@ class MemoryConfigResponse(BaseModel):
injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).") injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).")
shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.") shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.")
manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).") manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).")
backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend).") backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend); credential values such as api_key are masked as ***.")
@field_validator("backend_config")
@classmethod
def _mask_backend_secrets(cls, value: dict) -> dict:
"""Config loading already resolved ``$VAR`` references, so keys would be plaintext."""
return mask_secret_values(value, _is_secret_config_key, keep_unset=True)
class MemoryStatusResponse(BaseModel): class MemoryStatusResponse(BaseModel):
@ -392,7 +445,7 @@ async def clear_memory(request: Request, agent_name: str | None = None) -> Memor
async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request, agent_name: str | None = None) -> MemoryResponse: async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request, agent_name: str | None = None) -> MemoryResponse:
"""Create a single fact manually.""" """Create a single fact manually."""
manager = await asyncio.to_thread(get_memory_manager) manager = await asyncio.to_thread(get_memory_manager)
selected_agent = _management_agent_name_or_501(manager, agent_name) selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True)
scope_kwargs = _agent_scope_kwargs(selected_agent) scope_kwargs = _agent_scope_kwargs(selected_agent)
try: try:
memory_data, fact_id = await run_drained_write( memory_data, fact_id = await run_drained_write(
@ -519,13 +572,16 @@ async def export_memory(request: Request, agent_name: str | None = None) -> Memo
response_model=MemoryResponse, response_model=MemoryResponse,
response_model_exclude_none=True, response_model_exclude_none=True,
summary="Import Memory Data", summary="Import Memory Data",
description="Import the default memory document, or replace only one selected agent's facts while preserving shared summaries.", description=(
"Import the default memory document, or replace only one selected agent's facts while preserving shared summaries. "
"The request requires an explicit `facts` array. Omitting it returns HTTP 422, and an empty array clears the selected fact bucket."
),
) )
@require_permission("memory", "write") @require_permission("memory", "write")
async def import_memory(body: MemoryResponse, request: Request, agent_name: str | None = None) -> MemoryResponse: async def import_memory(body: MemoryImportRequest, request: Request, agent_name: str | None = None) -> MemoryResponse:
"""Import memory data, preserving shared summaries for a selected agent.""" """Import memory data, preserving shared summaries for a selected agent."""
manager = await asyncio.to_thread(get_memory_manager) manager = await asyncio.to_thread(get_memory_manager)
selected_agent = _management_agent_name_or_501(manager, agent_name) selected_agent = _management_agent_name_or_501(manager, agent_name, for_creation=True)
scope_kwargs = _agent_scope_kwargs(selected_agent) scope_kwargs = _agent_scope_kwargs(selected_agent)
try: try:
memory_data = await run_drained_write( memory_data = await run_drained_write(
@ -540,6 +596,8 @@ async def import_memory(body: MemoryResponse, request: Request, agent_name: str
raise _unsupported_501(manager, "import memory") from None raise _unsupported_501(manager, "import memory") from None
except (MemoryConflictError, MemoryCorruptionError) as exc: except (MemoryConflictError, MemoryCorruptionError) as exc:
raise _map_memory_manager_error(exc) from exc raise _map_memory_manager_error(exc) from exc
except MemoryDuplicateFactIdError as exc:
raise HTTPException(status_code=400, detail="Invalid memory import: fact IDs must be unique.") from exc
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid memory import: facts must be a list of objects with non-empty content.") from exc raise HTTPException(status_code=400, detail="Invalid memory import: facts must be a list of objects with non-empty content.") from exc
except OSError as exc: except OSError as exc:

View File

@ -66,6 +66,7 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description") description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)") supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)") supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)")
provider: str | None = Field(default=None, description="Managed provider display label; no endpoint or credentials")
reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract") reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract")
@ -75,6 +76,7 @@ def _model_response(model: ModelConfig) -> ModelResponse:
model=model.model, model=model.model,
display_name=model.display_name, display_name=model.display_name,
description=model.description, description=model.description,
provider=model._managed_provider,
supports_thinking=model.supports_thinking, supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort, supports_reasoning_effort=model.supports_reasoning_effort,
reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))), reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))),

View File

@ -70,6 +70,7 @@ async def list_plugins(request: Request, response: Response):
principal = _principal(request) principal = _principal(request)
response.headers["Cache-Control"] = "private, no-store" response.headers["Cache-Control"] = "private, no-store"
entries = [] entries = []
documentation = dict(request.app.state.extensions.documentation)
for source, plugin in request.app.state.extensions.plugins: for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin) settings = plugin_settings(source, plugin)
module = plugin.frontend module = plugin.frontend
@ -93,8 +94,36 @@ async def list_plugins(request: Request, response: Response):
"transport": transport, "transport": transport,
"settings": {key: settings[key] for key in public}, "settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend], "backend_actions": [action.name for action in plugin.backend],
"model_tools": [tool.name for tool in plugin.tools],
"documentation": documentation.get(source) if documentation.get(source, {}).get("id") == plugin.namespace else None,
} }
) )
registered_sources = {source for source, _ in request.app.state.extensions.plugins}
known_ids = {entry["namespace"] for entry in entries}
for source, page in request.app.state.extensions.documentation:
if source in registered_sources or page["id"] in known_ids:
continue
locales = page["locales"]
copy = next((value for tag, value in locales.items() if tag.lower() == "en-us"), None)
if copy is None:
copy = next((value for tag, value in locales.items() if tag.partition("-")[0] == "en"), next(iter(locales.values())))
entries.append(
{
"namespace": page["id"],
"title": copy["title"],
"description": copy["description"],
"viewer_id": principal.user_id,
"module": None,
"entry": None,
"transport": None,
"settings": {"enabled": True},
"backend_actions": [],
"model_tools": [],
"documentation": page,
"runtime_only": True,
}
)
known_ids.add(page["id"])
return entries return entries

View File

@ -1,5 +1,6 @@
"""CRUD API for projects (Phase 1: organization only — no documents/trash).""" """CRUD API for projects (Phase 1: organization only — no documents/trash)."""
import asyncio
import logging import logging
from typing import Any, Literal from typing import Any, Literal
@ -8,9 +9,12 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.deps import get_config, get_project_repo, get_thread_store from app.gateway.deps import get_config, get_project_repo, get_thread_store
from app.gateway.persistent_writes import run_drained_write
from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.projects_config import ProjectsConfig from deerflow.config.projects_config import ProjectsConfig
from deerflow.projects.memory import MAX_FACTS, get_project_memory_repository
from deerflow.runtime.secret_context import redact_metadata_secrets from deerflow.runtime.secret_context import redact_metadata_secrets
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,6 +49,72 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] projects: list[ProjectResponse]
class ProjectMemoryFactResponse(BaseModel):
id: str
content: str
source_thread_id: str
created_at: str
class ProjectMemoryResponse(BaseModel):
enabled: bool
facts: list[ProjectMemoryFactResponse]
max_facts: int = MAX_FACTS
class ProjectMemoryPatch(BaseModel):
enabled: bool
class ProjectMemoryPromotion(BaseModel):
expected_instructions: str
class ProjectMemoryPromotionResponse(BaseModel):
instructions: str
memory: ProjectMemoryResponse
async def _memory_call(method: str, project_id: str, *args, **kwargs):
user_id = get_effective_user_id()
def call():
return getattr(get_project_memory_repository(), method)(project_id, user_id, *args, **kwargs)
result = await asyncio.to_thread(call) if method == "get" else await run_drained_write("Project memory update", call, (ValueError,))
if result is None:
raise _not_found()
return result
@router.get("/{project_id}/memory", response_model=ProjectMemoryResponse)
@require_permission("projects", "read")
async def get_project_memory(project_id: str, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("get", project_id))
@router.patch("/{project_id}/memory", response_model=ProjectMemoryResponse)
@require_permission("projects", "write")
async def patch_project_memory(project_id: str, body: ProjectMemoryPatch, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("set_enabled", project_id, body.enabled))
@router.delete("/{project_id}/memory/{fact_id}", response_model=ProjectMemoryResponse)
@require_permission("projects", "write")
async def delete_project_memory(project_id: str, fact_id: str, request: Request) -> ProjectMemoryResponse:
return ProjectMemoryResponse(**await _memory_call("delete", project_id, fact_id))
@router.post("/{project_id}/memory/{fact_id}/promote", response_model=ProjectMemoryPromotionResponse)
@require_permission("projects", "write")
async def promote_project_memory(project_id: str, fact_id: str, body: ProjectMemoryPromotion, request: Request) -> ProjectMemoryPromotionResponse:
try:
result = await _memory_call("promote", project_id, fact_id, max_bytes=_projects_config().instructions_max_bytes, expected_instructions=body.expected_instructions)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ProjectMemoryPromotionResponse(instructions=result["instructions"], memory=ProjectMemoryResponse(**result["memory"]))
class ProjectsConfigResponse(BaseModel): class ProjectsConfigResponse(BaseModel):
"""The projects-block knobs the UI needs before it can validate client-side.""" """The projects-block knobs the UI needs before it can validate client-side."""

View File

@ -338,6 +338,20 @@ async def _publish_skills_cache_reset(user_id: str | None) -> str | None:
return await asyncio.to_thread(publish_skills_cache_reset, user_id=user_id) return await asyncio.to_thread(publish_skills_cache_reset, user_id=user_id)
def _assert_extensions_config_readable() -> None:
"""Raise when the extensions config exists but cannot be read.
The storage layer fails closed on an unreadable config (every skill stays
listed, none is enabled), which keeps the runtime activation gate safe
without taking the listing down. The read surface must stay loud instead:
the ``enabled`` column it reports is derived from this config, so serving
a degraded list would present a broken deployment as a legitimately empty
one. The raised error reaches each handler's generic 500 branch, which
keeps the host path in the server log and out of the response body.
"""
ExtensionsConfig.from_file()
def _load_user_skills(config: AppConfig) -> list[Skill]: def _load_user_skills(config: AppConfig) -> list[Skill]:
"""Load every skill visible to the caller's storage, enabled or not. """Load every skill visible to the caller's storage, enabled or not.
@ -347,6 +361,7 @@ def _load_user_skills(config: AppConfig) -> list[Skill]:
each ``SKILL.md`` -- work that scales with the number of installed skills each ``SKILL.md`` -- work that scales with the number of installed skills
(#5747 moved the same call off the loop for ``get_custom_skill``). (#5747 moved the same call off the loop for ``get_custom_skill``).
""" """
_assert_extensions_config_readable()
return _get_user_skill_storage(config).load_skills(enabled_only=False) return _get_user_skill_storage(config).load_skills(enabled_only=False)

View File

@ -8,7 +8,7 @@ from collections.abc import AsyncIterator
from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi import APIRouter, HTTPException, Path, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.gateway.authz import require_permission from app.gateway.authz import require_cancel_permission_if, require_permission
from app.gateway.deps import ( from app.gateway.deps import (
get_current_user, get_current_user,
get_subagent_batch_repo, get_subagent_batch_repo,
@ -110,6 +110,7 @@ async def cancel_batch(thread_id: ThreadId, batch_id: str, request: Request) ->
if not getattr(request.app.state, "subagent_batches_available", False): if not getattr(request.app.state, "subagent_batches_available", False):
raise HTTPException(status_code=503, detail="Subagent batch worker is not running") raise HTTPException(status_code=503, detail="Subagent batch worker is not running")
_repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id) _repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id)
require_cancel_permission_if(request, _batch.get("continuation_status") is not None)
result = await get_subagent_batch_service(request).cancel_batch(batch_id=batch_id, user_id=user_id) result = await get_subagent_batch_service(request).cancel_batch(batch_id=batch_id, user_id=user_id)
if result is None: if result is None:
raise HTTPException(status_code=404, detail="Subagent batch not found") raise HTTPException(status_code=404, detail="Subagent batch not found")
@ -122,7 +123,7 @@ async def retry_batch_item(thread_id: ThreadId, batch_id: str, item_id: str, req
repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id) repo, user_id, _batch = await _owned_batch(request, thread_id, batch_id)
item = await repo.retry_item(batch_id, item_id, user_id=user_id) item = await repo.retry_item(batch_id, item_id, user_id=user_id)
if item is None: if item is None:
raise HTTPException(status_code=409, detail="Only failed items can be retried") raise HTTPException(status_code=409, detail="Only failed items of a batch that is not cancelled can be retried")
return item return item

View File

@ -35,6 +35,8 @@ class SubagentResponse(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str = "inherit" model: str = "inherit"
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int = 50 max_turns: int = 50
timeout_seconds: int = 900 timeout_seconds: int = 900
enabled: bool = True enabled: bool = True
@ -57,6 +59,8 @@ class ManagedSubagentCreateRequest(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str = "inherit" model: str = "inherit"
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int = Field(default=50, ge=1) max_turns: int = Field(default=50, ge=1)
timeout_seconds: int = Field(default=900, ge=1) timeout_seconds: int = Field(default=900, ge=1)
enabled: bool = True enabled: bool = True
@ -70,6 +74,8 @@ class ManagedSubagentUpdateRequest(BaseModel):
disallowed_tools: list[str] | None = None disallowed_tools: list[str] | None = None
skills: list[str] | None = None skills: list[str] | None = None
model: str | None = None model: str | None = None
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
max_turns: int | None = Field(default=None, ge=1) max_turns: int | None = Field(default=None, ge=1)
timeout_seconds: int | None = Field(default=None, ge=1) timeout_seconds: int | None = Field(default=None, ge=1)
enabled: bool | None = None enabled: bool | None = None
@ -82,9 +88,9 @@ def _validate_model(model: str, app_config) -> None:
raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.") raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.")
def _validate_path_name(name: str) -> str: def _validate_path_name(name: str, *, for_creation: bool = False) -> str:
try: try:
return normalize_managed_subagent_name(name) return normalize_managed_subagent_name(name, for_creation=for_creation)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
@ -94,7 +100,7 @@ def _explicit_overrides(name: str, app_config) -> dict[str, Any]:
if override is None: if override is None:
return {} return {}
result: dict[str, Any] = {} result: dict[str, Any] = {}
for field in ("timeout_seconds", "max_turns", "model", "skills"): for field in ("timeout_seconds", "max_turns", "model", "thinking_enabled", "reasoning_effort", "skills"):
if field not in override.model_fields_set: if field not in override.model_fields_set:
continue continue
value = getattr(override, field) value = getattr(override, field)
@ -119,6 +125,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
disallowed_tools=definition.disallowed_tools, disallowed_tools=definition.disallowed_tools,
skills=definition.skills, skills=definition.skills,
model=definition.model, model=definition.model,
thinking_enabled=definition.thinking_enabled,
reasoning_effort=definition.reasoning_effort,
max_turns=definition.max_turns, max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds, timeout_seconds=definition.timeout_seconds,
source="builtin", source="builtin",
@ -136,6 +144,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
disallowed_tools=definition.disallowed_tools, disallowed_tools=definition.disallowed_tools,
skills=definition.skills, skills=definition.skills,
model=definition.model, model=definition.model,
thinking_enabled=definition.thinking_enabled,
reasoning_effort=definition.reasoning_effort,
max_turns=definition.max_turns, max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds, timeout_seconds=definition.timeout_seconds,
source="config", source="config",
@ -171,6 +181,7 @@ async def list_subagents(request: Request) -> SubagentsListResponse:
@router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent") @router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent")
async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse: async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
_validate_path_name(body.name, for_creation=True)
try: try:
definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True)) definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True))
except ValidationError as exc: except ValidationError as exc:

View File

@ -38,11 +38,11 @@ class SuggestionsConfigResponse(BaseModel):
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence _strip_markdown_code_fence = llm_text.strip_markdown_code_fence
_strip_think_blocks = llm_text.strip_think_blocks _strip_think_blocks_outside_json_strings = llm_text.strip_think_blocks_outside_json_strings
def _parse_json_string_list(text: str) -> list[str] | None: def _parse_json_string_list(text: str) -> list[str] | None:
candidate = _strip_think_blocks(text) candidate = _strip_think_blocks_outside_json_strings(text)
candidate = _strip_markdown_code_fence(candidate) candidate = _strip_markdown_code_fence(candidate)
start = candidate.find("[") start = candidate.find("[")
end = candidate.rfind("]") end = candidate.rfind("]")

View File

@ -16,6 +16,7 @@ import hashlib
import logging import logging
import re import re
import uuid import uuid
from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
@ -54,6 +55,7 @@ from app.gateway.conversation_reader import (
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge from app.gateway.deps import get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id
from app.gateway.pagination import trim_run_message_page from app.gateway.pagination import trim_run_message_page
from app.gateway.run_inputs import RunInputRequest, change_run_input, list_thread_inputs, submit_run_input
from app.gateway.run_models import RunCreateRequest from app.gateway.run_models import RunCreateRequest
from app.gateway.services import ( from app.gateway.services import (
abuild_checkpoint_state_accessor, abuild_checkpoint_state_accessor,
@ -64,11 +66,13 @@ from app.gateway.services import (
wait_for_run_completion, wait_for_run_completion,
) )
from app.gateway.sse_headers import sse_response_headers from app.gateway.sse_headers import sse_response_headers
from app.gateway.tool_approvals import ApprovalResponseBody, answer_approval, list_approvals, revoke_approval_grants
from app.gateway.utils import sanitize_log_param from app.gateway.utils import sanitize_log_param
from deerflow.agents.human_input import read_human_input_response from deerflow.agents.human_input import read_human_input_response
from deerflow.agents.middlewares.dynamic_context_middleware import strip_injected_user_message_id_suffix from deerflow.agents.middlewares.dynamic_context_middleware import strip_injected_user_message_id_suffix
from deerflow.authz.sandbox_authz import safe_app_config_async from deerflow.authz.sandbox_authz import safe_app_config_async
from deerflow.config.paths import get_paths, make_safe_user_id from deerflow.config.paths import get_paths, make_safe_user_id
from deerflow.constants import DELIVERY_PRESENTATION_TOOL_NAMES
from deerflow.runtime import CancelOutcome, ConflictError, RunRecord, RunStatus, ThreadOperationKind, serialize_channel_values_for_api from deerflow.runtime import CancelOutcome, ConflictError, RunRecord, RunStatus, ThreadOperationKind, serialize_channel_values_for_api
from deerflow.runtime.goal import is_active_goal from deerflow.runtime.goal import is_active_goal
from deerflow.runtime.runs.store.base import format_run_cursor_created_at, normalize_run_created_at_iso from deerflow.runtime.runs.store.base import format_run_cursor_created_at, normalize_run_created_at_iso
@ -81,6 +85,50 @@ from deerflow.workspace_changes import get_workspace_changes_response
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["runs"]) router = APIRouter(prefix="/api/threads", tags=["runs"])
@router.get("/{thread_id}/tool-approvals")
@require_permission("threads", "read", owner_check=True, require_existing=True)
async def get_tool_approvals(thread_id: ThreadId, request: Request):
return await list_approvals(thread_id, request)
@router.post("/{thread_id}/runs/{run_id}/tool-approvals/{request_id}")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def submit_tool_approval(thread_id: ThreadId, run_id: str, request_id: str, body: ApprovalResponseBody, request: Request):
return await answer_approval(thread_id, run_id, request_id, body, request)
@router.delete("/{thread_id}/tool-approvals/grants")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def revoke_tool_approval_grants(thread_id: ThreadId, request: Request):
return await revoke_approval_grants(thread_id, request)
@router.post("/{thread_id}/runs/{run_id}/inputs")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def create_run_input(thread_id: ThreadId, run_id: str, body: RunInputRequest, request: Request):
return await submit_run_input(thread_id, run_id, body, request)
@router.get("/{thread_id}/inputs")
@require_permission("threads", "read", owner_check=True, require_existing=True)
async def get_thread_inputs(thread_id: ThreadId, request: Request):
return await list_thread_inputs(thread_id, request)
@router.delete("/{thread_id}/runs/{run_id}/inputs/{input_id}")
@require_permission("runs", "cancel", owner_check=True, require_existing=True)
async def remove_run_input(thread_id: ThreadId, run_id: str, input_id: str, request: Request):
return await change_run_input(thread_id, run_id, input_id, request)
@router.post("/{thread_id}/runs/{run_id}/inputs/{input_id}/retry")
@require_permission("runs", "create", owner_check=True, require_existing=True)
async def retry_run_input(thread_id: ThreadId, run_id: str, input_id: str, request: Request):
return await change_run_input(thread_id, run_id, input_id, request, retry=True)
REGENERATE_HISTORY_SCAN_LIMIT = 200 REGENERATE_HISTORY_SCAN_LIMIT = 200
# Doubled to keep ~200 effective checkpoints when duration-only checkpoints # Doubled to keep ~200 effective checkpoints when duration-only checkpoints
# (one per successful run in steady state) consume roughly half of history. # (one per successful run in steady state) consume roughly half of history.
@ -181,7 +229,7 @@ def compute_run_durations(runs) -> dict[str, int]:
return durations return durations
def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int]) -> None: def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int], *, thread_id: str | None = None) -> None:
"""Attach each run's elapsed seconds to that run's final visible AI message only. """Attach each run's elapsed seconds to that run's final visible AI message only.
``turn_duration`` is the run's wall-clock lifetime (``compute_run_durations``), ``turn_duration`` is the run's wall-clock lifetime (``compute_run_durations``),
@ -209,10 +257,37 @@ def stamp_turn_duration_on_last_ai(messages, run_durations: dict[str, int]) -> N
continue continue
content = msg.get("content") content = msg.get("content")
payload = content if isinstance(content, dict) else msg payload = content if isinstance(content, dict) else msg
metadata = msg.get("metadata") or {} metadata = msg.get("metadata")
if metadata is None:
metadata = {}
elif not isinstance(metadata, Mapping):
logger.warning(
"Ignoring malformed event metadata for thread %s at row seq %s: expected a mapping, got %s",
sanitize_log_param(thread_id or "<unknown>"),
msg.get("seq", "<unknown>"),
type(metadata).__name__,
)
metadata = {}
is_middleware = str(metadata.get("caller", "")).startswith("middleware:") is_middleware = str(metadata.get("caller", "")).startswith("middleware:")
if payload.get("type") == "ai" and not is_middleware: if payload.get("type") == "ai" and not is_middleware:
payload.setdefault("additional_kwargs", {})["turn_duration"] = run_durations[rid] raw_additional_kwargs = payload.get("additional_kwargs")
if raw_additional_kwargs is None:
additional_kwargs: dict[str, Any] = {}
elif isinstance(raw_additional_kwargs, Mapping):
# A Mapping is not necessarily mutable (for example, a
# MappingProxyType from a serializer), so copy it before
# attaching the duration.
additional_kwargs = dict(raw_additional_kwargs)
else:
logger.warning(
"Ignoring malformed message additional_kwargs for thread %s at row seq %s: expected a mapping, got %s",
sanitize_log_param(thread_id or "<unknown>"),
msg.get("seq", "<unknown>"),
type(raw_additional_kwargs).__name__,
)
additional_kwargs = {}
payload["additional_kwargs"] = additional_kwargs
additional_kwargs["turn_duration"] = run_durations[rid]
stamped.add(rid) stamped.add(rid)
@ -753,9 +828,19 @@ async def _require_successful_source_run(thread_id: str, run_id: str, request: R
raise HTTPException(status_code=409, detail="Could not find source run for assistant message") raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
if _run_status_value(record) != RunStatus.success.value: if _run_status_value(record) != RunStatus.success.value:
raise HTTPException(status_code=409, detail="Only successful assistant runs can be edited and rerun") raise HTTPException(status_code=409, detail="Only successful assistant runs can be edited and rerun")
await _reject_multi_input_replay(run_id, request, user_id=user_id)
return record return record
async def _reject_multi_input_replay(run_id: str, request: Request, *, user_id=None) -> None:
store = getattr(get_run_manager(request), "input_store", None)
if getattr(store, "supports_run_inputs", False) is not True:
return
row = await store.get(run_id, user_id=user_id)
if row is not None and any(entry.get("mode") == "steer" and entry.get("status") != "cancelled" for entry in (row.get("run_inputs") or {}).get("items", [])):
raise HTTPException(409, "This Run was adjusted while working; send a new message instead of replaying part of it")
async def _find_interrupted_target_run_id( async def _find_interrupted_target_run_id(
thread_id: str, thread_id: str,
source_human: Any, source_human: Any,
@ -832,6 +917,7 @@ async def _prepare_regenerate_payload(thread_id: str, message_id: str, request:
raise HTTPException(status_code=409, detail="Could not find the user message for this assistant response") raise HTTPException(status_code=409, detail="Could not find the user message for this assistant response")
if target_run_id is None: if target_run_id is None:
raise HTTPException(status_code=409, detail="Could not find source run for assistant message") raise HTTPException(status_code=409, detail="Could not find source run for assistant message")
await _reject_multi_input_replay(target_run_id, request, user_id=await _run_scope_user_id(request, thread_id))
previous_human_id = _message_id(previous_human) previous_human_id = _message_id(previous_human)
if not previous_human_id: if not previous_human_id:
raise HTTPException(status_code=409, detail="The source user message is missing an id") raise HTTPException(status_code=409, detail="The source user message is missing an id")
@ -1544,7 +1630,7 @@ async def list_thread_messages(
run_durations = compute_run_durations(runs) run_durations = compute_run_durations(runs)
if run_durations: if run_durations:
stamp_turn_duration_on_last_ai(messages, run_durations) stamp_turn_duration_on_last_ai(messages, run_durations, thread_id=thread_id)
return messages return messages
@ -1613,7 +1699,7 @@ async def _enrich_thread_message_page(
# endpoints already use ``stamp_turn_duration_on_last_ai``; the page # endpoints already use ``stamp_turn_duration_on_last_ai``; the page
# endpoint was inlining the equivalent loop but stamping every AI row, # endpoint was inlining the equivalent loop but stamping every AI row,
# which #4163 fixed for the other paths and missed here. # which #4163 fixed for the other paths and missed here.
stamp_turn_duration_on_last_ai(data, run_durations) stamp_turn_duration_on_last_ai(data, run_durations, thread_id=thread_id)
return data return data
@ -1678,7 +1764,7 @@ async def list_run_messages(
if record: if record:
durations = compute_run_durations([record]) durations = compute_run_durations([record])
if durations: if durations:
stamp_turn_duration_on_last_ai(data, durations) stamp_turn_duration_on_last_ai(data, durations, thread_id=thread_id)
return {"data": data, "has_more": has_more} return {"data": data, "has_more": has_more}
@ -1735,8 +1821,15 @@ def _presented_files_from_delivery(events: list[dict]) -> list[str]:
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery") raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
content = events[0].get("content") content = events[0].get("content")
by_tool = content.get("by_tool") if isinstance(content, dict) else None by_tool = content.get("by_tool") if isinstance(content, dict) else None
presented = by_tool.get("present_files") if isinstance(by_tool, dict) else None if not isinstance(by_tool, dict):
if not isinstance(presented, list) or not presented or any(not isinstance(path, str) for path in presented): raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
presented: list[str] = []
for tool_name in DELIVERY_PRESENTATION_TOOL_NAMES:
paths = by_tool.get(tool_name, [])
if not isinstance(paths, list):
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
presented.extend(paths)
if not presented or any(not isinstance(path, str) for path in presented):
raise HTTPException(status_code=409, detail="This response has no verified artifact delivery") raise HTTPException(status_code=409, detail="This response has no verified artifact delivery")
return presented return presented
@ -1854,12 +1947,18 @@ async def list_run_events(
user_id = await _run_scope_user_id(request, thread_id) user_id = await _run_scope_user_id(request, thread_id)
event_store = get_run_event_store(request) event_store = get_run_event_store(request)
if event_types: if event_types:
types = event_types.split(",") # Split on commas and normalise each entry: the value goes straight into
# a SQL ``IN``, so a padded entry ("message, tool") would silently match
# nothing and return a 200 with a truncated audit trail. Blank entries
# carry no intent and are dropped; a filter that reduces to nothing is
# the same as no filter, matching the empty-string case above.
types = [name for entry in event_types.split(",") if (name := entry.strip())]
if len(types) > MAX_EVENT_TYPE_FILTERS: if len(types) > MAX_EVENT_TYPE_FILTERS:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"event_types accepts at most {MAX_EVENT_TYPE_FILTERS} values", detail=f"event_types accepts at most {MAX_EVENT_TYPE_FILTERS} values",
) )
types = types or None
else: else:
types = None types = None
events = await event_store.list_events( events = await event_store.list_events(

View File

@ -38,6 +38,8 @@ from app.gateway.checkpoint_lineage import (
resolve_stamp_candidate_versions, resolve_stamp_candidate_versions,
) )
from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_run_store from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_run_store
from app.gateway.history_seed import reserve_history_seed, write_history_seed
from app.gateway.hitl_approval import find_pending_interrupts
from app.gateway.internal_auth import get_trusted_internal_owner_user_id from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.services import ( from app.gateway.services import (
abuild_checkpoint_state_accessor, abuild_checkpoint_state_accessor,
@ -63,7 +65,7 @@ from deerflow.runtime.context_compaction import (
ThreadCompactionResult, ThreadCompactionResult,
compact_thread_context, compact_thread_context,
) )
from deerflow.runtime.context_keys import checkpoint_agent_binding_metadata from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, checkpoint_agent_binding_metadata
from deerflow.runtime.events.message_seq import stamp_messages_with_seq from deerflow.runtime.events.message_seq import stamp_messages_with_seq
from deerflow.runtime.goal import ( from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS, DEFAULT_MAX_GOAL_CONTINUATIONS,
@ -80,6 +82,7 @@ from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, admit_origin
from deerflow.runtime.runs.manager import ConflictError from deerflow.runtime.runs.manager import ConflictError
from deerflow.runtime.runs.worker import RUN_MESSAGE_IDS_METADATA_KEY, valid_duration_entry, valid_run_message_id_entry from deerflow.runtime.runs.worker import RUN_MESSAGE_IDS_METADATA_KEY, valid_duration_entry, valid_run_message_id_entry
from deerflow.runtime.secret_context import redact_metadata_secrets from deerflow.runtime.secret_context import redact_metadata_secrets
from deerflow.runtime.serialization import serialize_lc_object
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.uploads.companions import register_companion, resolve_companion from deerflow.uploads.companions import register_companion, resolve_companion
from deerflow.utils.file_io import await_drained, run_file_io from deerflow.utils.file_io import await_drained, run_file_io
@ -126,6 +129,13 @@ def _checkpoint_mode_http_error(exc: Exception, thread_id: str) -> HTTPException
# ``deerflow_origin`` marks a thread the server created for a schedule or an # ``deerflow_origin`` marks a thread the server created for a schedule or an
# extension; only the server sets it (``create_thread`` below, run admission). # extension; only the server sets it (``create_thread`` below, run admission).
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id", THREAD_PROJECT_METADATA_KEY, DEERFLOW_ORIGIN_KEY}) _SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id", THREAD_PROJECT_METADATA_KEY, DEERFLOW_ORIGIN_KEY})
# Checkpoint metadata the server writes and later reads back as trusted:
# LangGraph's lineage keys and delta counters (``step`` drives the next turn,
# ``parents`` the history walk, and ``counters_since_delta_snapshot`` is read
# by every run and state update) and the agent binding that gates memory
# writes. ``create_thread`` copies client metadata into the root checkpoint
# without them; the thread row keeps the client's metadata as sent.
_CHECKPOINT_OWNED_METADATA_KEYS: frozenset[str] = frozenset({"step", "source", "writes", "parents", "created_at", "counters_since_delta_snapshot", CHECKPOINT_AGENT_NAME_METADATA_KEY})
_SIDECAR_METADATA_KEY = "deerflow_sidecar" _SIDECAR_METADATA_KEY = "deerflow_sidecar"
_BRANCH_METADATA_KEY = "deerflow_branch" _BRANCH_METADATA_KEY = "deerflow_branch"
_BRANCH_TITLE_SEQUENCE_METADATA_KEY = "branch_title_sequence" _BRANCH_TITLE_SEQUENCE_METADATA_KEY = "branch_title_sequence"
@ -569,7 +579,7 @@ class ThreadPatchRequest(BaseModel):
class ThreadStateUpdateRequest(BaseModel): class ThreadStateUpdateRequest(BaseModel):
"""Request body for updating thread state (human-in-the-loop resume).""" """Request body for updating thread state (checkpoint mutation, not a resume API)."""
values: dict[str, Any] | None = Field(default=None, description="Channel values to merge") values: dict[str, Any] | None = Field(default=None, description="Channel values to merge")
checkpoint_id: str | None = Field(default=None, description="Checkpoint to branch from") checkpoint_id: str | None = Field(default=None, description="Checkpoint to branch from")
@ -622,10 +632,13 @@ class HistoryEntry(_MetadataRedactingResponse):
checkpoint_id: str checkpoint_id: str
parent_checkpoint_id: str | None = None parent_checkpoint_id: str | None = None
created_at: str | None = Field(default=None, description="Checkpoint timestamp")
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
values: dict[str, Any] = Field(default_factory=dict) values: dict[str, Any] = Field(default_factory=dict)
created_at: str | None = None
next: list[str] = Field(default_factory=list) next: list[str] = Field(default_factory=list)
# Only the head entry carries pending interrupts; older entries are history,
# never a pending-approval signal. Shape: {id, name, interrupts} only.
tasks: list[dict[str, Any]] = Field(default_factory=list)
class ThreadHistoryRequest(BaseModel): class ThreadHistoryRequest(BaseModel):
@ -804,6 +817,13 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
# persisted runs/events/feedback and the thread_meta row all belong to the # persisted runs/events/feedback and the thread_meta row all belong to the
# same owner, so they must not resolve their scope independently. # same owner, so they must not resolve their scope independently.
user_id = get_effective_user_id() user_id = get_effective_user_id()
run_manager = get_run_manager(request)
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
approval_store = getattr(request.app.state, "approval_store", None)
if approval_store is not None:
await await_drained(approval_store.delete_thread(thread_id, user_id=user_id))
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
# Legacy IDs may predate the canonical filesystem-safe contract. They can # Legacy IDs may predate the canonical filesystem-safe contract. They can
# still be removed from metadata/checkpoint stores, but must never be # still be removed from metadata/checkpoint stores, but must never be
@ -822,14 +842,15 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
# being deleted under a run that has just been admitted. # being deleted under a run that has just been admitted.
response = await await_drained(run_file_io(_delete_thread_data, thread_id, user_id=user_id)) response = await await_drained(run_file_io(_delete_thread_data, thread_id, user_id=user_id))
# Remove checkpoints (best-effort) # Retain ownership if a configured checkpointer cannot delete the thread.
# Read routes treat rowless checkpoints as untracked legacy threads.
checkpointer = getattr(request.app.state, "checkpointer", None) checkpointer = getattr(request.app.state, "checkpointer", None)
if checkpointer is not None: if checkpointer is not None:
try: try:
if hasattr(checkpointer, "adelete_thread"):
await checkpointer.adelete_thread(thread_id) await checkpointer.adelete_thread(thread_id)
except Exception: except Exception as exc:
logger.debug("Could not delete checkpoints for thread %s (not critical)", sanitize_log_param(thread_id)) logger.exception("Failed to delete checkpoints for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to delete thread checkpoints.") from exc
# Remove historical runs (best-effort). Only ``operation_kind == "run"`` rows # Remove historical runs (best-effort). Only ``operation_kind == "run"`` rows
# are deleted, so the durable thread-operation reservation protecting this # are deleted, so the durable thread-operation reservation protecting this
@ -842,6 +863,16 @@ async def _delete_thread_data_with_reservation(thread_id: str, request: Request)
except Exception: except Exception:
logger.debug("Could not delete run records for thread %s (not critical)", sanitize_log_param(thread_id)) logger.debug("Could not delete run records for thread %s (not critical)", sanitize_log_param(thread_id))
# Drop this worker's in-memory copies too. ``RunManager.get()`` and
# ``list_by_thread()`` answer from the process-local registry before the
# store, so without this the deleted thread's runs — including ``kwargs``
# with the user's prompt — stay readable through ``GET /threads/{id}/runs``
# until the Gateway restarts, while the thread itself already 404s.
try:
await run_manager.discard_thread_runs(thread_id)
except Exception:
logger.debug("Could not evict in-memory runs for thread %s (not critical)", sanitize_log_param(thread_id))
# Remove persisted run events (best-effort). These are the user-visible # Remove persisted run events (best-effort). These are the user-visible
# conversation history, not a cache: leaving them behind makes a deleted # conversation history, not a cache: leaving them behind makes a deleted
# thread's feed readable again through GET /threads/{id}/messages. A legacy # thread's feed readable again through GET /threads/{id}/messages. A legacy
@ -965,11 +996,17 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
and an empty checkpoint (so state endpoints work immediately). and an empty checkpoint (so state endpoints work immediately).
Idempotent: returns the existing record when ``thread_id`` already exists. Idempotent: returns the existing record when ``thread_id`` already exists.
""" """
thread_id = resolve_thread_id(body.thread_id)
async with goal_thread_lock(thread_id):
return await _create_thread_locked(body, request, thread_id)
async def _create_thread_locked(body: ThreadCreateRequest, request: Request, thread_id: str) -> ThreadResponse:
"""Keep the idempotency read and initial writes in one lifecycle lock."""
from app.gateway.deps import get_thread_store from app.gateway.deps import get_thread_store
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request) thread_store = get_thread_store(request)
thread_id = resolve_thread_id(body.thread_id)
now = now_iso() now = now_iso()
thread_owner_user_id = get_trusted_internal_owner_user_id(request) thread_owner_user_id = get_trusted_internal_owner_user_id(request)
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {} thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
@ -1006,17 +1043,25 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
raise HTTPException(status_code=404, detail="Thread not found") from None raise HTTPException(status_code=404, detail="Thread not found") from None
except IntegrityError: except IntegrityError:
# The idempotency read above and this insert are not atomic: a # The idempotency read above and this insert are not atomic: a
# concurrent request for the same thread_id can commit in between, so # another worker's request for the same thread_id can commit in between, so
# the SQL-backed store rejects ours on the duplicate primary key. # the SQL-backed store rejects ours on the duplicate primary key.
# Honour the documented idempotency contract by resolving the # Honour the documented idempotency contract by resolving the
# now-existing record — running the same owner reconciliation the fast # now-existing record — running the same owner reconciliation the fast
# path does — instead of surfacing the conflict as a 500. The memory # path does — instead of surfacing the conflict as a 500. Same-process
# store serializes same-id creates under its per-thread lock and keeps # HTTP creates share the lifecycle lock; the memory store retains its
# its historical overwrite behavior, so it does not reach this branch. # historical overwrite behavior for direct callers.
existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs) existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs)
if existing_record is not None: if existing_record is not None:
return _existing_thread_response(thread_id, existing_record) return _existing_thread_response(thread_id, existing_record)
# A duplicate-key error with no row we can read back is a real failure. # The same-owner reconciliation above found nothing, yet the insert hit
# a duplicate primary key — so the surviving row belongs to *another*
# user. That is the ownership-conflict case, which the route already
# maps ``ThreadOwnershipConflictError`` to: a non-disclosing 404. Read
# the row back bypassing the owner scope to confirm a foreign row
# exists before collapsing to a 500.
if await thread_store.get(thread_id, user_id=None) is not None:
raise HTTPException(status_code=404, detail="Thread not found") from None
# A duplicate-key error with no surviving row is a real failure.
logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id)) logger.exception("Failed to write thread_meta for %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to create thread") raise HTTPException(status_code=500, detail="Failed to create thread")
except Exception: except Exception:
@ -1027,12 +1072,14 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
# Write an empty checkpoint so state endpoints work immediately # Write an empty checkpoint so state endpoints work immediately
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try: try:
# Client metadata cannot supply checkpoint-owned keys; see
# ``_CHECKPOINT_OWNED_METADATA_KEYS``.
ckpt_metadata = { ckpt_metadata = {
**{key: value for key, value in metadata.items() if key not in _CHECKPOINT_OWNED_METADATA_KEYS},
"step": -1, "step": -1,
"source": "input", "source": "input",
"writes": None, "writes": None,
"parents": {}, "parents": {},
**metadata,
"created_at": now, "created_at": now,
} }
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {}) await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
@ -1221,9 +1268,9 @@ async def _branch_thread_with_reservation(
# into a new thread. Preserve every valid spelling as create does. # into a new thread. Preserve every valid spelling as create does.
branch_assistant_id = None branch_assistant_id = None
async def _write_branch_row(project_id: str | None) -> None: async def _write_branch_row(project_id: str | None) -> dict:
try: try:
await thread_store.create( return await thread_store.create(
new_thread_id, new_thread_id,
assistant_id=branch_assistant_id, assistant_id=branch_assistant_id,
display_name=display_name, display_name=display_name,
@ -1238,13 +1285,13 @@ async def _branch_thread_with_reservation(
raise HTTPException(status_code=500, detail="Failed to create branch") from None raise HTTPException(status_code=500, detail="Failed to create branch") from None
try: try:
await _write_branch_row(source_project_id) branch_record = await _write_branch_row(source_project_id)
except ProjectNotAssignableError: except ProjectNotAssignableError:
# The source project became unassignable (archived, or deleted in a # The source project became unassignable (archived, or deleted in a
# race that cleared the source row's membership after our read): keep # race that cleared the source row's membership after our read): keep
# the branch usable as an unassigned thread — the pre-inheritance # the branch usable as an unassigned thread — the pre-inheritance
# behavior for sources without an active project. # behavior for sources without an active project.
await _write_branch_row(None) branch_record = await _write_branch_row(None)
# The thread feed (GET /messages, /messages/page) reads the run-event # The thread feed (GET /messages, /messages/page) reads the run-event
# store, not checkpoints, and a fresh branch has no run_events — so the # store, not checkpoints, and a fresh branch has no run_events — so the
@ -1254,6 +1301,7 @@ async def _branch_thread_with_reservation(
# from. Best-effort: on failure the branch stays usable, with history # from. Best-effort: on failure the branch stays usable, with history
# visible only through the checkpoint overlay until it is re-branched. # visible only through the checkpoint overlay until it is re-branched.
try: try:
async with reserve_history_seed(request, new_thread_id) as reservation:
seed_events = build_branch_history_seed_events( seed_events = build_branch_history_seed_events(
_checkpoint_messages(snapshot), _checkpoint_messages(snapshot),
thread_id=new_thread_id, thread_id=new_thread_id,
@ -1261,7 +1309,9 @@ async def _branch_thread_with_reservation(
parent_thread_id=thread_id, parent_thread_id=thread_id,
) )
if seed_events: if seed_events:
await get_run_event_store(request).put_batch(seed_events) event_store = get_run_event_store(request)
if reservation is None or not await event_store.list_messages(new_thread_id, limit=1, user_id=None):
await write_history_seed(request, seed_events, reservation=reservation, seed_kind="branch", thread=branch_record)
history_seed_mode = "seeded" history_seed_mode = "seeded"
else: else:
history_seed_mode = "skipped_empty" history_seed_mode = "skipped_empty"
@ -1472,6 +1522,7 @@ async def get_thread(thread_id: ThreadId, request: Request) -> ThreadResponse:
updated_at=coerce_iso(record.get("updated_at", "")), updated_at=coerce_iso(record.get("updated_at", "")),
metadata=record.get("metadata", {}), metadata=record.get("metadata", {}),
values=serialize_channel_values_for_api(snapshot.values), values=serialize_channel_values_for_api(snapshot.values),
interrupts={task_id: [serialize_lc_object(interrupt) for interrupt in interrupts] for task_id, interrupts in (await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)).items()},
) )
@ -1497,11 +1548,21 @@ async def set_thread_goal(thread_id: ThreadId, body: ThreadGoalRequest, request:
this endpoint creates the missing thread checkpoint on demand. this endpoint creates the missing thread checkpoint on demand.
""" """
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
try:
accessor, read_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id, accessor=accessor, read_config=read_config)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
try: try:
goal = build_goal_state(body.objective, max_continuations=body.max_continuations) goal = build_goal_state(body.objective, max_continuations=body.max_continuations)
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await _ensure_thread_for_goal(thread_id, request) await _ensure_thread_for_goal(thread_id, request)
await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True) await write_thread_goal(checkpointer, thread_id, goal, as_node="goal", create_if_missing=True)
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
except ConflictError: except ConflictError:
@ -1519,9 +1580,19 @@ async def set_thread_goal(thread_id: ThreadId, body: ThreadGoalRequest, request:
async def clear_thread_goal(thread_id: ThreadId, request: Request) -> ThreadGoalResponse: async def clear_thread_goal(thread_id: ThreadId, request: Request) -> ThreadGoalResponse:
"""Clear the active goal for a thread.""" """Clear the active goal for a thread."""
checkpointer = get_checkpointer(request) checkpointer = get_checkpointer(request)
try:
accessor, read_config = await build_thread_checkpoint_state_accessor(request, thread_id=thread_id)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id, accessor=accessor, read_config=read_config)
except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc
try: try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await write_thread_goal(checkpointer, thread_id, None, as_node="goal") await write_thread_goal(checkpointer, thread_id, None, as_node="goal")
batch_repo = getattr(request.app.state, "subagent_batch_repo", None)
if batch_repo is not None:
await batch_repo.suppress_thread_continuations(thread_id)
except ConflictError: except ConflictError:
raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None raise HTTPException(status_code=409, detail="Thread has a run in flight. Clear the goal after the run finishes.") from None
except LookupError: except LookupError:
@ -1554,11 +1625,14 @@ async def compact_thread(thread_id: ThreadId, body: ThreadCompactRequest, reques
# forward by checkpoint fork inheritance, so the base-schema mutation # forward by checkpoint fork inheritance, so the base-schema mutation
# graph is sufficient (and avoids building the full lead graph per call). # graph is sufficient (and avoids building the full lead graph per call).
try: try:
accessor, _ = build_checkpoint_state_mutation_accessor( accessor, read_config = build_checkpoint_state_mutation_accessor(
request, request,
thread_id=thread_id, thread_id=thread_id,
as_node="manual_compaction", as_node="manual_compaction",
) )
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id)
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc raise _checkpoint_mode_http_error(exc, thread_id) from exc
keep = body.keep.to_tuple() if body.keep is not None else None keep = body.keep.to_tuple() if body.keep is not None else None
@ -1593,6 +1667,32 @@ async def compact_thread(thread_id: ThreadId, body: ThreadCompactRequest, reques
return _thread_compact_response(result) return _thread_compact_response(result)
def _pending_interrupt_tasks(tasks_raw: Any, pending_interrupts: dict[str, list[Any]]) -> list[dict[str, Any]]:
"""Project pending interrupts without dropping run-store-only identities."""
tasks: list[dict[str, Any]] = []
matched_task_ids: set[str] = set()
for task in tasks_raw:
task_id = getattr(task, "id", "")
matched_task_ids.add(task_id)
tasks.append(
{
"id": task_id,
"name": getattr(task, "name", ""),
"interrupts": [serialize_lc_object(interrupt) for interrupt in pending_interrupts.get(task_id, [])],
}
)
for task_id, interrupts in pending_interrupts.items():
if task_id not in matched_task_ids:
tasks.append(
{
"id": task_id,
"name": "",
"interrupts": [serialize_lc_object(interrupt) for interrupt in interrupts],
}
)
return tasks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/{thread_id}/state", response_model=ThreadStateResponse) @router.get("/{thread_id}/state", response_model=ThreadStateResponse)
@require_permission("threads", "read", owner_check=True) @require_permission("threads", "read", owner_check=True)
@ -1616,13 +1716,12 @@ async def get_thread_state(thread_id: ThreadId, request: Request) -> ThreadState
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id") checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id")
if not checkpoint_id: if not checkpoint_id:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
parent_config = snapshot.parent_config or {} parent_config = snapshot.parent_config or {}
pending_interrupts = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)
parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id") parent_checkpoint_id = parent_config.get("configurable", {}).get("checkpoint_id")
metadata = snapshot.metadata or {} metadata = snapshot.metadata or {}
created_at = snapshot.created_at or metadata.get("created_at", "") created_at = snapshot.created_at or metadata.get("created_at", "")
tasks_raw = snapshot.tasks or () tasks = _pending_interrupt_tasks(snapshot.tasks or (), pending_interrupts)
tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in tasks_raw]
values = serialize_channel_values_for_api(snapshot.values) values = serialize_channel_values_for_api(snapshot.values)
messages = values.get("messages") messages = values.get("messages")
@ -1648,7 +1747,9 @@ async def get_thread_state(thread_id: ThreadId, request: Request) -> ThreadState
async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
"""Replace selected thread-state fields through the materialized graph.""" """Replace selected thread-state fields through the materialized graph."""
from app.gateway.deps import get_thread_store from app.gateway.deps import get_thread_store
from app.gateway.hitl_approval import reject_new_run_when_approval_pending
await reject_new_run_when_approval_pending(request, thread_id)
thread_store = get_thread_store(request) thread_store = get_thread_store(request)
# Validate external roles before materializing a graph or reserving a write. # Validate external roles before materializing a graph or reserving a write.
values = strip_server_owned_state_metadata(dict(body.values or {})) values = strip_server_owned_state_metadata(dict(body.values or {}))
@ -1712,6 +1813,7 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
updates[GOAL_OUTCOME_CHANNEL] = None updates[GOAL_OUTCOME_CHANNEL] = None
try: try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()): async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
await reject_new_run_when_approval_pending(request, thread_id)
source_metadata = await accessor.aget_metadata(read_config) source_metadata = await accessor.aget_metadata(read_config)
update_config = { update_config = {
**read_config, **read_config,
@ -1728,6 +1830,8 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None raise HTTPException(status_code=409, detail="Thread has a run in flight. Update state after the run finishes.") from None
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:
raise _checkpoint_mode_http_error(exc, thread_id) from exc raise _checkpoint_mode_http_error(exc, thread_id) from exc
except HTTPException:
raise
except Exception: except Exception:
logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id)) logger.exception("Failed to update state for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to update thread state") raise HTTPException(status_code=500, detail="Failed to update thread state")
@ -1996,6 +2100,7 @@ async def get_thread_history(
stamp_turn_duration_on_last_ai( stamp_turn_duration_on_last_ai(
serialized_msgs, serialized_msgs,
checkpoint_run_durations, checkpoint_run_durations,
thread_id=thread_id,
) )
raise raise
@ -2046,6 +2151,7 @@ async def get_thread_history(
stamp_turn_duration_on_last_ai( stamp_turn_duration_on_last_ai(
serialized_msgs, serialized_msgs,
resolved_run_durations, resolved_run_durations,
thread_id=thread_id,
) )
except Exception: except Exception:
@ -2061,10 +2167,14 @@ async def get_thread_history(
serialized_msgs, serialized_msgs,
) )
is_latest_checkpoint = False
next_tasks = list(snapshot.next or ()) next_tasks = list(snapshot.next or ())
if is_latest_checkpoint:
pending_interrupts = await find_pending_interrupts(request, thread_id, accessor=accessor, read_config=config, snapshot=snapshot)
head_tasks = _pending_interrupt_tasks(getattr(snapshot, "tasks", None) or (), pending_interrupts)
else:
head_tasks = [{"id": getattr(task, "id", ""), "name": getattr(task, "name", "")} for task in (getattr(snapshot, "tasks", None) or ())]
is_latest_checkpoint = False
# Strip LangGraph internal keys from metadata # Strip LangGraph internal keys from metadata
user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents", "run_durations", RUN_MESSAGE_IDS_METADATA_KEY)} user_meta = {k: v for k, v in metadata.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents", "run_durations", RUN_MESSAGE_IDS_METADATA_KEY)}
# Keep step for ordering context # Keep step for ordering context
@ -2079,6 +2189,7 @@ async def get_thread_history(
values=values, values=values,
created_at=coerce_iso(snapshot.created_at or metadata.get("created_at", "")), created_at=coerce_iso(snapshot.created_at or metadata.get("created_at", "")),
next=next_tasks, next=next_tasks,
tasks=head_tasks,
) )
) )
except _CHECKPOINT_MODE_ERRORS as exc: except _CHECKPOINT_MODE_ERRORS as exc:

View File

@ -14,6 +14,7 @@ not a server-enforced handshake (§11).
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import time import time
from typing import Literal from typing import Literal
@ -103,13 +104,10 @@ class PurgeResponse(BaseModel):
def _projects_config() -> ProjectsConfig: def _projects_config() -> ProjectsConfig:
"""Projects config, falling back to defaults when the app config is unavailable.""" """Read the retention policy; an unavailable config must not shorten it."""
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
try:
return get_app_config().projects return get_app_config().projects
except Exception:
return ProjectsConfig()
def _not_found() -> HTTPException: def _not_found() -> HTTPException:
@ -149,10 +147,11 @@ async def list_trash_documents(
repo = get_project_document_repo(request) repo = get_project_document_repo(request)
user_id = get_effective_user_id() user_id = get_effective_user_id()
try: try:
config = await asyncio.to_thread(_projects_config)
await run_trash_retention_sweep( await run_trash_retention_sweep(
repo, repo,
get_paths(), get_paths(),
retention_days=_projects_config().trash_retention_days, retention_days=config.trash_retention_days,
user_id=user_id, user_id=user_id,
include_reconciliation=_reconciliation_due(user_id), include_reconciliation=_reconciliation_due(user_id),
) )

View File

@ -0,0 +1,193 @@
"""HTTP admission and ordinary AgentRuns dispatch for in-flight user inputs."""
import copy
import json
import logging
from typing import Literal
from uuid import NAMESPACE_URL, uuid5
from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.gateway.deps import get_current_user, get_run_context, get_run_manager
from deerflow.runtime.runs.inputs import InputCancelled, InputConflict, RunInputs
logger = logging.getLogger(__name__)
class RunInputRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: str = Field(min_length=1, max_length=96)
text: str = Field(min_length=1, max_length=16 * 1024)
mode: Literal["queue", "steer"]
context: dict = Field(default_factory=dict)
@model_validator(mode="after")
def bounded_context(self):
if len(json.dumps(self.context, ensure_ascii=True, allow_nan=False).encode()) > 32 * 1024:
raise ValueError("Input context exceeds 32 KiB")
return self
def _store(request):
store = get_run_manager(request).input_store
if store is None:
raise HTTPException(503, "Run inputs are unavailable")
return store
async def submit_run_input(thread_id: str, run_id: str, body: RunInputRequest, request: Request):
manager = get_run_manager(request)
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
run = await manager.get(run_id, user_id=user_id)
if run is None or run.thread_id != thread_id:
raise HTTPException(404, "Run not found")
if get_run_context(request).agent_runs is None:
raise HTTPException(503, "Run inputs require an authenticated session")
row = await _store(request).get(run_id, user_id=user_id)
if row is None or row.get("run_inputs") is None:
raise HTTPException(409, "This Run has not enabled interactive inputs")
try:
from app.gateway.services import _CONTEXT_CONFIGURABLE_KEYS
from deerflow.runtime.run_origin import admit_origin
context = {key: value for key, value in body.context.items() if key in _CONTEXT_CONFIGURABLE_KEYS and key not in ("agent_name", "is_bootstrap")}
if isinstance(body.context.get("locale"), str):
context["locale"] = body.context["locale"]
source = "extension" if admit_origin(getattr(request.state, "run_origin", None)) is not None else "user"
item = await RunInputs(_store(request), run_id, user_id).submit(body.request_id, body.text, body.mode, context=context, source=source)
except InputConflict as exc:
raise HTTPException(409, str(exc)) from exc
return {**item, "target_run_id": run_id}
async def list_thread_inputs(thread_id: str, request: Request):
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
rows = await _store(request).list_input_runs(thread_id, user_id=user_id)
items = []
for row in reversed(rows):
for entry in (row.get("run_inputs") or {}).get("items", []):
status = entry["status"]
if status in ("pending", "applying") and (row["status"] not in ("pending", "running", "success") or row.get("stop_reason")):
status = "not_applied"
items.append({key: value for key, value in {**entry, "status": status, "target_run_id": row["run_id"]}.items() if key != "context"})
# Rank unresolved inputs (pending / not_applied / ...) ahead of terminal
# applied/cancelled receipts, prefer the newest entry within each group,
# cap at 32 records, then restore chronological order. A plain items[-32:]
# slice lets two full batches of receipts hide an older retryable input
# (issue #7972). If more than 32 inputs are unresolved, this deliberately
# returns 32 unresolved rows and no receipts for submission reconciliation;
# that trade-off follows the priority the issue asks for.
keep = sorted(range(len(items)), key=lambda i: (items[i]["status"] in ("applied", "cancelled"), -i))[:32]
return {"items": [items[i] for i in sorted(keep)], "active_run_id": await _store(request).active_input_run(thread_id, user_id=user_id)}
async def dispatch_queued_inputs(inputs: RunInputs, *, agent_runs, thread_id: str):
"""Use normal admission/authorization and its stable idempotency key.
This runs after the previous worker has fully finalized. No second main
Agent, task queue service or recovery scheduler is introduced.
"""
row = await inputs.store.get(inputs.run_id, user_id=inputs.user_id)
if row is None or row["status"] != "success" or row.get("stop_reason"):
return
state = await inputs.read()
for entry in state["items"]:
if entry["mode"] != "queue" or entry["status"] != "pending":
continue
try:
try:
run = await agent_runs.dispatch_input(thread_id=thread_id, run_id=inputs.run_id, input_id=entry["id"])
except InputCancelled:
# Cancellation can win after the snapshot, even during
# admission. The atomic claim proves no Run was started.
logger.info("Skipping cancelled queued input %s for Run %s", entry["id"], inputs.run_id)
continue
while run.status in ("pending", "running"):
# AgentRuns.wait retains its capped backoff and revocation
# checks; a long task exceeding a wait window is still live.
try:
run = await agent_runs.wait(thread_id=thread_id, run_id=run.run_id, timeout=60)
except TimeoutError:
continue
if run.status != "success" or run.stop_reason:
await inputs.pause()
return
except Exception:
# Unknown admission outcomes retain the original key and input;
# retrying can reconcile the same Run instead of running it twice.
logger.warning("Queued input remains undelivered for Run %s", inputs.run_id, exc_info=True)
return
async def dispatch_one_input(thread_id, run_id, input_id, request, *, retry=False):
from deerflow_extension_api.agent_runs import AgentRun
from app.gateway.routers.thread_runs import create_run, get_run
from app.gateway.run_models import RunCreateRequest
from app.gateway.services import resolve_thread_assistant_id
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
source = await _store(request).get(run_id, user_id=user_id)
if source is None or source["thread_id"] != thread_id:
raise HTTPException(404, "Run not found")
inputs = RunInputs(_store(request), run_id, user_id)
entry = await inputs.claim(input_id, retry=retry)
if entry.get("run_id"):
run = await get_run(thread_id, entry["run_id"], request)
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
message_id = str(uuid5(NAMESPACE_URL, f"deerflow-dispatch:{inputs.run_id}:{input_id}"))
context = entry.get("context", {})
source_config = (source.get("kwargs") or {}).get("config") or {}
limit = source_config.get("recursion_limit")
config = {"recursion_limit": limit} if type(limit) is int else None
# Carry only the parent's admitted retrieval scope, never its files,
# credentials or framework markers. Normal admission rechecks access.
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY
scope = None
for message in ((source.get("kwargs") or {}).get("input") or {}).get("messages", []):
if message.get("type", message.get("role")) in ("human", "user"):
scope = message.get("additional_kwargs", {}).get(KNOWLEDGE_SCOPE_KEY)
message = {"role": "user", "content": entry["text"], "id": message_id}
if scope is not None:
message["additional_kwargs"] = {KNOWLEDGE_SCOPE_KEY: copy.deepcopy(scope)}
if entry.get("source", "user") == "user":
# This fact comes from the stored, authenticated input, not from text
# or an arbitrary extension-supplied metadata flag.
request.state.run_origin = None
body = RunCreateRequest(assistant_id=await resolve_thread_assistant_id(request, thread_id, fail_closed=True), input={"messages": [message]}, context=context, config=config, on_disconnect="continue")
run = await create_run(thread_id, body, request, idempotency_key=f"run-input:{run_id}:{input_id}")
await inputs.dispatched(entry["id"], run.run_id)
return AgentRun(thread_id, run.run_id, run.status, run.assistant_id, run.stop_reason)
async def change_run_input(thread_id: str, run_id: str, input_id: str, request: Request, *, retry=False):
user_id = await get_current_user(request)
if user_id is None:
raise HTTPException(401, "Authentication required")
run = await get_run_manager(request).get(run_id, user_id=user_id)
if run is None or run.thread_id != thread_id:
raise HTTPException(404, "Run not found")
inputs = RunInputs(_store(request), run_id, user_id)
try:
if retry:
handle = get_run_context(request).agent_runs
if handle is None:
raise HTTPException(503, "Run inputs require an authenticated session")
from deerflow.runtime.run_origin import admit_origin
if admit_origin(getattr(request.state, "run_origin", None)) is not None:
raise HTTPException(403, "An unapplied input requires an explicit user retry")
dispatched = await dispatch_one_input(thread_id, run_id, input_id, request, retry=True)
return {"run_id": dispatched.run_id}
await inputs.cancel(input_id)
return {"removed": True}
except InputConflict as exc:
raise HTTPException(409, str(exc)) from exc

View File

@ -26,7 +26,6 @@ from app.gateway.deps import get_config, get_local_provider, get_scheduled_task_
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
from app.gateway.scheduled_task_errors import active_occurrence_error, repository_error, scheduler_error, tool_error_payload from app.gateway.scheduled_task_errors import active_occurrence_error, repository_error, scheduler_error, tool_error_payload
from app.gateway.scheduled_task_validation import prepare_resume_updates, validate_scheduled_task_create, validate_scheduled_task_update from app.gateway.scheduled_task_validation import prepare_resume_updates, validate_scheduled_task_create, validate_scheduled_task_update
from app.scheduler.service import TASK_CHANGED_ERROR
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
from deerflow.scheduler import host_notes from deerflow.scheduler import host_notes
@ -62,99 +61,6 @@ _HOST_RUN_ERROR_KEYS = {
_REASON_CODE = re.compile(r"[a-z_]+(:[a-z_]+)?") _REASON_CODE = re.compile(r"[a-z_]+(:[a-z_]+)?")
_ZONE_ERRORS = (ValueError, KeyError, OSError, TypeError) _ZONE_ERRORS = (ValueError, KeyError, OSError, TypeError)
# --- Trial consent -----------------------------------------------------------
_TRIAL_TRAILING_PUNCTUATION = ".!。!~~…"
_TRIAL_ACK_SEPARATORS = ",,、 "
_TRIAL_ACKS = tuple(sorted(("yes please", "yes", "sure", "ok", "okay", "please", "go ahead and", "alright", "好的", "好", "可以", "行", "嗯", "那就", "请", "麻烦"), key=len, reverse=True))
_TRIAL_PARTICLES = (" please", "吧", "呗", "啊", "呀", "哈", "看看")
_TRIAL_VERBS = ("run", "try", "test")
_TRIAL_TARGETS = ("it", "this task", "the task", "this")
_TRIAL_TIMINGS = ("now", "once", "once now", "right now", "now please")
_TRIAL_PHRASES = frozenset(
{f"{verb} {target} {timing}" for verb in _TRIAL_VERBS for target in _TRIAL_TARGETS for timing in _TRIAL_TIMINGS}
| {"run it", "try it", "run a trial", "run a trial now", "do a trial run", "do a test run", "do a trial run now", "do a test run now", "try it out", "test it now"}
| {
"先跑一次",
"现在跑一次",
"先试跑一次",
"现在试跑一次",
"先运行一次",
"现在运行一次",
"试跑一下",
"试跑一次",
"先试一下",
"先试试",
"试一下",
"试试",
"跑一次",
"跑一下",
"先跑一下",
"现在跑一下",
"马上跑一次",
"立即运行一次",
"运行一次",
"执行一次",
"先执行一次",
"现在执行一次",
"试运行一次",
"先试运行一次",
"现在试运行",
"立即试运行",
}
)
def _strip_trailing_punctuation(text: str) -> str:
return text.rstrip(_TRIAL_TRAILING_PUNCTUATION).strip()
def _strip_acknowledgement(command: str) -> str | None:
"""Remove one leading "yes"/"好的"-style word; None when there is none."""
for ack in _TRIAL_ACKS:
if not command.startswith(ack):
continue
rest = command[len(ack) :]
# An English word must end at a separator ("okay" is not "ok" + "ay").
if ack.isascii() and rest and rest[0] not in _TRIAL_ACK_SEPARATORS:
continue
return rest.lstrip(_TRIAL_ACK_SEPARATORS)
return None
def _has_explicit_trial_request(user_text: str, task: Mapping[str, Any]) -> bool:
"""Accept a bounded direct-run request in the current turn, never a keyword in arbitrary text.
This is deliberately not a general intent parser: after removing up to
two leading acknowledgements and one trailing particle, the whole turn
must be one of the known direct requests. Bare confirmations, quoted,
conditional or compound turns, other tasks and task titles are rejected.
"""
if not isinstance(user_text, str) or not user_text.strip() or len(user_text) > 500:
return False
command = _strip_trailing_punctuation(" ".join(user_text.casefold().split()))
for _ in range(2):
rest = _strip_acknowledgement(command)
if rest is None:
break
command = rest
if not command:
return False
for particle in _TRIAL_PARTICLES:
if command.endswith(particle) and len(command) > len(particle):
command = _strip_trailing_punctuation(command[: -len(particle)])
break
phrases = set(_TRIAL_PHRASES)
# Titles are arbitrary prose and may contain quoted/conditional clauses.
# Only an opaque selector can safely be interpolated into a direct request.
task_id = task.get("id")
if isinstance(task_id, str) and 5 < len(task_id) <= 64 and task_id.startswith("task-") and task_id.isascii() and task_id.replace("-", "").replace("_", "").isalnum():
target = task_id.casefold()
phrases.update(f"{verb} {target} {timing}" for verb in _TRIAL_VERBS for timing in _TRIAL_TIMINGS)
phrases.update({f"先试跑{target}", f"现在试跑{target}", f"先运行{target}一次", f"现在运行{target}一次"})
return command in phrases
# --- Projections --------------------------------------------------------------- # --- Projections ---------------------------------------------------------------
@ -545,21 +451,15 @@ class _SchedulerCapability:
return self._result("note", task=await self._view(updated)) return self._result("note", task=await self._view(updated))
async def _trial(self, task: dict[str, Any]) -> dict[str, Any]: async def _trial(self, task: dict[str, Any]) -> dict[str, Any]:
if not _has_explicit_trial_request(self._original_user_text, task): # Natural-language wording is not an execution authorization protocol.
raise scheduler_error(403, "trial_requires_direct_request", "A trial requires a direct request in the current user turn, such as 'Run this task now' or '先跑一次'; a task mention or bare confirmation is insufficient") # The authenticated task-card/page button owns manual dispatch. This
result = await self._service.dispatch_task(task, now=datetime.now(UTC), trigger="manual") # capability has already checked owner, conversation and permissions.
outcome = result.get("outcome") return self._result(
error = result.get("error") or "Scheduled task trial could not be dispatched" "trial",
if outcome == "not_found": task=await self._view(task),
raise scheduler_error(404, "task_not_found", error) requires_user_action="run_once_now",
if outcome == "conflict": instruction="No trial has started. Use the task card's Run once now button, or open /workspace/scheduled-tasks and run this task there.",
raise scheduler_error(409, "task_changed" if error == TASK_CHANGED_ERROR else "task_running", error) )
if outcome == "failed":
raise scheduler_error(502, "trigger_failed", error)
current = await self._task_repo.get(task["id"], user_id=self._owner_id) or task
thread_id = result.get("thread_id")
trial = {"outcome": outcome, "existing": bool(result.get("existing")), "thread_id": thread_id if isinstance(thread_id, str) else None}
return self._result("trial", task=await self._view(current), trial=trial)
async def _pause_or_delete(self, action: str, task: dict[str, Any]) -> dict[str, Any]: async def _pause_or_delete(self, action: str, task: dict[str, Any]) -> dict[str, Any]:
task_id = task["id"] task_id = task["id"]

View File

@ -0,0 +1,30 @@
"""Shared masking for credential values that Gateway routes echo back from config."""
import re
from collections.abc import Callable
from typing import Any
MASKED_VALUE = "***"
def normalize_config_key(key: str) -> str:
"""Snake-case a config key so ``apiKey``, ``API-Key`` and ``api_key`` compare equal."""
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
def mask_secret_values(value: Any, is_secret_key: Callable[[str], bool], *, keep_unset: bool = False) -> Any:
"""Return a copy of ``value`` with every value under a secret-named key masked, at any depth.
A secret-named key masks its whole value, containers included: a
``credentials`` mapping can hold keys under arbitrary names, so masking only
its secret-named leaves could pass one through. With ``keep_unset``, a
``None`` or ``""`` value stays as it is, so a response still shows that no
secret is configured.
"""
if isinstance(value, dict):
return {key: MASKED_VALUE if is_secret_key(str(key)) and not (keep_unset and nested in (None, "")) else mask_secret_values(nested, is_secret_key, keep_unset=keep_unset) for key, nested in value.items()}
if isinstance(value, list):
return [mask_secret_values(item, is_secret_key, keep_unset=keep_unset) for item in value]
return value

View File

@ -30,7 +30,8 @@ from langgraph.types import Command
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_INTERNAL, get_auth_disabled_user, is_auth_disabled from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_INTERNAL, get_auth_disabled_user, is_auth_disabled
from app.gateway.authz import require_cancel_permission_if, resolve_route_permissions from app.gateway.authz import require_cancel_permission_if, resolve_route_permissions
from app.gateway.deps import get_checkpointer, get_local_provider, get_run_context, get_run_manager, get_stream_bridge from app.gateway.deps import get_checkpointer, get_local_provider, get_run_context, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.history_seed import reserve_history_seed, write_history_seed
from app.gateway.internal_auth import ( from app.gateway.internal_auth import (
INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_OWNER_USER_ID_HEADER_NAME,
INTERNAL_SYSTEM_ROLE, INTERNAL_SYSTEM_ROLE,
@ -105,7 +106,7 @@ from deerflow.runtime.secret_context import (
validate_run_metadata_secrets, validate_run_metadata_secrets,
) )
from deerflow.runtime.stream_modes import normalize_stream_modes from deerflow.runtime.stream_modes import normalize_stream_modes
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import get_current_user, reset_current_user, set_current_user
from deerflow.sandbox.lease import SANDBOX_SERVER_OWNED_CONTEXT_KEYS from deerflow.sandbox.lease import SANDBOX_SERVER_OWNED_CONTEXT_KEYS
from deerflow.scheduler.runtime import scheduler_tools_enabled from deerflow.scheduler.runtime import scheduler_tools_enabled
from deerflow.scheduler.schedules import validate_timezone from deerflow.scheduler.schedules import validate_timezone
@ -157,6 +158,7 @@ async def reserve_checkpoint_write(
_TERMINAL_RUN_STATUSES = { _TERMINAL_RUN_STATUSES = {
RunStatus.success, RunStatus.success,
RunStatus.waiting_for_input,
RunStatus.error, RunStatus.error,
RunStatus.timeout, RunStatus.timeout,
RunStatus.interrupted, RunStatus.interrupted,
@ -164,6 +166,8 @@ _TERMINAL_RUN_STATUSES = {
_TERMINAL_STREAM_RECOVERED = "recovered" _TERMINAL_STREAM_RECOVERED = "recovered"
_TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired" _TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired"
# Includes same-thread create admission and metadata I/O; timeout rejects strict
# starts and leaves non-strict starts without verified metadata (MCP fails closed).
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0 _THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
# Message metadata a scheduled launch puts on its prompt: the task, run and the # Message metadata a scheduled launch puts on its prompt: the task, run and the
@ -200,6 +204,10 @@ _SERVER_OWNED_MESSAGE_METADATA_KEYS = (
SUBAGENT_RECEIPT_VERDICT_KEY, SUBAGENT_RECEIPT_VERDICT_KEY,
SUBAGENT_ACCEPTANCE_VERDICT_KEY, SUBAGENT_ACCEPTANCE_VERDICT_KEY,
UNTRUSTED_INPUT_KEY, UNTRUSTED_INPUT_KEY,
"run_input_id",
"run_input_source",
"origin_task_id",
"subagent_pending",
} }
) )
| PROVENANCE_KEYS | PROVENANCE_KEYS
@ -261,6 +269,18 @@ async def _ensure_thread_metadata(
require_existing_thread: bool = False, require_existing_thread: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Ensure an admitted run's thread exists without delaying task attachment.""" """Ensure an admitted run's thread exists without delaying task attachment."""
async with goal_thread_lock(record.thread_id):
return await _ensure_thread_metadata_locked(run_ctx, record, owner_user_id=owner_user_id, require_existing_thread=require_existing_thread)
async def _ensure_thread_metadata_locked(
run_ctx: RunContext,
record: RunRecord,
*,
owner_user_id: str | None,
require_existing_thread: bool,
) -> dict[str, Any]:
"""Share create admission with HTTP while retaining detached task ownership."""
thread_store = run_ctx.thread_store thread_store = run_ctx.thread_store
existing = await thread_store.get(record.thread_id) existing = await thread_store.get(record.thread_id)
if existing is None and owner_user_id: if existing is None and owner_user_id:
@ -550,15 +570,17 @@ def _strip_external_metadata_from_message_like(item: Any) -> Any:
# unmarked ``name="summary"`` would reach the model on the guardrail's # unmarked ``name="summary"`` would reach the model on the guardrail's
# genuine-user fallback. Treat it as empty for both steps rather than # genuine-user fallback. Treat it as empty for both steps rather than
# returning early. # returning early.
source_kwargs = item.get("additional_kwargs") raw_kwargs = item.get("additional_kwargs")
source_kwargs = source_kwargs if isinstance(source_kwargs, dict) else {} source_kwargs = raw_kwargs if isinstance(raw_kwargs, dict) else {}
additional_kwargs = {key: value for key, value in source_kwargs.items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY} additional_kwargs = {key: value for key, value in source_kwargs.items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY}
if _is_human_message_like(item): if _is_human_message_like(item):
additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, item.get("name")) additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, item.get("name"))
if additional_kwargs == source_kwargs: if additional_kwargs == raw_kwargs:
# Nothing to change — including the ordinary key-omitted message, which # Nothing to change — including the ordinary key-omitted message, which
# must not gain an empty dict just by passing through here. # must not gain an empty dict just by passing through here.
return item return item
if "additional_kwargs" not in item and not additional_kwargs:
return item
return {**item, "additional_kwargs": additional_kwargs} return {**item, "additional_kwargs": additional_kwargs}
@ -621,7 +643,63 @@ def _normalize_input_messages(
#: State channels only the server writes. ``goal_outcome`` is the "goal met" #: State channels only the server writes. ``goal_outcome`` is the "goal met"
#: record that clearing a satisfied goal writes; a caller copy would forge one. #: record that clearing a satisfied goal writes; a caller copy would forge one.
SERVER_OWNED_STATE_CHANNELS = frozenset({"sandbox", "thread_data", "viewed_images", "goal_outcome"}) SERVER_OWNED_STATE_CHANNELS = frozenset({"sandbox", "thread_data", "viewed_images", "goal_outcome", "applied_run_inputs"})
#: The ``write_todos`` status contract the todo middleware renders.
_TODO_STATUSES = frozenset({"pending", "in_progress", "completed"})
def _normalize_external_todos(value: Any, *, location: str) -> list[dict[str, Any]] | None:
"""Reject todo lists the middleware cannot render.
``todos`` is a reducer channel a caller may Overwrite directly, and the todo
middleware reads it on every model call. A value that is not a list of
objects with string content would fail those calls on every turn instead of
being refused here, so validate the shape at the boundary the same way
``goal`` is.
"""
if value is None:
return None
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{location} must be a list of todo objects")
normalized: list[dict[str, Any]] = []
for index, entry in enumerate(value):
if not isinstance(entry, Mapping):
raise HTTPException(status_code=400, detail=f"{location}[{index}] must be an object")
content = entry.get("content")
if not isinstance(content, str):
raise HTTPException(status_code=400, detail=f"{location}[{index}].content must be a string")
status = entry.get("status", "pending")
if not isinstance(status, str) or status not in _TODO_STATUSES:
raise HTTPException(status_code=400, detail=f"{location}[{index}].status must be pending, in_progress, or completed")
normalized.append({**entry, "content": content, "status": status})
return normalized
def _normalize_external_skill_context(value: Any, *, location: str) -> list[dict[str, Any]] | None:
"""Reject skill references the durable-context reducer cannot read.
``skill_context`` is a checkpointed reducer channel a caller may Overwrite.
``merge_skill_context`` normalizes each entry with ``entry.get(...)`` and
``str(entry["path"])``, so a non-object entry or a missing path raises while
the state update merges — failing the run with an unhandled
``AttributeError``/``KeyError`` instead of a 400 at this boundary. The other
fields are optional: the reducer coerces ``name``/``description`` and
``loaded_at`` itself, so only the path needs to be usable here.
"""
if value is None:
return None
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{location} must be a list of skill reference objects")
normalized: list[dict[str, Any]] = []
for index, entry in enumerate(value):
if not isinstance(entry, Mapping):
raise HTTPException(status_code=400, detail=f"{location}[{index}] must be an object")
path = entry.get("path")
if not isinstance(path, str) or not path:
raise HTTPException(status_code=400, detail=f"{location}[{index}].path must be a non-empty string")
normalized.append({**entry, "name": str(entry.get("name") or "")})
return normalized
def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None: def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | None:
@ -639,7 +717,7 @@ def _normalize_external_goal(value: Any, *, location: str) -> dict[str, Any] | N
for field in ("continuation_count", "max_continuations", "no_progress_count", "max_no_progress_continuations"): for field in ("continuation_count", "max_continuations", "no_progress_count", "max_no_progress_continuations"):
if field in value and (type(value[field]) is not int or value[field] < 0): if field in value and (type(value[field]) is not int or value[field] < 0):
raise HTTPException(status_code=400, detail=f"{location}.{field} must be a non-negative integer") raise HTTPException(status_code=400, detail=f"{location}.{field} must be a non-negative integer")
return {**value, "objective": objective} return {**{key: item for key, item in value.items() if key != "last_batch_wakeup_run_id"}, "objective": objective}
def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]: def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]:
@ -668,10 +746,19 @@ def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, An
for channel, value in values.items(): for channel, value in values.items():
if channel == "goal": if channel == "goal":
stripped[channel] = _strip_external_metadata_from_message_like(_normalize_external_goal(value, location="values.goal")) stripped[channel] = _strip_external_metadata_from_message_like(_normalize_external_goal(value, location="values.goal"))
elif channel == "todos":
normalized_todos = _normalize_external_todos(value, location="values.todos")
# Todos keep their extra fields, but they are still caller-supplied
# channel values: a dedicated validator must not bypass the
# per-entry metadata strip the generic list branch applies.
stripped[channel] = None if normalized_todos is None else [_strip_external_metadata_from_message_like(item) for item in normalized_todos]
elif channel == "messages" and value is not None: elif channel == "messages" and value is not None:
stripped[channel] = _normalize_input_messages(value, location="values.messages") stripped[channel] = _normalize_input_messages(value, location="values.messages")
elif channel == "delegations" and isinstance(value, list): elif channel == "delegations" and isinstance(value, list):
stripped[channel] = [_strip_external_delegation_verdict(item) for item in value] stripped[channel] = [_strip_external_delegation_verdict(item) for item in value]
elif channel == "skill_context":
normalized_skill_context = _normalize_external_skill_context(value, location="values.skill_context")
stripped[channel] = None if normalized_skill_context is None else [_strip_external_metadata_from_message_like(item) for item in normalized_skill_context]
elif isinstance(value, list): elif isinstance(value, list):
stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value] stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value]
else: else:
@ -740,9 +827,22 @@ def normalize_input(raw_input: dict[str, Any] | None, *, trusted_internal: bool
cleaned = [_strip_external_delegation_verdict(entry) for entry in delegations] cleaned = [_strip_external_delegation_verdict(entry) for entry in delegations]
if cleaned != delegations: if cleaned != delegations:
result = {**result, "delegations": cleaned} result = {**result, "delegations": cleaned}
if "skill_context" in result:
result = {**result, "skill_context": _normalize_external_skill_context(result["skill_context"], location="input.skill_context")}
# merge_goal ignores None, so only a goal value replaces the goal. # merge_goal ignores None, so only a goal value replaces the goal.
if result.get("goal") is not None: if result.get("goal") is not None:
result = {**result, "goal": _normalize_external_goal(result["goal"], location="input.goal"), "goal_outcome": None} normalized_goal = _normalize_external_goal(result["goal"], location="input.goal")
result = {
**result,
"goal": _strip_external_metadata_from_message_like(normalized_goal),
"goal_outcome": None,
}
if "todos" in result:
normalized_todos = _normalize_external_todos(result["todos"], location="input.todos")
result = {
**result,
"todos": None if normalized_todos is None else [_strip_external_metadata_from_message_like(item) for item in normalized_todos],
}
return result return result
@ -784,11 +884,11 @@ def _canonical_run_record_input(
raw_input: dict[str, Any] | None, raw_input: dict[str, Any] | None,
graph_input: object, graph_input: object,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Persist the same normalized messages that cross run admission. """Persist the same normalized messages, goal, and todos that cross run admission.
The run record is a client-visible audit surface. Keeping the original raw The run record is a client-visible audit surface. Keeping the original raw
message there would preserve a non-canonical scope even though the graph message or state metadata there would preserve a non-canonical scope even
receives the validated form. though the graph receives the validated form.
""" """
if not isinstance(graph_input, dict): if not isinstance(graph_input, dict):
return raw_input return raw_input
@ -796,6 +896,9 @@ def _canonical_run_record_input(
messages = graph_input.get("messages") messages = graph_input.get("messages")
if isinstance(messages, list): if isinstance(messages, list):
canonical["messages"] = [message.model_dump(mode="json") if isinstance(message, BaseMessage) else message for message in messages] canonical["messages"] = [message.model_dump(mode="json") if isinstance(message, BaseMessage) else message for message in messages]
for key in ("goal", "goal_outcome", "todos", "delegations"):
if key in graph_input:
canonical[key] = graph_input[key]
return canonical return canonical
@ -868,6 +971,45 @@ def _legacy_stored_idempotency_request(kwargs: dict[str, Any]) -> dict[str, Any]
return identity return identity
async def _find_admitted_run_for_idempotency_key(
run_mgr: RunManager,
thread_id: str,
idempotency_key: str,
*,
user_id: str | None,
) -> RunRecord | None:
"""Resolve an existing key on this thread and owner before pause checks."""
before_created_at: str | None = None
before_run_id: str | None = None
while True:
records = await run_mgr.list_by_thread(
thread_id,
user_id=user_id,
limit=100,
before_created_at=before_created_at,
before_run_id=before_run_id,
)
for record in records:
if record.idempotency_key == idempotency_key and record.user_id == user_id:
return record
if len(records) < 100:
return None
cursor = records[-1]
before_created_at = cursor.created_at
before_run_id = cursor.run_id
def _is_retryable_resume_start_failure(record: RunRecord | None) -> bool:
"""Whether admission failed before a resume worker could attach."""
return record is not None and record.status == RunStatus.error and isinstance(record.error, str) and record.error.startswith("Failed to attach run worker:")
async def _discard_failed_resume_admission(request: Request, run_mgr: RunManager, record: RunRecord) -> None:
"""Release a failed admission's key so an identical retry can start work."""
await get_run_store(request).delete(record.run_id, user_id=record.user_id)
await run_mgr.cleanup(record.run_id, delay=0)
_DEFAULT_ASSISTANT_ID = "lead_agent" _DEFAULT_ASSISTANT_ID = "lead_agent"
@ -880,6 +1022,7 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
{ {
"model_name", "model_name",
"tool_approval_mode",
"mode", "mode",
"thinking_enabled", "thinking_enabled",
"reasoning_effort", "reasoning_effort",
@ -918,6 +1061,8 @@ _SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = (
"__run_loop_detection_recorder", "__run_loop_detection_recorder",
"__run_tool_promotion_recorder", "__run_tool_promotion_recorder",
"__run_tool_progress_recorder", "__run_tool_progress_recorder",
"__run_input_control",
"__tool_approval_runtime",
"langgraph_auth_user", "langgraph_auth_user",
"langgraph_auth_user_id", "langgraph_auth_user_id",
THREAD_INCARNATION_METADATA_GUARD_KEY, THREAD_INCARNATION_METADATA_GUARD_KEY,
@ -1308,11 +1453,17 @@ def _clamp_recursion_limit(value: Any, max_limit: int, default_limit: int) -> in
return min(value, max_limit) return min(value, max_limit)
def custom_agent_name(assistant_id: str | None) -> str | None: # Error details echo caller-supplied agent names; bound them so an oversized
# value cannot bloat the response or any log line that records the detail.
_MAX_ECHOED_AGENT_NAME_CHARS = 200
def custom_agent_name(assistant_id: str | None, *, field: str = "assistant_id") -> str | None:
"""Return the custom agent *assistant_id* selects, or ``None`` for the lead agent. """Return the custom agent *assistant_id* selects, or ``None`` for the lead agent.
Custom names are normalized (trimmed, lowercased, ``_`` to ``-``) and must Custom names are normalized (trimmed, lowercased, ``_`` to ``-``) and must
then contain only letters, digits and hyphens. then contain only letters, digits and hyphens. *field* names the request
field in the error message.
Raises: Raises:
ValueError: *assistant_id* cannot name an agent. ValueError: *assistant_id* cannot name an agent.
@ -1321,7 +1472,7 @@ def custom_agent_name(assistant_id: str | None) -> str | None:
return None return None
normalized = assistant_id.strip().lower().replace("_", "-") normalized = assistant_id.strip().lower().replace("_", "-")
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") raise ValueError(f"Invalid {field} {assistant_id!r:.{_MAX_ECHOED_AGENT_NAME_CHARS}}: must contain only letters, digits, and hyphens after normalization.")
return normalized return normalized
@ -1906,24 +2057,30 @@ async def ensure_checkpoint_history_seeded(
if await event_store.list_messages(thread_id, limit=1, user_id=None): if await event_store.list_messages(thread_id, limit=1, user_id=None):
return return
checkpoint_config = { checkpoint_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
if await get_checkpointer(request).aget_tuple(checkpoint_config) is None: if await get_checkpointer(request).aget_tuple(checkpoint_config) is None:
return return
accessor, config = await abuild_checkpoint_state_accessor( async def checkpoint_messages() -> list:
request, accessor, config = await abuild_checkpoint_state_accessor(request, thread_id=thread_id, assistant_id=assistant_id)
thread_id=thread_id,
assistant_id=assistant_id,
)
snapshot = await accessor.aget(config) snapshot = await accessor.aget(config)
values = getattr(snapshot, "values", None) values = getattr(snapshot, "values", None)
messages = values.get("messages") if isinstance(values, dict) else None messages = values.get("messages") if isinstance(values, dict) else None
if not isinstance(messages, list) or not messages: return messages if isinstance(messages, list) else []
# Eligibility is advisory; an empty head needs no migration reservation.
# Discard this preview and reread under admission before any publication.
if not await checkpoint_messages():
return
async with reserve_history_seed(request, thread_id) as reservation:
if reservation is not None and await event_store.list_messages(thread_id, limit=1, user_id=None):
return
if await get_checkpointer(request).aget_tuple(checkpoint_config) is None:
return
messages = await checkpoint_messages()
if not messages:
return return
events = build_checkpoint_history_seed_events( events = build_checkpoint_history_seed_events(
@ -1933,7 +2090,7 @@ async def ensure_checkpoint_history_seeded(
) )
if not events: if not events:
return return
await event_store.put_batch(events) await write_history_seed(request, events, reservation=reservation, seed_kind="checkpoint", assistant_id=assistant_id)
logger.info("Seeded %d checkpoint-history events for thread %s", len(events), thread_id) logger.info("Seeded %d checkpoint-history events for thread %s", len(events), thread_id)
@ -2048,20 +2205,30 @@ async def _load_scope_agent_config(
) -> Any | None: ) -> Any | None:
if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID: if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID:
return None return None
try: # The caller's config/context ``agent_name`` arrives here unvalidated, and
# The caller's config/context ``agent_name`` arrives here unvalidated. # every run that names a custom agent passes through, with or without a
# knowledge scope, so each rejection says what is wrong with the agent.
if not isinstance(assistant_id, str): if not isinstance(assistant_id, str):
raise ValueError("agent_name must be a string") raise HTTPException(
normalized = assistant_id.strip().lower().replace("_", "-") status_code=422,
detail=f"Invalid agent_name: expected a string, got {type(assistant_id).__name__}.",
)
try:
normalized = custom_agent_name(assistant_id, field="agent_name")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
return await asyncio.to_thread( return await asyncio.to_thread(
load_agent_config, load_agent_config,
normalized, normalized,
user_id=user_id, user_id=user_id,
) )
except (FileNotFoundError, ValueError) as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=422, detail=f"Agent '{normalized:.{_MAX_ECHOED_AGENT_NAME_CHARS}}' not found") from exc
except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail="knowledge_scope assistant configuration could not be resolved", detail=f"Agent '{normalized:.{_MAX_ECHOED_AGENT_NAME_CHARS}}' configuration could not be loaded",
) from exc ) from exc
@ -2185,6 +2352,7 @@ async def start_run(
admission_run_id: str | None = None admission_run_id: str | None = None
producer_lease_started = False producer_lease_started = False
producer_lease_handed_off = False producer_lease_handed_off = False
validated_approval_request_ids: dict[str, str] | None = None
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
@ -2257,10 +2425,43 @@ async def start_run(
if admitted_origin not in EVIDENCE_ORIGINS: if admitted_origin not in EVIDENCE_ORIGINS:
raise ValueError("Unsupported host evidence origin") raise ValueError("Unsupported host evidence origin")
command = getattr(body, "command", None) command = getattr(body, "command", None)
from app.gateway.hitl_approval import reject_new_run_when_approval_pending, validate_resume_request
current_user = get_current_user()
run_user_id = str(current_user.id) if current_user is not None else None
admitted_run = (
await _find_admitted_run_for_idempotency_key(
run_mgr,
thread_id,
idempotency_key,
user_id=run_user_id,
)
if idempotency_key is not None
else None
)
if command and command.get("resume") is not None: if command and command.get("resume") is not None:
graph_input = Command(resume=command["resume"]) graph_input = Command(resume=command["resume"])
stored_idempotency_request = (admitted_run.idempotency_request or _legacy_stored_idempotency_request(admitted_run.kwargs or {})) if admitted_run is not None else None
retry_failed_resume = _is_retryable_resume_start_failure(admitted_run) and stored_idempotency_request == idempotency_request
if admitted_run is None or retry_failed_resume:
accessor, read_config = await build_thread_checkpoint_state_accessor(
request,
thread_id=thread_id,
)
validated_approval_request_ids = await validate_resume_request(
request=request,
thread_id=thread_id,
accessor=accessor,
read_config=read_config,
enabled=get_app_config().hitl_approval.enabled,
body=body,
)
resume_map = getattr(validated_approval_request_ids, "resume_map", None)
if isinstance(resume_map, dict):
graph_input = Command(resume=resume_map)
else: else:
# LangGraph continues pending work with None; {} starts new input. if admitted_run is None:
await reject_new_run_when_approval_pending(request, thread_id)
graph_input = normalized_input if body.input is not None else None graph_input = normalized_input if body.input is not None else None
# Keep the idempotency identity in the caller's pre-stamp shape. A # Keep the idempotency identity in the caller's pre-stamp shape. A
# keyed retry can then reuse a run even though its stored run input has # keyed retry can then reuse a run even though its stored run input has
@ -2306,6 +2507,12 @@ async def start_run(
# that carries agent configuration (model_name, thinking_enabled, etc.). # that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored. # Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller) merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
from deerflow.approvals.policy import resolve_mode
try:
resolve_mode(get_app_config().hitl_approval, config.get("context") or {})
except (ValueError, TypeError) as exc:
raise HTTPException(422, detail={"code": "invalid_approval_mode", "message": "The selected tool approval mode is not allowed"}) from exc
if not is_internal_caller: if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by # ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there. # ``build_run_config``; scrub internal-only keys smuggled there.
@ -2599,6 +2806,28 @@ async def start_run(
except Exception: except Exception:
logger.warning("Scheduler tool capability is unavailable for this run", exc_info=True) logger.warning("Scheduler tool capability is unavailable for this run", exc_info=True)
execution_context = replace(run_ctx, scheduler_capability=scheduler_capability) if scheduler_capability is not None else run_ctx execution_context = replace(run_ctx, scheduler_capability=scheduler_capability) if scheduler_capability is not None else run_ctx
artifact_imports = getattr(request.state, "extension_artifact_imports", None)
artifact_lifetime = None
if artifact_imports:
from contextlib import AsyncExitStack
from deerflow_extension_api import AgentRunError
from app.gateway.extension_artifacts import verify_imports
artifact_lifetime = AsyncExitStack()
async def verify_artifacts():
# RunContext invokes this after durable admission and before
# Agent construction. A reused key never launches a worker.
try:
await await_drained(verify_imports(request, record.user_id, thread_id, artifact_imports, lifetime=artifact_lifetime))
except AgentRunError:
record.stop_reason = "artifact_conflict"
raise
execution_context = replace(execution_context, verify_artifacts=verify_artifacts)
try:
await run_agent( await run_agent(
bridge, bridge,
run_mgr, run_mgr,
@ -2614,6 +2843,14 @@ async def start_run(
knowledge_scope=admitted_knowledge_scope, knowledge_scope=admitted_knowledge_scope,
**incarnation_kwargs, **incarnation_kwargs,
) )
finally:
if artifact_lifetime is not None:
await await_drained(artifact_lifetime.aclose())
if execution_context.agent_runs is not None and run_mgr.input_store is not None:
from app.gateway.run_inputs import dispatch_queued_inputs
from deerflow.runtime.runs.inputs import RunInputs
await dispatch_queued_inputs(RunInputs(run_mgr.input_store, record.run_id, record.user_id), agent_runs=execution_context.agent_runs, thread_id=thread_id)
try: try:
async with goal_thread_lock(thread_id): async with goal_thread_lock(thread_id):
@ -2651,8 +2888,15 @@ async def start_run(
"Stream producer tracking unavailable for run %s; admitting it with untracked (conservative) stream recovery", "Stream producer tracking unavailable for run %s; admitting it with untracked (conservative) stream recovery",
sanitize_log_param(admission_run_id), sanitize_log_param(admission_run_id),
) )
async def admit_run() -> RunRecord:
try: try:
record = await run_mgr.create_or_reject( if admitted_run is None and not (command and command.get("resume") is not None):
# Validation above is intentionally early, but a pause can
# appear while this request prepares its admission. Recheck
# under the thread lock so a fresh input cannot replace it.
await reject_new_run_when_approval_pending(request, thread_id)
return await run_mgr.create_or_reject(
thread_id, thread_id,
body.assistant_id, body.assistant_id,
on_disconnect=disconnect, on_disconnect=disconnect,
@ -2687,6 +2931,8 @@ async def start_run(
# a different request for the run already admitted. # a different request for the run already admitted.
raise HTTPException(status_code=409, detail="Idempotency-Key already used with a different request") from exc raise HTTPException(status_code=409, detail="Idempotency-Key already used with a different request") from exc
record = await admit_run()
if record.idempotency_reused: if record.idempotency_reused:
stored = record.kwargs or {} stored = record.kwargs or {}
stored_input = stored.get("input") stored_input = stored.get("input")
@ -2713,13 +2959,48 @@ async def start_run(
status_code=409, status_code=409,
detail="Idempotency-Key already used with a different request", detail="Idempotency-Key already used with a different request",
) )
if request_identity["kind"] == "resume" and _is_retryable_resume_start_failure(record):
# The original request never attached a worker. Preserve
# idempotency for real execution outcomes, but let this
# identical retry replace only that failed admission.
await _discard_failed_resume_admission(request, run_mgr, record)
record = await admit_run()
if record.idempotency_reused:
# A concurrent identical retry won the replacement.
return record
else:
return record return record
worker = run_after_metadata(record) worker = run_after_metadata(record)
answered_approval = None
try: try:
if command and command.get("resume") is not None:
from app.gateway.hitl_approval import _all_reject, _responses_of, current_approval_request_ids, mark_approval_answered
responses = _responses_of(command)
answered_approval = await mark_approval_answered(
request,
thread_id,
expected_request_ids=(validated_approval_request_ids if responses is not None else None),
)
if answered_approval is None and responses is not None:
if not _all_reject(responses):
# Competing or stale approvals fail closed.
raise HTTPException(status_code=409, detail={"code": "no_pending_approval", "message": "No resumable pause exists for this thread"})
# A crashed attempt may have consumed the run row while
# leaving the same checkpoint pause live. Only that exact
# pause may use checkpoint-only recovery; a stale reject
# must never dismiss a newer approval.
current_request_ids = await current_approval_request_ids(
request,
thread_id,
accessor=accessor,
read_config=read_config,
)
if current_request_ids != validated_approval_request_ids:
raise HTTPException(status_code=409, detail={"code": "no_pending_approval", "message": "No resumable pause exists for this thread"})
# The stream lease was established before durable admission. # The stream lease was established before durable admission.
# Keep the admission-to-task-attachment window await-free so # Once resume persistence succeeds, keep task attachment
# cancellation cannot strand a pending row. # await-free so cancellation cannot strand a pending row.
record.task = asyncio.create_task(worker) record.task = asyncio.create_task(worker)
if producer_lease_started: if producer_lease_started:
transfer_producer_lease = _declared_bridge_method( transfer_producer_lease = _declared_bridge_method(
@ -2732,12 +3013,28 @@ async def start_run(
record.task, record.task,
) )
producer_lease_handed_off = True producer_lease_handed_off = True
except Exception as exc: except BaseException as exc:
attachment_error = f"Failed to attach run worker: {exc}"
task = record.task
if task is None:
worker.close() worker.close()
async def cleanup_failed_attachment() -> None:
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
if record.task is task:
record.task = None
await run_mgr.fail_start_if_pending( await run_mgr.fail_start_if_pending(
record.run_id, record.run_id,
error=f"Failed to attach run worker: {exc}", error=attachment_error,
) )
if answered_approval is not None:
restored = await run_mgr.restore_approval_answer(answered_approval)
if not restored:
raise RuntimeError(f"Failed to restore approval run {answered_approval.run_id} after worker attachment failed")
await asyncio.shield(cleanup_failed_attachment())
raise raise
except ConflictError as exc: except ConflictError as exc:
raise BusyThreadConflict(str(exc)) from exc raise BusyThreadConflict(str(exc)) from exc
@ -2849,6 +3146,7 @@ async def launch_scheduled_thread_run(
# scheduler service's own per-occurrence scope -- ensure_trace_context # scheduler service's own per-occurrence scope -- ensure_trace_context
# keeps that trace instead of minting a competing one. # keeps that trace instead of minting a competing one.
with ensure_trace_context(): with ensure_trace_context():
try:
record = await start_run( record = await start_run(
body, body,
thread_id, thread_id,
@ -2857,6 +3155,14 @@ async def launch_scheduled_thread_run(
evidence_origin="scheduled", evidence_origin="scheduled",
scheduled_task_runtime=scheduled_task_runtime, scheduled_task_runtime=scheduled_task_runtime,
) )
except HTTPException as exc:
if isinstance(exc.detail, dict) and exc.detail.get("code") == "approval_pending":
# Skip-and-retry: an approval pause on the thread is temporary,
# not a permanent launch failure. The scheduler treats this as
# an overlap conflict and retries on its normal cadence.
logger.info("Scheduled launch skipped: thread %s has a pending approval", thread_id)
raise ConflictError(str(exc.detail)) from exc
raise
return {"run_id": record.run_id, "thread_id": record.thread_id} return {"run_id": record.run_id, "thread_id": record.thread_id}
@ -2941,6 +3247,9 @@ async def launch_mcp_task_notification_run(
require_existing_thread=True, require_existing_thread=True,
) )
except HTTPException as exc: except HTTPException as exc:
if isinstance(exc.detail, dict) and exc.detail.get("code") == "approval_pending":
# Temporary condition, not a permanent notification failure.
raise ConflictError(str(exc.detail)) from exc
if isinstance(exc, BusyThreadConflict): if isinstance(exc, BusyThreadConflict):
raise ConflictError(str(exc.detail)) from exc raise ConflictError(str(exc.detail)) from exc
if exc.status_code in {400, 401, 403, 404, 409, 422, 501}: if exc.status_code in {400, 401, 403, 404, 409, 422, 501}:

View File

@ -0,0 +1,105 @@
"""Owner-scoped HTTP decisions for live delegated calls and thread grants."""
import time
from fastapi import HTTPException, Request
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.deps import get_current_user, get_run_manager, get_thread_store
from deerflow.approvals.policy import canonical_json
from deerflow.approvals.store import ApprovalConflict
from deerflow.utils.file_io import await_drained
class ApprovalResponseBody(BaseModel):
model_config = ConfigDict(extra="forbid")
responses: list[dict] = Field(min_length=1, max_length=32)
async def _scope(request, thread_id):
owner = await get_current_user(request)
if not owner:
raise HTTPException(401, "Authentication required")
thread = await get_thread_store(request).get(thread_id, user_id=owner)
if thread is None:
raise HTTPException(404, "Thread not found")
incarnation = thread.get("incarnation")
if not isinstance(incarnation, str):
raise HTTPException(409, "Thread approval identity is unavailable")
return (owner, thread_id, incarnation)
def _store(request):
store = getattr(request.app.state, "approval_store", None)
if store is None:
raise HTTPException(503, "Approval storage is unavailable")
return store
async def _active(request, row, owner):
if row["expires_at"] is not None and row["expires_at"] <= time.time():
return False
run = await get_run_manager(request).get(row["run_id"], user_id=owner)
return run is not None and run.thread_id == row["thread_id"] and run.status in ("running", "pending") and not run.ownership_lost and not run.abort_event.is_set()
async def list_approvals(thread_id, request):
scope = await _scope(request, thread_id)
rows = await _store(request).list(scope=scope, status="pending")
requests = [row["payload"] for row in rows if row["kind"] == "live" and await _active(request, row, scope[0])]
grants = await _store(request).list(scope=scope, grants=True)
return {"requests": requests, "grants": [{"request_id": row["request_id"], "operation": row["payload"]} for row in grants]}
async def answer_approval(thread_id, run_id, request_id, body, request: Request):
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT
if getattr(request.state, "auth_source", None) in (AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT):
raise HTTPException(403, "Tool approval requires an interactive user session")
scope = await _scope(request, thread_id)
store = _store(request)
row = await store.get(request_id, scope=scope)
if row is None or row["run_id"] != run_id or row["kind"] != "live":
raise HTTPException(404, "Approval not found")
if not await _active(request, row, scope[0]):
raise HTTPException(409, "The originating run stopped or the approval expired")
actions = row["payload"]["actions"]
if len(body.responses) != len(actions):
raise HTTPException(422, "An answer is required for each operation")
by_id = {action["request_id"]: action for action in actions}
seen = set()
for response in body.responses:
response_id = response.get("request_id")
if not isinstance(response_id, str):
raise HTTPException(422, "Invalid approval request identity")
if not isinstance(response.get("value", ""), str) or len(response.get("value", "")) > 2000:
raise HTTPException(422, "Approval reason must be text of at most 2000 characters")
action = by_id.get(response_id)
if action is None or action["request_id"] in seen or response.get("tool_call_id") != action["tool_call_id"]:
raise HTTPException(422, "Stale or mismatched approval response")
seen.add(action["request_id"])
decision = response.get("option_id")
if decision not in row["payload"]["allowed_decisions"]:
raise HTTPException(422, "Unsupported approval decision")
if decision == "edit":
args, schema = response.get("args"), action.get("args_schema")
try:
if not isinstance(args, dict) or len(canonical_json(args).encode()) > row["payload"].get("max_payload_bytes", 512 * 1024):
raise ValueError("Invalid arguments")
Draft202012Validator(schema).validate(args)
if isinstance(schema.get("properties"), dict) and set(args) - set(schema["properties"]):
raise ValueError("Unknown or injected arguments")
except Exception as exc:
raise HTTPException(422, "Edited arguments do not match the tool schema") from exc
try:
await await_drained(store.decide(request_id, {"responses": body.responses}, scope=scope, fingerprint=row["fingerprint"]))
except ApprovalConflict as exc:
raise HTTPException(409, str(exc)) from exc
return {"accepted": True, "request_id": request_id}
async def revoke_approval_grants(thread_id, request):
scope = await _scope(request, thread_id)
await await_drained(_store(request).revoke(scope=scope))
return {"revoked": True}

View File

@ -1689,11 +1689,60 @@ class McpTaskService:
_CANCELLATION_DRAIN_TIMEOUT_SECONDS, _CANCELLATION_DRAIN_TIMEOUT_SECONDS,
) )
def _log_pending_compensation_tasks(self, *, reason: str) -> None:
pending_count = sum(not task.done() for task in self._compensation_tasks)
if pending_count:
logger.warning(
"%s; %d MCP compensation task(s) remain in the background",
reason,
pending_count,
)
async def _drain_compensation_tasks(self, *, deadline: float) -> None:
"""Drain service-owned compensation work before Gateway dependencies close."""
loop = asyncio.get_running_loop()
while self._compensation_tasks:
tasks = tuple(self._compensation_tasks)
remaining = deadline - loop.time()
if remaining <= 0:
break
done, _ = await asyncio.wait(
tasks,
timeout=remaining,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
break
# Run completion callbacks before taking the next snapshot. A
# handoff may finish one task while registering another.
await asyncio.sleep(0)
self._log_pending_compensation_tasks(
reason="MCP compensation drain reached its deadline",
)
async def _drain_compensation_tasks_preserving_cancellation(self, *, deadline: float) -> None:
"""Finish the bounded drain before propagating caller cancellation."""
drain_task = asyncio.create_task(
self._drain_compensation_tasks(deadline=deadline),
name="deerflow-mcp-compensation-drain",
)
try:
await asyncio.shield(drain_task)
except asyncio.CancelledError:
await wait_for_task_until(drain_task, deadline=deadline)
if drain_task.done():
drain_task.result()
raise
async def stop(self) -> None: async def stop(self) -> None:
loop = asyncio.get_running_loop()
task = self._task task = self._task
if task is None: if task is None:
await self._drain_compensation_tasks_preserving_cancellation(
deadline=loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS,
)
return return
loop = asyncio.get_running_loop()
if self._stopping_task is not task: if self._stopping_task is not task:
self._stopping_task = task self._stopping_task = task
self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS
@ -1710,9 +1759,19 @@ class McpTaskService:
except asyncio.CancelledError: except asyncio.CancelledError:
if not await wait_for_task_until(task, deadline=deadline): if not await wait_for_task_until(task, deadline=deadline):
self._log_stop_timeout(task) self._log_stop_timeout(task)
self._log_pending_compensation_tasks(
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
)
else:
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
raise raise
if task not in done: if task not in done:
self._log_stop_timeout(task) self._log_stop_timeout(task)
self._log_pending_compensation_tasks(
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
)
return
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
async def _run_loop(self) -> None: async def _run_loop(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():

View File

@ -29,7 +29,7 @@ from deerflow.persistence.user.model import UserPreferenceRow
from deerflow.runtime import ConflictError, RunRecord from deerflow.runtime import ConflictError, RunRecord
from deerflow.runtime.run_origin import origin_kind_of from deerflow.runtime.run_origin import origin_kind_of
from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED
from deerflow.scheduler.schedules import next_run_at, should_advance_schedule_cursor from deerflow.scheduler.schedules import pauses_unschedulable, should_advance_schedule_cursor, stored_next_run_at
from deerflow.scheduler.stop_rule import launch_prompt from deerflow.scheduler.stop_rule import launch_prompt
from deerflow.trace_context import ensure_trace_context from deerflow.trace_context import ensure_trace_context
from deerflow.utils.thread_id import validate_thread_id from deerflow.utils.thread_id import validate_thread_id
@ -450,10 +450,15 @@ class ScheduledTaskService:
else: else:
await self._reconcile_active_state(now=now) await self._reconcile_active_state(now=now)
else: else:
try:
await self._task_run_repo.recover_expired_launch_claims( await self._task_run_repo.recover_expired_launch_claims(
error=_LEASE_RECOVERY_ERROR, error=_LEASE_RECOVERY_ERROR,
now=now, now=now,
) )
except Exception:
# Like the reconciliation below: a failed recovery pass must
# not keep every other due task from being admitted.
logger.exception("Failed to recover expired scheduled launch claims")
try: try:
finalized = await self._task_repo.reconcile_terminal_runs(now=now) finalized = await self._task_repo.reconcile_terminal_runs(now=now)
if finalized: if finalized:
@ -475,7 +480,17 @@ class ScheduledTaskService:
limit=self._max_concurrent_runs, limit=self._max_concurrent_runs,
) )
for task in claimed: for task in claimed:
try:
await self.dispatch_task(task, now=now, trigger="scheduled") await self.dispatch_task(task, now=now, trigger="scheduled")
except Exception:
# A per-record failure inside one task's dispatch must not abort
# the rest of this poll's claimed batch: every claimed task
# already holds a lease, and the next tick would skip them the
# same way. Claim-level store failures stay loud above the loop.
logger.exception(
"Scheduled task %s: dispatch failed; continuing with the claimed batch",
task.get("id"),
)
@staticmethod @staticmethod
def _is_overlap_conflict(exc: Exception) -> bool: def _is_overlap_conflict(exc: Exception) -> bool:
@ -530,22 +545,39 @@ class ScheduledTaskService:
*, *,
trigger: str, trigger: str,
now: datetime, now: datetime,
) -> datetime | str | None: ) -> tuple[datetime | str | None, str | None]:
"""Keep a recurring task's cadence when a manual trial launches. """Keep a recurring task's cadence when a manual trial launches.
A manual run is an extra trial and must not consume the task's next A manual run is an extra trial and must not consume the task's next
scheduled occurrence. One-time tasks retain their existing boundary scheduled occurrence. One-time tasks retain their existing boundary
semantics, so only recurring manual launches keep the stored value. semantics, so only recurring manual launches keep the stored value.
The second value is why a stored schedule no longer computes; the
launch bookkeeping then pauses the task instead of raising out of the
poll (see ``_unschedulable_outcome``).
""" """
if not should_advance_schedule_cursor(task["schedule_type"], trigger=trigger): if not should_advance_schedule_cursor(task["schedule_type"], trigger=trigger):
return task.get("next_run_at") return task.get("next_run_at"), None
return next_run_at( return stored_next_run_at(
task["schedule_type"], task["schedule_type"],
task["schedule_spec"], task["schedule_spec"],
task["timezone"], task["timezone"],
now=now, now=now,
) )
@staticmethod
def _unschedulable_outcome(
task: dict[str, Any],
*,
status: str,
last_error: str | None,
schedule_error: str | None,
) -> tuple[str, str | None]:
"""The status and ``last_error`` to write, pausing an unschedulable task."""
if not pauses_unschedulable(task["schedule_type"], status, schedule_error):
return status, last_error
logger.warning("Paused scheduled task %s: %s", task["id"], schedule_error)
return "paused", schedule_error
async def dispatch_task( async def dispatch_task(
self, self,
task: dict[str, Any], task: dict[str, Any],
@ -570,15 +602,21 @@ class ScheduledTaskService:
# surface as HTTP 500 on manual trigger and, in the poller, abort # surface as HTTP 500 on manual trigger and, in the poller, abort
# the rest of the claimed batch every cycle while the task itself # the rest of the claimed batch every cycle while the task itself
# is never marked with last_error. # is never marked with last_error.
task_status = self._task_status_for_failure(task, trigger=trigger) next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now)
task_status, last_error = self._unschedulable_outcome(
task,
status=self._task_status_for_failure(task, trigger=trigger),
last_error=str(exc),
schedule_error=schedule_error,
)
await self._task_repo.update_after_launch( await self._task_repo.update_after_launch(
task["id"], task["id"],
status=task_status, status=task_status,
next_run_at=self._next_run_at_after_launch(task, trigger=trigger, now=now), next_run_at=next_at,
last_run_at=now, last_run_at=now,
last_run_id=None, last_run_id=None,
last_thread_id=execution_thread_id, last_thread_id=execution_thread_id,
last_error=str(exc), last_error=last_error,
increment_run_count=False, increment_run_count=False,
expected_lease_owner=expected_lease_owner, expected_lease_owner=expected_lease_owner,
) )
@ -736,8 +774,13 @@ class ScheduledTaskService:
launch_succeeded = True launch_succeeded = True
launched_run_id = result["run_id"] launched_run_id = result["run_id"]
launched_thread_id = result["thread_id"] launched_thread_id = result["thread_id"]
next_at = self._next_run_at_after_launch(task, trigger=trigger, now=now) next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now)
task_status = self._task_status_for_launch(task, trigger=trigger, next_at=next_at) task_status, last_error = self._unschedulable_outcome(
task,
status=self._task_status_for_launch(task, trigger=trigger, next_at=next_at),
last_error=None,
schedule_error=schedule_error,
)
await self._record_launched_run( await self._record_launched_run(
task_run_id=task_run_id, task_run_id=task_run_id,
task_id=task["id"], task_id=task["id"],
@ -751,7 +794,7 @@ class ScheduledTaskService:
last_run_at=now, last_run_at=now,
last_run_id=launched_run_id, last_run_id=launched_run_id,
last_thread_id=launched_thread_id, last_thread_id=launched_thread_id,
last_error=None, last_error=last_error,
increment_run_count=True, increment_run_count=True,
task_run_id=task_run_id, task_run_id=task_run_id,
# Same race as the run-row write above: a fast-failing run's # Same race as the run-row write above: a fast-failing run's
@ -774,8 +817,6 @@ class ScheduledTaskService:
) )
return self._queued_result(task_run_id, execution_thread_id, error=str(exc)) return self._queued_result(task_run_id, execution_thread_id, error=str(exc))
next_at = self._next_run_at_after_launch(task, trigger=trigger, now=now)
if launch_succeeded: if launch_succeeded:
# _launch_run succeeded, so a run is live even though # _launch_run succeeded, so a run is live even though
# post-launch bookkeeping raised. Keep the task-run row # post-launch bookkeeping raised. Keep the task-run row
@ -786,7 +827,13 @@ class ScheduledTaskService:
# if the DB is still down the row stays "queued" -- still # if the DB is still down the row stays "queued" -- still
# active, still holding the slot -- so we log and still report # active, still holding the slot -- so we log and still report
# the run as launched so callers know a run is in flight. # the run as launched so callers know a run is in flight.
task_status = self._task_status_for_launch(task, trigger=trigger, next_at=next_at) next_at, schedule_error = self._next_run_at_after_launch(task, trigger=trigger, now=now)
task_status, last_error = self._unschedulable_outcome(
task,
status=self._task_status_for_launch(task, trigger=trigger, next_at=next_at),
last_error=None,
schedule_error=schedule_error,
)
try: try:
await self._record_launched_run( await self._record_launched_run(
task_run_id=task_run_id, task_run_id=task_run_id,
@ -816,7 +863,7 @@ class ScheduledTaskService:
# on a task whose run is actively running; the real # on a task whose run is actively running; the real
# terminal outcome is written by handle_run_completion. # terminal outcome is written by handle_run_completion.
# The transient itself is logged above. # The transient itself is logged above.
last_error=None, last_error=last_error,
increment_run_count=True, increment_run_count=True,
task_run_id=task_run_id, task_run_id=task_run_id,
protect_terminal=True, protect_terminal=True,

View File

@ -0,0 +1,144 @@
"""Native batch follow-ups through the existing revocable AgentRuns host."""
import copy
import json
import logging
from deerflow_extension_api.agent_runs import AgentRunError, ExecutionFence
from deerflow.agents.human_input import extract_unanswered_human_input_request
from deerflow.mcp_scope import THREAD_INCARNATION_CONTEXT_KEY
from deerflow.runtime.goal import goal_waits_for_external_result, read_thread_goal
from deerflow.runtime.runs.schemas import GOAL_RUN_HARD_STOP_REASONS
logger = logging.getLogger(__name__)
_TRUSTED = object()
class BatchContinuations:
def __init__(self, app):
self.app = app
async def prepare(self, request):
from app.gateway.services import _CONTEXT_CONFIGURABLE_KEYS, _MODEL_SELECTION_CONTEXT_KEYS
host = self.app.state.agent_runs_host
grant = host.capture_batch_authority(request.continuation_authority, user_id=request.user_id)
source = await request.continuation_authority.get(thread_id=request.thread_id, run_id=request.run_id)
if source.status not in ("pending", "running"):
raise AgentRunError(409, "The originating Run has already stopped")
record = await self.app.state.run_manager.get(request.run_id, user_id=request.user_id)
if record is None or record.user_id != request.user_id or record.thread_id != request.thread_id:
raise AgentRunError(403, "Batch follow-up must belong to its originating Run")
captured = request.continuation_context or {}
if (record.metadata or {}).get("deerflow_origin", {}).get("kind") == "subagent_batch" and (captured.get("goal") or {}).get("status") != "active":
raise AgentRunError(409, "A batch follow-up cannot register another automatic follow-up")
context = {key: value for key, value in captured.get("context", {}).items() if key in _CONTEXT_CONFIGURABLE_KEYS | _MODEL_SELECTION_CONTEXT_KEYS and key not in ("agent_name", "is_bootstrap")}
if isinstance(captured.get("context", {}).get("locale"), str):
context["locale"] = captured["context"]["locale"]
scope = request.execution_spec.get("knowledge_scope")
return {
"prompt": request.continuation,
"grant": grant,
"thread_incarnation": request.execution_spec.get(THREAD_INCARNATION_CONTEXT_KEY),
"goal": copy.deepcopy(captured.get("goal")),
"context": copy.deepcopy(context),
"config": copy.deepcopy(captured.get("config", {})),
"knowledge_scope": copy.deepcopy(scope),
}
async def dispatch(self, batch):
continuation = batch["continuation"]
try:
runs = await self.app.state.agent_runs_host.restore_batch_authority(user_id=batch["user_id"], grant=continuation["grant"])
existing_id = await self.app.state.subagent_batch_repo.continuation_run_id(batch["id"], user_id=batch["user_id"])
if existing_id is not None:
existing = await runs.get(thread_id=batch["thread_id"], run_id=existing_id)
return {"status": "dispatched", "run_id": existing.run_id}
source = await runs.get(thread_id=batch["thread_id"], run_id=batch["run_id"])
if source.status in ("pending", "running"):
return None
if source.status != "success" or source.stop_reason in GOAL_RUN_HARD_STOP_REASONS:
return {"status": "suppressed"}
thread = await self.app.state.thread_store.get(batch["thread_id"], user_id=batch["user_id"])
if thread is None or thread.get("incarnation") != continuation.get("thread_incarnation"):
return {"status": "suppressed"}
state = await runs.get_state(thread_id=batch["thread_id"])
values = state.get("values", {})
if extract_unanswered_human_input_request(values.get("messages")) is not None:
return {"status": "waiting"}
goal = values.get("goal")
goal_identity = {key: goal.get(key) for key in ("created_at", "status", "objective")} if isinstance(goal, dict) else None
expected_goal = continuation.get("goal")
if goal_identity != expected_goal:
return {"status": "suppressed"}
goal_wakeup = None
if expected_goal is not None:
latest = await self.app.state.subagent_batch_repo.latest_run_state(batch["thread_id"], user_id=batch["user_id"])
if latest and latest["status"] in ("pending", "running"):
return None
if latest and (latest["status"] != "success" or latest.get("stop_reason") in GOAL_RUN_HARD_STOP_REASONS):
return {"status": "suppressed"}
if not goal_waits_for_external_result(goal, expected_goal):
return None if not (goal.get("last_evaluation") or {}).get("stand_down_reason") else {"status": "suppressed"}
goal_wakeup = expected_goal
public = await self.app.state.subagent_batch_repo.get_batch(batch["id"], user_id=batch["user_id"])
if public is None or public.get("continuation_status") == "cancelled" or public["status"] == "cancelled":
return {"status": "suppressed"}
if public["status"] not in ("completed", "failed"):
return None
# Stable input is essential after an unknown admission outcome.
# Counts/results stay in the existing owner-scoped result reader.
content = "The requested background batch has finished. Continue the agreed follow-up using its stored results; preserve any failures or missing information.\n" + json.dumps(
{"batch_id": batch["id"], "follow_up": continuation["prompt"]}, ensure_ascii=False
)
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY
metadata = {"hide_from_ui": True, "untrusted_input": True}
if continuation.get("knowledge_scope") is not None:
metadata[KNOWLEDGE_SCOPE_KEY] = continuation["knowledge_scope"]
result = await runs.start_batch_followup(
thread_id=batch["thread_id"],
batch_id=batch["id"],
input={"messages": [{"role": "user", "content": content, "additional_kwargs": metadata}]},
context=continuation.get("context"),
config=continuation.get("config"),
execution_fence=ExecutionFence(**batch["execution_fence"]),
goal_wakeup=goal_wakeup,
)
return {"status": "dispatched", "run_id": result.run_id}
except AgentRunError as exc:
if exc.status_code == 409:
return None
if exc.status_code in (400, 401, 403, 404, 422):
return {"status": "suppressed"}
raise
async def cancel(self, batch_id, user_id, *, thread_id=None, authority=_TRUSTED, authorize_only=False):
if authority is not _TRUSTED:
self.app.state.agent_runs_host.capture_batch_authority(authority, user_id=user_id)
async with authority._request("runs:cancel", thread_id):
if authorize_only:
return
elif authorize_only:
return
run_id = await self.app.state.subagent_batch_repo.continuation_run_id(batch_id, user_id=user_id)
if run_id is None:
return
record = await self.app.state.run_manager.get(run_id, user_id=user_id)
if record is not None:
await self.app.state.run_manager.cancel(run_id)
async def source_finished(self, record):
if record.stop_reason == "goal_wakeup_superseded":
return
if record.status == "success" and record.stop_reason not in GOAL_RUN_HARD_STOP_REASONS:
return
for batch in await self.app.state.subagent_batch_repo.list_continuation_batches_for_run(record.run_id):
try:
await self.app.state.subagent_batch_service.cancel_batch(batch_id=batch["id"], user_id=batch["user_id"])
except Exception:
logger.warning("Batch %s cancellation failed for run %s (non-fatal)", batch["id"], record.run_id, exc_info=True)
goal = await read_thread_goal(self.app.state.checkpointer, record.thread_id)
if goal and goal.get("status") == "active" and goal.get("created_at"):
await self.app.state.subagent_batch_repo.suppress_thread_continuations(record.thread_id, goal_created_at=goal["created_at"])

View File

@ -188,6 +188,11 @@ only letters, digits and hyphens; any other value is rejected with `422`.
Branches preserve valid stored IDs, including their original spelling. If a Branches preserve valid stored IDs, including their original spelling. If a
legacy source has an invalid ID, its new branch stores `null` and uses the lead agent. legacy source has an invalid ID, its new branch stores `null` and uses the lead agent.
`metadata` is stored on the thread as sent and copied into its first
checkpoint, except for the keys the server owns there: `step`, `source`,
`writes`, `parents`, `created_at`, `counters_since_delta_snapshot` and
`deerflow_agent_name`.
**Response:** **Response:**
```json ```json
{ {
@ -351,7 +356,7 @@ does not accept this header and keeps the existing missing-stream close of
**Run Option Compatibility:** **Run Option Compatibility:**
- Supported concurrency strategies: `reject`, `rollback`, and `interrupt` - Supported concurrency strategies: `reject`, `rollback`, and `interrupt`
- Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior - Compatibility default: `if_not_exists="create"`; this matches DeerFlow's current behavior
- Artifact delivery is enforced automatically when a run creates or modifies regular files under `/mnt/user-data/outputs`. `present_files` must present at least one path produced by the current run (or a directory containing it), and the terminal receipt must be persisted; presenting only an unrelated file does not satisfy delivery. Runs without changed outputs retain ordinary conversational behavior. `artifact_delivery` is not a client-settable run option. - Artifact delivery is enforced automatically when a run creates or modifies regular files under `/mnt/user-data/outputs`. `present_files` or `present_shared_artifact` must present at least one path produced by the current run (or a directory containing it), and the terminal receipt must be persisted; presenting only an unrelated file does not satisfy delivery. Runs without changed outputs retain ordinary conversational behavior. `artifact_delivery` is not a client-settable run option.
- Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"` - Unsupported options return `422`: `webhook`, `stream_resumable=true`, `after_seconds`, `feedback_keys`, any non-null `on_completion` value (including the SDK values `"complete"` and `"continue"`), `if_not_exists="reject"`, and `multitask_strategy="enqueue"`
- `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves - `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves
- Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded - Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded
@ -1458,7 +1463,7 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
### Thread Cleanup ### Thread Cleanup
Remove DeerFlow-managed local thread files under `.deer-flow/threads/{thread_id}` after the LangGraph thread itself has been deleted. Delete a thread's checkpoint data and DeerFlow-managed files and metadata.
```http ```http
DELETE /api/threads/{thread_id} DELETE /api/threads/{thread_id}
@ -1475,6 +1480,14 @@ DELETE /api/threads/{thread_id}
**Error behavior:** **Error behavior:**
- `422` for invalid thread IDs - `422` for invalid thread IDs
- `500` returns a generic `{"detail": "Failed to delete local thread data."}` response while full exception details stay in server logs - `500` returns a generic `{"detail": "Failed to delete local thread data."}` response while full exception details stay in server logs
- `500` with `{"detail": "Failed to delete thread checkpoints."}` when a configured
checkpointer cannot delete the thread's checkpoints. The owner row is retained
for a retry.
- `409` with `Thread has work in flight. Delete it after the work finishes.` when
thread work blocks deletion. After a checkpoint error, a retry can also return
this response until the durable delete reservation is released or reconciled.
In a single-worker deployment with ownership heartbeats disabled, a stranded
reservation is reconciled at Gateway startup.
Deleting a thread also removes every user's read marker for it. Deleting a thread also removes every user's read marker for it.

View File

@ -109,7 +109,7 @@ The web conversation delete flow first deletes Gateway-managed thread state thro
**Trash tier (spec §8).** Trashed rows are invisible to the shelf index, tools, and listing APIs; they surface only through the `/api/trash` routes with their `trash_origin` display snapshot. Restore is a database re-point — `stored_relpath` is projects-root-relative, so no file ever moves (spec §10.6) — inside one transaction that locks the target active project row, then the source and any merge-candidate rows in ID order; identical active bytes in the target merge (trash row deleted, discarded namespace unlinked best-effort after commit), and a read-only existence/size check under the document lock rejects restores of missing or size-mismatched content with `409 content_missing`, leaving the row trashed. Purge holds the document-row lock continuously across trashed-state revalidation, unlink of the original and `derived/converted.md`, row deletion, and commit: already-absent content counts as removed, any other unlink error rolls back and keeps the row retryable, and retention callers revalidate the selected trash timestamp and cutoff under the lock so a restored-and-re-trashed row is never purged on its former expiry. Empty trash (`POST /api/trash/purge`) runs that same guarded purge once per trashed row of the caller with no age filter — the confirmation covers the whole listing — so the retention cutoff gates only the sweep's candidate selection. The retention sweep runs lazily on `GET /api/trash/documents` and once at gateway startup (no daemon): it purges rows at or past `projects.trash_retention_days` through the same guarded path, then reconciles storage — `.staging/*` entries and unreferenced files older than 24 hours are removed (nothing younger is ever collected; any active or trashed row protects its whole namespace, even after restore to another project) — and detects rows with missing or size-mismatched content, logging them at warning and surfacing them as `content_missing` without ever deleting the row (the row is the user's only record; disposal always starts with the user trashing it). **Trash tier (spec §8).** Trashed rows are invisible to the shelf index, tools, and listing APIs; they surface only through the `/api/trash` routes with their `trash_origin` display snapshot. Restore is a database re-point — `stored_relpath` is projects-root-relative, so no file ever moves (spec §10.6) — inside one transaction that locks the target active project row, then the source and any merge-candidate rows in ID order; identical active bytes in the target merge (trash row deleted, discarded namespace unlinked best-effort after commit), and a read-only existence/size check under the document lock rejects restores of missing or size-mismatched content with `409 content_missing`, leaving the row trashed. Purge holds the document-row lock continuously across trashed-state revalidation, unlink of the original and `derived/converted.md`, row deletion, and commit: already-absent content counts as removed, any other unlink error rolls back and keeps the row retryable, and retention callers revalidate the selected trash timestamp and cutoff under the lock so a restored-and-re-trashed row is never purged on its former expiry. Empty trash (`POST /api/trash/purge`) runs that same guarded purge once per trashed row of the caller with no age filter — the confirmation covers the whole listing — so the retention cutoff gates only the sweep's candidate selection. The retention sweep runs lazily on `GET /api/trash/documents` and once at gateway startup (no daemon): it purges rows at or past `projects.trash_retention_days` through the same guarded path, then reconciles storage — `.staging/*` entries and unreferenced files older than 24 hours are removed (nothing younger is ever collected; any active or trashed row protects its whole namespace, even after restore to another project) — and detects rows with missing or size-mismatched content, logging them at warning and surfacing them as `content_missing` without ever deleting the row (the row is the user's only record; disposal always starts with the user trashing it).
**Document summaries (opt-in).** `project_documents.summary` is a nullable, best-effort LLM one-liner. The two shelf write routes enqueue a job after row creation (`projects/summaries.py`, off unless `projects.summaries_enabled`); a bounded worker pool (`summary_concurrency` + bounded `summary_queue_size`, full queue drops) resolves text through `read_text_serving_path` (never re-deriving the convertible-first rule), calls the model via `run_oneshot_llm` with a caller-side wait limit (the worker slot is held until the provider call actually finishes; late results are discarded), rejects length-capped output through the shared provider-normalized detectors, and applies a write-time contract (unwrap → single line → empty is NULL → byte cap on a character boundary) before persisting with `updated_at` pinned so a landing summary never reorders the shelf. The summary rides the row's lifecycle (trash/restore/purge need no hooks), renders into the `<documents>` index and `list_project_documents` tag-neutralized at both exits, and is counted through `eligible/success/failed/skipped/queue_dropped/uncapped_accepted` metrics. All failure modes leave `summary = NULL`, which renders exactly like a shelf without the feature. **Document summaries (default on).** `project_documents.summary` is a nullable, best-effort LLM one-liner. The two shelf write routes enqueue a job after row creation (`projects/summaries.py`, enabled by default; disabled with `projects.summaries_enabled: false`); a bounded worker pool (`summary_concurrency` + bounded `summary_queue_size`, full queue drops) resolves text through `read_text_serving_path` (never re-deriving the convertible-first rule), calls the model via `run_oneshot_llm` with a caller-side wait limit (the worker slot is held until the provider call actually finishes; late results are discarded), rejects length-capped output through the shared provider-normalized detectors, and applies a write-time contract (unwrap → single line → empty is NULL → byte cap on a character boundary) before persisting with `updated_at` pinned so a landing summary never reorders the shelf. The summary rides the row's lifecycle (trash/restore/purge need no hooks), renders into the `<documents>` index and `list_project_documents` tag-neutralized at both exits, and is counted through `eligible/success/failed/skipped/queue_dropped/uncapped_accepted` metrics. All failure modes leave `summary = NULL`, which renders exactly like a shelf without the feature.
### Agent Architecture ### Agent Architecture

View File

@ -1494,7 +1494,7 @@ done
docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10 docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10
``` ```
**预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头把请求归入 `default` 用户桶。 **预期:** 无 auth 相关错误。渠道不依赖浏览器 cookie;服务端通过内部认证头接受请求,run 归入绑定 owner 的用户桶(未绑定时为规范化后的平台用户 ID;仅 `DEER_FLOW_AUTH_DISABLED=1` 时为 `default`)。
#### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志) #### TC-DOCKER-05: reset_admin 密码写入 0600 凭证文件(不再走日志)

View File

@ -127,7 +127,7 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
- **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化 - **本地开发**(`make dev`):Gateway embedded runtime 完全兼容;无 admin 时访问 `/setup` 初始化
- **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API - **Gateway embedded runtime**:标准脚本、Docker dev 和生产部署均通过 Gateway 提供认证与 LangGraph-compatible API
- **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载 - **Docker 部署**:完全兼容,`.deer-flow/data/deerflow.db` 需持久化卷挂载
- **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信,使用 `default` 用户桶 - **IM 渠道**(Feishu/Slack/Telegram):通过 Gateway 内部认证通信。已通过 `/connect` 绑定的发送者使用绑定 owner 的用户桶,未绑定的发送者使用规范化后的平台用户 ID;只有设置 `DEER_FLOW_AUTH_DISABLED=1` 的单用户模式才统一使用 `default` 用户桶。详见 [IM_CHANNEL_CONNECTIONS.md](IM_CHANNEL_CONNECTIONS.md)
- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响 - **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响
## 故障排查 ## 故障排查

View File

@ -0,0 +1,64 @@
# Native batch dependencies and follow-up
Optional fields extend the existing SQL-backed batch service. Omitting them keeps
independent batch execution. Items remain read-only or idempotent under the existing
retry/recovery policy.
## Dependencies
Use `depends_on` to name prerequisite item keys in the same batch. Unknown keys,
self-references and cycles are rejected before saving. Dependent items consume no
worker slot until every prerequisite succeeds without a guardrail stop reason.
Retryable attempts keep dependents waiting; exhausted failures, cancellation or
terminal capped results fail unstarted descendants. Independent items continue.
Retrying a failed prerequisite by hand does not requeue the descendants it already
failed; retry each blocked item as well.
Item prompts must name their input/output paths. Ordering does not certify factual
accuracy or make advisory acceptance mandatory. Use existing result readers; full
reports are not copied into every dependent's context.
## Requested follow-up
`continuation` describes remaining work the user already requested. After terminal
batch results are saved, the lead performs that work in the original conversation
through ordinary Run admission. Preserve the originating model selection, reasoning,
limits and admitted retrieval scope. Busy conversations wait. While a question is
unanswered the claim is kept until its lease expires, so the conversation is not
reread on every poll.
A stable idempotency key reconciles lost admission replies without another Run.
Pending claims rotate so busy conversations do not starve others. Manual item retries
hold an unadmitted follow-up but do not re-arm one already dispatched. Without a Goal,
a follow-up cannot request another automatic follow-up.
An active Goal wakes only from its own `external_wait`, consuming one existing
continuation allowance, once even after recovery. Other blockers, no-progress limits
and hard stops remain effective. Goal replacement, clearing and thread deletion
suppress pending work; a failed, cancelled or hard-stopped source Run cannot restart.
## Authority, cancellation and recovery
Follow-up needs a session-authorized Gateway AgentRuns capability or local auth-disabled
mode. Internal/PAT callers and unsupported embedded runtimes retain ordinary batches.
Persist only the original permission ceiling, account token version and auth source;
no cookie, API key or Python capability. Restore checks current account/permissions,
conversation ownership and incarnation. Private continuation data is not returned.
Batch cancellation revokes the existing SQL execution fence and stops an admitted
follow-up, including after a lost launch reply. HTTP and tool cancellation of a batch
with a follow-up also require `runs:cancel`. Trusted internal cleanup retains its existing
path. Claim ownership/expiry lives only in the existing execution-lease table.
Completed item results remain available. The Web panel discovers follow-up Runs
through existing observers; refresh never launches work. Chat Stop remains available.
## Upgrade and verification
Migration `0052_batch_continuation` adds dependency edges and nullable follow-up data,
status and Run ID. Existing rows need no backfill. Upgrade all batch workers before
using dependencies. Do not downgrade with opted-in work outstanding; downgrade drops
new metadata while retaining items and reports.
Focused regressions cover dependency/retry ordering, SQL claims and recovery,
permission revocation, admission races, Goal limits and page reconnection. PostgreSQL
coverage uses `TEST_POSTGRES_URI`; browser backend timing is simulated.

View File

@ -323,7 +323,7 @@ are:
| Provider ID | Service / credential scope | Endpoint | Protocol / authentication | | Provider ID | Service / credential scope | Endpoint | Protocol / authentication |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `openai` | OpenAI API | `https://api.openai.com/v1` | Chat Completions / Bearer | | `openai` | OpenAI API | `https://api.openai.com/v1` | Responses for the GPT-6 IDs below; otherwise Chat Completions / Bearer |
| `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` | | `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` |
| `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer | | `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer |
| `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer | | `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer |
@ -334,6 +334,7 @@ are:
| `zai` | Z.AI, international ordinary API | `https://api.z.ai/api/paas/v4` | Chat Completions / Bearer | | `zai` | Z.AI, international ordinary API | `https://api.z.ai/api/paas/v4` | Chat Completions / Bearer |
| `minimax` | MiniMax, international API | `https://api.minimax.io/v1` | Chat Completions / Bearer | | `minimax` | MiniMax, international API | `https://api.minimax.io/v1` | Chat Completions / Bearer |
| `minimax-cn` | MiniMax, China API | `https://api.minimaxi.com/v1` | Chat Completions / Bearer | | `minimax-cn` | MiniMax, China API | `https://api.minimaxi.com/v1` | Chat Completions / Bearer |
| `stepfun` | StepFun, ordinary API | `https://api.stepfun.com/v1` | Chat Completions / Bearer |
| `openai-compatible` | Custom OpenAI-compatible, including unauthenticated local endpoints | Administrator-supplied HTTP(S) base URL | Chat Completions / optional key | | `openai-compatible` | Custom OpenAI-compatible, including unauthenticated local endpoints | Administrator-supplied HTTP(S) base URL | Chat Completions / optional key |
Keys for different regions and Coding Plans are not interchangeable. These Keys for different regions and Coding Plans are not interchangeable. These
@ -342,6 +343,47 @@ Plan from the model name. Plans/regions without a preset can use the custom
OpenAI-compatible option if their documented protocol is compatible; native OpenAI-compatible option if their documented protocol is compatible; native
protocols need a verified backend preset or operator YAML configuration. protocols need a verified backend preset or operator YAML configuration.
The StepFun preset uses the existing `PatchedChatStepFun` adapter to capture
`reasoning` / `reasoning_content` in responses and replay reasoning history.
Both connection probes and chat send the documented `max_tokens` output bound.
Model IDs remain free-form; this provider registration does not infer thinking,
vision or context-window capabilities from their names. The preset is for
ordinary API credentials. Step Plan has a separate `/step_plan/v1` endpoint;
use the custom OpenAI-compatible option for that channel. See the official
[OpenAI migration guide](https://platform.stepfun.com/docs/zh/guides/developer/openai)
and [Chat Completions reference](https://platform.stepfun.com/docs/zh/api-reference/chat/chat-completion-create).
Settings → Models → **Fetch and add models** reads the connection's model
catalog without changing configuration. Administrators can reuse an existing
saved connection without exposing its key, search IDs, select visible results,
and test/add the selection. Each selected new model must pass the existing
streaming tool-call probe before it is enabled. Tests can incur provider charges.
Failures are displayed individually; stop/retry preserves successful imports and
deduplicates by provider, endpoint, credential and model ID. Existing disabled
profiles remain disabled. Renaming and enabling/disabling stay in the model editor.
Discovery uses the provider's model-list endpoint (including Anthropic pagination)
and reports unsupported endpoints for manual entry. It follows no redirects and
caps reads at 20 seconds, 2 MiB and 2,000 model IDs/20 pages; truncated catalogs are
marked. IDs do not establish chat/tool capabilities. No background synchronization
or automatic enabling occurs just because a model appears in the catalog.
Chat model pickers search display names, configured names, IDs and provider labels,
with provider filtering and favorites/recent groups. The eight recent selections
are browser-local and user-scoped, and only currently available models appear.
Selecting a model continues to use the existing conversation/account preferences.
The `openai` preset recognizes the exact IDs `gpt-6-astra`, `gpt-6.1-sol`,
`gpt-6.1-terra`, `gpt-6-sol` and `gpt-6-luna`. They use the Responses API
for both the connection probe and chat, including tool calls and tool-result
follow-ups. Astra and GPT-6.1 Sol require thinking, default to `medium`, and
accept `low`, `medium`, `high`, `xhigh` and `max`. The other three also accept
`none`: disabling thinking uses `none`, while enabling it without an explicit
effort uses `medium`. Generic `minimal` maps to `low`. Image input is enabled.
Explicit capability overrides remain supported; routing stays provider-owned.
Unknown IDs, older OpenAI presets and custom endpoints keep their existing
behavior. See OpenAI's [GPT-6 protocol and parameter guide](https://developers.openai.com/api/docs/guides/latest-model).
The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and
`kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history, `kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history,
and accept top-level `reasoning_effort` values `low`, `high` and `max`, with and accept top-level `reasoning_effort` values `low`, `high` and `max`, with
@ -813,7 +855,7 @@ Notes:
scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx
scripts/dev_multi_instance.sh check # automated cross-instance checks (below) scripts/dev_multi_instance.sh check # automated cross-instance checks (below)
scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back
scripts/dev_multi_instance.sh logs a -f scripts/dev_multi_instance.sh logs a -f # follow Gateway A; also b, nginx, or all (both Gateways and nginx)
scripts/dev_multi_instance.sh down scripts/dev_multi_instance.sh down
``` ```
@ -891,6 +933,16 @@ agent_storage:
- `agent_storage` is restart-required (the backend is captured at Gateway lifespan startup). - `agent_storage` is restart-required (the backend is captured at Gateway lifespan startup).
- In a multi-worker Postgres deployment (`GATEWAY_WORKERS > 1`), leaving `agent_storage.backend: file` logs a startup warning — agents written to one node's local disk are invisible to the others, which is exactly the divergence the `db` backend fixes. - In a multi-worker Postgres deployment (`GATEWAY_WORKERS > 1`), leaving `agent_storage.backend: file` logs a startup warning — agents written to one node's local disk are invisible to the others, which is exactly the divergence the `db` backend fixes.
Managed subagents also follow this backend selection. With `file`, their directory
must support hard links: creation atomically publishes a fully written temporary
file without replacing an existing name. Workers sharing that directory therefore
return one HTTP 201 and one HTTP 409 for concurrent same-name creates. Other
filesystem errors propagate rather than falling back to an overwrite.
Gateway startup probes hard-link creation once per worker in that directory,
off the event loop, and logs a warning if the probe fails. Startup continues;
use a volume with working hard links or migrate to `backend: db` before creating
managed subagents on an incompatible volume. Probe files are removed afterward.
Migrating an existing install from `file` to `db`: Migrating an existing install from `file` to `db`:
Run from the repository root using the backend's `uv` environment. The importer Run from the repository root using the backend's `uv` environment. The importer
@ -1262,8 +1314,8 @@ offline mocked tests verify request composition and local filtering, not provide
retrieval behavior. No paid API calls are needed for the regression suite. retrieval behavior. No paid API calls are needed for the regression suite.
**Built-in Tools**: **Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya, You.com) - `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya, SpecStream, You.com)
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, Unbrowse) - `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, SpecStream, Unbrowse)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless) - `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave) - `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)
- `ls` - List directory contents - `ls` - List directory contents
@ -1330,6 +1382,14 @@ shared (`100.64.0.0/10`, used by CGNAT and Tailscale), other non-global, or
cloud-metadata addresses; set `allow_private_addresses: true` only when you cloud-metadata addresses; set `allow_private_addresses: true` only when you
intentionally point the tool at an internal target. intentionally point the tool at an internal target.
Each Browserless tool entry reads its request timeout from `timeout_s`, with
`timeout` as an alias when `timeout_s` is absent. The default is 30 seconds.
Zero, negative, and non-finite values fall back to the default with a warning.
Strings that cannot be parsed as numbers also trigger this fallback. HTTPX can
time out immediately for non-positive values. It raises an error for NaN and
applies no deadline for positive infinity. Browserless preserves positive finite
values in seconds, including very large values.
Both `web_fetch` (Browserless provider) and `web_capture` need a running Both `web_fetch` (Browserless provider) and `web_capture` need a running
Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/) Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/)
(set `BROWSERLESS_TOKEN`) or run one locally with Docker: (set `BROWSERLESS_TOKEN`) or run one locally with Docker:
@ -1596,6 +1656,7 @@ sandbox:
request_timeout: 30 # management request timeout seconds request_timeout: 30 # management request timeout seconds
ready_timeout: 30 # create/readiness timeout seconds ready_timeout: 30 # create/readiness timeout seconds
use_server_proxy: false # proxy execd/file traffic through server use_server_proxy: false # proxy execd/file traffic through server
secure_access: false # per-sandbox endpoint token; Kubernetes + gateway mode only
sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup sandbox_timeout: 14400 # remote lifetime; 0 = explicit cleanup
bash_command_timeout: 600 # default remote command timeout seconds bash_command_timeout: 600 # default remote command timeout seconds
replicas: 3 # active + warm cap per gateway process replicas: 3 # active + warm cap per gateway process
@ -1604,6 +1665,10 @@ sandbox:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
``` ```
`use_server_proxy` and `secure_access` accept YAML booleans. Omitted or `null`
values default to `false`. Quoted values such as `"false"` cause a provider
startup error.
Install the optional SDK before selecting this provider: Install the optional SDK before selecting this provider:
```bash ```bash
@ -1931,7 +1996,7 @@ while WSL resolves it to `/mnt/d/data/report.csv`. WSL-native files can instead
use `/home/alice/...`. Native Windows paths can use forward slashes as above, use `/home/alice/...`. Native Windows paths can use forward slashes as above,
or single-quoted backslashes (`'D:\data'`). Plain, unquoted YAML scalars also keep or single-quoted backslashes (`'D:\data'`). Plain, unquoted YAML scalars also keep
backslashes literal; only double-quoted scalars interpret backslash escapes. backslashes literal; only double-quoted scalars interpret backslash escapes.
These are path examples; the [local development prerequisites](../../README.md#option-2-local-development) These are path examples; the [local development prerequisites](../../docs/guide/deployment.md#option-2-local-development)
still apply, including the documented WSL limitations of the startup scripts. still apply, including the documented WSL limitations of the startup scripts.
**Gateway in Docker:** both bundled Compose files mount the repository's **Gateway in Docker:** both bundled Compose files mount the repository's
@ -2035,6 +2100,7 @@ models:
- `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`) - `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`)
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing) - `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
- `SOFYA_API_KEY` - [Sofya](https://sofya.co) key for `web_search` and `web_fetch` - `SOFYA_API_KEY` - [Sofya](https://sofya.co) key for `web_search` and `web_fetch`
- `SPECSTREAM_API_KEY` - [SpecStream](https://specstream.ai) key for `web_search` and `web_fetch`
- `UNBROWSE_API_KEY` - [Unbrowse](https://unbrowse.ai) key for `web_fetch` - `UNBROWSE_API_KEY` - [Unbrowse](https://unbrowse.ai) key for `web_fetch`
- `YDC_API_KEY` - You.com Search API key for `web_search` (optional: without it the provider uses You.com's keyless free-tier endpoint) - `YDC_API_KEY` - You.com Search API key for `web_search` (optional: without it the provider uses You.com's keyless free-tier endpoint)
- `BROWSERLESS_TOKEN` - Browserless token for `web_fetch` (Browserless provider) and `web_capture`, sent as the `token` query parameter (required by Browserless Cloud and by a self-hosted instance started with `TOKEN`) - `BROWSERLESS_TOKEN` - Browserless token for `web_fetch` (Browserless provider) and `web_capture`, sent as the `token` query parameter (required by Browserless Cloud and by a self-hosted instance started with `TOKEN`)
@ -2212,6 +2278,92 @@ only when the agent is expected to edit files or run commands for a trusted
task. task.
## HITL Tool Approval
Set `hitl_approval.enabled: true` to gate bound tools immediately before execution.
The default is disabled for existing installations. When enabled, unknown tools,
file writes, shell commands, and both personal and deployment MCP tools are
reviewed. Concrete host read/navigation tools can run directly; an MCP server's
read-only annotation never grants permission.
```yaml
hitl_approval:
enabled: true
default_mode: ask
allowed_modes: [ask, auto]
review_unknown_tools: true
tools: []
allow_tools: []
deny_tools: []
always_ask_tools: []
reviewer_model: null
reviewer_timeout_seconds: 30
reviewer_max_input_bytes: 65536
max_payload_bytes: 524288
child_approval_timeout_seconds: 300
non_interactive: deny
max_actions: 32
```
| Mode | Behavior |
| --- | --- |
| `ask` | Present an approval card for operations requiring review. |
| `auto` | Independently assess the concrete operation against user intent. Allow low/medium-risk authorized operations; ask for uncertainty or high risk; deny critical violations. |
| `full` | Skip individual approvals within mandatory deployment and account restrictions. |
Clients select `context.tool_approval_mode` per run from the operator's `allowed_modes`.
The default ceiling permits Ask and Auto. Full requires explicitly adding `full`
to that operator-owned list; the client selection is not persisted server-side
and cannot extend the ceiling.
This setting is separate from Flash/Thinking/Pro/Ultra. `deny_tools` blocks in
every mode. `always_ask_tools` requires a human in every mode and cannot receive
a chat grant. Uninspectable tool implementations cannot receive chat grants.
Implementation fingerprints include both execution callables and their callable
adapters, so changing an asynchronous implementation requires fresh approval. `allow_tools` is an explicit operator exception. `tools` adds
otherwise routine tools to review; disabling `review_unknown_tools` restores a
selected-tool policy. These are trusted deployment settings, never model output.
Auto uses a separate, tool-free model invocation. `reviewer_model` names an
existing configured profile; null reuses the effective execution profile. No new
provider or credentials are required. Review input contains original user intent,
tool identity/schema, and complete arguments, treating tool material as untrusted
data. Reviewer callbacks cannot enter the Agent message stream; the host records
its token usage. Each review adds model latency and usage. Invalid output,
timeout, provider failure, or oversized input requests human approval. In an
unattended run these outcomes deny execution. Model review supplements the host's
rules and sandbox; it is not a security isolation boundary.
Cards show complete arguments and support approve, reject with a reason, edit
and approve, or allow the exact operation for this chat. Edits are validated
against the public tool schema and rerun through mandatory authorization. Injected
runtime fields cannot be edited. Grants bind owner, thread incarnation, tool
implementation/schema/connection, arguments, and policy. A changed MCP connection
requires fresh approval. Revoke grants from the Tool permissions menu. SQL-backed
deployments persist grants; memory-only deployments keep them for the process.
Root pauses use native checkpoints and block fresh runs until resolved. Native
parallel interrupts resume by their individual IDs; decisions cannot approve a
stale proposal. Root pauses have no time expiry; policy/tool changes fail closed,
and disabling the feature still permits the existing all-reject escape hatch.
The full card must fit `max_payload_bytes`; oversized cards deny execution rather
than hiding part of the operation. `max_actions` bounds calls in a model turn;
zero removes that count limit.
Delegated calls use an owner-scoped live approval endpoint and suspend the original
child invocation. The wait expires after `child_approval_timeout_seconds`, or the
child's execution deadline if earlier. Cancellation, lost run ownership and worker
restart invalidate pending child approvals; they do not replay the delegated task.
The UI polls pending child cards while the parent is active. Authenticated session
ownership is required to answer these cards. Requests and audit records are deleted
with their thread. Scheduled, webhook and other unattended origins cannot open a
human wait. The legacy `non_interactive: allow` explicitly bypasses Ask for those
root runs; it never overrides mandatory deny/ask rules, Auto uncertainty, or an
unresumable child.
Approval permits an execution attempt. Guardrails, read-before-write rules,
sandbox restrictions and tool-account authorization still run afterward.
## Best Practices ## Best Practices
1. **Place `config.yaml` in project root** - Set `DEER_FLOW_PROJECT_ROOT` if the runtime starts elsewhere 1. **Place `config.yaml` in project root** - Set `DEER_FLOW_PROJECT_ROOT` if the runtime starts elsewhere

View File

@ -187,6 +187,16 @@ graph TB
Msg --> Stream3 --> Stream --> Mid1 --> Mid2 --> Final Msg --> Stream3 --> Stream --> Mid1 --> Mid2 --> Final
``` ```
Feishu rejects a card whose request body exceeds 30 KB (`230025`). The channel
budgets card text at 24 KB of that body, measured as the bytes the text adds
after both JSON escapes, and sends CJK unescaped (3 bytes per character, about
8,000 per card). A streamed update that no longer fits shows the part that does,
ending in `…`. A longer final answer fills the running card and continues in up
to 10 cards replied to the same source message, split at line breaks; past that
the last card is clipped and marked `... (truncated)`. A retry of a partially
delivered answer resumes after the cards already sent, and a reply to any of the
cards continues the conversation.
For the special GitHub case (`fire_and_forget=True` channel policy), the manager calls `runs.create()` and returns once the run is `pending` — no outbound reply, because GitHub agents post via the `gh` CLI from inside their sandbox. See [GITHUB_AGENTS.md](GITHUB_AGENTS.md) for the full GitHub flow. For the special GitHub case (`fire_and_forget=True` channel policy), the manager calls `runs.create()` and returns once the run is `pending` — no outbound reply, because GitHub agents post via the `gh` CLI from inside their sandbox. See [GITHUB_AGENTS.md](GITHUB_AGENTS.md) for the full GitHub flow.
## Attachment delivery feedback ## Attachment delivery feedback
@ -828,6 +838,12 @@ chat identity. That compatibility applies only to the old group-binding shape;
a new private binding never authorizes messages from an unbound group. Bind a a new private binding never authorizes messages from an unbound group. Bind a
specific group again with a name to make it available as a notification target. specific group again with a name to make it available as a notification target.
Each WeCom group is its own conversation, with one DeerFlow thread per member;
a private chat is keyed by the sender alone. A group bound with its own
`/connect` keeps the thread it had before groups got their own conversation. A
legacy group binding covers every group of the bot, so its earlier thread is
not continued in any of them; each group starts a new one.
### WeChat private notifications ### WeChat private notifications
Use the existing iLink QR/bot setup and bind in a private chat. Send one private Use the existing iLink QR/bot setup and bind in a private chat. Send one private

View File

@ -51,6 +51,32 @@ may retain `search(query)` when it uses an operator-owned connection with a
fixed resource visibility. User/tenant mappings and resource authorization fixed resource visibility. User/tenant mappings and resource authorization
remain provider responsibilities; DeerFlow does not infer tenants from client remain provider responsibilities; DeerFlow does not infer tenants from client
metadata or create a second ACL store. metadata or create a second ACL store.
The optional `authenticated_user_id` field carries separate host authentication
provenance and defaults to `None`. Gateway binds it only for verified sessions
and PATs, never internal-service or auth-disabled synthetic identities. Generic
retrieval tools and catalog routes read it from a dedicated task-local binding;
client flags, runtime `user_id`, saved assistant configuration and storage owner
restoration cannot create it. Child tasks, threads and ordinary isolated
subagents launched from an authenticated execution inherit its proof; a
lost/fresh context denies shared access. Durable batch items are launched from
the Gateway's startup poller and do not inherit the submitting request's proof,
even within the same process. Like scheduled-task and IM-channel runs, they
cannot use authenticated shared retrieval merely by carrying an owner `user_id`.
Trusted embedding hosts may use `bind_authenticated_user()` from
`deerflow.runtime.user_context` only after authenticating the end user, and must
bind `None` for non-user callers.
The bound value must be exactly the same nonempty string that
`resolve_runtime_user_id()` returns for the execution. LangGraph Server
identities from `runtime.server_info.user.identity` or
`configurable.langgraph_auth_user_id` take precedence over runtime context and
are normalized with `make_safe_user_id()`. A trusted embedding host must bind
that same resolved ID after authentication, rather than a raw identity such as
an email address. Binding a different representation denies shared access;
providers must not normalize the two values independently to bypass the mismatch.
Providers opting into shared access must require a nonempty exact match with
`context.user_id` and still enforce their connection/resource/Agent policy.
The WeKnora extension's explicit `access_mode: authenticated` uses this field
for dynamic-user shared retrieval; its default exact-user mode is unchanged.
`agent_name` identifies the current executor: the selected assistant name, `agent_name` identifies the current executor: the selected assistant name,
defaulting to `lead_agent`, or the executing subagent's configured name from defaulting to `lead_agent`, or the executing subagent's configured name from
its server runtime `agent_id`. It does not identify the parent assistant. its server runtime `agent_id`. It does not identify the parent assistant.
@ -200,9 +226,13 @@ Inline shared references accept both the raw `#knowledge-source-ID` and the
sanitized `#user-content-knowledge-source-ID`; the sanitizer's DOM-clobbering sanitized `#user-content-knowledge-source-ID`; the sanitizer's DOM-clobbering
protection stays enabled. Missing or malformed references render inert labels. protection stays enabled. Missing or malformed references render inert labels.
`format_result` budgets chunk text and source evidence together: a cited hit `format_result` budgets chunk text and source evidence together: a hit
that cannot fit is omitted as a whole. Results display at most 100 hits, matching that cannot fit is omitted as a whole, and later fitting hits remain eligible.
the bounded artifact readers. Only displayed excerpts enter artifacts. It examines only the first 100 input hits in provider order, including skipped
hits. Per-chunk truncation precedes packing; the remaining total budget does
not shorten a retained excerpt or change its source ID. Only displayed excerpts
enter artifacts. Omission notices use leftover space rather than displacing
complete evidence that fits.
The global tool-output budget prunes evidence removed by truncation or The global tool-output budget prunes evidence removed by truncation or
externalization and replaces complete orphan citations with their labels. externalization and replaces complete orphan citations with their labels.
Subagents accumulate at most 100 validated sources across values frames before Subagents accumulate at most 100 validated sources across values frames before

View File

@ -163,10 +163,7 @@ Use `routing` when an MCP server should be preferred for specific requests, such
as internal database questions that should use a PostgreSQL MCP tool before web as internal database questions that should use a PostgreSQL MCP tool before web
search. Routing hints are soft model guidance: they add a search. Routing hints are soft model guidance: they add a
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use `<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use
agent-level allow/deny policy for hard restrictions. If `tool_search.enabled` agent-level allow/deny policy for hard restrictions. When deferred schemas are enabled, the model uses tool_search to select a tool explicitly. Keyword mentions, quotes and negation do not automatically promote schemas. The legacy auto_promote_top_k setting is accepted for configuration compatibility and has no automatic effect.
defers MCP tool schemas, matching routing metadata can also auto-promote the
deferred schema before the model call. Auto-promotion is controlled by the
top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
```json ```json
{ {
@ -197,21 +194,15 @@ top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
- `routing.mode`: `off` disables hints; `prefer` emits hints. - `routing.mode`: `off` disables hints; `prefer` emits hints.
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first. - `routing.priority`: `0` to `100`; higher-priority hints are rendered first.
When `tool_search.enabled=true`, priority also orders auto-promote matches. This ordering does not select or authorize tools.
- `routing.keywords`: operator-authored terms that describe when to prefer the - routing.keywords: operator-authored capability descriptions for advisory hints. They are not matched against conversation text to select a tool or infer user intent. Empty keywords emit no hint line.
MCP tool. Empty keywords are allowed but do not emit a hint line and do not
trigger auto-promotion. Auto-promote matching is a case-insensitive substring
test against the latest user message (not token/word-boundary matching), so
prefer distinctive keywords — a short term like `api` also matches `rapid`.
Over-matching only exposes an extra tool schema (soft/additive), never
disables other tools.
- `tools.<original_tool_name>.routing`: overrides only the fields explicitly - `tools.<original_tool_name>.routing`: overrides only the fields explicitly
set for that tool. The key is the MCP server's original tool name, before the set for that tool. The key is the MCP server's original tool name, before the
`<server>_` prefix added for model binding. If the server-level `<server>_` prefix added for model binding. If the server-level
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting `routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
only `priority` or `keywords` still inherits `off` and emits no hint. only `priority` or `keywords` still inherits `off` and emits no hint.
- `tool_search.auto_promote_top_k`: global limit for auto-promoted deferred MCP - tool_search.auto_promote_top_k: legacy setting; default 3 and accepted range 1..5 remain compatible, but do not expose deferred schemas automatically.
schemas per model call. Default `3`; valid range `1..5`.
## Tool Name Prefixes ## Tool Name Prefixes

View File

@ -74,6 +74,15 @@ into a normal assistant error message), so the Layer-1 golden can't catch a miss
by shape alone — it inspects `replay_provider.replay_misses()` and fails loud by shape alone — it inspects `replay_provider.replay_misses()` and fails loud
instead. Layer-2 already fails on a miss (the recorded turns never render). instead. Layer-2 already fails on a miss (the recorded turns never render).
The `write_read_file.ultra.json` fixture includes a transport-encoding migration
for the request-only tool-result JSON envelope. Its two affected lead-agent input
and conversation hashes were recomputed after checking that decoding the envelope
exactly matched the original recorded inputs. All recorded assistant outputs,
title/suggestion entries, and the SSE golden are unchanged. This is an offline
fixture migration, not a new live recording or evidence of model behavior under
the new encoding; normal replay still requires exact hashes, including tool
content and execution status.
## Record a new scenario (needs a real key — dev machine only) ## Record a new scenario (needs a real key — dev machine only)
Recording drives the **real frontend** so captured inputs match exactly what the Recording drives the **real frontend** so captured inputs match exactly what the

View File

@ -5,6 +5,8 @@ as the single source for lead-agent tool visibility, clarification middleware
behavior, sandbox network approval eligibility, and system-prompt guidance. behavior, sandbox network approval eligibility, and system-prompt guidance.
The implementation lives in `../packages/harness/deerflow/agents/interaction_policy.py`. The implementation lives in `../packages/harness/deerflow/agents/interaction_policy.py`.
Interactive runs inspect available context and proceed with reversible assumptions or independent work when appropriate. Ask when essential information, consequential choices, or authorization for high-risk work is missing; a fixed clarify-plan-act sequence is not required. Clarification interrupts and trust boundaries remain unchanged.
## Modes and precedence ## Modes and precedence
Interactive runs may ask a human for clarification. Runs in `scheduled`, Interactive runs may ask a human for clarification. Runs in `scheduled`,
@ -34,3 +36,52 @@ remain runtime-only and are never copied into checkpoint-persisted
`configurable`. Public run requests cannot use these keys to disable `configurable`. Public run requests cannot use these keys to disable
clarification, impersonate a webhook channel, or override a scheduled run back clarification, impersonate a webhook channel, or override a scheduled run back
to interactive behavior. to interactive behavior.
## HITL tool approval
Tool permissions are independent of interaction and reasoning modes. The opt-in
`hitl_approval` policy offers Ask, Auto and Full with mandatory deny/ask rules and
an operator-controlled ceiling. Native tool calls pause at their execution boundary
using LangGraph interrupts; the durable run becomes `waiting_for_input`. Resume
consumption is compare-and-swap bound to every pending action's request ID, and
parallel interrupts are routed by their individual graph IDs.
Auto runs a separate, tool-free reviewer over the original user's intent and exact
bound operation. Review preparation is checkpointed: resuming cannot obtain a new
review verdict or silently replace the approved arguments. The model's result
cannot override host policy or inner authorization. Invalid/uncertain review
requires a human, and fails closed when the run cannot collect an answer.
Interactive child calls wait through a parent-owned bridge with a time limit.
They retain their original execution and use a session-authenticated, owner-scoped
HTTP answer. Cancellation, expiry, ownership loss or worker restart invalidates
these live requests. Unattended parent origins cannot open child approval waits.
A child without this bridge fails closed. The legacy `non_interactive: allow`
only bypasses Ask on explicitly configured unattended root runs.
The resume run's journal adopts the paused calls it answers, recovered from the
durable interrupt payload. Approved calls reach the history feed through their
tool callback; rejections have none, so the journal reconciles them under the
resume run like other middleware-written results (#4666). Results from other
earlier runs are not re-attributed.
A call that cannot carry a stable approval decision is never paused. A null or
duplicated call id can never match the resume validator and would block new runs
forever; an empty id is technically matchable, but it is denied as well because
strict providers reject an empty tool-call id when the conversation is replayed.
The middleware removes those calls from the model response with a visible
rejection note instead of pausing, so the tool is never executed and the thread
stays usable. The same uniqueness rule applies when a call is denied by result:
answering a denial `ToolMessage` also answers any other call that shares its id,
so a shared id is stripped rather than answered, and the next request cannot
carry the id twice. A pause stored by an older release with a null or duplicated
id is **not** cleared by this: every resume, including all-reject, is still
rejected, so continue the work in a new thread. In-place recovery is tracked in
[#8071](https://github.com/bytedance/deer-flow/issues/8071).
`waiting_for_input` is not terminal success or failure. Goals, delivery checks,
and batch continuation must preserve a native pause. New runs cannot discard it.
Root checkpoint approvals do not expire and block new runs until resolved;
live child approvals retain the time limit described above.
See [HITL Tool Approval](CONFIGURATION.md#hitl-tool-approval) for grant scoping,
parameter editing, limits and persistence semantics.

View File

@ -143,7 +143,7 @@ sequenceDiagram
关键组件: 关键组件:
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。 - `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON,再 `bridge.publish()`。
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。 - `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher;显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件。`MemoryStreamBridge` 在分配事件 ID 前深拷贝发布的 payload,并在 stream Condition 外为每个订阅者返回独立深拷贝,因此生产者复用 payload 或订阅者修改数据都不会影响保留帧及重连重放;心跳与 END sentinel 保持对象身份。游标早于保留水位线时返回 `StreamGap`,不会从当前最早事件静默部分重放。Redis backend 会在写入数据或首个 END 时刷新 retained stream key TTL;启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run,再处理恢复 task;尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error;周期恢复不做非原子的 thread 投影。普通 durable terminal status 本身仍不足以证明尾帧已发布;Redis producer 在 durable admission 前取得短租约,经 request task、worker、finalizer 转移并保留到 END。只有显式 `orphan_recovered` 或 durable terminal 加已失效的 tracked producer lease 才能在 heartbeat 上触发 END fallback;lease loss 会阻止后续 stream 写入,active 或 untracked producer 继续等待,避免截断尾帧。Redis END 通过同一个 Lua 原子完成 lease 校验、既有 END 检查和必要写入,因此重复调用、owner/recovery 竞争和 ACK 丢失重试至多追加一个 END;当 END 已位于 stream 尾部时,后续事件写入会被拒绝;旧 stream 中 END 后已有迟到数据时,仍允许继续写入数据。cleanup 后无 owner 的迟到 END 重试是成功 no-op,不会复活已删除的 stream。普通事件只做 O(1) 尾部 END 检查。`stream_bridge.legacy_end_scan_enabled` 默认 `true`:尾部不是 END 时扫描整个保留窗口,包括正常 run 的首次 END;原子 Lua 的耗时随保留条目数量和 payload 总字节数增长,期间会阻塞其他 Redis 客户端。只有全部旧 writer 停止且旧 replay buffer 已过期或清除后,才可设为 `false` 并重启所有 Gateway,使 END 检查也只看尾部。TTL 为 0 时必须显式清除旧 buffer。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `stream_bridge.producer_lease_recovery_enabled` 是上述 producer-lease fallback 的 restart-required activate gate。升级时先保持 `false` 完成所有 writer 的滚动发布并排空旧 run,再在所有 Gateway 上同时设为 `true` 并重启;mixed-version 阶段不得启用。 - `stream_bridge.producer_lease_recovery_enabled` 是上述 producer-lease fallback 的 restart-required activate gate。升级时先保持 `false` 完成所有 writer 的滚动发布并排空旧 run,再在所有 Gateway 上同时设为 `true` 并重启;mixed-version 阶段不得启用。
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。 - `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。 - `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple` 把 `(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`。

View File

@ -45,7 +45,7 @@ once the feed's loaded page window no longer reaches back to it.
store already assigned, keyed by store already assigned, keyed by
`runtime/events/message_identity.py::message_identity` — the backend half of `runtime/events/message_identity.py::message_identity` — the backend half of
the identity rule `frontend/src/core/threads/hooks.ts::messageIdentity` applies the identity rule `frontend/src/core/threads/hooks.ts::messageIdentity` applies
(tool messages by `tool_call_id`; `X` / `X__user` human copies collapse to (tool results by their own message ID; `X` / `X__user` human copies collapse to
one). The two halves must stay in sync: a mismatch is silent, degrading one). The two halves must stay in sync: a mismatch is silent, degrading
placement rather than raising. `runs/worker.py::_MessageSeqStamper` attaches placement rather than raising. `runs/worker.py::_MessageSeqStamper` attaches
the result as `additional_kwargs.deerflow_seq` on root `values` frames only — the result as `additional_kwargs.deerflow_seq` on root `values` frames only —
@ -74,6 +74,26 @@ fetch-and-decode of every message row's tool outputs on long threads.
client input, because a welded-in seq goes stale when a fork re-seeds the feed client input, because a welded-in seq goes stale when a fork re-seeds the feed
(#4380). (#4380).
`RunJournal` assigns missing ToolMessage IDs on the returned message object
before serializing callback results, including `Command` message updates, so
the graph checkpoint and feed retain the same ID. Middleware results receive
the same treatment when reconciled. Provider `tool_call_id` values may repeat
within a run and across runs; they remain protocol correlation data, never
thread-global result identities. Legacy results without message IDs have no
dedupe identity or seq lookup rather than aliasing unrelated invocations.
For a registered lead-agent call, the journal remembers the captured result ID
until a new canonical assistant response emits that call ID again. Reconciliation
restores the remembered ID on a copy serialized before its first capture, so
the copy does not append another result or consume a sequence. An explicit
message ID is preserved. A later invocation starts fresh even if its provider
ID and output content are identical; duplicate callbacks for the same LLM run
do not reset this bookkeeping. Callbacks without a registered lead call still
assign distinct IDs to their returned objects. This cache is journal-local and
is cleared on detach, not a fallback identity for historical rows.
The web execution timeline and write-file preview resolve a provider call ID
only inside its originating assistant-message occurrence; UI keys include that
occurrence while outgoing protocol IDs remain unchanged.
**Run delivery receipts** (`runtime/journal.py` + `runs/worker.py`): **Run delivery receipts** (`runtime/journal.py` + `runs/worker.py`):
`RunJournal` records each non-empty artifact update once per tool `Command` for `RunJournal` records each non-empty artifact update once per tool `Command` for
the terminal `run.delivery` event. When a command contains multiple messages, a the terminal `run.delivery` event. When a command contains multiple messages, a
@ -96,7 +116,7 @@ regular file created or modified under `/mnt/user-data/outputs`; internal
process-feedback files are excluded (the scanner's `EXCLUDED_DIR_NAMES` plus process-feedback files are excluded (the scanner's `EXCLUDED_DIR_NAMES` plus
the configured `tool_output.storage_subdir`), so a run that only externalized the configured `tool_output.storage_subdir`), so a run that only externalized
oversized tool outputs does not fail delivery. At least one candidate must be oversized tool outputs does not fail delivery. At least one candidate must be
covered by a path attributed by the journal to `present_files`; presenting only covered by a path attributed by the journal to `present_files` or `present_shared_artifact`; presenting only
an unrelated pre-existing path does not satisfy delivery. Receipts for such an unrelated pre-existing path does not satisfy delivery. Receipts for such
runs add `produced_paths`, `presented_paths`, `matched_paths`, `verification`, runs add `produced_paths`, `presented_paths`, `matched_paths`, `verification`,
`stage`, and `satisfied` to the Slice 1 fact fields. Missing a matching `stage`, and `satisfied` to the Slice 1 fact fields. Missing a matching
@ -323,7 +343,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Where things live**: **Where things live**:
- `runtime/checkpoint_mode.py` — mode + snapshot-frequency freeze, marker injection, delta detection, compatibility gate, both error types - `runtime/checkpoint_mode.py` — mode + snapshot-frequency freeze, marker injection, delta detection, compatibility gate, both error types
- `runtime/checkpoint_state.py` — `CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint` - `runtime/checkpoint_state.py` — `CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint`
- `checkpoint_patches.py` (package root) — the one remaining patch: `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty channel (#4380; probe-guarded). The `InMemorySaver` delta-history patch is gone: `langgraph-checkpoint` 4.2.0 fixed that write loss upstream (#8526) while keeping its override, so the dependency floor plus the full → delta migration contract test are the gate — never re-add a version-guarded saver patch. - `checkpoint_patches.py` (package root) — the one remaining patch: `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty channel (#4380; probe-guarded). The `InMemorySaver` delta-history patch is gone: `langgraph-checkpoint` 4.2.0 fixed that write loss upstream (#8526) while keeping its override. The 4.3.0 floor also keeps same-superstep replay in live task-path order; SQLite 3.1.2 and Postgres 3.1.3 carry the matching saver behavior, including paged Postgres ancestor walks. Dependency floors plus the migration, ordering and pagination contract tests are the gates — never re-add version-guarded saver patches or defect-triggered skips.
- `agents/thread_state.py` — `ThreadState`/`DeltaThreadState`, `delta_messages_field` / `DELTA_MESSAGES_FIELD` (`DeltaChannel` at the configured `snapshot_frequency`, default 10), schema adaptation helpers - `agents/thread_state.py` — `ThreadState`/`DeltaThreadState`, `delta_messages_field` / `DELTA_MESSAGES_FIELD` (`DeltaChannel` at the configured `snapshot_frequency`, default 10), schema adaptation helpers
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer). Runs stamp their effective agent into server-owned checkpoint metadata; manual compaction uses that binding—not request `agent_name`—for memory policy and bucket. Missing/invalid legacy bindings and unreadable agent configs fail closed by skipping the optional flush while compaction may continue with the default model; a missing pre-binding checkpoint emits a warning so the skipped write is observable. - `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer). Runs stamp their effective agent into server-owned checkpoint metadata; manual compaction uses that binding—not request `agent_name`—for memory policy and bucket. Missing/invalid legacy bindings and unreadable agent configs fail closed by skipping the optional flush while compaction may continue with the default model; a missing pre-binding checkpoint emits a warning so the skipped write is observable.
- `runtime/checkpoint_cache/` + `runtime/checkpointer/cached_saver.py` — delta-mode checkpoint history cache; checkpoint state reads MUST go through `CheckpointStateAccessor`, and the checkpointer may be a `CachedHistorySaver` wrapper — never rely on concrete saver types - `runtime/checkpoint_cache/` + `runtime/checkpointer/cached_saver.py` — delta-mode checkpoint history cache; checkpoint state reads MUST go through `CheckpointStateAccessor`, and the checkpointer may be a `CachedHistorySaver` wrapper — never rely on concrete saver types
@ -333,6 +353,32 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
## Deferred terminal commits and lease ownership ## Deferred terminal commits and lease ownership
RunManager serializes creation, replacement and assembly publication per thread.
The global registry lock never spans Store I/O; unrelated cached reads and
heartbeat snapshots do not queue behind an admission gate. Their own Store I/O
can still encounter database or connection-pool contention. A pending-ID map
lets readers of preallocated run IDs join the same-thread gate before the record
is published. Failed or cancelled admissions remove that temporary membership;
normal creation registers only after its initial write succeeds. Heartbeats use
the short registry snapshot, not the thread gate, so an admitted run can renew
while its replacement awaits a Store result. Preserve terminal-attribution and
cancellation compensation below; changing lock granularity does not grant a new
worker authority over a durable row.
With heartbeat enabled, assembly evidence is merged into the row's metadata
through `RunStore.merge_owned_metadata()`, which requires this Worker's live
lease on a `pending`/`running` row and writes no other field. A full snapshot
would also rewrite status, owner, lease and error, reviving a row a peer already
reclaimed. A rejection fences the run and fails assembly before execution; a
locally fenced run is refused before any write, and a Store without the method
fails closed instead of falling back to `put()`. Without heartbeat, evidence
still persists a full row snapshot. Serialize either write with the
same run's lease renewal and local acknowledgment, so it cannot restore an old
lease after renewal. Lock order is thread gate, then snapshot gate, then the
short registry lock. Renewal uses only the snapshot gate and must release it
before signaling local cancellation, which acquires the thread gate. Keep the
lease-expiry timer armed while waiting for the snapshot gate.
**Deferred terminal commit:** With an event store, the worker stages its terminal **Deferred terminal commit:** With an event store, the worker stages its terminal
status locally and commits it only after finalization's receipt and duration status locally and commits it only after finalization's receipt and duration
writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that

View File

@ -25,6 +25,17 @@ PII-redacted before splitting, then escaped before insertion into prompt blocks.
No human-anchor filter or successful "too long" placeholder can discard selected No human-anchor filter or successful "too long" placeholder can discard selected
source. Recent-message retention and AI/Tool partition boundaries stay unchanged. source. Recent-message retention and AI/Tool partition boundaries stay unchanged.
State system messages, tagged dynamic-context reminders and the latest genuine
user request remain outside compression. Summary source conversion uses message
copies to include native document `text`/`content` plaintext, title and context
in their original block order, before redaction and chunking. Nonempty string
titles and context are retained even when the source is opaque, absent or
malformed; unsupported source bodies are not stringified. A `content` source
contributes only direct strings and `text` blocks. Nested `document` blocks are
not traversed, including their metadata; recursive extraction is outside this
fix's scope. Compaction does not decode binary documents, fetch remote media or
change checkpoint content.
Only after all chunks succeed are hooks, the compaction event, history capture Only after all chunks succeed are hooks, the compaction event, history capture
and message replacement committed once. Any failed chunk leaves active history and message replacement committed once. Any failed chunk leaves active history
unchanged; manual `/compact` reports generation failure. A pass processes at most unchanged; manual `/compact` reports generation failure. A pass processes at most
@ -232,7 +243,7 @@ keep:
#### `summary_prompt` #### `summary_prompt`
- **Type**: String or null - **Type**: String or null
- **Default**: `null` (uses LangChain's default prompt) - **Default**: `null` (uses DeerFlow's built-in prompt)
- **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context. - **Description**: Custom prompt template for generating summaries. The prompt should guide the model to extract the most important context.
#### `skill_file_read_tool_names` #### `skill_file_read_tool_names`
@ -243,11 +254,12 @@ keep:
Legacy `preserve_recent_skill_*` settings are no longer used. Loaded skill retention is handled by the durable `skill_context` reference channel instead of by preserving raw skill-read messages in the summarization window. Legacy `preserve_recent_skill_*` settings are no longer used. Loaded skill retention is handled by the durable `skill_context` reference channel instead of by preserving raw skill-read messages in the summarization window.
**Default Prompt Behavior:** **Default Prompt Behavior:**
The default LangChain prompt instructs the model to: The built-in DeerFlow prompt extends LangChain's prompt and asks the model to:
- Extract highest quality/most relevant context - Extract highest quality/most relevant context
- Focus on information critical to the overall goal - Focus on information critical to the overall goal
- Avoid repeating completed actions - Avoid repeating completed actions
- Return only the extracted context - Return only the extracted context
- Preserve current user requirements, literal approval phrases, conditions for acting, and later revisions
## How It Works ## How It Works
@ -271,6 +283,11 @@ The default LangChain prompt instructs the model to:
- Uses approximate token counting based on character count - Uses approximate token counting based on character count
- For Anthropic models: ~3.3 characters per token - For Anthropic models: ~3.3 characters per token
- For other models: Uses LangChain's default estimation - For other models: Uses LangChain's default estimation
- For `VllmChatModel` using Chat Completions: History and token-based retention
estimates include the assistant reasoning replayed to vLLM. A non-null `reasoning` field takes
precedence over `reasoning_content`, including when it is empty. This applies
to both automatic and manual compaction. The estimate is approximate and
leaves the summary prompt and its trimming budget unchanged.
- Can be customized with a custom `token_counter` function - Can be customized with a custom `token_counter` function
### Message Preservation ### Message Preservation

View File

@ -5,6 +5,9 @@ production authentication or a substitute for Gateway run-control tests.
""" """
import asyncio import asyncio
import base64
import json
import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@ -24,8 +27,10 @@ from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.outputs import ChatGeneration, ChatResult
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
from app.gateway.extension_artifacts import read_output_snapshot, write_import_snapshot
from app.gateway.routers.plugins import router from app.gateway.routers.plugins import router
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.config.paths import get_paths
from deerflow.extensions.loader import ExtensionSpec, load_extensions from deerflow.extensions.loader import ExtensionSpec, load_extensions
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
@ -37,9 +42,13 @@ class PreviewModel(FakeMessagesListChatModel):
def _generate(self, messages, stop=None, run_manager=None, **kwargs): def _generate(self, messages, stop=None, run_manager=None, **kwargs):
latest = next(m for m in reversed(messages) if m.type == "human") latest = next(m for m in reversed(messages) if m.type == "human")
response = latest.additional_kwargs.get("human_input_response") response = latest.additional_kwargs.get("human_input_response")
try:
current_request = json.loads(latest.content.split("\n", 1)[1]).get("request", "")
except (ValueError, IndexError):
current_request = latest.content
if response: if response:
answer = AIMessage(content=f"Clarification accepted: {response['value']}.") answer = AIMessage(content=f"Clarification accepted: {response['value']}.")
elif "Clarification fixture" in latest.content: elif "Clarification fixture" in current_request:
answer = AIMessage(content="", tool_calls=[{"name": "ask_clarification", "id": "clarify-environment", "args": {"question": "Which environment should I check?", "clarification_type": "missing_info"}, "type": "tool_call"}]) answer = AIMessage(content="", tool_calls=[{"name": "ask_clarification", "id": "clarify-environment", "args": {"question": "Which environment should I check?", "clarification_type": "missing_info"}, "type": "tool_call"}])
else: else:
answer = AIMessage(content="Release evidence verified. See the shared team record.") answer = AIMessage(content="Release evidence verified. See the shared team record.")
@ -51,6 +60,7 @@ class PreviewRuns:
self.threads = {} self.threads = {}
self.runs = {} self.runs = {}
self.tasks = set() self.tasks = set()
self.artifact_gate = asyncio.Event()
self.graph = create_agent(PreviewModel(responses=[]), tools=[ask_clarification_tool], middleware=[ClarificationMiddleware()], checkpointer=InMemorySaver()) self.graph = create_agent(PreviewModel(responses=[]), tools=[ask_clarification_tool], middleware=[ClarificationMiddleware()], checkpointer=InMemorySaver())
def bound(self, owner): def bound(self, owner):
@ -75,9 +85,25 @@ class PreviewRuns:
state = await host.graph.aget_state({"configurable": {"thread_id": thread_id}}) state = await host.graph.aget_state({"configurable": {"thread_id": thread_id}})
if not state.values: if not state.values:
raise AgentRunError(404, "No checkpoint") raise AgentRunError(404, "No checkpoint")
return {"values": {"messages": [m.model_dump() for m in state.values["messages"]]}, "next": list(state.next)} return {"values": {"messages": [m.model_dump() for m in state.values["messages"]]}, "next": list(state.next), "checkpoint_id": state.config["configurable"]["checkpoint_id"]}
async def start(self, *, thread_id, input, idempotency_key): async def validate_artifact_source(self, *, thread_id, run_id, idempotency_key):
self.check(thread_id)
run = host.runs.get(run_id)
if run is None or run.thread_id != thread_id or run.status != "running" or run_id != idempotency_key:
raise AgentRunError(422, "Producing run is not executing")
async def read_artifact(self, *, thread_id, path, expected_checkpoint_id=None):
self.check(thread_id)
if expected_checkpoint_id is not None and (await self.get_state(thread_id=thread_id))["checkpoint_id"] != expected_checkpoint_id:
raise AgentRunError(412, "Producing checkpoint changed")
return await asyncio.to_thread(read_output_snapshot, owner, thread_id, path)
async def import_artifact(self, *, thread_id, filename, content):
self.check(thread_id)
return await asyncio.to_thread(write_import_snapshot, owner, thread_id, "community.agent-teams", filename, content)
async def start(self, *, thread_id, input, idempotency_key, artifact_imports=None):
self.check(thread_id) self.check(thread_id)
if idempotency_key in host.runs: if idempotency_key in host.runs:
return host.runs[idempotency_key] return host.runs[idempotency_key]
@ -87,6 +113,8 @@ class PreviewRuns:
host.runs[idempotency_key] = run host.runs[idempotency_key] = run
async def execute(): async def execute():
if "Artifact fixture" in input["messages"][0]["content"]:
await host.artifact_gate.wait()
await host.graph.ainvoke(input, config={"configurable": {"thread_id": thread_id}}) await host.graph.ainvoke(input, config={"configurable": {"thread_id": thread_id}})
host.runs[idempotency_key] = AgentRun(thread_id, idempotency_key, "success", run.assistant_id) host.runs[idempotency_key] = AgentRun(thread_id, idempotency_key, "success", run.assistant_id)
@ -103,6 +131,7 @@ class PreviewRuns:
def create_app(directory): def create_app(directory):
os.environ["DEER_FLOW_HOME"] = str(directory)
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams")) sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(Path(directory) / "teams.sqlite")}, required=True)]) extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(Path(directory) / "teams.sqlite")}, required=True)])
assert not diagnostics assert not diagnostics
@ -143,6 +172,40 @@ def create_app(directory):
handlers = {"publish": service.publish_state, "read": service.read_state, "update": service.update_state} handlers = {"publish": service.publish_state, "read": service.read_state, "update": service.update_state}
return await handlers[payload["operation"]](payload["input"], ToolContext(ExtensionPrincipal(request.state.user.id), {"enabled": True}, payload["source_thread"], agent_runs=runs.bound(request.state.user.id))) return await handlers[payload["operation"]](payload["input"], ToolContext(ExtensionPrincipal(request.state.user.id), {"enabled": True}, payload["source_thread"], agent_runs=runs.bound(request.state.user.id)))
@app.post("/preview/artifact")
async def artifact(payload: dict, request: Request):
from deerflow_extension_api.plugins import ActionContext
owner = request.state.user.id
bound = runs.bound(owner)
ctx = ActionContext(ExtensionPrincipal(owner), {"enabled": True}, agent_runs=bound)
team = await service.get({"team_id": payload["team_id"]}, ctx)
member = team["members"][0]
runs.artifact_gate.clear()
try:
await service.send({"team_id": team["id"], "member_id": member["id"], "request_id": payload["request_id"], "text": "Artifact fixture: produce a release report"}, ctx)
await service.tick()
path = get_paths().sandbox_outputs_dir(member["thread_id"], user_id=owner) / "report.bin"
def write():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(base64.b64decode(payload["content_base64"], validate=True))
await asyncio.to_thread(write)
return await service.register_artifact(
{"team_id": team["id"], "request_id": payload["request_id"], "name": payload["name"], "path": "/mnt/user-data/outputs/report.bin", **({"artifact_id": payload["artifact_id"]} if "artifact_id" in payload else {})},
ToolContext(ExtensionPrincipal(owner), {"enabled": True}, member["thread_id"], agent_runs=bound, run_id=next(r.run_id for r in runs.runs.values() if r.thread_id == member["thread_id"] and r.status == "running")),
)
finally:
runs.artifact_gate.set()
@app.get("/preview/imported")
async def imported(team_id: str, member_id: str, request: Request):
team = await service.db("get", request.state.user.id, team_id)
member = next(member for member in team["members"] if member["id"] == member_id)
directory = get_paths().sandbox_uploads_dir(member["thread_id"], user_id=request.state.user.id)
return await asyncio.to_thread(lambda: [{"filename": file.name, "content_base64": base64.b64encode(file.read_bytes()).decode()} for file in directory.glob("artifact-*")])
@app.get("/api/agents") @app.get("/api/agents")
async def catalog(): async def catalog():
return { return {

View File

@ -102,7 +102,7 @@ from deerflow_extension_api.state import ExtensionData
#: Contract version. Before 1.0, minors may break and patches are additive. #: Contract version. Before 1.0, minors may break and patches are additive.
#: From 1.0 on, bump the major for breaking changes. #: From 1.0 on, bump the major for breaking changes.
API_VERSION = "0.2.9" API_VERSION = "0.2.11"
__all__ = [ __all__ = [
"AGENT_RUNS_CONTEXT_KEY", "AGENT_RUNS_CONTEXT_KEY",

View File

@ -11,7 +11,7 @@ AGENT_RUNS_CONTEXT_KEY = "__deerflow_extension_agent_runs"
class AgentRunError(Exception): class AgentRunError(Exception):
"""Host rejection with an HTTP-equivalent status (403, 404, 409, 422, 503).""" """Host rejection with an HTTP-equivalent status (403, 404, 409, 412, 422, 503)."""
def __init__(self, status_code: int, message: str): def __init__(self, status_code: int, message: str):
super().__init__(message) super().__init__(message)
@ -76,6 +76,22 @@ class AgentRuns(Protocol):
""" """
... ...
async def send_input(self, *, thread_id: str, run_id: str, text: str, mode: str, request_id: str, context: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Queue a new turn or steer the active Run; never change its model."""
...
async def list_inputs(self, *, thread_id: str) -> dict[str, Any]:
"""Read host-recorded input IDs/status, including across reconnects."""
...
async def authorize_control(self, *, thread_id: str, run_id: str, clear_goal: bool = False) -> None:
"""Recheck cancellation and optional Goal-write authority, without a write."""
...
async def dispatch_input(self, *, thread_id: str, run_id: str, input_id: str) -> AgentRun:
"""Dispatch a stored queued input after its Run, using ordinary admission."""
...
async def configuration_evidence(self, *, thread_id: str, run_id: str) -> dict[str, Any]: async def configuration_evidence(self, *, thread_id: str, run_id: str) -> dict[str, Any]:
"""Read immutable host evidence captured from this run's actual assembly. """Read immutable host evidence captured from this run's actual assembly.
@ -84,11 +100,32 @@ class AgentRuns(Protocol):
""" """
... ...
async def read_artifact(self, *, thread_id: str, path: str) -> bytes: async def validate_artifact_source(self, *, thread_id: str, run_id: str, idempotency_key: str) -> None:
"""Require this plugin's executing keyed Run and safe output capture.
Checking the durable key also handles admission whose acknowledgement
has not yet reached the plugin. Caller-selected queue state is not proof.
"""
...
async def read_artifact(self, *, thread_id: str, path: str, expected_checkpoint_id: str | None = None) -> bytes:
"""Read a bounded regular output file through owner authorization. """Read a bounded regular output file through owner authorization.
Returns at most 2 MiB, rejecting symlinks and concurrent mutations. Returns at most 2 MiB, rejecting symlinks and concurrent mutations.
The caller must retain these bytes to create an immutable version. The caller must retain these bytes to create an immutable version.
With expected_checkpoint_id, reject a changed checkpoint under the same
durable reservation as the read. Remote reads require sandbox authority.
"""
...
async def import_artifact(self, *, thread_id: str, filename: str, content: bytes) -> dict[str, Any]:
"""Place at most 2 MiB of retained bytes in an idle thread's uploads.
Requires a plugin-scoped handle and current thread-write authority.
The host selects a content-addressed filename and synchronizes remote
sandboxes. Identical calls reuse the copy; a changed destination is
rejected without overwriting it. Returns path, filename, sha256 and size.
Caller cancellation drains file/sandbox IO before releasing the thread.
""" """
... ...
@ -108,12 +145,25 @@ class AgentRuns(Protocol):
"""Create an owned thread, or return the caller's existing thread ID.""" """Create an owned thread, or return the caller's existing thread ID."""
... ...
async def start(self, *, thread_id: str, input: Mapping[str, Any], context: Mapping[str, Any] | None = None, idempotency_key: str | None = None, execution_fence: ExecutionFence | None = None) -> AgentRun: async def start(
self,
*,
thread_id: str,
input: Mapping[str, Any],
context: Mapping[str, Any] | None = None,
idempotency_key: str | None = None,
execution_fence: ExecutionFence | None = None,
artifact_imports: list[dict[str, Any]] | None = None,
) -> AgentRun:
"""Start or continue the thread's Agent with ordinary run admission. """Start or continue the thread's Agent with ordinary run admission.
Scoped handles namespace local idempotency keys automatically. An active Scoped handles namespace local idempotency keys automatically. An active
run conflicts; start never implicitly cancels it. execution_fence is run conflicts; start never implicitly cancels it. execution_fence is
host-only admission authority, never copied into Agent input/context. host-only admission authority, never copied into Agent input/context.
artifact_imports contains up to four import receipts (path, filename,
sha256, size). A new run verifies the copies while holding admission,
before constructing its Agent. Idempotent retries reuse the original Run
without revalidating files that it may already have edited.
""" """
... ...

View File

@ -26,6 +26,13 @@ text later reaches a message (the ``durable_context_data`` block), but
will not equal ``output_content_hash``. A consumer must join a compaction to what came will not equal ``output_content_hash``. A consumer must join a compaction to what came
after it through this event alone, never by re-hashing a later projection of the same after it through this event alone, never by re-hashing a later projection of the same
text. text.
``task_id`` / ``run_id`` / ``thread_id`` name the task the compaction happened in. The
host copies them from the ``TaskInfo`` it seeds into the live task store — the same
store the observer receives — so a consumer can key a compaction on
``(thread_id, run_id)`` instead of matching hashes across sessions. They stay ``None``
when no task identity exists: a manual ``/compact`` outside any run (observers then
receive a detached store) or a subagent executed without a parent run.
""" """
from __future__ import annotations from __future__ import annotations
@ -46,6 +53,9 @@ class CompactionEvent:
output_content_hash: str output_content_hash: str
compacted_message_count: int compacted_message_count: int
kept_message_count: int kept_message_count: int
task_id: str | None = None
run_id: str | None = None
thread_id: str | None = None
class ContextCompactionObserver(Protocol): class ContextCompactionObserver(Protocol):

View File

@ -40,6 +40,8 @@ class ToolContext(ActionContext):
""" """
thread_id: str | None thread_id: str | None
# Host-stamped execution identity, never supplied by tool arguments.
run_id: str | None = field(default=None, kw_only=True)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -1,6 +1,6 @@
[project] [project]
name = "deerflow-extension-api" name = "deerflow-extension-api"
version = "0.2.9" version = "0.2.11"
description = "Public contracts for DeerFlow extensions" description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12" requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public # Keep the contract package import-light and independent from the host. Public

View File

@ -1,143 +1,56 @@
### Request Trace Context (`packages/harness/deerflow/trace_context.py`) # Harness package (`deerflow/`)
DeerFlow's request-level correlation id — the `X-Trace-Id` header and the `deerflow_trace_id` key. Not Langfuse's trace id, not `run_id`, not the short subagent `trace_id` log label. The publishable `deerflow-harness` package: agents, runtime, tools, sandbox,
models, MCP, skills, config and persistence. Each subpackage has its own guide,
listed in [backend/AGENTS.md](../../../AGENTS.md). This file covers the modules
at the package root. The harness never imports `app.*`.
**The ContextVar is the only source.** Every path that reaches a run binds one first; downstream treats the id as a plain `str`, no `if trace_id:` guards. ## Request trace context (`trace_context.py`)
Entry points and binders: Gateway HTTP — `TraceMiddleware`; scheduled occurrence — `ScheduledTaskService._attempt_queued_run` → `launch_scheduled_thread_run`; MCP task notification — `launch_mcp_task_notification_run`; IM inbound — `ChannelManager._worker_loop`; embedded / TUI / CLI turn — `DeerFlowClient.stream()`. The request correlation ID is the `X-Trace-Id` header and the `deerflow_trace_id`
key. It is not the Langfuse trace ID and not the `run_id`.
Only the first is HTTP; the rest run outside ASGI, so the binding cannot live in middleware alone. Each scopes **one unit of work**, never a poller loop — a leaked binding on a reused worker task would tag later occurrences with the first id. `ensure_trace_context` inherits, keeping layered scheduled bindings and a manual trigger inside a Gateway request on one trace. - **The ContextVar is the only source.** Every entry point that starts a run
binds an ID first: the HTTP middleware, a scheduled occurrence, an MCP task
notification, an IM message and an embedded client turn. A new entry point
binds one too.
- **Bind one unit of work, never a poller loop.** A binding that leaks on a
reused worker task tags later work with the first ID.
- **Every other carrier is an output.** A `deerflow_trace_id` that a caller sends
is replaced, because the stored run would otherwise disagree with the header
and the logs.
- **An HTTP request does not inherit an ambient ID.** A crafted header must not
fall back to the previous request's ID.
- **Use `ensure_trace_id()` and `resolve_trace_id()`** in place of a local
fallback chain. A ContextVar does not survive a bare thread hop, so an ID that
crosses one travels as data.
- **`DeerFlowClient.stream()` binds per step, never across a `yield`.** A sync
generator shares the caller's context.
- **A crash-recovered scheduled launch keeps the first attempt's ID** on the run
record. That is accepted: restamping would rewrite an existing record.
**Every other carrier is a derived output, never read back as an input.** `worker._bind_trace_id` stamps the runtime context and `config["metadata"]`; `services.start_run` stamps the run record; a caller-sent `deerflow_trace_id` (`body.metadata`, `body.config.context`) is replaced — honouring it would let the persisted run disagree with the header and the logs. `_SERVER_OWNED_RUNTIME_CONTEXT_KEYS` covers the embedded path and also rejects caller-supplied sandbox lease/scope identities, `redact_config_secrets` scrubs the kwargs echo (`runs.kwargs_json`), and `build_run_config` merges metadata onto a copy so the stamp cannot reach `body.config`. Callers pin an id with `X-Trace-Id`. ## Embedded client (`client.py`)
Accepted divergence: a crash-recovered scheduled launch reuses its run via the idempotency key without restamping — the record keeps the first attempt's id, the retry's logs a fresh one; restamping would rewrite an existing record. Not a bug. Thread metadata omits the key entirely — a thread spans many runs. `DeerFlowClient` runs the harness without HTTP and shares config, data
directories and response schemas with the Gateway.
**Do not open-code fallback chains.** Two helpers own the resolution order: - **A method that mirrors a Gateway route returns the same schema.**
`TestGatewayConformance` in `tests/test_client.py` parses each result with the
Gateway model; add a new method there.
- **The client does not delete a thread's data directory.** Only the Gateway
does, after the LangGraph thread is deleted.
- `resolve_trace_id(*carriers)` — first usable carrier, else ambient. For ids travelling as data in `runtime.context`; ContextVars do not survive a bare thread hop. ## Other root modules
- `ensure_trace_context(trace_id)` — reuse the surrounding scope, else start a self-contained one. For boundary crossings (`SubagentExecutor._aexecute`, the memory `trace_context_manager` hook) and non-HTTP entry points; no argument mints a scoped id.
`request_trace_context` (HTTP) deliberately does **not** inherit: a crafted header must not fall back to the previous request's id. - **Lark CLI credentials (`integrations/lark_cli.py`).** A managed skill pack is
staged privately and published with one rename, so a failed write leaves the
`get_current_trace_id()` stays nullable only for the logging filter (pre-entry-point records render as `trace_id=-`); everything else uses `ensure_trace_id()`/`resolve_trace_id()`. previous pack in place. When you switch the app, clear the old OAuth data
before `lark-cli config init`: on Linux the new secret is written under the
`DeerFlowClient.stream()` binds per `next()` step and around `inner.close()`, never across a `yield`: a sync generator shares the caller's context, so a scope held across yields would leak the id and break on cross-context GC finalization. same data directory, and a later clear would remove it.
- **Workspace snapshots (`workspace_changes/recorder.py`).** On cancellation,
`logging.enhance.enabled` gates **log output only** (`trace_id` field presence and format) — not the id, the header, or the run metadata — so `TraceMiddleware` reads no `AppConfig`; `logging` stays restart-required (`STARTUP_ONLY_FIELDS["logging"]`). `X-Trace-Id` is in `CORS_EXPOSED_HEADERS` (not safelisted). Unhandled-exception 500s keep the header — `TraceMiddleware` sends its own plain 500 (CORS-opaque, see its docstring) before re-raising; mid-stream failures propagate unchanged. drain a text scan before you remove the cache that its worker reads. A
metadata scan owns no cache and is cancelled at once.
Tests: the `tests/test_trace_*` and `tests/test_worker_trace_binding.py` suites, `test_gateway_services.py`, `test_run_metadata_secret_safety.py`, plus the Langfuse suites in `tracing/AGENTS.md`. - **Project summaries (`projects/summaries.py`).** `stop()` closes admission
first, and `start()` reopens it only after the stop has finished.
### Managed Lark CLI credentials (`integrations/lark_cli.py`) - **Environment values are strings.** Parse a boolean before you pass it to an
SDK, because `bool("false")` is true.
Installed `lark-shared` guidance points to Capability Center > Plugins > Lark
(`/workspace/capabilities?tab=plugins&plugin=lark`). Guidance changes bump the
version marker; reinstalling the managed skill pack refreshes the stored text.
Managed skill-pack installation holds `_lark_install_lock` while preparing a
private tree; the final `staging_target.rename(target)` publishes the validated
skill bodies and manifest together, keeping earlier writes private. A write
failure leaves the previous pack unchanged and discards staging; replacement
moves the old target to a backup first and restores it on an ordinary
publication error. Atomic writes fsync only their temporary files, so
whole-tree durability after power loss is not guaranteed.
`_publish_bytes_atomically` removes a failed temporary through
`cleanup_failed_atomic_write`, so an unlink or warning-log failure cannot
replace the original publication error, including interruptions. Callers own
final file modes: runtime artifacts executable, skill bodies and manifests
read-only. Coverage: `tests/test_lark_cli_atomic_publish.py`.
App registration and direct app switching replace the per-user Lark credential
tree transactionally. Clear the old OAuth data before `lark-cli config init`:
on Linux it writes the new app secret into the file-backed keychain under the
data directory, so clearing afterward would leave `config.json` with a dangling
keychain reference. The snapshot still supplies the previous OAuth data for
logout and restores the complete old tree on any switch failure.
### DDG image search
Offload config/DDGS together. Only returned empties mean `No images found`;
the SDK sentinel can hide timeouts. See `tests/test_image_search_errors.py`.
### Browser Progress Screenshots (`community/browser_automation/`)
Hidden per-action browser progress frames use JPEG at quality 80 to keep their
storage and transfer cost bounded relative to lossless PNG. The explicit
`browser_screenshot` tool remains PNG because it creates a user-requested
artifact. New automatic capture entry points must reuse the shared progress
encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot
drift. `browser_screenshot` never replaces an `outputs/` file: it hard-links
onto the first free `-N` name.
### Embedded Client (`packages/harness/deerflow/client.py`)
`DeerFlowClient` embeds the harness without HTTP/FastAPI, sharing Gateway modules, config, data directories, and response schemas.
**Agent Conversation**:
- `chat(message, thread_id)` — synchronous; returns final AI text.
- The first `values` snapshot reconciles any suffix appended to already-streamed AI text by a model wrapper; unchanged text and non-prefix replacements are not re-emitted. Later snapshots retain the same append-only rule.
- `stream(message, thread_id)` — subscribes to LangGraph `stream_mode=["values", "messages", "custom"]` and yields `StreamEvent`:
- `"values"` — state snapshot (title, messages, artifacts, summary_text). Always forward `summary_text` (current summary or `None`), including unchanged values/resets. Never re-emit AI text delivered via `messages`; serialized `ToolMessage` entries retain non-`None` native `artifact`
- `"messages-tuple"` — current-turn AI text **deltas** by `id` and each tool call/result once; excludes resumed history and preserves result `artifact`
- `"custom"` — forwarded from `StreamWriter`; DeerFlow-built-in custom events are dual-emitted through `deerflow.utils.custom_events`, so `astream_events(version="v2")` consumers also receive one `on_custom_event` with `name=payload["type"]` and the unchanged payload as `data`
- `"end"` — turn `usage`: signed chunks + positive full-message remainders, deduped by id
- **Custom-event invariant** — use `emit_custom_event` / `aemit_custom_event`, never `StreamWriter` alone. Built-in payloads require a non-empty string `type`; typeless payloads stay writer-only, absent from `astream_events`. The writer runs first and is authoritative for Gateway/Web UI/embedded clients; best-effort callbacks must not break it. Async graph hooks must await the async helper, never dispatch synchronously on a running event loop.
- Lazy graph creation uses `create_agent()` + `build_middlewares()`.
- Cache graphs by storage `user_id` and the unordered set of named-agent `mcp_plugins`. `stream()` materializes `user_id` before worker/loop boundaries in every auth mode.
- Supports `checkpointer` parameter for state persistence across turns
- `reset_agent()` reloads AgentConfig and rebuilds the graph. Every run's metadata carries `mcp_plugins` for delegation, including cache hits.
- [Streaming design](../../../docs/STREAMING.md): Gateway/client parallel paths, LangGraph `stream_mode`, per-id deduplication, and regression tests
**Gateway Equivalent Methods** (replaces Gateway API):
| Category | Methods | Return format |
|----------|---------|---------------|
| Models | `list_models()`, `get_model(name)` | `{"models": [...]}`, `{name, display_name, ...}` |
| MCP | `get_mcp_config()`, `update_mcp_config(servers)` | `{"mcp_servers": {...}}` |
| Skills | `list_skills()`, `get_skill(name)`, `update_skill(name, enabled)`, `install_skill(path)` | `{"skills": [...]}` |
| Goals | `get_goal(thread_id)`, `set_goal(thread_id, objective, max_continuations=8)`, `clear_goal(thread_id)` | `{"goal": {...}}` or `{"goal": None}` |
| Memory | `get_memory()`, `reload_memory()`, `get_memory_config()`, `get_memory_status()` | dict |
| Uploads | `upload_files(thread_id, files)`, `list_uploads(thread_id)`, `delete_upload(thread_id, filename)` | `{"success": true, "files": [...]}`, `{"files": [...], "count": N}` |
| Artifacts | `get_artifact(thread_id, path)` → `(bytes, mime_type)` | tuple |
**Gateway differences**: Upload takes local `Path`, not `UploadFile`, rejects directories before copying, and reuses one conversion worker inside an active event loop. Artifacts return `(bytes, mime_type)`, not HTTP Response. Gateway alone deletes `.deer-flow/threads/{thread_id}` after LangGraph thread deletion; the client has no equivalent. `update_mcp_config()` and `update_skill()` invalidate the cached agent.
Upload capture, filename claims and publication: [guide](uploads/AGENTS.md).
**Tests**: `tests/test_client.py` is offline, including `TestGatewayConformance`.
`tests/test_client_live.py` requires root `config.yaml`, valid API credentials,
and opt-in via `make test-live` or `DEER_FLOW_RUN_LIVE_TESTS=1`. It calls real
APIs (possible costs) and may create local sandboxes, artifacts, and files.
Marked `live`, it is excluded from `make test` and skipped in default CI.
**Gateway Conformance Tests** (`TestGatewayConformance`): Parse every dict-returning client method's output through its Gateway Pydantic model so missing required fields raise `ValidationError` in CI. Covers: `ModelsListResponse`, `ModelResponse`, `SkillsListResponse`, `SkillResponse`, `SkillInstallResponse`, `McpConfigResponse`, `UploadResponse`, `MemoryConfigResponse`, `MemoryStatusResponse`.
### AIO Sandbox Network Policy
Restricted AIO keeps sandboxes internal; a per-sandbox, ICC-disabled sidecar
handles egress and its token-authenticated API relay. Parse headers strictly;
reject policy-denied names before DNS and try all validated answers. Claim the
oldest unsurfaced denial; subagent/non-interactive runs drain and deny. Approvals
never replay tools; policy labels fence reuse. CONNECT/SNI cannot inspect
encrypted authority. Discovery and enumeration are read-only, including on a
policy or network-mode mismatch; only the provider may replace it after the
orphan grace, local teardown reservation, and cross-instance teardown lease.
Destroy the sandbox, sidecar, and both networks together.
### Tenki `sticky`
Env values stay strings; parse booleans before SDK calls (`bool("false")` is true).
### E2B Mount Uploads
Before changing creation uploads, policy projections, budgets, deadlines, or result
reporting, read [the E2B upload contract](community/e2b_sandbox/AGENTS.md#mount-uploads).
### Workspace Snapshot Cancellation (`workspace_changes/recorder.py`)
After `_prepare_capture()` hands off roots, cancellation must drain text scans
(`include_text=True`) before removing the cache the worker may still access.
Metadata scans (`include_text=False`) own no cache: cancel promptly, let the worker
continue, and consume/log its outcome in a completion callback. Prepare-stage
cancellation retains its handoff/reclaim path. Regressions in
`tests/blocking_io/test_workspace_changes_cancellation.py` must cover prompt
metadata cancellation and text-cache drain/cleanup.

View File

@ -1,56 +1,60 @@
### Agent System # Agents (`deerflow/agents/`)
Web runs may supply `context.locale` (`en-US` / `zh-CN`) as a response-language The lead agent factory, its system prompt, the thread state and its reducers.
preference; explicit user language requests win. DynamicContext projects it as Scoped guides: [middlewares](middlewares/AGENTS.md), [memory](memory/AGENTS.md),
request-only human data, leaving the static system prompt independent of locale. [task continuity](task_continuity/AGENTS.md). Runtime options and state fields
Clients without a supported locale retain message-language inference. are in `lead_agent/agent.py` and `thread_state.py`.
`AppConfig.lead_prompt_overlay` wraps the fully rendered lead system prompt with ## Verify
literal operator prepend/append text. Do not template-format these extensions
or source them from run context. The graph and assembly descriptor must see the
same resulting prompt. Empty overlays preserve bytes.
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`): ```bash
- `make_lead_agent(config: RunnableConfig)` is the published `langgraph.json` cd backend
entry point; preserve its signature and bare-graph return type. python -m pytest tests -q -k "lead_agent or thread_state or assembly or prompt"
- Gateway calls `assemble_lead_agent(config, *, app_config=None)` for a ```
`LeadAgentAssembly(graph, descriptor, effective_model)`; `make_lead_agent` returns `.graph`.
`effective_model` is available even when no observer requests a descriptor,
so graph-root tracing can tag the selected default or fallback model.
`assembly_descriptor.py::build_assembly_descriptor()` records the model after
runtime overrides, rendered prompt hash, authorized tools and middleware order.
Consumers must unwrap via `runtime/runs/worker.py::_agent_graph` to also accept
third-party factories returning bare graphs.
The hashed skill catalog preserves `allowed_tools=None` (field omitted), `()` (declared empty), and non-empty allowlists as distinct compatibility-metadata states; allowlist ordering does not affect the fingerprint. The field is exported metadata only and never filters tools.
- Dynamic model selection via `create_chat_model()` with thinking/vision support
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
- Custom Agent `memory_enabled: false` disables memory reads, writes, tools, and compaction flushes while retaining date context; manual compaction trusts the state-producing checkpoint's agent binding, and global disable stays authoritative. Embedded clients cache the named policy by agent/user until `reset_agent()`; unreadable configs keep the legacy enabled default and log a warning.
- **Prompt trust**: framework authority uses the system channel; user/model-influenced text uses the sanitized `HumanMessage` data channel. Never interpolate untrusted values into system text—even escaped tags do not neutralize natural-language injection (PR #5090).
- Pass the same rendered prompt and middleware objects to the graph and assembly descriptor so observers describe the live graph, including Custom Agent `allowed_subagents` scope.
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`): ## Lead agent
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`
- Uses custom reducers: `merge_artifacts` (deduplicate), `merge_viewed_images` (merge/clear), `merge_goal` (preserve the active goal across ordinary state updates unless the goal writer replaces it), `merge_promoted` (union deferred tools within one catalog/user turn; replace on scope change; hash-only tool updates inherit scope), `merge_delegations` (append task delegation entries, same (run,id) wins, terminal status never downgraded, capped to the most recent entries), and `merge_skill_context` (dedupe active-skill references by path, keep the most recently read entries; entries store a name/path/description reference, not the SKILL.md body). `summary_text` is a LastValue channel updated by summarization and projected into model requests as durable context data instead of being stored as a `messages` item.
- Delta-mode `merge_message_writes` normalizes the current message state once,
then folds normalized writes in order with message-ID position indexes and
deferred tombstone compaction. It preserves public `add_messages` behavior,
including duplicate IDs, replacement position, removal errors,
`REMOVE_ALL_MESSAGES`, null-write errors, and missing-ID allocation order,
without rescanning the accumulated state for every write. Keep this
full-parity contract covered by differential tests: LangGraph's private
`_messages_delta_reducer` is also linear, but intentionally omits some of
those public `add_messages` semantics and cannot be substituted directly.
**Runtime Configuration** (via `config.configurable`): - **`make_lead_agent(config)` is the published `langgraph.json` entry point.**
- `thinking_enabled` - Enable model's extended thinking Keep its signature and its bare-graph return type. The Gateway calls
- `model_name` - Select specific LLM model `assemble_lead_agent`, which also returns the descriptor and the effective
- `is_plan_mode` - Enable TodoList middleware model.
- `subagent_enabled` - Enable task delegation tool - **A consumer unwraps through `runtime/runs/worker.py::_agent_graph`**, because
- `max_concurrent_subagents` - Per-response `task` call concurrency limit (clamped by `SubagentLimitMiddleware`) a third-party factory can return a bare graph.
- `max_total_subagents` - Per-run total delegation cap override (unset/`null` uses `subagents.max_total_per_run`, clamped to 1-50) - **The graph and the assembly descriptor receive the same rendered prompt and
Gateway and `DeerFlowClient.stream()` always provide the runtime `run_id`; custom the same middleware objects.** Observers then describe the graph that runs.
graph integrations must do the same. If it is absent, enforcement deliberately - **Skill `allowed_tools` in the descriptor is exported metadata.** It never
counts the thread's full delegation ledger (fail-restrictive) and emits a warning. filters tools. Omitted, declared empty and non-empty are three states.
- **A custom graph integration supplies the runtime `run_id`.** Without it, the
delegation cap counts the thread's whole ledger and logs a warning.
- **One `SubagentRuntime` is shared per capacity boundary** when you build the
graph directly with `create_deerflow_agent`.
**Direct subagent runtime**: `create_deerflow_agent(..., subagent_runtime=runtime)` is the explicit dependency-injection path for direct graph callers. Reuse one `deerflow.subagents.SubagentRuntime` across every graph that belongs to the same application capacity boundary. With the default subagent feature it binds middleware concurrency/total limits, the ordinary `task` tool, one real execution controller, and any active durable-batch submitter to the same snapshot. A caller-owned batch repository requires `await runtime.start()` (or `async with runtime`) before graph construction and `stop()` at shutdown; the factory fails closed while that worker is stopped, and already-built bound batch tools must fail unavailable after it stops rather than falling through to another process-global submitter. The factory never creates SQL infrastructure, renders the caller-owned `system_prompt`, or mounts Gateway API/UI routes. Full middleware takeover cannot be combined with this runtime; direct callers and custom subagent middleware remain responsible for model-visible call-policy wording. ## Prompt trust
- **Framework authority uses the system channel. Text that a user or a model can
influence goes into the sanitized `HumanMessage` data channel.** Do not
interpolate an untrusted value into system text; escaping tags does not
neutralize an instruction written in natural language.
- **The locale preference is request-only human data.** The static system prompt
stays independent of it, and an explicit language request from the user wins.
- **`lead_prompt_overlay` is literal operator text.** Do not template-format it or
source it from the run context.
## Thread state
- **`merge_message_writes` keeps the public `add_messages` behavior in full.**
LangGraph's private delta reducer omits part of it and cannot replace it. The
differential tests cover the parity.
- **Reducers encode rules that a plain overwrite breaks**: the active goal
survives ordinary updates, and a terminal delegation status is never
downgraded. Read the reducer before you write its field.
## Import hygiene
- **The package roots of `deerflow.agents` and `deerflow.subagents` load the graph
and the executor lazily.** A module that needs only a type, a config or a
registry imports the concrete submodule. An eager import at the package root
pulls the tool graph into every state and schema import.
- **`deerflow.agents:make_lead_agent` is a real module-level function**, because
LangGraph Server resolves factories from the module dictionary. Its heavy
imports stay inside the function.

View File

@ -30,6 +30,7 @@ class GoalState(TypedDict):
no_progress_count: int no_progress_count: int
max_no_progress_continuations: int max_no_progress_continuations: int
last_evaluation: NotRequired[dict[str, Any]] last_evaluation: NotRequired[dict[str, Any]]
last_batch_wakeup_run_id: NotRequired[str]
class GoalOutcomeState(TypedDict): class GoalOutcomeState(TypedDict):

View File

@ -20,73 +20,16 @@ class RunInteractionMode(StrEnum):
_INTERACTIVE_CLARIFICATION_SYSTEM = """<clarification_system> _INTERACTIVE_CLARIFICATION_SYSTEM = """<clarification_system>
**WORKFLOW PRIORITY: CLARIFY → PLAN → ACT** Use the request and available context to determine what is needed. Inspect
1. **FIRST**: Analyze the request in your thinking - identify what's unclear, missing, or ambiguous relevant information and continue independent work where possible. Make and
2. **SECOND**: If clarification is needed, call `ask_clarification` tool IMMEDIATELY - do NOT start working state reasonable, reversible assumptions when they do not materially change
3. **THIRD**: Only after all clarifications are resolved, proceed with planning and execution the result. Ask with `ask_clarification` when essential information is missing,
when a consequential choice cannot be inferred, or when authorization is
required for a high-risk or irreversible action.
**CRITICAL RULE: Clarification ALWAYS comes BEFORE action. Never start working and clarify mid-execution.** Once `ask_clarification` is called, execution is interrupted and sibling tool
calls in that turn are dropped. Wait for the user's answer before work that
**MANDATORY Clarification Scenarios - You MUST call ask_clarification BEFORE starting work when:** depends on it.
1. **Missing Information** (`missing_info`): Required details not provided
- Example: User says "create a web scraper" but doesn't specify the target website
- Example: "Deploy the app" without specifying environment
- **REQUIRED ACTION**: Call ask_clarification to get the missing information
2. **Ambiguous Requirements** (`ambiguous_requirement`): Multiple valid interpretations exist
- Example: "Optimize the code" could mean performance, readability, or memory usage
- Example: "Make it better" is unclear what aspect to improve
- **REQUIRED ACTION**: Call ask_clarification to clarify the exact requirement
3. **Approach Choices** (`approach_choice`): Several valid approaches exist
- Example: "Add authentication" could use JWT, OAuth, session-based, or API keys
- Example: "Store data" could use database, files, cache, etc.
- **REQUIRED ACTION**: Call ask_clarification to let user choose the approach
4. **Risky Operations** (`risk_confirmation`): Destructive actions need confirmation
- Example: Deleting files, modifying production configs, database operations
- Example: Overwriting existing code or data
- **REQUIRED ACTION**: Call ask_clarification to get explicit confirmation
5. **Suggestions** (`suggestion`): You have a recommendation but want approval
- Example: "I recommend refactoring this code. Should I proceed?"
- **REQUIRED ACTION**: Call ask_clarification to get approval
**STRICT ENFORCEMENT:**
- ❌ DO NOT start working and then ask for clarification mid-execution - clarify FIRST
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
- ❌ DO NOT call any other tool in the same turn as ask_clarification — sibling calls are dropped
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
- ✅ After calling ask_clarification, execution will be interrupted automatically
- ✅ Wait for user response - do NOT continue with assumptions
**How to Use:**
```python
ask_clarification(
question="Your specific question here?",
clarification_type="missing_info", # or other type
context="Why you need this information", # optional but recommended
options=["option1", "option2"] # optional, for choices
)
```
**Example:**
User: "Deploy the application"
You (thinking): Missing environment info - I MUST ask for clarification
You (action): ask_clarification(
question="Which environment should I deploy to?",
clarification_type="approach_choice",
context="I need to know the target environment for proper configuration",
options=["development", "staging", "production"]
)
[Execution stops - wait for user response]
User: "staging"
You: "Deploying to staging..." [proceed]
</clarification_system>""" </clarification_system>"""
_AUTONOMOUS_CLARIFICATION_SYSTEM = """<clarification_system> _AUTONOMOUS_CLARIFICATION_SYSTEM = """<clarification_system>
@ -126,7 +69,7 @@ class RunInteractionPolicy:
@property @property
def thinking_guidance(self) -> str: def thinking_guidance(self) -> str:
if self.allows_clarification: if self.allows_clarification:
return "- **PRIORITY CHECK: If anything is unclear, missing, or has multiple interpretations, you MUST ask for clarification FIRST - do NOT proceed with work**" return "- **INTERACTION CHECK: Use available context and reasonable reversible assumptions; ask when essential information or authorization is missing.**"
return "- **INTERACTION CHECK: This run has no synchronous human. Resolve ambiguity with the run context, choose the lowest-risk reversible path, and record material assumptions.**" return "- **INTERACTION CHECK: This run has no synchronous human. Resolve ambiguity with the run context, choose the lowest-risk reversible path, and record material assumptions.**"
@property @property
@ -139,7 +82,7 @@ class RunInteractionPolicy:
@property @property
def clarification_reminder(self) -> str: def clarification_reminder(self) -> str:
if self.allows_clarification: if self.allows_clarification:
return "- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess" return "- **Clarification**: Continue independent work and ask when an essential decision or authorization is missing."
return "- **Autonomous Interaction**: Do not wait for a human response; make minimal reversible assumptions, list them, or return a structured `BLOCKED` result for high-risk ambiguity" return "- **Autonomous Interaction**: Do not wait for a human response; make minimal reversible assumptions, list them, or return a structured `BLOCKED` result for high-risk ambiguity"
@classmethod @classmethod

Some files were not shown because too many files have changed in this diff Show More