316 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
一片一片
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
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
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
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
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
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
一片一片
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
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
王一帆
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
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
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
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
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
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
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
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
一片一片
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
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
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
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
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
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
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
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
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
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