* 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.
* 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>
* 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.
* 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>
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>
* 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>
* 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>
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>
_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>
* 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>
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.
* 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.
* 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>
* 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>
* 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>
* 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>
* 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
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.
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>
* 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>
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.
* 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.
* 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>
* 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>
* 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.
* 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.
* 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>
* 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
* 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>
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.
* 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>
* 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>
* 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>
* 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>