# Changelog All notable changes to DeerFlow are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] This section accumulates work toward the **2.2.0** milestone ([2.2.0](https://github.com/bytedance/deer-flow/milestone/3)). This release closes that milestone with **439 merged pull requests**. ### Added #### Scheduler - **scheduler:** Opt-in conversation tools create and manage owner-bound schedules, support bounded automatic launches and per-occurrence goals, and let a scheduled agent request stopping its own schedule. Unmet goals and automatic pause use the existing notification outbox; explicit notes and authorized previous-run references carry context forward without changing the goal lifecycle. Independent of these tools, existing scheduled, webhook and autonomous goal runs may now accept disclosed low-risk, reversible assumptions, recorded as `relied_on_assumption`; interactive goal evaluation stays strict. Goal evaluator calls and tokens now count toward run usage. ([#6229]) - **scheduler:** The tasks page shows the per-run goal and end conditions of conversation-created tasks. Run history shows whether a goal was met, including when it relied on stated assumptions; an unmet run shows a readable reason in neutral styling instead of a raw code in the error style; and the run whose agent asked to stop the schedule is marked. Agent-stop, auto-pause and unmet values in a task's last error appear as text in the UI language, and goal-unmet/auto-pause IM notices state the reason in words. Tasks without a goal render as before; no API change. ([#6326]) - **scheduler:** Scheduled tasks can be searched by title or prompt. Finding a task previously meant scanning every title or opening details to read its prompt. A search field above the existing filters matches a literal, trimmed, case-insensitive substring of either field (including Chinese), composes with the status/type filters and thread scope, and clearing it retains the other filters; when nothing matches, task actions hide with localized feedback. Runs entirely on the existing authorized list response — no API change. ([#5355]) #### Agents & runtime - **uploads:** Add stable cursor pagination to the `list_uploaded_files` discovery tool. With more than 100 historical uploads matching the same filters the tool could only return the first page, giving an agent no continuation path. An optional `cursor` input and `next_cursor` output now paginate past the default page of 20 (maximum 100), sorted by nanosecond modification time descending with a deterministic raw-filename tie-breaker. The token binds to the runtime-resolved user/thread, normalized filters, current-run exclusions, and ordinary-file metadata; malformed or invalidated continuation returns controlled restart instructions telling the client to discard previously collected pages. `total_count` still covers all filtered matches and no new dependency, storage layer, or HTTP endpoint is involved. ([#5570]) - **agents:** Middleware-declared tools are now covered by Layer-1 tool authorization on every assembly path (lead agent, native subagents, and the embedded client). LangChain merges each middleware's `tools` into the bound tool set *after* the host's explicit-list filter, so declarations such as an extension-contributed tool or plan mode's `write_todos` previously bypassed the `tools` policy entirely. Each build now collects declarations from the assembled stack, decides only the names the ordinary pass never saw — seeded with that pass's verdicts so a name denied for the build (including by a fail-closed provider failure) stays denied — and narrows the stack on independent state-preserving copies without mutating caller-owned middleware instances. **Behavior change:** with `authorization.enabled` and an explicit `tools` policy that does not allow `write_todos`, plan-mode builds no longer bind `write_todos`, and `TodoMiddleware` correspondingly stops injecting the todo system prompt and incomplete-todo completion reminders; the built-in RBAC default (missing tool policy = unrestricted) is unchanged, as is every deployment with authorization disabled. Layer 2 also forwards host-resolved tool provenance into `AuthzRequest.context["tool_provenance"]` and binds the infrastructure exemption (the generated `tool_search` helper) to the concrete host-created tool object instead of its name, so a same-named foreign tool can no longer inherit the exemption. ([#6105]) - **agents:** Custom agents can persist a default knowledge scope in agent settings, so a specialized agent starts each conversation with its own corpus instead of all operator-approved knowledge bases. When a new turn omits an explicit selection — including callers without the composer selector — gateway admission applies the saved default; an explicit per-message selection still wins, selecting all knowledge bases clears the binding, and regenerate/resume retain the source turn's accepted scope. This is a default, not an access-control policy: operator allowlists and dataset availability are still enforced. ([#5579]) - **config:** Operators can now overlay core system prompts without source patches. `lead_prompt_overlay.prepend/append` wraps the assembled lead prompt, `subagents.agents..prompt_overlay` extends built-in and configured subagent prompts without mutating the shared registry, and DeerMem's `memory.backend_config.prompt_prepend/prompt_append` extends the rendered memory-update system message. Empty overlays preserve prompt bytes and overlay text is literal, so stock behavior is unchanged until an operator opts in. ([#5794]) - **agents:** Tool-produced file paths, URLs, and task ids become short, stable artifact handles (`art_xxxxxxxx`) that survive context compaction, so the model no longer loses or fabricates exact references after earlier tool messages are summarized. Each thread keeps a bounded registry in `ThreadState.tool_artifacts` projected into the model's hidden durable-context block, and a handle passed in any tool argument — bare, backticked, or nested in dict/list args — is resolved back to the real reference just before the tool executes; unknown or expired handles fail the call without running the tool. A new `tool_artifacts` config section (`enabled`, `max_entries` default 100, `detect_refs_in_text`, `inject_model_context`, `resolve_handles_in_args`) switches each piece, all on by default. Handles are agent-local: `task` arguments resolve parent handles to concrete references, and delegated reports must return concrete ones. Tool-call steps in the chat timeline show a badge per captured artifact, and SSE `values` snapshots gain an additive `tool_artifacts` key. ([#4929]) - **context:** `history_search` accepts an optional `role` (`user`, `assistant`, or `tool`). The search returns at most eight matches across all roles, so when a caller needs a user's earlier correction, repeated assistant discussion or tool output mentioning the same keyword could fill that budget first. The filter applies to active and archived messages before the eight-result limit, using the role already stored in archive records; omitted or null keeps the unchanged all-roles search, returned roles stay `human`/`ai`/`tool`, an unknown role returns an error, and no storage migration or `history_read` change is involved. ([#5927]) - **subagents:** Delegations can opt into a JSON syntax acceptance criterion, `acceptance_criteria=["file:../outputs/report.json json-valid"]`, on both `task` and `batch_task`. A delegated task could produce a non-empty `report.json` that is not valid JSON, and the existing existence and non-emptiness checks could not express the difference. The criterion holds for complete UTF-8 JSON up to 50,000 bytes; invalid JSON, empty files, and `NaN`/`Infinity` do not hold; a missing file is negative once absence is established; oversize, incomplete, or unavailable reads and parser resource limits stay `UNVERIFIED`. Local reads are bounded binary reads; remote reads keep the fresh-shell, absolute-utilities, regular-file, and containment checks and transfer at most 50,001 bytes through a base64 envelope, with no unbounded `read_file` fallback — unsupported remote tools stay `UNVERIFIED`. Scalars, duplicate keys, and syntactically valid large numbers are allowed; a UTF-8 BOM is rejected. Syntax only — no schema or business-field checks; existing criteria and verdict semantics are unchanged. ([#5947]) - **gateway:** The multi-process startup gate now fires on an explicit `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`) as well as `GATEWAY_WORKERS > 1`. The worker-count variables only see one process tree, so a Kubernetes Deployment with several one-worker Pods passed every check while each Pod's startup orphan reconciliation wrote the other Pods' lease-less runs off as crashed on every rolling update. Both paths require Postgres, `run_events.backend: db`, `run_ownership.heartbeat_enabled: true` and now also a Redis stream bridge, and refuse an explicit `sandbox.ownership.type: memory`, process-local browser tools and a scheduler without `scheduler.multi_instance`. The agent-storage divergence warning, the inbound webhook dedupe warning and the WeChat QR-login guard honor the same declaration. **Behavior change:** `GATEWAY_WORKERS > 1` with the memory stream bridge no longer starts; configure `stream_bridge.type: redis` or `DEER_FLOW_STREAM_BRIDGE_REDIS_URL` (docker-compose and the Helm chart already inject it). ([#6328]) - **config:** `DEER_FLOW_ENV_FILE` selects one explicit UTF-8 dotenv file for the backend at startup, shared by configuration loading, authentication startup, and the debug entry point; relative paths resolve from the backend process working directory, existing process variables keep precedence, and empty, missing, unreadable, or invalid-encoding selections are rejected without fallback. Unset keeps default dotenv discovery; an explicit selection errors when `PYTHON_DOTENV_DISABLED` disables loading. ([#6227]) - **agents:** Run events carry per-LLM-call telemetry: `llm.ai.response` metadata gains optional keys for the LangChain run id, caller category (`lead_agent`, `middleware`, `subagent`, `fallback`), provider, model, stop reason, provider-reported token counts, and rendered request size; `llm.error` carries the same identity plus `error_type`; and the summarization middleware emits `middleware:summarize`, flagging when the summarizer output is identical to the existing `summary_text`. Observation only: prompts, requests, and token accounting are unchanged, and every telemetry helper fails soft. ([#6236]) - **context:** Compaction skips the summarizer LLM call when the exact rendered summary prompt previously produced the existing summary unchanged. A bounded cache keyed on the rendered prompt plus the ordered model candidates stores prompts whose first candidate returned the current summary byte-identical; only first-candidate results are cached, so an unchanged fallback response cannot suppress retrying a failed primary model, reuse also requires today's previous summary to equal the cached output, and cache errors fall back to normal generation. The `middleware:summarize` journal event gains `llm_call_skipped` and `skip_count`. ([#6318]) #### Memory - **memory:** Cross-session memory gains `user.cognitiveStyle` for stable collaboration preferences (response structure, depth, correction style), stored and injected separately from `personalContext` and task skills so a protocol like "conclusions first" no longer competes with project facts under `max_injection_tokens`. Legacy `memory.json` files are normalized on load, the `` injection gains a `Thinking Style:` line (plus optional `category: cognitive` facts), and the field is editable in Settings → Memory and exposed through the Gateway memory API. ([#3182]) - **memory:** The sample-memory loader can now bulk-load registered users. It previously wrote the legacy shared memory path while authenticated users read per-user storage, so a successful load still left Settings showing no memory. `scripts/load_memory_sample.py` requires exactly one of `--target PATH` or `--all-users`; `--all-users` enumerates registered users from the configured persistent database and backs up each user's existing memory before replacing it. Non-persistent database modes are rejected. ([#4066]) - **memory:** Memory search and injection can rank candidates by lexical relevance instead of confidence alone. Confidence-only injection could prefer unrelated facts over ones useful to the current task; opt-in `retrieval_relevance_enabled` (default `false`) blends IDF-weighted query coverage with confidence, applies category filtering before `top_k`, and can diversify selections — no embeddings, vector storage, or persisted-format changes; the tokenizer handles CJK bigrams without new dependencies. Legacy `get_context` implementations keep working. ([#5251]) - **memory:** Add an opt-in tolerant `MarkdownMemoryStorage` (`memory.storage_class: markdown`) for the user-memory summary store. Corrupt or partially written files no longer raise `MemoryStorageCorruption` and crash the agent: a Markdown summary is understood via a lossless fenced `memory-json` block with a best-effort structured fallback, and plain JSON still loads. The on-disk JSON format and the JSON UI are unchanged, and writes remain journaled JSON — this ships read-path tolerance only; a full Markdown write path is a follow-up. ([#5545]) - **memory:** Add a deterministic DeerMem scope-isolation benchmark under `backend/scripts/benchmark/deermem_scope_isolation`. The opt-in validate/run/report workflow exercises lifecycle durability, concurrent corrections, and isolation across agent and user memory scopes, and changes no runtime memory behavior. ([#5564]) - **memory:** The memory management API — read, reload, status, import/export, clear, and single-fact CRUD — accepts an optional `agent_name` and then operates only on that agent's bucket, so each custom agent's memories can be inspected and maintained in isolation. Omitting the parameter preserves the existing behavior: reads and import use the default bucket, and clear stays user-wide. Names are validated (letters, digits, hyphens; otherwise 422), and a backend must declare `supports_agent_scoped_management` or scoped requests fail with 501 instead of silently editing the wrong bucket. The Gateway preserves the caller's spelling because Mem0 treats the agent name as a case-sensitive storage identity, while DeerMem keeps its own lowercase canonicalization. ([#5565]) - **memory:** Memory extraction gains an opt-in Jev (TypeSafe) pre-screen and signal classification, both off by default. `memory.prescreen` (`off` / `shadow` / `enforce`) classifies each conversation batch before the extraction LLM call: `shadow` only records the verdict; `enforce` lets a low-probability batch skip the call and advance the watermark, consuming that batch as "nothing durable here". `memory.signal_classification` (`off` / `shadow` / `hints`) merges `reinforcement`/`correction` labels into the extraction hints and, only while the pre-screen is `enforce`, may veto a skip. Both ride a new shared TypeSafe client, `deerflow.typesafe`, extracted from the guardrail risk gate — whose behavior is unchanged — with one error taxonomy: a request-level failure raises, a question-level failure is data (`AnswerSet.errors_by_question`). A new optional top-level `typesafe:` block (api key/env, `base_url`, model, timeout, deadline, attempts, backoff) is shared by all consumers; consumer `config` still wins, so existing guardrail deployments keep working. `config_version` moves 49 → 50 (`make config-upgrade` merges the keys), and `backend/scripts/eval_memory_prescreen.py` runs the shadow evaluation — exact Clopper–Pearson miss bound, per-stratum checks — that must pass before `enforce`; missing evidence reports `INSUFFICIENT`. ([#5906]) #### Knowledge & retrieval - **knowledge:** Custom-agent chats can scope RAGFlow retrieval per message. An opt-in Knowledge selector picks all operator-approved datasets, specific datasets/files, or no retrieval for one turn; the choice is stored as a validated, immutable `knowledge_scope` snapshot on the HumanMessage and enforced at Gateway admission, middleware, RAGFlow retrieval, and native and batch subagents — out-of-allowlist selections fail closed, and scope ids never reach model inputs or tracing. Off by default via `knowledge_base.scope_selection_enabled` (config version 41). ([#5238]) - **ragflow:** RAGFlow retrieval results now carry verifiable source citations. Clicking an inline knowledge citation, a document-title link in Sources, or the answer's knowledge sources list opens the retrieved excerpt, dataset and document names, and page numbers when RAGFlow supplies them, and sources remain available after conversation reload. `knowledge_search` pairs the opaque citation links with bounded native tool-message artifacts holding the exact excerpt given to the model (truncation marked, configured API key redacted); ordinary `task` results forward only captured sources cited in the child's result; and output budgeting retains complete evidence entries under the active limit, omitting non-fitting entries with a notice. Missing records display as unavailable. These are retrieval-time evidence snapshots, not a live full-document viewer; existing direct `knowledge_search()` callers keep the string return type. ([#5551]) - **community:** The image search tool exposes the `color` and `license_image` filters. Both were wired into the request path but unreachable from the tool signature, so agents could not narrow results to a palette or to license-cleared images when sourcing references for image generation. Valid values follow `ddgs` (`color`: `Monochrome` through `White`; `license_image`: `any`, `Public`, `Share`, `ShareCommercially`, `Modify`, `ModifyCommercially`); unset filters produce exactly the same request as before. ([#5723]) - **search:** Serper `web_search` accepts the optional model argument `time_range` of `day`, `week`, `month`, or `year`, mapped to Serper's `tbs` parameter (`qdr:d|w|m|y`); omitting it or passing `null` keeps the unrestricted request, invalid values are rejected before any HTTP call, and Serper `image_search` is unchanged. ([#6113]) - **fetch:** Jina `web_fetch` supports opt-in bounded retries through `max_retries` (default `0`, off) and `retry_budget_seconds` (default `30`) on its `tools` entry. Only HTTP 502/503/504 and connection-establishment errors retry; backoff waits are randomized inside a shared budget that caps every attempt, and enabling retries can send up to `1 + max_retries` upstream requests at additional cost. ([#6143]) #### Skills - **skills:** Show which skills an answer loaded, with inspectable snapshots. Successful configured `SKILL.md` reads and explicit slash activations now record a bounded, read-only snapshot, and the answer toolbar gains a Skills menu listing only the skills that answer's own run loaded; selecting one opens the captured file (original Markdown including frontmatter, package-relative links kept as non-navigating references) in a resizable desktop panel or a mobile sheet, with copy returning the original Markdown. The run journal persists a first-load-order aggregate on the terminal answer so paginated history keeps the evidence, and the entry is hidden when no load evidence exists. ([#5758]) #### Models & integrations - **community:** New Unbrowse `web_fetch` provider - returns a page as markdown over plain HTTP, or through Unbrowse's hosted cloud browser for JavaScript-heavy pages (`render: auto|never|always`). One JSON-RPC POST per fetch, no new dependencies. ([#5981]) - **models:** Administrators can manage shared models from Settings → Models without editing server configuration. New administrator-only `GET/PUT /api/managed-models` and `POST /api/managed-models/test` endpoints maintain an encrypted catalog under `$DEER_FLOW_HOME/managed-models/` — credentials are never returned, omitted keys retain the saved value, and stale edits are rejected by revision. Enabled managed profiles are appended to new effective configuration snapshots; YAML profiles remain read-only and win name conflicts. The first version supports OpenAI-compatible Chat Completions endpoints; existing model authorization still applies. ([#5596]) - **models:** Add a declarative reasoning capability contract for model profiles. A model can declare an optional `reasoning:` block (`thinking: unsupported|optional|required`, `on_disable_request`, `dialect`, `history`, and `effort: {values, default, aliases, path}`) beside the legacy booleans, and contradictory profiles fail at config load. `create_chat_model` enforces one normalized policy everywhere — lead agent, subagents, summarization, and title generation — so a required-thinking model never enters the disable branch and effort values map through aliases or fall back to the declared default instead of being sent verbatim. `GET /api/models` and `DeerFlowClient` gain a `reasoning` object (`source: legacy|contract`), the frontend derives the effort menu from the model's declared values (e.g. `Low/High/Max` for GLM-5.3-Flash) and hides "off" for required-thinking models, and `PATCH /api/v1/auth/preferences` accepts provider-specific effort tokens. Profiles without the block keep the unchanged legacy path. ([#5780]) - **community:** The InfoQuest tools no longer block the agent event loop. `InfoQuestClient.fetch`/`web_search_raw_results`/`image_search_raw_results` used blocking `requests.post(..., timeout=30)`, so with the community tools enabled each call could stall the loop for up to 30 seconds. They now use `httpx.AsyncClient`, the `web_search`/`web_fetch`/`image_search` tool wrappers are coroutines, and `web_fetch` offloads the CPU-bound readability extraction via `asyncio.to_thread` — matching the earlier `jina_ai` migration. Return values, error strings, and the 30-second transport timeout are unchanged. ([#5782]) - **community:** The Tavily `web_search`/`web_fetch` tools stop blocking the event loop: `_get_tavily_client()` now returns `AsyncTavilyClient`, and `web_search_tool`/`web_fetch_tool` are coroutines that await `client.search(...)`/`client.extract(...)`, closing the client on every outcome. Credentials handling is unchanged — with no key in the tool config the client is still built with `api_key=None`, so the SDK keeps reading `TAVILY_API_KEY` from the environment. ([#5784]) - **community:** The Firecrawl `web_search`/`web_fetch` tools become coroutine tools: `_get_firecrawl_client()` returns `AsyncFirecrawlApp` and the tools await `search()`/`scrape()` instead of the previously blocking sync calls. Result normalization, error handling, API key handling, and the custom `api_url` behavior are unchanged. ([#5786]) - **community:** The image-search tool no longer runs DuckDuckGo search on the event loop. `image_search_tool` is now a coroutine that offloads both config resolution and the blocking `DDGS` call to a worker via `asyncio.to_thread` (the `ddgs` SDK has no async API), keeping the loop responsive during config file access and network I/O. Search parameters, result shape, timeout, and error handling are unchanged; an already-started request cannot be cancelled and may keep a bounded worker alive until the existing 30-second timeout. ([#5788]) #### Channels - **channels:** WeChat can be connected by scanning a QR code from the web UI. The connection dialog in the sidebar and Settings gains QR login with progress, pairing-code input, expiry and retry states, and a "Scan again" option that preserves an unexpired binding command; credentials are saved on the backend and only the WeChat channel restarts. New WeChat connections default to QR login, with manual token entry still available. ([#5582]) - **scheduler:** Push scheduled-task outcomes to the owner's bound IM identities via a durable `notification_deliveries` outbox and `NotificationDeliveryWorker`. Activates only when `channel_connections.enabled` is true and a channel service is running; manual triggers and interrupts stay silent, and a target disconnected while its delivery waited is dropped. WeCom implements proactive `send_notification`; other providers fail visibly in the outbox until they grow a push path. (issue #4254, [#4843], [#6135]) - **channels:** A QQ channel connects over Tencent's official QQ Open Platform WebSocket gateway — no public callback URL needed. It answers text C2C messages and group @mentions with text replies while respecting QQ's passive reply budgets (four C2C per 60 minutes, five group per 5 minutes per source), and supports `allowed_users`, `channel_connections` one-time `/connect ` binding, and per-sender group topics. ([#6081]) #### Auth & guardrails - **authz:** Make the `skills` policy key govern visibility on the user-facing skill listing surfaces. A role whose policy denied skills still saw every skill in the slash-command autocomplete, `GET /api/skills`, `GET /api/skills/custom`, and `GET /api/skills/{name}`, discovering the denial only at activation time. Listing visibility now goes through `filter_resources(principal, "skill", ...)`, mirroring `list_models`: denied names disappear from the listings, the detail endpoint returns a 404 byte-identical to a genuine miss (no existence oracle), anonymous callers stay unfiltered, and provider resolution errors follow `authorization.fail_closed`. Admin management endpoints stay `require_admin_user`-gated; runtime activation authorization remains #4541's layer. ([#5489]) - **middleware:** Add deterministic, opt-in PII redaction for model-bound context (`pii_redaction.enabled`, default off). `PiiRedactionMiddleware` rewrites PII in genuine user messages and remote-content tool results (same allowlist as the tool-result sanitizer) to irreversible placeholders like `[EMAIL_1]`, using regex-only detectors — email; OpenAI/AWS/GitHub/Slack/Google API keys; Luhn-validated credit cards; international/CN/US phones; CN ID, CPF, CUIT/RFC national IDs. No mapping is stored. Placeholder numbering is stable across turns (the conversation is re-redacted per model call with value-deduplicated counters), externalized tool-output copies hold redacted text too, and subagents inherit the middleware automatically. Raw text stays in thread state; default behavior is unchanged. ([#5527]) - **frontend:** Gate composer send on the `runs:create` permission. Both chat routes resolve `canCreateRuns` and the shared composer checks it at the top of `submitThreadMessage`, the single choke point for the submit button, Enter-to-send, goal-set-triggered runs, and programmatic submits. A denied role gets a disabled send button that explains itself via `aria-label`/`title`, an info toast on submit, and its draft text kept for retry; while streaming the button remains the `runs:cancel` stop affordance as before. Absent or null permission lists stay permissive for mixed deployments. Mirrors the stop gating from #5294. ([#5528]) - **guardrails:** New opt-in TypeSafe (Jev) risk gate for tool calls, `deerflow.guardrails.typesafe:TypeSafeGuardrailProvider`, selected through `guardrails.provider.use`. Each probed call sends one yes/no question with the tool name and full argument JSON to TypeSafe System One and denies at `threshold` (default 0.5) with an error `ToolMessage` the model can adapt to. `allowed_tools` stays a hard permission list enforced locally before any request; oversized or unserializable arguments are refused locally with zero network requests; provider errors follow `guardrails.fail_closed`. This is the only guardrail provider that sends tool arguments to a third party — assess egress before enabling. `config_version` moves 46 → 47. ([#5712]) - **authz:** Gate plugin actions and management operations through the configured authorization provider. `POST /api/plugins/{ns}/actions/{name}` now requires a `plugin_action`/`invoke` decision on `/`, checked after resolution and before the request body is read, denying with `403`; new `require_plugin_management`/`arequire_plugin_management` in `deerflow-extension-api` give contributed management routes a fail-closed check with independent `read`/`write` scopes; built-in RBAC gains `plugin_actions` and `plugin_management` keys, an omitted key leaving the resource unrestricted; plugin tool descriptors now report `source: "plugin:"`. Enforcement stays off until `authorization.enabled: true`; extension-api contract 0.2.4 and `config_version` 49. ([#5842]) #### Extensions & plugins - **plugins:** Full-stack plugin APIs and a bookmark example package. A deployment-installed Python extension can now ship its own browser page, conversation actions and model tools: `registry.plugin(PluginContribution(...))` (extension-api 0.2.2) registers contributions in one plugin namespace, authenticated descriptors, ES modules and declared actions are served under `/api/plugins`, and the frontend loads plugin pages, workspace pages and conversation actions at runtime. The `deerflow-extension-bookmarks` example exercises the path end to end; no plugin ships enabled by default. ([#5647]) - **plugins:** Support manifests and static asset directories for full-stack plugins. `BrowserAssets(module, root, manifest="ui_manifest.json")` (extension-api 0.2.3) joins `BrowserModule`: a versioned manifest names the entry module and lists the files served from namespace/content-revision URLs, with authentication, correct MIME types, `nosniff`, a document sandbox policy, and immutable caching. Packaged entries load as native credentialed module graphs, so a plugin can split its JavaScript and resolve CSS/images with relative imports; existing inline modules keep working. ([#5685]) - **extensions:** Extension routes can request a run-evidence reader bound to the authenticated request user. New `resolve_run_evidence_reader(request)` / `require_run_evidence_reader(request)` APIs return an immutable user-scoped `RunEvidenceReader` gated by `runs:read`, with invisible-run behavior and scope-bound cursor validation preserved. The existing global reader stays available to trusted Gateway-lifetime services. `deerflow-extension-api` is bumped to 0.2.2. ([#5727]) - **plugins:** New standalone `deerflow-extension-jev-context` example plugin (`community.jev-context`) prunes stale tool results from long conversations. Through the lead agent's `before_model`/`abefore_model` hooks it asks Jev about old read-only tool results and shortens low keep-probability ones, protecting recent messages, tool-call records, host-stamped errors, skill reads, and multimodal content. Requires deployment opt-in and an environment-only Jev key; failed requests preserve the original history. Pruning is lossy and persists in graph state — disabling the plugin does not restore omitted text. ([#5731]) - **plugins:** New standalone `deerflow-extension-jev-classify` example plugin (`community.jev-classify`) contributes a `classify_texts` model tool that labels `{id, text}` items against 2-32 caller-supplied categories and returns one label and status per item in input order. Two deployment-selected backends: Jev (one choice question per item) or an OpenAI-compatible chat endpoint asked for a validated JSON label list. Calls are batched (default 10 per request) with bounded concurrency and a 25s deadline; failures are explicit per item, and nothing is retried. ([#5735]) - **extensions:** Packaged extensions can now invoke the host model under operator-granted limits. `plugins[].host_access.model_invocation` maps logical roles to models with shared per-installation concurrency, bounded admission, timeout, and input/output limits; extension API 0.2.4 adds the request/result contracts and `ExtensionRuntimeDeps.model_invoker`. Calls use the host model factory and tracing with inline Draft 2020-12 schema validation in child processes; ungranted extensions still get `None`, and grants follow the restart-required plugin lifecycle. ([#5796]) - **examples:** Add `deerflow-extension-jev-screening`, an opt-in packaged extension that flags injected instructions in fetched content as advisories. A `TOOL_VISIBLE` middleware classifies each text message in visible `web_fetch`/`web_search`/`image_search`/`web_capture` and MCP tool results with its own model request over the sanitized excerpt (at most eight messages per call), and `before_model` replaces the flagged messages of the latest tool step with a fixed advisory prefix, never mutating the originals. Loaded from `plugins:`; inert until its `config.enabled` is set, provider failures and timeouts pass the result through, and runs without a task store screen nothing. ([#5833]) - **mcp:** The admin cache reset (`POST /api/mcp/cache/reset`) now publishes a durable generation marker beside `extensions_config.json`, so every Gateway worker sharing that directory retires its cached tools and pooled sessions before the next MCP lookup — covering remote `tools/list` changes that never touched the config file. The response reports `scope: shared_config` for a shared-directory reset and `scope: process` when no config path resolves; replicas with independent filesystems are not implicitly covered. ([#6126]) - **extensions:** The Capability Center's extension gallery shows the repository's bundled extension catalog by default — **Behavior change:** a deployment without registered plugins now sees five example packages with localized names, descriptions, search, and links to installation instructions instead of an empty page. Runtime descriptors merge into the same list by explicit namespace without duplicate rows. Catalog-only entries are discovery metadata: they claim no installation or activation state, never enter the module loader, and stay visible while runtime discovery is pending or unavailable. ([#6188]) - **extensions:** Full-stack plugins can contribute candidates to the composer's `@` mention picker. A browser module's optional `mentionProviders` entries are searched alongside built-in skills, files, and conversations; selecting one inserts an inline token, and submitting sends readable `@label` text plus namespaced `additional_kwargs.extension_mentions` metadata (at most 16 references). Queries are debounced, truncated, viewer- and thread-bound, and fenced on query, viewer, locale, or installed-snapshot changes; a failed or slow provider cannot remove healthy candidates. The metadata is user input, not authorization — plugins must revalidate referenced objects and viewer permissions. ([#6189]) - **extensions:** Packaged extensions gain host-bound control of full Agent runs (extension-api 0.2.5). Plugin backend actions and model tools receive `context.agent_runs` — contributed routes can call `require_agent_runs(request)` — and get `create_thread`, `start`, `resume`, `get`, `get_state`, `wait`, and `cancel` operations that go through the Gateway's normal admission, checkpoints, and cancellation. The handle is bound to the authenticated user: permissions and ownership are rechecked on every operation, the capability exists only for session or auth-disabled credentials (PAT and internal/channel callers get none), idempotency keys are scoped to the plugin namespace, and handles cannot be serialized or survive a restart. ([#6190]) - **extensions:** A new agent-teams example plugin lets existing full Custom Agents collaborate as a team: each member keeps its own persistent conversation bound to its agent, members delegate work through native `@` mentions and directed requests, and peer requests run asynchronously with a shared activity feed on a dedicated team page. Teams are owner-scoped and opt-in via `plugins:` in `config.yaml`; Capability Center lists the example with localized installation information even before it is installed. ([#6243]) #### Persistence - **persistence:** Add a checkpoint retention service implementing the deletion side of #4189 item 3 on the #5255 contract. Trailing duration-only leaves are pruned by default (each materializes its parent's payload, so removal reclaims parent-sized rows without touching the surviving line); superseded leaf sibling branches stay opt-in via `prune_leaf_sibling_branches` because clients may hold them as resume targets. The resume head, its ancestor chain, and checkpoints with live pending-writes rows are protected. No production trigger yet — the invocation wiring lands separately. ([#5308]) - **storage:** Add a content-addressed blob store contract (`deerflow/storage/`), the storage-abstraction half of #4189 item 2. `BlobRef` (sha256, size, kind, content type) plus a tiered `BlobStore` interface ship with a `local_fs` default backend: sharded layout, atomic publish, digest verified on read, and concurrent puts converge. Purely additive and opt-in — `blob_storage.enabled` defaults to `false` and no producer is migrated yet (viewed images and externalized tool results are the named follow-ups), so unset deployments behave exactly as today. ([#5361]) - **persistence:** With blob storage enabled, oversized tool results externalized to a virtual `read_file` path are also persisted to the shared blob store, and the Gateway handling the next model call restores the exact digest-verified bytes locally — the full content previously lived only on the Gateway that ran the tool, so a load-balanced follow-up read hit a broken path. Blob write failures and outputs above the 64 MiB producer cap fall back inline instead of advertising a non-durable path; blob storage stays disabled by default. ([#6159]) - **persistence:** With blob storage enabled, images viewed through `view_image` are persisted to the shared blob store alongside their local path, so a later model call landing on another Gateway rebuilds the image context from the validated shared bytes instead of losing it when the writer's local file is gone. Store write failures return a generic tool error instead of checkpointing state that falsely claims cross-instance durability; blob storage stays disabled by default. ([#6160]) #### Frontend - **frontend:** Unify the Capability Center into a searchable catalog with plugin configuration and custom-agent selection. Catalog entries over the existing MCP, Lark CLI, and skill services carry localized metadata, categories, icons, and editable configuration forms; MCP installs validate required transport fields before saving, ordinary users see safe installation projections, and administrator mutations keep their role checks. Custom agents gain stable MCP installation IDs with explicit plugin/skill selection — an omitted/null selection inherits all enabled connections, an empty list selects none — with colliding installation IDs rejected on write and ambiguous IDs excluded at runtime; selection carries through ordinary and durable batch delegation without changing the shared MCP cache or granting authorization. Bundles DingTalk/WeCom group notification and HubSpot CRM tools; existing MCP and Lark workflows are preserved and nothing is enabled automatically. ([#5497]) - **frontend:** The composer supports inline `@` references: `@` at the cursor or the `@` button opens a grouped picker that searches enabled skills, current-project documents, and other conversations, inserting atomic tokens that normal editing removes. Multiple skills can be selected in one message (up to 16 unique skills), each validated against the user registry and agent allowlist before activation. ([#6063]) - **frontend:** The composer's `/` menu now lists only the built-in `/goal` and `/compact` commands. **Behavior change:** the web slash picker no longer selects skills — select skills as inline `@` references instead; the legacy selected-skill chip is removed, older drafts restore their skill selection as an inline reference, and manually typed legacy slash text still submits as a normal message. ([#6154]) ### Fixed - **sandbox:** With host bash enabled, the local sandbox no longer keeps a thread on the skill view of the last restricted Agent that ran there. That view is only maintained while host bash is off, but `LocalSandboxProvider` mounted it whenever it existed, so later unrestricted runs on the thread kept the old allowlist and could not read other enabled or newly added skills under `/mnt/skills`. Those runs now use the shared skill views; the thread view is kept and enforced again once host bash is turned off. ([#6344]) - **sandbox:** The local sandbox no longer rewrites line endings. `read_file` translated CRLF to LF, so `str_replace` on a CRLF file wrote every line back as LF, and the read-before-write gate could not see a change that only touched line endings; on Windows, `write_file` turned LF content into CRLF, breaking scripts such as `bash run.sh`. Local reads and writes now keep line endings as stored, like the remote providers, and `str_replace` spells a `\n`-written `old_str`/`new_str` with CRLF when the file uses it, which also lets multi-line edits match CRLF files on remote providers. Local `grep` ends lines at `\n` like `read_file`, so a hit's line number is the line a ranged read returns even when the file contains a bare `\r`. On Windows, oversized tool output saved under `outputs/.tool-results/` was likewise written as CRLF, no longer matched its stamped blob reference, and was deleted on the next model call when no blob store was configured; it is now written byte-exact. ([#6343]) - **skills:** Editing a custom skill no longer runs filesystem work on the event loop. `PUT /api/skills/custom/{name}` built the user-scoped skill storage, probed the custom, public, legacy and integration roots, validated the frontmatter by writing the draft into a temporary directory, and read the content being replaced, all on the loop; only the final write and history append had been offloaded. Those steps now run in worker threads, matching the rollback route. The custom-skill delete and archive-install routes and the agent's `skill_manage` tool, whose storage lookup stats `config.yaml` on every call, also build their storage off the loop. ([#6332]) - **gateway:** The knowledge retrieval catalog no longer blocks the Gateway event loop while it loads a custom agent's config. Both `GET /api/knowledge/retrieval-catalog/datasets` and `.../datasets/{id}/documents` read the agent through the sync agent store on the loop, which means file IO on the `file` backend and a synchronous SQLAlchemy round trip on the `db` backend, so a slow disk or database stalled every other request. The load now runs in `asyncio.to_thread`, like the other Gateway routes that read agent configs; responses and the 404 for an unknown agent are unchanged. ([#6313]) - **gateway:** Deleting a thread with a large workspace no longer freezes every other Gateway request while its files are removed. `DELETE /api/threads/{id}` ran `shutil.rmtree` over the thread directory on the event loop, so other requests and live SSE streams stalled until the whole tree was gone (about 0.7 seconds for 20,000 small files on a local SSD, longer on mounted volumes). The removal now runs on the file-IO pool, and a cancelled request keeps its thread reservation until the removal finishes, so no new run can start on a thread whose files are still being deleted. ([#6319]) - **sandbox:** Medium-risk audit warnings no longer hide a failed shell exit from subagent evidence. `SandboxAuditMiddleware` appended its warning after the trailing `Exit Code: N` marker and rebuilt the `ToolMessage` from four fields, so `_bash_evidence_status` could not find the marker and fell back to `deerflow_tool_meta`, which reports `success`; a failed `sudo pytest -q` could satisfy a `tests_passed` acceptance criterion. The warning is now inserted before a trailing `Exit Code: N` or `Command exited with code N`, an output that is only `Command exited with code N` is left unchanged, and the result keeps `deerflow_tool_meta`, `artifact` and `id`. ([#6307]) - **deploy:** The Helm chart and docker-compose no longer ship a multi-replica trap. The chart's default `config` enables `run_ownership.heartbeat_enabled` and `run_events.backend: db` (so a starting Pod only reclaims runs whose owner is really gone, and message history is shared across Pods), exports `DEER_FLOW_MULTI_INSTANCE` for the Gateway's startup gate from `gateway.replicas` or an explicit `gateway.multiInstance` (set it before scaling with `kubectl scale` or an HPA, which the chart cannot see), generates and injects a shared `AUTH_JWT_SECRET` (per-Pod `.jwt_secret` files logged users out across replicas; the key is required whenever the gateway is multi-instance so concurrently booting Pods cannot race to write different keys), declares a surge-then-drain rollout strategy, renders a `PodDisruptionBudget` for a multi-instance gateway, and bounds uvicorn's graceful shutdown (`gateway.uvicornGracefulShutdownSeconds`, 10s; `0` is honored) inside a 90s termination grace period so an idle SSE stream cannot push the memory drain into SIGKILL. `existingAppSecret` is now honored by every consumer (the gateway and frontend kept referencing the generated `-app` Secret, which was not rendered, so Pods never started). Both compose files bound the same uvicorn timeout and set `stop_grace_period: 90s` (Docker's 10s default cut the 30s memory flush short; the lifespan's worst case is about 61s once every bounded teardown hook is counted, and the tests read those bounds from the Gateway). The chart README and compose comments stop citing the long-closed issue #3948 as the reason to stay at one replica and list what actually remains single-instance (IM channels, WeChat QR login, browser tools). Upgrading a release that predates `AUTH_JWT_SECRET` generates a new key and signs every browser session out once; the chart README shows how to seed the previous key into the Secret first to keep sessions. ([#6347]) - **persistence:** A second Gateway instance no longer fails startup with `TimeoutError` while another instance runs a PostgreSQL schema migration. The bootstrap advisory lock was taken with a blocking `pg_advisory_lock` on the app engine, whose asyncpg `database.command_timeout` (30s by default) also applies to that statement, so any migration longer than the timeout aborted the waiting instance. Acquisition now polls the non-blocking `pg_try_advisory_lock`: the wait lasts as long as the holder's migration, each attempt stays bounded by `command_timeout`, and the wait is logged once. ([#6306]) - **projects:** Reading a shelf document for the first time no longer blocks every other database write on SQLite while the document converts. Lazy conversion ran pymupdf/markitdown inside the `BEGIN IMMEDIATE` transaction that serializes the publish against trash and purge, and on SQLite that lock is database-wide, so run status, thread metadata and scheduler writes waited for the whole conversion and failed with `database is locked` after 30 seconds. Conversion now writes into `.staging/` outside any transaction; the lock is held only to revalidate the row and atomically rename the output into place, so a document trashed or purged meanwhile still publishes nothing. Concurrent first reads of one document share a single conversion rather than each holding a file-IO worker. ([#6305]) - **gateway:** Per-run reads now return the rows of IM-channel owners. `start_run` stamps run rows and run events with the raw trusted owner id (for example `feishu:owner-777`), but several run-scoped routes filtered by the internal caller's normalized id, so on the SQL stores any owner id containing characters outside `[A-Za-z0-9_-]` matched nothing: `GET /api/threads/{id}/runs/{rid}/messages` and `/events` returned an empty list, `/workspace-changes` reported no changes, `/artifacts/archive` answered 404, and the regenerate source-run lookup fell back or failed with 409. These reads now use the same data identity as the thread message routes (#5448), and every event store accepts `user_id` on `list_messages_by_run()`. Browser and API sessions keep their per-user filter. ([#6282]) - **runtime:** A multi-worker run that finished successfully is no longer reclaimed as an orphan `error` while its worker is still finalizing. With an event store, the worker records the terminal status in memory first and writes it to the run store only after the journal flush, delivery receipt, workspace scan and duration checkpoint. Lease renewal skipped runs whose local status was already terminal, so a finalization longer than the lease plus grace (about 30–40 seconds by default) let a peer, or the worker's own reconciler, claim the still-active row. The heartbeat now keeps renewing until that deferred write is attempted, and fences the run if a peer claims it. Affects only `run_ownership.heartbeat_enabled` deployments. ([#6263]) - **channels:** Buzz now follows a thread without a fresh mention for authors bound with `/connect`. With `channel_connections.enabled`, the manager maps a bound author's threads only in the connection repository, but Buzz's thread-follow gate read only the JSON channel store, so every unmentioned reply in a thread the bot was already answering was silently dropped. Buzz now resolves the connection before the mention gate and looks the thread up through the same helper as the manager (`lookup_thread_id`), so a bound author's engaged thread is the one the manager will reuse, and a legacy JSON mapping no longer counts for that author. The manager's slash-skill whitelist check had its own copy of the same read and is fixed with it: a bound user with no thread yet was checked against the agent of a legacy JSON thread for the same chat and could be told an enabled skill was not available. ([#6232]) - **memory:** Reading DeerMem agent memory no longer fails while another write deletes a fact. `load()`, `reload()`, and the full `rebuild_index()` scan list the fact files without the storage locks, so a delete committed between the listing and opening a file raised `MemoryStorageCorruption` for data that was intact: the memory API returned HTTP 500 ("Stored memory data is corrupted"), prompt injection dropped the whole memory block for that turn (or failed the run under `failure_policy.read: fail_closed`), and a full index rebuild counted the fact as failed. A fact that vanishes after the listing is now treated as deleted; an entry that is still present but unreadable, such as a dangling symlink, is still reported as corruption. ([#6255]) - **memory:** A DeerMem memory reload no longer pins an older document in the cache. `reload()` read the document before computing its cache signature, so a write committed in between (for example by the background memory updater) cached the old document under the new signature, and every later `load()` returned the outdated memory until the next write. `reload()` now computes the signature first, as `load()` already did, so a racing write forces a re-read instead. ([#6238]) - **channels:** Discord now runs its channel-connection database work on the Gateway event loop. discord.py delivers messages on a private loop in the client thread, and the Discord adapter awaited the connection repository there even though its SQLAlchemy engine and pool belong to the Gateway loop. With `channel_connections.enabled` on PostgreSQL, the first Discord message after the Gateway had used the pool failed with `got Future … attached to a different loop` and was dropped. With SQLite, a burst that exhausted the pool failed with `Queue … is bound to a different event loop`, and the wait queue stayed bound to the Discord loop, so the Gateway's own queries then failed the same way. The identity lookup now runs together with the intake commit, and `/connect` binding runs separately, both on the Gateway loop through `_submit_threadsafe_coroutine` like Telegram, Feishu, and DingTalk. Bind replies go back through the Discord loop, and `stop()` now drains that work before tearing the client down. The typing indicator still registers before the hand-off, and a failed lookup skips the ack reaction and stops the indicator unless another message to the same target still relies on it, so a dropped message never shows the bot as working. ([#6214]) - **community:** The shared SSRF guard now refuses every non-global address, including the `100.64.0.0/10` shared address space that its flag checks let through. That range holds CGNAT and Tailscale hosts and Alibaba Cloud's `100.100.100.200` instance metadata endpoint, so `web_fetch` (crawl4ai, Browserless, fastcrw), `web_capture`, the agentic browser, and personal MCP connections could reach them, including through the IPv4-mapped `::ffff:100.100.100.200` form a DNS answer can carry. The existing flag checks stay, because some non-public forms such as the NAT64 spelling of a metadata address still report as global. Operators who intentionally fetch tailnet or CGNAT hosts with these tools must now set `allow_private_addresses: true`. ([#6202]) - **browser:** The agentic browser can no longer be steered to a private or cloud-metadata host by a DNS answer that changes after the SSRF check. The navigate screen and the per-request guard resolve a hostname to vet it, but Chromium resolved it again to connect, so a rebinding DNS server could answer the checks with a public address and the connection with a private one. Each launched browser now sends every TCP connection through a per-session loopback SOCKS5 proxy: Chromium hands it the hostname, and the proxy resolves it once under the same `allow_private_addresses` policy and connects to exactly the vetted addresses. Loopback traffic goes through the proxy too. WebRTC UDP does not traverse the proxy and is not covered. CDP-attached Chrome is unchanged, and delegated fetch services (crawl4ai, Browserless, fastcrw) still resolve on their own side, which the Gateway cannot pin. ([#6201]) - **channels:** The Discord typing indicator is now actually sent while the agent works on a reply. `_start_typing()` called `channel.trigger_typing()`, which discord.py removed in 2.0 (the project requires `>=2.7.0`), and its loop swallowed every exception, so each tick raised `AttributeError` and nothing was ever sent. It now awaits `channel.typing()`, the 2.x API that sends one indicator. A typing loop's first failed tick is logged at WARNING (a missing permission or sustained rate limiting is visible at the default level) and later ticks at DEBUG, instead of being dropped. ([#6138]) - **community:** The SSRF URL guard no longer stalls the Gateway event loop while it resolves a hostname. `validate_public_http_url` resolves with blocking `socket.getaddrinfo`, and the crawl4ai and Browserless `web_fetch`, `web_capture`, `browser_navigate`, the Gateway browser navigate route, and the Live stream's navigate input and seed called it directly from async code, so a slow DNS answer for a model- or user-chosen URL froze every other request and stream for the length of the lookup. The Playwright request guard, which screens every redirect hop and subresource, did the same on the shared browser loop and stalled Live frames and input for every browser session. These callers now run the guard through `asyncio.to_thread`; what it allows and rejects is unchanged. The strict blocking-IO gate gains a `socket.getaddrinfo` rule, because Blockbuster's defaults wrap socket methods but not the module-level resolver. ([#6140]) - **agents:** Loop-detection integer thresholds now reject YAML booleans instead of coercing `true` to `1`. A configuration such as `warn_threshold: true` and `hard_limit: true` previously made the first tool-call set meet the hard limit and forced the agent to stop; booleans on the tracking-window, per-tool-frequency, and per-tool override fields similarly collapsed their limits to one. All integer threshold fields now fail configuration loading with a field-specific error while valid integers and numeric strings retain their existing behavior.([#6017]) - **agents:** App-config integer settings now reject YAML booleans instead of coercing `true` to `1`. A configuration such as `recursion_limit: true` previously made every Gateway run that does not supply its own limit hit the LangGraph recursion ceiling at the first super-step, and booleans on the `llm_call` integers (`retry_max_attempts`, `max_concurrent_calls`, the two backoff delays) collapsed retries and the concurrency cap to one. All seven integer fields now fail configuration loading with a field-specific error while valid integers and numeric strings retain their existing behavior.([#6171]) - **uploads:** Converted Markdown ownership is now recorded when a document is converted. `list_uploaded_files` hides only verified conversion outputs, and document outlines use only the recorded companion; a user-uploaded Markdown file with the same stem stays visible and cannot become another document's outline. Existing conversions have no ownership record and cannot be safely distinguished from user-authored Markdown. After upgrading, those Markdown files appear separately in historical listings and their source documents have no inferred outline or preview. Re-uploading the source creates a new verified conversion when automatic conversion is enabled. ([#6101]) - **agents:** A retried model call in plan mode no longer loses the todo completion reminder that `TodoMiddleware` had queued for it. The middleware drained the reminder inside `wrap_model_call`; because `LLMErrorHandlingMiddleware` wraps it and retries by calling its handler again, the second attempt went out without the reminder while the run had already spent one of its two reminders on it. `TodoMiddleware` now puts the drained reminder back when the handler raises, without counting it again, so the retry carries it and the cap still allows the second reminder. Successful calls are unchanged, and a run whose reminder state was cleared meanwhile does not get it back. ([#6132]) - **channels:** Stopping or restarting the Slack, Feishu, DingTalk or Discord channel no longer freezes the Gateway event loop. `SlackChannel.stop()` called `SocketModeClient.close()` inline, which joins the SDK's message-processor thread (about 0.7s every time) and waits for in-flight event listeners, whose blocking Slack Web API calls can run up to the client timeout; Feishu and DingTalk joined their SDK threads inline with a 5s timeout, and because those threads only exit on a fatal error the join normally waited the full 5s; Discord joined its client thread inline with a 10s timeout, which a timed-out client close or a slow drain could use up. Every run, stream and channel on the Gateway stalled meanwhile, including on `POST /api/channels/{name}/restart`. The teardown now runs in a worker thread; Slack's close is shielded and tracked, so a cancelled shutdown leaves it running and a retried `stop()` awaits it instead of closing twice. ([#6134]) - **middleware:** `_externalize_to_sandbox` now validates the full byte count of externalized tool outputs instead of only testing non-emptiness with `test -s`. When a remote sandbox write truncated the file part-way (e.g. disk full or pipe failure), the partial file previously passed validation, handing the model a truncated file path. It now verifies the file size exactly matches the payload, returning `None` and falling back to inline truncation if a write was truncated. ([#6112]) - **gateway:** A non-ASCII CSRF token, GitHub webhook signature, internal auth token, OIDC `state`, or provisioner `X-API-Key` is now rejected with the usual 403/401 instead of a 500. `hmac.compare_digest` raises `TypeError` for `str` operands with non-ASCII characters, and Starlette decodes header bytes as latin-1, so a single `0xE9` byte crashed the comparison. The Gateway now compares the UTF-8 bytes through one helper, `app.gateway.utils.constant_time_equals`, and the standalone provisioner encodes inline. No bypass was possible; the request was already failing, just with the wrong status. ([#6076]) - **agents:** Context-compaction fraction triggers and fraction-based retention now use the active run model's context profile; a separate `summarization.model_name` remains generation-only. This prevents mismatched run and summary windows from compacting too late or too early. The middleware release identity now records `profile_model` separately from `summary_model`, intentionally refreshing the identity when either owner changes. ([#5566]) - **events:** Run-scoped reads no longer return 500 on the JSONL backend for a run ID it cannot use as a filename. `GET /api/threads/{thread_id}/runs/{run_id}/events`, `.../messages`, and `.../workspace-changes` pass the URL's run ID to the event store unchecked; with `run_events.backend: jsonl` an ID such as `run.1` raised `ValueError`, while the memory and database stores return an empty result. JSONL reads and deletes now treat such an ID as an unknown run; writes still reject it. ([#6070]) - **agents:** A run started with `"max_total_subagents": null` in its context now uses the configured `subagents.max_total_per_run` instead of failing with a `TypeError`. The key was present, so `dict.get(key, default)` returned `None`, and building `SubagentLimitMiddleware` crashed that run with an internal error (the web UI never sends the key; API and embedded-client callers could). The Gateway lead agent, `DeerFlowClient`, and the system prompt now resolve the cap through one helper that treats `null` as unset and clamps to 1-50, so the extension-facing host policy and the release policy also report the enforced cap rather than an out-of-range request. ([#6088]) - **scheduler:** Fixed-hour cron tasks no longer fire twice on the daylight-saving fall-back day. `croniter` returns both occurrences of an ambiguous wall-clock hour (the first with `fold=0`, the second with `fold=1`). For tasks where neither minute nor hour contains a wildcard, the second occurrence is skipped to preserve once-per-day semantics (Vixie cron contract), while wildcard schedules (such as `0 * * * *`) still run in both occurrences of the repeated hour. (issue #6052, [#6066]) - **persistence:** A SQLite `checkpointer.connection_string` written as a `file:` URI now fails at startup instead of silently writing somewhere else. LangGraph's SQLite checkpointer and Store open connection strings without `uri=True`, so SQLite treated the URI as a literal filename: `file:checkpoints.db?mode=rwc` created a file with that exact name in the working directory, `file::memory:?cache=shared` persisted to disk, and a `file:///...` URI failed to open. The readiness probe did parse URIs, so it checked a different file than the runtime used and reported in-memory URIs as `not_configured`. All four SQLite checkpointer/Store factories now reject `file:` URIs with an error that names the setting, and `/health/ready` reports them unreachable. Use a filesystem path or `:memory:` instead. ([#6069]) - **config:** `make config-upgrade` (also run by `make dev` / `make start`) upgrades the `config.yaml` the Gateway loads. With both `/config.yaml` and `backend/config.yaml` present, the script upgraded the `backend/` copy while the Gateway read the checkout copy, so the file in use stayed outdated even though the upgrade reported success. It also ignored `DEER_FLOW_PROJECT_ROOT` and a `DEER_FLOW_CONFIG_PATH` set in `.env`, and fell back to another file when `DEER_FLOW_CONFIG_PATH` named a missing one. The script now asks the harness resolver (`AppConfig.resolve_config_path`) for the file, and a missing `DEER_FLOW_CONFIG_PATH` or invalid `DEER_FLOW_PROJECT_ROOT` fails with the Gateway's error instead of upgrading a fallback. ([#5991]) - **sandbox:** Unwrap `Overwrite`-wrapped sandbox state in `ToolOutputBudgetMiddleware` and `ReadBeforeWriteMiddleware`. In delta checkpoint mode, forked or restored threads deliver the `sandbox` channel wrapped in LangGraph's `Overwrite`. Without unwrapping, `isinstance(sandbox_state, dict)` returned `False`, causing large tool output externalization to fail and fall back to inline truncation, and read-before-write lock scoping to miss the active sandbox ID. ([#6015]) - **doctor:** `make doctor` now checks the config file the Gateway actually loads. It always inspected `/config.yaml` and ignored `DEER_FLOW_CONFIG_PATH` and `DEER_FLOW_PROJECT_ROOT`, so a missing override that stops the Gateway from starting still reported `✓ config.yaml found` and `✓ config.yaml loadable`, and a valid override pointing elsewhere got the wrong file checked. Doctor now resolves the path through the harness's own resolver and hands it the location variables the way `make dev` does: `.env` values override the shell (expanding an unquoted leading `~`), and an unset or empty `DEER_FLOW_PROJECT_ROOT` becomes the checkout. An override the Gateway would reject fails `config.yaml found` with the Gateway's error, and the config checks skip. ([#5987]) - **frontend:** The optimistic human bubble keeps its quote and conversation reference chips once a file upload finishes. The upload-complete update replaced the bubble's `additional_kwargs` with only the uploaded files, so those chips disappeared until the server echoed the message; project attachments staged with the send were also missing from the bubble, both while uploading and after. The submitted message was always intact. The optimistic copy (before and after the upload) and the submit now build `additional_kwargs` through one helper, `buildHumanMessageAdditionalKwargs`. ([#5982]) - **scheduler:** Editing an interval task's title or prompt no longer fails with a 500. The edit dialog always sends `schedule_spec` beside the changed field, and when the cadence is unchanged `PATCH /api/scheduled-tasks/{id}` keeps the task's existing `next_run_at` — a value the repository had handed back serialized as an ISO string. `ScheduledTaskRepository.update()` assigned it to the `DateTime` column untouched, so SQLite raised `StatementError` ("only accepts Python datetime") and Postgres a `DataError`; only changing the cadence worked, because that path computes a fresh datetime. The repository now coerces every serialized timestamp it accepts in `update()`, as `update_after_launch()` already did. ([#5964]) - **gateway:** `GET /api/skills`, `GET /api/skills/custom` and `GET /api/skills/{name}` no longer walk the skill directories on the event loop. Each called `load_skills()` inline, which resolves the caller's storage, scans every public and custom skill directory and parses each `SKILL.md` — filesystem work that grows with the number of installed skills. #5747 moved that same call off the loop for the custom-skill content route and documented why; these three routes were missed, so the strict Blockbuster gate raised `BlockingError` on them and, in production, a large or slow skills tree stalled every other request on the worker for the duration of the scan. All three now offload the load with `asyncio.to_thread` through one shared helper. ([#5945]) - **gateway:** `GET` and `PUT /api/user-profile` no longer run their filesystem work on the event loop. Both handlers resolved the per-user `USER.md` path (which builds absolute paths on every call), stat'ed, read, created the user bucket and wrote the file inline, while every other handler in the custom-agent router offloads that work with `asyncio.to_thread`. Under the strict Blockbuster gate the pair raised `BlockingError`; in production a slow disk stalled every other request on the worker for the duration. Both handlers now offload the whole resolve-stat-read / resolve-mkdir-write sequence. ([#5935]) - **docker:** Project-document uploads larger than 1 MB no longer fail with a bare nginx `413` through the unified entry point. `POST /api/projects/{id}/documents` is a multipart upload that Gateway accepts up to `uploads.max_file_size` (50 MiB by default), but no nginx location matched it, so it fell through to the `/api/` catch-all and nginx's default `client_max_body_size 1m` rejected the request before Gateway saw it — a 2 MB PDF was refused while the same file uploaded fine into a thread. All three maintained configs (Docker, `make dev`, Helm) now give `/api/projects/{id}/documents` its own location with the thread-uploads settings (100M ceiling, streamed request body) and the read timeout the catch-all already granted; the catch-all itself keeps nginx's defaults. ([#5934]) - **docker:** The production stack (`make up` / `scripts/deploy.sh`) now starts on hosts with IPv6 disabled. `docker/nginx/nginx.conf` also listens on `[::]:2026`; on a kernel booted with `ipv6.disable=1` that listen makes nginx exit at startup, so the container restart-looped and `make up` never became healthy. The dev compose file has stripped the IPv6 listen when `/proc/net/if_inet6` is absent since #2027, and the Helm chart mirrors it; the production compose file was the one launcher still without the guard. It now uses the same launcher, and starts nginx with `exec` so it is PID 1. ([#5900]) - **deploy:** `make up` / `scripts/deploy.sh` now honors `BETTER_AUTH_SECRET` and `DEER_FLOW_INTERNAL_AUTH_TOKEN` written to the repo-root `.env`. The script only checked the shell before reloading a persisted secret or generating a new one and exporting it, and Compose interpolation lets shell variables outrank `--env-file`, so the value the deployment docs tell operators to put in `.env` was silently replaced: sessions were signed with a secret the operator never chose, and Gateway workers running outside the stack with the configured token got `401`. A `.env`-provided secret is now left for Compose to read itself (shell → `.env` → persisted file → generated). Whether `.env` provides one is decided by Compose itself — the script renders a stub project with `${KEY}` through `docker compose config` and reads the value back — so `KEY: VALUE` lines and `${VAR}` interpolation count the way Compose counts them on every Compose v2 client. A value that resolves empty, like an exported-but-empty shell variable, still triggers generation because Compose would otherwise pass the empty value through. ([#5928]) - **skills:** `skill_manage(action="remove_file")` and `write_file` now work on binary support files, and reject directories cleanly. A `.skill` archive may carry `assets/logo.png` (the installer only rejects *executable* binaries), but both actions read the previous content as UTF-8 text — purely for the history record — before touching the file, so a binary member raised `UnicodeDecodeError` and was never removed or overwritten. A bare support directory such as `assets` also slipped through path validation and raised `IsADirectoryError`. Non-text content is now recorded as no previous text, and a directory path is a validation error instead of a crash. ([#5893]) - **models:** `when_thinking_enabled` and `when_thinking_disabled` no longer replace a profile's whole `extra_body` on the legacy (no `reasoning:` block) path. Both templates were applied with a shallow `dict.update`, so a profile carrying `extra_body: {tool_stream: true}` beside `when_thinking_enabled.extra_body.thinking` / `when_thinking_disabled` templates — the shape of most `extra_body`-based examples in `config.example.yaml` — lost `tool_stream` in both directions, while the synthesized disable payloads and the contract path already deep-merged. Both legacy templates now deep-merge the same way. The merge semantics are: keys are never removed, a template can only add or override, nested mappings inherit the profile's other keys, and template values win on conflicts. The merge also keeps the template's vLLM switch authoritative across its two spellings: when a profile spells the switch differently from its template (`chat_template_kwargs.enable_thinking: false` beside the legacy `thinking: true` alias), the template's value is mirrored onto the profile's spelling, so `VllmChatModel` and plain OpenAI-compatible classes alike send the template's intent under whichever key the server reads — on both the legacy and the contract path, in both directions. Non-mapping template values are forwarded unchanged. Migration note: because keys are never removed, a `when_thinking_disabled` template can no longer clear a key the profile's base `extra_body` sets — a base `extra_body.thinking: {type: enabled, budget_tokens: 4096}` now reaches the provider as `{type: disabled, budget_tokens: 4096}` when thinking is off, which Anthropic-style APIs reject. Enable-only keys such as `budget_tokens` belong in `when_thinking_enabled`, not in the base `extra_body`; the synthesized disable payloads and the contract path already behaved this way. Templates are also deep-copied as they are merged, so constructor kwargs never alias the cached profile. ([#5894]) - **projects:** The conversation-files view no longer shows an empty heading for a member thread that has no title yet. A thread's `display_name` is `null` on the wire until title generation has run (or if it never does), but the file-group type declared it as a required string and rendered it as-is, so files uploaded before the first reply sat under a blank line. Such groups now read "Untitled", matching the project's thread list. ([#5879]) - **skills:** Deleting a custom skill whose package directory is an operator-managed symlink now succeeds. The storage contract accepts a one-level link under `custom/`, but `delete_custom_skill` removed the package with `shutil.rmtree`, which refuses symlinks: the delete failed with `OSError` after its history record had been written, the user's projected skill view was cleared on the way out, and `DELETE /api/skills/custom/{name}` answered 500. The link itself is now removed; the external tree it points at is never touched. ([#5881]) - **frontend:** Stop mutating subtask render state during `MessageList` render. Subtask synchronization moved from render into an effect, so cards immediately receive pure message-derived snapshots even before the task context publishes an update — fixing the regression where final streamed arguments and `task_started` arrive together but the card keeps its partial title or empty prompt. Message arguments supply title, prompt and subagent type; live state preserves lifecycle, model, usage and step history, with terminal snapshots keeping status/result precedence. ([#3157]) - **skills:** Surface failed skill enable/disable requests in Settings instead of treating them as success. The frontend skills helpers parsed the response JSON without checking `response.ok`, so a 4xx/5xx from the skills list or toggle endpoints resolved as success and the UI flipped the switch even though the backend rejected the change. Non-OK responses now stay on the error path and show the backend error message. ([#3823]) - **agent:** Align unattended-run prompting with the tool policy. Non-interactive runs removed `ask_clarification` from the toolset while the system prompt still demanded it for ambiguity, leaving GitHub, webhook, and scheduled runs unable to ask or proceed. A shared `RunInteractionPolicy` now drives both toolset and prompt — `webhook` infers from issue and event context, exiting with structured blocking information when context is insufficient; `scheduled`/`autonomous` make minimal reversible assumptions and report high-risk ambiguity as blocked. ([#4919]) - **mcp:** Make the durable MCP task claim lifecycle cancellation-safe. Caller cancellation after a claim was acquired could leave rows leased until expiry, abandon a started release, or let cleanup failure replace the original `CancelledError`; routine shutdown was also recorded as a task failure. Compensation now covers every await after the claim, ambiguous operations stay service-owned past the foreground deadline, and per-claim lease tokens reject stale-generation writes atomically (closing a SQLite race). Migration `0026` adds nullable lease-token columns; no public MCP API changes. ([#4966]) - **frontend:** Keep streamed answer text out of the Thinking panel. A streaming AI message that already carried reasoning plus answer text was grouped into the processing disclosure until the turn settled, so the answer could sit in the Thinking panel for a long run; such messages now render as an assistant bubble immediately while plain pre-tool narration stays grouped. The bash tool's model-visible contract also describes cross-platform local environment discovery — start with `uname -s`, use `sw_vers` on Darwin, recover from blocked absolute paths with command-only probes. ([#5001]) - **runtime:** Harden model response recovery at provider boundaries. An empty completed response could end a run with no usable answer, and length-capped responses could carry truncated tool calls into the loop. A true empty stop is now retried once at the model boundary (one shared retry per run); exhaustion yields a visible same-step fallback instead of a hidden graph-level recovery turn; a detected length cap drops all tool calls in that response, even with parsed arguments. DeepSeek thinking-mode history keeps `reasoning_content` on assistant tool turns. ([#5080]) - **sandbox:** Reject overflowing sandbox ownership lease TTLs at config validation. `renewal_interval_seconds` and `ttl_multiplier` were each checked finite, but their product could overflow to `inf`, failing only at the first lease write or silently changing the promised timing. Non-finite derived TTLs and sub-millisecond Redis TTLs are now rejected, Redis TTLs are capped at half the signed 64-bit millisecond range, and fractional milliseconds round up so the stored lease is never shorter than validated. ([#5104]) - **subagents:** Close the subagent graph stream before releasing runtime resources. Cooperative cancellation could return a terminal result without explicitly closing the active `agent.astream()` iterator, so the executor released the sandbox lease and sent the task-stop notification while graph-stream teardown was still in flight. The stream is now retained and closed before terminalization and release, the original stream error or host cancellation wins if close also fails, and teardown still running after 10s logs a warning. ([#5221]) - **composer:** Reserve `context` as a slash command alias. `/context compact` is a built-in composer command, but `context` was not reserved in the shared slash-skill contract, so a skill could claim the alias and shadow it. The shared contract now reserves `context`, frontend and backend slash parsing stay aligned through it, and IM channel command classification is unchanged. ([#5279]) - **scheduler:** Preserve a one-time task's execution instant across title and prompt edits. The schedule control converted the stored timestamp to minute-precision local time and back on mount, so in New York `2026-11-01T06:30:00Z` was saved as `05:30:00Z` and ordinary timestamps lost seconds and milliseconds — a metadata-only edit silently moved the run. The edit form now snapshots the original timestamp, local input, and timezone and re-emits the original `run_at` until the time or timezone changes. ([#5330]) - **scheduler:** Reject wall times skipped by daylight saving in the one-time schedule form. New York `2027-03-14T02:30` converted to UTC and back as `01:30`, so create and edit could save an instant different from the preview. The form now validates local-to-UTC-to-local equality, shows localized inline feedback with the input marked invalid, emits an empty schedule spec until corrected, and keeps Create (and edit-save) disabled. ([#5348]) - **sandbox:** Drain the previous sandbox release during async rebind. Async rebind paths released the previous sandbox client through a bare `await asyncio.to_thread(provider.release, ...)`; cancelling the caller cancelled only the awaiter and let the per-thread lifecycle serializer release while the old release was still running, so a later same-thread lifecycle transition could overlap it. The three async previous-release paths now route through the cancellation-safe `run_sync_lifecycle_operation()` helper, which holds the serializer until the old release completes and then propagates the original `CancelledError`. ([#5498]) - **subagents:** Close delegation-ledger entries a stopped run left in progress. On Stop, the `task` tool cancels the subagent and re-raises cancellation, so no `ToolMessage` is written and the ledger keeps the delegation `in_progress` — every later model call was told the work was "already delegated; do NOT delegate again" and waited for a result that never arrived. When a run starts with a new user message, `DurableContextMiddleware` now marks such entries `cancelled` ("cancelled attempt; may retry with a changed plan"). Resumed runs, goal continuations sharing the run id, and entries with a result are unchanged. ([#5507]) - **frontend:** Keep clarification text outside the execution-steps panel. Answer text accompanying an `ask_clarification` call rendered inside the bordered execution panel, and submitting a hidden reply moved previously completed answer bubbles back into it while the continuation streamed. Clarification results now act as completed-run boundaries so earlier answers stay in the conversation across continuation and reconnect, and the accompanying text renders once outside the panel with the original messages, tool-result associations, and usage accounting preserved. ([#5508]) - **projects:** Drain trash reconciliation file workers across Gateway shutdown cancellation. Cancelling the startup trash-retention sweep cancelled only the asyncio awaiter while its executor worker kept walking/statting/unlinking files, so `_shutdown_startup_trash_sweep()` could finish Gateway teardown while the detached worker was still mutating the projects tree. Both reconciliation `run_file_io()` operations now drain through the cancellation-safe `await_drained()` helper: an already-started worker reaches terminal state first, then cancellation propagates and later stages never start. The hook timeout stays the graceful-completion budget, not a hard cap after cancellation. ([#5511]) - **mcp:** Insert bare-filename rewrites literally instead of as a regex replacement template. `_rewrite_unique_bare_filenames` passed the correlated `/mnt/user-data/...` path to `Pattern.subn` as a template, so a literal backslash in a filename (a model passing a Windows-style path to a stdio MCP server on POSIX creates a file literally named `screenshots\q3.png`) either raised `re.error: bad escape` out of the tool-call conversion and failed the whole call after the server had already written the file, or — for escapes `re` accepts — silently substituted the escape byte (`\r` became a CR) into the returned path, riding it into the transcript and checkpoint. The replacement now goes through a callable, so the path is inserted verbatim. ([#5522]) - **nginx:** Allow model-bound `/api/` and `/api/skills` requests to outlive 60 seconds. The catch-all `location /api/` (e.g. `POST /api/runs/wait`, which also cancelled the run when nginx dropped the upstream connection, and `POST /api/input-polish`) and `location /api/skills` (`.skill` installs scan every archive file with sequential LLM calls) still used nginx's 60-second `proxy_read_timeout` default and failed with `504`. Both locations now set `proxy_read_timeout 600s`, matching the model-bound locations; applied to the Docker, `make dev`, and Helm nginx configs. More specific locations are untouched. ([#5524]) - **subagents:** Drain the owned batch-service stop across caller cancellation. `SubagentRuntime.stop()` cleared its batch-submitter flag before awaiting `SubagentBatchService.stop()`; an interrupted await left the runtime reporting no submitter — later `stop()` calls returned immediately — while the owned service was still part-way through stopping, so runtime-owned batch work could outlive the runtime's lifecycle ownership. The stop now runs in a strongly-owned task that is shielded and drained across repeated cancellation; the first caller `CancelledError` propagates only after the owned stop reaches a terminal state. ([#5525]) - **memory:** Drain Gateway memory shutdown workers across lifespan cancellation. The flush and backend close ran as separate `asyncio.to_thread()` awaits, so cancelling the lifespan task cancelled only the awaiter: `manager.close()` could start while the flush worker was still running — closing the backend connection under an in-flight flush — and repeated cancellation could detach the close worker entirely. Manager resolution, `shutdown_flush()`, and `close()` now form one owned shutdown operation drained through the cancellation-safe `await_drained()` helper; the first caller cancellation propagates only after the started work reaches a terminal state. ([#5531]) - **sandbox:** Report an exactly-full sandbox search result as complete. `glob` and `grep` in the AIO, E2B, OpenSandbox, Tenki, and BoxLite providers treated "collected `max_results` matches" as `truncated=True`, so a tree holding exactly that many eligible matches told the agent its search was partial and it re-ran or narrowed the search for nothing. The filtered-match cap now looks one match past the cap before deciding — a genuinely dropped match still reports truncated — leaving the matches themselves and the remote raw-output cap unchanged. ([#5534]) - **threads:** Clean persisted records safely on thread deletion. `DELETE /api/threads/{id}` removed the thread directory, checkpoints, browser session, and meta row but left `run_events`, historical `runs` rows, and `feedback` behind — the messages feed stayed readable after the delete (the owner check passes on a missing meta row), and re-creating the same thread id inherited the deleted thread's messages. Deletion now also removes runs, run events, and feedback, owner-scoped and best-effort inside the existing delete reservation; run cleanup deletes only `operation_kind == "run"` rows so the in-flight delete reservation survives. Durable event stores take an explicit per-thread mutation fence, so a concurrently admitted writer can no longer commit a row for a deleted thread after deletion returns. ([#5535]) - **frontend:** Rejoin active runs after reopening a chat. The SSE reconnect path only knew the run id stored in the current tab's `sessionStorage`, so reopening the browser or opening a new tab lost the pointer while the server-side run kept going. Opening a thread without a reconnect pointer now discovers the newest pending or running run, persists the pointer before joining so stop/cancel still targets the right run, and rejoins its resumable stream, retrying a failed recovery twice with bounded 1s/2s delays; a matching pointer suppresses duplicate joins. ([#5536]) - **channels:** Roll back a partially started channel service across startup cancellation. `start_channel_service()` published the global singleton before awaiting `ChannelService.start()`, so cancellation after partial resource acquisition exited without stopping the service and left a singleton whose startup never completed. Startup failure/cancellation now rolls back the partial service and drains the rollback across repeated cancellation with `await_drained()`; the singleton clears only after cleanup succeeds, and a failed cleanup retains it so later shutdown can retry. ([#5537]) - **docker:** Add a `make prod-logs` entry point for the production stack and stop `make docker-logs` from exiting silently after a production start. `make up` runs compose project `deer-flow` while `make docker-logs` tailed the dev project `deer-flow-dev`, which has no containers then, so the command printed nothing. `scripts/docker.sh logs --prod` now tails the production stack with the same compose file, `--env-file ../.env`, and the interpolation defaults `deploy.sh` exports (without them compose cannot parse the production volume specs on checkouts without a `.env`); dev-only logs with no containers print a hint pointing at `make prod-logs`. ([#5538]) - **frontend:** Preserve literal `` tags in fenced, indented, and inline code during reasoning extraction, including unfinished code while streaming. The parser only protected an opener immediately following a backtick, so a code example explaining `` tags was moved into the reasoning disclosure — corrupting the rendered answer and the copied text — and a literal unclosed opener swallowed the following explanation. Real closed or streaming reasoning after literal code is still extracted, and Markdown delimiters inside actual reasoning no longer prevent finding its closing tag, so an unfinished fence there cannot hide the answer. ([#5540]) - **uploads:** Handle a UTF-8 BOM in uploaded Markdown when building document summaries. A leading BOM stopped the outline extractor from recognizing the first heading or opening code fence — in the fence case a code comment became a document heading and the closing fence was mistaken for an opening one, hiding the real sections — and a BOM-only first line consumed a fallback-preview slot. Outlines and previews are now read with `utf-8-sig`, which consumes the optional BOM while preserving ordinary UTF-8, embedded U+FEFF characters, physical line numbers, and the original uploaded bytes. ([#5541]) - **gateway:** Preserve confirmed clarification-card answers when preparing a thread regeneration. The regenerate endpoint only selected visible human messages, but clarification-card answers are stored as hidden `HumanMessage` objects with `human_input_response` metadata, so it could skip the confirmed answer and replay an older visible prompt instead. A regenerate-specific predicate now accepts hidden messages carrying a structurally valid `human_input_response` and preserves their hidden and request-correlation metadata; summaries, goal-control messages, malformed hidden responses, and unrelated internal messages remain rejected. ([#5544]) - **sandbox:** Wrap the `remote_list_dir_command` and `remote_search_command` probe scripts in a subshell so their trailing `exit` cannot kill the AIO sandbox's implicit persistent shell. A bare `exit` terminated the session shell and the server's response for that `exec_command` request never completed, hanging the client indefinitely — making every `list_dir`/`grep`/`glob` call a deterministic wedge and deadlocking entire runs that issued parallel `[ls, bash]` tool calls. Output markers and exit codes still propagate from the subshell; only the probe scripts change. ([#5546]) - **uploads:** Delete the requested upload entry instead of a symlink's resolved target. `delete_file_safe` resolved the requested path first and unlinked the result, so a symlink planted at an upload name (the sandbox maps the uploads directory writable) made `DELETE /api/threads/{id}/uploads/{filename}` remove the target file and its companion `.md`, leave the dangling link in place, and answer `200` for the wrong file. The requested entry itself is now checked and unlinked: a symlink, like any non-regular entry, returns `404`; links pointing outside the uploads directory still return `400`; and the companion `.md` name comes from the requested file. Regular-file deletes are unchanged. ([#5547]) - **extensions:** Drain extension-service shutdown across host cancellation. The Gateway registered extension-service cleanup with `AsyncExitStack` but awaited `stop_services()` directly, so a cancellation landing while an extension `stop()` was blocked aborted the cleanup callback and let stack unwinding continue while extension-owned resources were still alive, breaking the ownership ordering the stack exists to provide. Shutdown now runs through the cancellation-safe `await_drained()` helper, so later runtime teardown waits until extension services reach a terminal state within the per-service timeout budget. ([#5549]) - **memory:** Report malformed `memory.backend_config` values by key name instead of unnamed tracebacks. The Honcho backend read `failure_policy`, `workspace_overrides`, and `user_peer_overrides` through a falsy-only `or {}` fallback, so the most natural YAML spelling — `failure_policy: fail_closed` as an unquoted string — survived as a truthy non-mapping and crashed with `AttributeError: 'str' object has no attribute 'get'`, naming neither the key nor `backend_config`. Non-mapping values are now rejected as config errors naming the key, while falsy values still mean unset. The mem0 and OpenViking backends get the same key-naming guard for numeric knobs (`top_k`, `timeout_seconds`, ...) instead of bare `int()`/`float()` casts that escaped unnamed `TypeError`s. ([#5555]) - **mcp:** Scope MCP sessions and durable-task access by thread incarnation. Deleting and recreating the same thread ID reused the previous lifecycle's persistent MCP session scope, and the replacement thread could list, read, and request cancellation of durable tasks created by the old lifecycle because those operations scoped only by user and thread ID. The server-owned thread incarnation now reaches the agent runtime: non-legacy MCP sessions get a versioned `(user, thread, incarnation)` scope, durable task list, detail, and cancellation bind to the caller-captured and current incarnation, and submissions are atomically rejected when the thread is deleted or recreated between remote submission and local persistence. Explicit legacy `NULL` incarnations keep the pre-activation scope, worker polling and cancellation keep working through the creating session, and incarnation fields stay out of public task responses. ([#5556]) - **subagents:** Recognize empty regular files in remote subagent acceptance probes. GNU `stat -c %F` reports a zero-byte file as `regular empty file`, but the remote size and readability probes accepted only `regular file`, so an empty artifact was rejected as `NONREGULAR` and `exists`, `non-empty`, and `file_written` all stayed UNVERIFIED even when the file was present and readable. Both labels are now accepted: empty readable files can satisfy `exists` and `file_written`, and `non-empty` yields a deterministic negative result. Symlink, FIFO, and containment checks are unchanged. ([#5559]) - **frontend:** Derive the `web_fetch` tool-step title from the first non-blank line of the fetched markdown. `extractTitleFromMarkdown()` only accepted a document whose very first characters were `# `, so a blank line or up-to-three spaces of indentation before the first heading — which CommonMark allows, and which the crawl4ai backend hands through unchanged — cost the chat transcript its step title and silently fell back to the raw URL. An indented code block still does not count as a title, and an empty heading yields no title instead of a literal `#`. ([#5560]) - **sandbox:** Stop E2B reconciliation from reviving warm-pool sandboxes. Every reconciliation pass issued `Sandbox.connect()` against sandboxes parked in the warm pool — which the SDK normalizes to a 300-second timeout update, pushing the remote expiry forward forever — and the first pass adopted the warm sandbox back into the active set so it was never released again; idle billed VMs stayed alive indefinitely and held capacity slots. Reconciliation now treats locally tracked sandboxes (active or warm) as canonical and never probes them with `connect()`, refreshes active-sandbox TTLs through the cached client, and sweeps warm entries parked past `sandbox.idle_timeout`, releasing their ownership leases, mount results, and capacity tracking. Warm sandboxes now actually expire at the configured `sandbox.idle_timeout`. ([#5562]) - **utils:** Return empty text for content-less messages instead of the literal string `"None"`. `message_content_to_text(None)` stringified its argument, so a content-less sub-agent final message bypassed the `No response generated` sentinel, a content-less structured LLM-error fallback hid its `error_detail`, and the task-continuity archive failed to skip the empty text. Only `None` content is treated as empty — the literal string `"None"`, `0`, `False`, and list-block conversions behave exactly as before. ([#5563]) - **doctor:** Validate the contents of CLI-backed model credentials in `make doctor`. The check reported Codex and Claude auth as healthy whenever the credential path merely existed, so an empty, logged-out, malformed, or expired file produced a green checkmark while the model loader could not obtain a usable token. Codex `auth.json` is now validated across all three supported token layouts and Claude `.credentials.json` through `claudeAiOauth.accessToken` including the runtime's one-minute expiry buffer; missing files, directories, non-object JSON, empty tokens, and invalid expiry values report provider-specific failures with the existing remediation hints. The check stays standard-library-only, offline, and redacted; environment and file-descriptor sources remain non-consuming presence checks. ([#5567]) - **middleware:** End length-capped turns cleanly instead of letting `TodoMiddleware` re-engage the model. When a model hit its per-response output cap (`finish_reason=length`) while emitting a `write_file` call, the suppressed call still triggered the todo completion-reminder jump (`jump_to=model`), re-emitting the same oversized call into the same cap — up to three futile responses with junk fragments instead of a clean truncation notice. `TodoMiddleware` now skips the reminder jump when the `model_length_termination` marker is present, the length notice is appended even when partial text survived (fixing a latent `append_visible_text` bug that silently dropped string content), and `write_file`'s model-visible description is annotated with the configured `max_tokens` output budget so the model does not assume the 80 KB streaming ceiling is the practical single-write limit. ([#5569]) - **ragflow:** Validate RAGFlow document selections in batches of at most 100 IDs. Selecting more than 100 documents from one dataset is valid under the 1000-document scope limit, but validation sent every ID in a single request, which the provider rejects, so `knowledge_search` failed before retrieval. Selections now validate in provider-sized batches that preserve input order and the fail-closed behavior for missing or unsearchable documents, sharing one concurrency budget across datasets. ([#5572]) - **persistence:** Drain the PostgreSQL bootstrap advisory unlock across cancellation. The unlock in `_postgres_lock()`'s `finally` was awaited directly, so startup/lifespan cancellation arriving while `pg_advisory_unlock` was still in flight could abort the release and return the connection to the pool while it still held the bootstrap mutex. The release now completes through the cancellation-safe `await_drained()` helper on the same connection that acquired the lock. ([#5573]) - **persistence:** Drain engine disposal across cancellation during gateway teardown. `close_engine()` awaited `AsyncEngine.dispose()` directly, so cancellation while disposal was blocked let teardown return while the process-global engine and session factory still pointed at a partially disposed resource. Disposal now completes through the cancellation-safe `await_drained()` helper, the globals stay published until it finishes, and stale cleanup can no longer erase a replacement engine. ([#5576]) - **channels:** Photos and files sent to the Telegram bot now download and reach the agent. Two bugs compounded: the download opened a fresh connection on the manager event loop and failed with a generic `NetworkError`, and the ingested file was written `0600 root:root`, so the non-root sandbox user got `Permission denied` reading it. Downloads now route through a loop-bound download bot, inbound files get sandbox-readable permissions, and failures log the underlying cause with the token-bearing Bot API URL masked. ([#5581]) - **models:** Treat a Codex auth file whose top level is not a JSON object as "no credential from this source". An array, string, or number in `~/.codex/auth.json` (or `$CODEX_AUTH_PATH`) previously raised `AttributeError` out of `CodexChatModel` construction, preempting the documented "Codex CLI credential not found" error; the loader now logs and falls through. ([#5584]) - **memory:** Keep an explicit `confidence: 0.0` from scoring as the 0.5 default. Two read sites rescued the field with `or 0.5`, which cannot tell "explicitly zero" from "unset", so a zero-confidence fact was inflated by the default weight and ranked differently depending on which write path indexed it. Zero now ranks strictly below 0.5 through both the FTS5 index and the substring fallback; an absent or null confidence still defaults to 0.5. ([#5586]) - **skills:** Load a `SKILL.md` saved as UTF-8 with a BOM. The front-matter anchor rejected the leading `U+FEFF` written by Notepad and PowerShell's `Set-Content -Encoding UTF8`, so Windows-authored skills silently vanished from the catalog with no warning. The mark is now consumed by the shared compiled anchor and reaches neither skill metadata, rendered instructions, nor the skill-context description, and the loader, validator and middleware copies of the pattern can no longer drift. ([#5588]) - **models:** Skip Claude Code credentials sources with a non-numeric `expiresAt`. A string, null, list, or object value raised `TypeError` out of the loader and stopped the lookup loop, so one malformed file at `$CLAUDE_CODE_CREDENTIALS_PATH` prevented every `ClaudeChatModel` from ever reaching `~/.claude/.credentials.json`; the source is now skipped with a debug log like its sibling guards. ([#5591]) - **skills:** Render an explicit empty `allowed-tools` as no tools. The skill metadata renderer truthiness-tested the field, so a skill declaring `allowed-tools: []` advertised `Allowed tools: (all)` while the tool policy stripped every business tool — the model planned against tools that would be rejected. The empty declaration now renders `(none)`; an omitted or non-empty declaration renders as before. ([#5593]) - **gateway:** Classify the Windows `image/svg` MIME alias as active content. On Windows the MIME database maps `.svg` to `image/svg` rather than the standard `image/svg+xml`, and the shared active-content classifier only recognized the standard type, so SVG artifacts, `.skill` archive members, and project documents could be served inline instead of forced to download. The alias is now treated as active content on every host. ([#5594]) - **models:** Tolerate a null Codex `account_id` before it reaches the request header. `or`-chaining let a JSON `null` survive the loader as `None`, so `CodexChatModel` construction crashed on `self._account_id[:8]`; a numeric `account_id` died later with `httpx` rejecting a non-string `ChatGPT-Account-ID` header. Null now falls back to the unknown-account value `""` and any other non-string is stringified. ([#5601]) - **llm:** Fence circuit-breaker probe settlement by generation and ownership. `LLMErrorHandlingMiddleware` settled successes and failures unconditionally, so a request admitted under an older closed generation could finish after a newer recovery probe was acquired and then close the circuit during the outage, reopen it and discard a healthy probe, or release another call's probe. Every admission now captures the circuit generation, and success, failure, or release only mutates the breaker when both the generation and — in half-open state — the exact probe token match. ([#5602]) - **memory:** Reject a Honcho `base_url` that can never resolve. The field was read with no validation, so the natural scheme-less spelling for an internal host (`base_url: honcho:8000`) was accepted at startup — httpx parses the host as the scheme — and memory then failed on every operation with an unsupported-protocol error, or silently returned empty results while the Gateway ran green; the api_key-over-plain-HTTP guard was skipped too, since it matched only a literal `http://` prefix. Malformed values now fail fast at Gateway startup, matching the documented behavior, and every URL that resolves today keeps working. ([#5607]) - **subagents:** Report an explicit zero batch limit by name instead of defaulting it. `SubagentBatchService.submit()` resolved `max_live_items` / `max_running_items` with `or`, so a caller-supplied `0` read as "not supplied" and was silently replaced with the configured default — a `batch_task` asking for zero concurrent items ran three at a time — and paired with a small `max_live_items`, the request was refused for a limit it did not violate. Both resolutions now compare against `None`, so an explicit `0` reaches the range guard and is rejected by name; an absent key still means "use the configured default". ([#5609]) - **sandbox:** Drop ignored directories from remote `list_dir` output. A local sandbox skips `node_modules`, `.git` and the other ignore patterns, but the same call in a remote sandbox listed every path under them up to the 500-line cap, even though the remote `grep` and `glob` already treat those paths as invisible. The shared remote parser now drops entries containing an ignored directory name, matched relative to the listing root like the local walk: explicitly listing an ignored directory still works, and a fully ignored directory returns empty instead of `FileNotFoundError`. ([#5612]) - **middleware:** Discard stale todo reminders during context compaction. After a todo reminder had been injected, compaction could summarize its old task statuses or keep the stale reminder in the retained message tail, which also stopped TodoMiddleware from rebuilding task context from the current `todos` state. Automatic and manual compaction now exclude `todo_reminder` snapshots before selecting the summary and retained partitions, and the current todo state survives: the middleware regenerates a reminder before the next model call when no `write_todos` call remains visible. ([#5614]) - **sandbox:** Honor `start_line`/`end_line` in Tenki's `read_file`. The Tenki provider accepted only `path`, so a ranged read — the normal way an agent continues a truncated read — raised `TypeError: ... unexpected keyword argument 'start_line'`, rendered as a generic read error. It now slices like the sibling providers, with negative bounds clamping the way the local sandbox does. ([#5616]) - **persistence:** Drain the PostgreSQL schema bootstrap connection close across cancellation. `ensure_postgres_schema_async()` closed its dedicated psycopg connection with a bare `await`, so cancellation arriving during lifespan teardown could abort the close and return while the connection was still live. The close now goes through the cancellation-safe `await_drained()` helper, which defers the caller's cancellation until the owned connection finishes closing. ([#5617]) - **runtime:** Drain runtime provider teardown across cancellation. The checkpoint-cache and stream-bridge provider context managers own their backends and awaited `aclose()`/`close()` directly on context exit, so host cancellation arriving mid-teardown could abort the close and return while a memory or Redis backend was still live. Both closes now go through the cancellation-safe `await_drained()` helper for the memory and Redis backends; cancellation propagates only after the owned close finishes. ([#5622]) - **helm:** Align the provisioner's state root with the Gateway's when the shared home PVC is enabled. The chart set the Gateway's `DEER_FLOW_HOST_BASE_DIR` but left the provisioner on its runtime default `/.deer-flow`, so valid Gateway skill-projection paths fell outside the provisioner's allowed base and could be rejected before sandbox creation in `USERDATA_PVC_NAME` mode. The provisioner now receives the same logical root so projections map to `deer-flow/` on the PVC; it stays unset when `persistence.home.enabled=false`. ([#5625]) - **client:** Honor an agent's MCP plugin selections in the embedded client. An agent saved with `mcp_plugins: [installation-A]` still received tools from every enabled MCP server through `DeerFlowClient`, an empty selection was ignored as well, and delegated tasks did not inherit the agent's selection. The client now applies the saved selection when loading tools and passes it to delegated tasks on every run, including cached-graph runs; the selection joins the graph cache key as an unordered set, and saved configuration changes still take effect after `reset_agent()`. ([#5630]) - **persistence:** Drain Alembic migration workers across cancellation. `bootstrap_schema()` held the SQLite in-process mutex or the PostgreSQL advisory lock while `stamp`/`upgrade` ran in `asyncio.to_thread()`, but those awaits were cancellation-vulnerable: cancelling the host task could return from the bootstrap critical section while the worker was still mutating schema state, letting a second bootstrap overlap a live migration. Every Alembic worker await now goes through the cancellation-safe `await_drained()` helper; cancellation propagates only after the worker finishes. ([#5631]) - **sandbox:** Enforce command timeouts in the AIO sandbox. `AioSandbox` ignored the caller-provided timeout, so `sandbox.bash_command_timeout` never applied — a long-running command ran to completion, and a stalled persistent-shell request kept the sandbox-wide serialization lock occupied, blocking later operations on the same client. Commands now carry a server-side `hard_timeout` on supported AIO images (the frozen legacy `:latest` gets a bounded host wait only), ambiguous outcomes are never replayed, and `sandbox.bash_command_timeout` is now the provider default. ([#5634]) - **channels:** Log Slack Socket Mode connect failures instead of dropping them. `SlackChannel.start()` ran `SocketModeClient.connect()` through `run_in_executor()` and discarded the returned future, so a rejected `app_token` (or unreachable Slack) left the bot silently dead — the log said `Slack channel started` and the exception surfaced only as asyncio's "Future exception was never retrieved" at garbage-collection time. Connect failures are now logged with their traceback, matching what the Telegram and Discord channels already did. ([#5640]) - **mcp:** Honor a stdio MCP server's configured `cwd`. `McpServerConfig` accepted the setting as an extra field, but `build_server_params()` dropped it before either launch path saw it, so a server configured with a working directory could fail discovery when its script path was relative, or discover with an absolute path and then fail relative file reads during pooled tool calls. The directory is now forwarded to stdio connections when non-empty (including environment-variable references) and omitted for HTTP/SSE; omitted, `null`, or empty values keep the default behavior. ([#5643]) - **skills:** Read Python secret assignments from the AST in SkillScan. The `secret-env-assignment` rule swept every text file with a `name[:=]value` regex, which misreads Python: a typed-optional parameter default (`token: Optional[str] = None`) and `api_key = os.getenv("...")` — the rule's own documented remediation — were both reported as hardcoded credentials, failing the review gate on unchanged bundled skills. Python sources are now analyzed from their AST, reporting only real literal values; non-Python coverage is unchanged, and a test pins the bundled skills stay clean. ([#5648]) - **persistence:** Drain the database auto-create maintenance dispose across cancellation. `_auto_create_postgres_db()` released its throwaway engine with a bare `await maint_engine.dispose()`, so cancellation during a slow first boot could abort the dispose and leave one live server session against `postgres` for the lifetime of the process — a leak that is not self-healing, since the next boot takes the non-missing-database path and never retries the disposal. The dispose now goes through the cancellation-safe `await_drained()` helper; a successful first boot behaves as before. ([#5649]) - **skills:** Keep per-user skill-install directory setup off the event loop. `UserScopedSkillStorage.ainstall_skill_from_archive` created the per-user custom directory with a blocking `os.mkdir` on the loop before any real work was dispatched, so `POST /api/skills/install` stalled the Gateway event loop; the existing blocking-IO anchor missed it because it covered only the host-scoped base class. The `mkdir` now runs on the worker thread like the extraction and validation around it, and the anchor covers the user-scoped path. Install semantics and on-disk layout are unchanged. ([#5650]) - **sandbox:** Clamp negative `read_file` range bounds in the e2b, OpenSandbox, and BoxLite providers the way `LocalSandbox` already does. Negative values previously fell through to Python negative-index slicing, so `start_line=-1` silently returned the last lines of a file and `end_line=-1` a truncated range instead of reading from the first line and yielding an empty range. The tool layer already rejected negative bounds; direct `Sandbox` consumers hit the gap. ([#5655]) - **skills:** Align the bundled Vercel deploy skill's package directory with its declared name and point its deployment commands at the real mounted path `/mnt/skills/public/vercel-deploy/scripts/deploy.sh`. The skill lived in `vercel-deploy-claimable` while activating as `/vercel-deploy`, and its instructions referenced a script path DeerFlow never mounts, so a deployment request could select the correct skill and still receive a command pointing at a file that does not exist. ([#5656]) - **frontend:** Show explicit empty skill export requirements instead of a blank value. The export dialog formatted `allowed-tools` and `required-secrets` with `join(", ")`, so an explicitly empty declaration rendered as an empty string and was indistinguishable from an omitted one, even though the two have different policy semantics; absent declarations now render as "Not declared" and explicitly empty ones as a localized "None". ([#5659]) - **sandbox:** Bound AIO `list_dir` with a dedicated directory deadline. The listing ran under the sandbox-wide lock with only a 600-second no-change timeout and inherited the SDK transport budget, so a stalled relay or wedged `find` could block later operations on the same sandbox for minutes. On supported images the remote `find` now carries a 60-second server-side `hard_timeout` inside a 65-second no-retry host envelope: `hard_timeout` raises `TimeoutError` and keeps the shell generation reusable, ambiguous outcomes raise `OSError` and fence it. No new configuration. ([#5662]) - **runtime:** Share one change position across an atomic thread operation in the memory run store. `create_thread_operation_atomic` advanced the change clock per row, so an interrupt-and-replace landed its interrupted row and replacement on two `change_seq` values and a `list_changed` reader paging by `(change_seq, run_id)` could observe half of the atomic set; every claimed row and the new row now carry one position, matching the SQL store. The default (no durable database) deployment selects this store. ([#5663]) - **runtime:** Drain synchronous checkpoint mutations across cancellation. Goal writes and rollback offload sync checkpointers with `asyncio.to_thread()`, and cancelling the caller previously stopped waiting while the `put`, `put_writes`, or `delete_thread` worker kept running — the caller could observe cancellation and the mutation could still commit afterwards. These fallbacks now drain through the cancellation-safe `await_drained()` helper; the read fallback `get_tuple` stays directly cancellable. ([#5664]) - **agent:** Distinguish undeclared and explicitly empty skill `allowed-tools` in the agent assembly fingerprint. Both declaration states hashed to an empty list, so switching a skill from the legacy allow-all to a no-business-tools policy changed runtime tool availability without changing the fingerprint; undeclared `allowed_tools` now hashes as `None` while explicit allowlists stay sorted lists. Frontmatter parsing and runtime tool policy are unchanged. ([#5669]) - **uploads:** Stop deleting a converted Markdown companion when its document is deleted. `delete_file_safe` recomputed `stem.md`, so two documents sharing a stem (`a.docx`, `a.pdf`) made deleting one document remove the other's converted text — or destroy a user's own `report.md` next to an unrelated `report.pdf`. `DELETE /api/threads/{id}/uploads/{filename}` now removes only the requested file; orphaned companions stay in the listing and can be deleted individually. No API shape or config change. ([#5673]) - **sandbox:** Prune ignored entries before applying the remote `ls` listing limit. The 500-entry cap was applied before ignored paths were dropped, so a dependency tree could fill the entire window and hide a normal file beside it, sometimes leaving only the root in the returned listing; ignored descendants are now pruned inside the shared `find` command before `head`, preserving depth and output limits. ([#5676]) - **persistence:** Apply `database.postgres_schema` to the synchronous SQLAlchemy store used by DB-backed custom agents and managed subagents. The async ORM and LangGraph stores already honored the configured schema, but synchronous agent-store queries fell back to `public` and failed to find the application tables; the connection URL now preserves existing libpq options while adding `search_path`. ([#5678]) - **logging:** Collapse every non-absolute Redirecting slot to `/`. The #5225 URL-redaction pass kept a slot verbatim unless it started with `/`, so slash-less relative `Location` references (`download?sign=…`), query-only, fragment-only, network-path, and `data:` targets rendered their signed queries verbatim in DEBUG redirect logs; only a target with a scheme at position 0 is now handed to the generic absolute-URL pass, and non-URL `-> /path` sandbox mount arrows still pass through. ([#5680]) - **channels:** Log DingTalk inbound-file skips instead of dropping the attachment silently. A download that returned no content vanished with zero log lines, indistinguishable from a message that never had one; the skip now emits a neutral guard line naming the file, matching the WeChat channel's shape, with the accurate reason already logged inside the download helper. No behavior change. ([#5683]) - **gateway:** Drain readiness-probe connection close across cancellation. `/health/ready` closed its SQLite and PostgreSQL probe connections with a bare `await connection.close()`, so a second cancellation during teardown could interrupt it and let the probe return while its connection was still live, detaching database cleanup from the request lifecycle; close now drains through the cancellation-safe `await_drained()` helper. Response shape is unchanged. ([#5684]) - **logging:** Collapse space-carrying Redirecting slots to `/`. Round 15 kept scheme-bearing slots verbatim for the generic absolute-URL pass, but that pass's pattern stops at whitespace, so a slot like `https://mirror.example/other page?sig=…` rewrote only the text before the space and leaked the rest into DEBUG logs; a slot is now kept only when a whitespace-free absolute URL fills it, and ordinary absolute redirect pairs still keep their host. ([#5687]) - **skills:** SkillScan's `secret-env-assignment` rule again reports secrets bound outside plain statement assignments. The AST walk from #5648 only saw bare-constant assignments, so keyword arguments (`connect(api_key="...")`), parameter defaults, walrus bindings, literal concatenation, and all-constant f-strings hid a hardcoded credential from the HIGH-severity gate. The constant fold is iterative: a recursive one could raise `RecursionError`, which the scanner records as a per-file error that drops every finding for the file. The #5648 precision (annotations, `os.getenv` calls) is preserved. ([#5691]) - **community:** Browserless `web_fetch` now reads its documented wait settings the way `web_capture` does. `wait_for_timeout_ms` goes through the tolerant `_as_int` helper, so a value like `2s` falls back to the default instead of failing the call with `invalid literal for int()`, and `wait_for_selector_timeout_ms` is read from tool config instead of a hardcoded `5000` (default unchanged, so existing configs behave the same). Both keys are now documented on the `web_fetch` entry in `config.example.yaml`. ([#5702]) - **community:** SearXNG `max_results` above one page is now honored. The SearXNG API has no `limit` parameter, so the client sent an ignored `limit`, requested only page 1, and sliced an already-truncated list — `max_results: 20` silently returned one page (10 by default). `search` now walks `pageno` and accumulates results until the cap is reached, a page comes back empty, or a page adds nothing new (deduped by URL), capped at 5 pages; the dead `limit` parameter is no longer sent. Default `max_results` still costs one request. ([#5705]) - **sandbox:** Ambiguous AIO session creation can no longer be replayed or executed. Shell and bash creation planes track each attempted id through a pending/owned/absent/tombstoned state machine: timeouts, transport errors, 5xx responses, and malformed HTTP-200 responses tombstone the id — no execution on it, no replayed create, bounded best-effort cleanup that never clears the tombstone — and new creates on that plane are blocked. A dirty sandbox is released through the existing teardown path instead of returning to the warm pool; shell ambiguity does not affect bash creation. ([#5711]) - **models:** Official DeepSeek models added through Settings now use the native `PatchedChatDeepSeek` adapter. Managed profiles and their connection probes always built `ChatOpenAI`, so adding `deepseek-flash` failed the connection test with HTTP 400 `Thinking mode does not support this tool_choice`, and streamed tool calls dropped `reasoning_content` and sent `max_completion_tokens` instead of DeepSeek's native `max_tokens`. Thinking is disabled only for the bounded forced-tool probe — normal chat settings are untouched — and matching is limited to `api.deepseek.com`, so third-party compatible endpoints keep their behavior. Existing saved profiles gain the adapter without a catalog migration. ([#5718]) - **community:** Browserless `web_fetch` now reads `reject_resource_types` and `reject_request_pattern` from its tool config. Both parameters were declared and passed to the client but only ever assigned `None`, so a configured value was silently ignored and resource-type/request-pattern rejection — the main lever for render latency and bandwidth against a self-hosted Browserless — could not be turned on. Values accept a YAML list or a comma-separated string; unusable values omit the parameter instead of sending it. Defaults are unchanged, and both keys are documented in `config.example.yaml`. ([#5719]) - **skills:** The custom-skill rollback route no longer reads the skill's edit history on the event loop. `POST /api/skills/custom/{skill_name}/rollback` constructed the storage, probed existence, and parsed `custom/.history/.jsonl` inline, and each history entry carries the full previous and new skill contents, so a rollback stalled every other run and stream on the worker for the parse. The work now runs on a worker thread, matching the adjacent history handler; status codes and response shapes are unchanged, and blocking-IO anchors guard the route. ([#5729]) - **mcp:** Durable MCP task calls now bound connection setup and MCP initialization under the same `session_init_timeout` deadline as session entry. The timeout previously started only after entering the session context, so an SSE server that accepted HTTP but never sent the MCP endpoint event kept the call waiting indefinitely. After initialization succeeds the deadline is disabled and the existing independent tool-call timeout applies; `None` still disables the initialization bound. ([#5733]) - **skills:** `required-secrets[].optional` must now be a YAML boolean. A quoted value like `optional: "false"` passed frontmatter validation and was later parsed with Python truthiness, so `bool("false")` recorded a required secret as optional and produced wrong missing-secret diagnostics and exported requirement metadata. Validation rejects non-boolean values, and the runtime parser fails closed to `optional=False` with a warning (value type and secret name only) for mounted or historical skills. The skill-review analyzer flags the shape as `structure.invalid-required-secrets-optional`. ([#5738]) - **skills:** Skill review no longer keeps sentence punctuation on extracted resource references. A bare path at the end of a sentence ("See references/setup.md.") was extracted with its trailing period, producing a false `resource.missing` finding and a spurious orphan for the real file. Extracted references now strip trailing `.`, `?`, and `!`, while real dotted filenames such as `references/config.yaml` and link targets like `references/v1.0.md` stay intact. ([#5739]) - **uploads:** Remove a staged upload's `.part` only after its document-conversion descriptor is released. With `uploads.auto_convert_documents` enabled, commit unlinked the staged file while conversion still held an open descriptor, so on Windows every convertible upload (PDF/DOCX/PPTX/XLSX) failed with `WinError 32` (500) where POSIX silently allowed it. The staged name is now removed in the same cleanup that releases the descriptor, and abort-path cleanup is best-effort so a secondary removal failure can no longer mask the original error. ([#5740]) - **composer:** Keep IME-composition Enter out of the slash-skill catalog. The main prompt textarea's suggestion handler lacked the `isIMEComposing` guard its sibling call sites had, so with an IME active and the `/` catalog open, the Enter that committed a CJK composition instead applied the highlighted skill and discarded the in-progress text. IME-consumed keydowns now fall through to the normal submit path; plain Enter still selects the highlighted skill. ([#5741]) - **sandbox:** Cancel BoxLite loop work whose synchronous bridge wait timed out. `run_coroutine_threadsafe(...).result(timeout)` raised to the caller but left the submitted coroutine running on the private loop, so the sandbox could keep mutating or retain acquire ownership after the operation was reported as timed out; the pending future is now cancelled before the timeout re-raises, while already-completed futures are left alone. ([#5748]) - **mcp:** Invalidate the MCP tool cache only when the effective MCP configuration changes. Invalidation previously keyed on the whole `extensions_config.json` signature, so toggling an unrelated skill rebuilt cached tools and retired every pooled stdio session. The cache now compares a snapshot of the enabled servers' parsed settings (declaration order) and global `mcpInterceptors` before retiring anything; when no servers are enabled, a non-initializing refresh retires stale state without adding discovery overhead, and malformed-config error logging no longer emits resolved credentials. ([#5750]) - **gateway:** Drain artifact updates to completion across request cancellation. Cancelling `PUT /api/threads/{thread_id}/artifacts/{path}` unwound the thread-operation reservation and sandbox lease while the non-mounted remote sync was still mutating the sandbox, leaving the remote and host copies at different bytes and letting later work overlap the abandoned mutation. The full remote-sync/local-replace transaction is now drained before ownership is released, with rollback semantics preserved and the primary commit failure logged before rollback. ([#5755]) - **uploads:** Preserve the original link error when staged-upload cleanup fails. On a failed link commit whose conversion descriptor was still open, Windows rejected the error handler's unlink with a sharing violation and that secondary error replaced the real endpoint error; the generic link-failure path now respects `unlink_staged=False` and all staged cleanup is best-effort. A cleanup failure after a successful link also no longer returns 500 for an already-published destination. ([#5756]) - **runtime:** Drain database provider context exits across caller cancellation. The async SQLite/PostgreSQL checkpointer and Store providers used raw `async with`, so a cancellation delivered while the backend's `__aexit__()` was blocked cancelled the teardown itself and let the provider finish with incomplete database/pool cleanup — deterministically across all six provider paths. A `drained_async_context()` helper now preserves exception and suppression semantics while draining the exit to completion. ([#5757]) - **channels:** Resolve the Buzz seen-event store path off the event loop. `ChannelService._start_channel` built the default `seen_event_store_path` inline, dereferencing `Paths.base_dir` (a `realpath`) on the loop in a path reachable from `POST /api/channels/{name}/restart`; the resolution now runs in a worker thread, with a blocking-IO anchor pinning it to the strict gate. ([#5759]) - **sandbox:** Resolve the AIO sandbox acquire lock path off the event loop. `_discover_or_create_with_lock_async` offloaded every acquire step except the inline lock-file path construction, which dereferences `Paths.base_dir` (a `realpath`) on the loop on the hot path of every sandboxed tool call; the resolution now joins the surrounding steps in a worker thread, and the sibling ownership-publish anchor exercises the production path layer again. ([#5760]) - **threads:** Do not leave orphan threads behind when a LangGraph run is rejected. Standalone run admission pre-creates a missing thread before LangGraph re-checks assistant ownership, so a run against another user's non-system assistant returned the expected 404 but had already persisted an idle thread owned by the caller. Admission now verifies the assistant's server-owned provenance before `Threads.put` (with the ambient auth context temporarily cleared), while ordinary users can still implicitly create threads for registered system assistants. ([#5762]) - **setup:** Run the pre-commit hook through `uv` so `make install` no longer fails when uv's tool bin directory is not on PATH, and make the Node.js prerequisite check actionable: nvm users get an install command and everyone else a link to the official download page. ([#5767]) - **scheduler:** Read the dispatch lease owner under SQLite's writer lock. `release_dispatch_lease` was the only mutating path that skipped `_lock_task`, and because `FOR UPDATE` is ignored on SQLite its guard evaluated a stale snapshot: a pause landing inside the dispatch window could be overwritten, leaving the task `enabled` and firing again even though the API had confirmed the pause. The read now takes the writer lock first; PostgreSQL behavior is unchanged. ([#5777]) - **utils:** Initialize `Article.url` so `to_message()` no longer raises `AttributeError` for pages containing images. `ReadabilityExtractor` never passed the page URL into `Article`, so resolving a relative image URL via `urljoin(self.url, …)` crashed; the attribute now defaults to `""` and the extractor passes the real URL. ([#5785]) - **tools:** Coerce DDG web-search result limits from environment variables. `max_results: $DDG_MAX_RESULTS` kept the substituted string, and DDGS 9.14.1 raised a `TypeError` sizing its workers that the search wrapper reported as `No results found` for every query. Configured limits now accept numeric strings; invalid or non-positive values log a warning and fall back to the default of 5, while integer configs, the default, and config-over-call precedence are unchanged. ([#5792]) - **gateway:** Treat a blank `GATEWAY_HOST` or `GATEWAY_PORT` as unset. A compose file with `GATEWAY_PORT=` (or `GATEWAY_PORT=${PORT}` with `PORT` unset) handed an empty string to `int()`, killing the Gateway at import time before uvicorn could bind. Blank values now fall back to the declared defaults (`0.0.0.0` / `8001`); `GATEWAY_PORT=0` still asks the OS for a free port, and every non-empty value parses exactly as before. ([#5801]) - **channels:** Persist channel runtime configuration as UTF-8. The store read its JSON as UTF-8 but wrote its temporary file with the host's default encoding and `ensure_ascii=False`, so on non-UTF-8 hosts (CP1252, GBK) non-ASCII values — such as a configured Buzz relay URL — could fail to save or be written in bytes the store could not read back. The temporary write now specifies UTF-8; atomic replace, locking, and chmod behavior are unchanged, and legacy-encoded files are not migrated. ([#5803]) - **setup:** Accept successful Apple Container image pulls in `make setup-sandbox`. On macOS with Apple Container installed and Docker absent, the script fell through to the Docker availability check and exited 1 with "Neither Docker nor Apple Container is available" after the image had already been pulled. An Apple-only pull now counts as success and continues to the default-image note; Docker behavior, including fallback after an Apple Container failure, is unchanged. ([#5804]) - **runtime:** Keep agent stream teardown alive across repeated cancellation. `close_agent_stream()` awaited `stream.aclose()` in the caller task, so a second `Task.cancel()` during teardown cancelled the close itself and let callers abandon releasing graph, provider, sandbox, and tool resources; a close-originated `CancelledError` also made a broken close look interrupted instead of failed. The close now runs in a dedicated shielded task, the first caller cancellation is deferred until it completes, and stream-originated close cancellation is reported as a close failure so the run terminalizes as failed. ([#5806]) - **tools:** Apply the #5792 numeric-string coercion to DDG image search. `image_search_tool` passed the environment-substituted `max_results` string straight to DDGS, which raised a `TypeError` sizing its workers, so `max_results: $IMAGE_MAX_RESULTS` made every query return `No images found`; `0` removed the limit and negative values truncated results. Numeric strings now work, and invalid, zero, or negative values warn and fall back to 5, with integer configs and the default unchanged. ([#5807]) - **client:** Stop resumed embedded-client streams from replaying prior turns. On a resumed thread, `DeerFlowClient.stream()` re-emitted earlier turns' message deltas — including tool results — in `messages-tuple` events and counted their tokens in `end.usage`, overstating per-turn usage. Historical messages stay in `values` full-state snapshots but are suppressed from the current turn's deltas and usage, with the per-run `run_id` on the current user message as the boundary; Gateway SSE is unchanged. ([#5811]) - **persistence:** Drain the postgres schema-setup connection exit across cancellation. `_ensure_postgres_schema_with_pool` still acquired its connection with a raw `async with`, so caller cancellation landing while the connection's `__aexit__` was blocked interrupted the exit and the connection was never returned to the pool, whose drained teardown then closed with a checked-out connection. The connection now goes through `drained_async_context()` like the rest of the checkpointer provider; no happy-path behavior change. ([#5812]) - **mcp:** Stop the bare-filename correlation pass from mangling backslash paths. After a stdio MCP call created `page.yml`, a result mentioning `C:\outside\page.yml` had its basename rewritten to point at the new workspace file, because the pass treated a backslash as a bare-filename boundary. Backslashes now act as path separators on both sides of a correlated name, so genuine bare filenames still resolve while names inside unresolved Windows, UNC, or relative paths stay unchanged. ([#5815]) - **mcp:** Resolve generated file paths containing spaces in MCP text results. A file named `artifact with space.txt` kept its absolute host path in the free-text tool result, and a follow-up `read_file` failed with `Permission denied` because whitespace hid the complete filename from the token matcher. Whitespace-containing absolute and workspace-relative paths now correlate with files the call created, validated inside the current thread's user-data tree; outside paths and ambiguous suffixes are untouched. ([#5819]) - **middleware:** Preserve human message metadata in `ThreadDataMiddleware`. Adding run metadata reconstructed the last `HumanMessage` from parts, silently dropping fields it did not copy such as `response_metadata`; the middleware now copies the existing message and updates only its default name and `additional_kwargs`. ([#5823]) - **uploads:** Keep document-conversion `stat()` and markdown write-back off the event loop. `convert_file_to_markdown` offloaded the conversion itself but still ran the size-check `stat()` and the multi-megabyte `write_text` of the converted markdown on the loop, blocking upload ingestion for large documents. Both now go through the shared `run_file_io` pool; the small-file inline conversion threshold is unchanged. ([#5830]) - **mcp:** Run lazy MCP tool discovery once when initialization itself raises. The no-event-loop `except RuntimeError` fallback in `get_cached_mcp_tools()` also caught discovery's own errors: with no running loop a failing call spawned every stdio server and re-fetched OAuth tokens twice — repeated on every later call, since a failed init never marks the cache initialized — and inside a running loop the log buried the real cause under "asyncio.run() cannot be called from a running event loop". Initialization is now driven exactly once with the real failure logged; errors still return `[]` and leave the cache uninitialized for the next retry. ([#5836]) - **config:** Honor `$VAR` substitution for `request_admission` integer fields. `requests_per_minute` and `max_queue_size` are declared `strict=True` so `true` and `60.0` stay rejected, but an environment substitution like `requests_per_minute: $RPM` arrived as the string `"60"` and failed the whole `config.yaml` load with `int_type` — quotas kept in the environment could not pace a model at all. A `BeforeValidator` now converts a plain decimal string to `int` before the strict check; bools, floats, `"60.0"`, empty and non-numeric values are still rejected and `gt=0` is unchanged. ([#5838]) - **tools:** Coerce Exa's configured result limits before calling the SDK. `$VAR` substitution resolves to strings, so `max_results: $EXA_MAX_RESULTS` made every `web_search` call fail — exa-py rejects a string `num_results` — and `contents_max_characters` went out as `"maxCharacters": "1000"`. Both are now parsed as integers (a non-boolean integer or integer-form string only); a blank, non-numeric, fractional, boolean, zero or negative value logs a warning and falls back to the defaults (5 and 1000) instead of failing the search, matching the DDG and SearXNG providers. ([#5839]) - **gateway:** Treat a blank `GATEWAY_WORKERS` and blank Lark broker port or timeout as unset, extending #5801's blank-env rule. `int("")` in the WeChat QR-login guard turned a blank `GATEWAY_WORKERS` (e.g. `-e GATEWAY_WORKERS=`) into a 503 "requires a single Gateway worker" on every QR route even though the Gateway runs one worker, and a blank `DEERFLOW_LARK_BROKER_PORT`/`_TIMEOUT` aborted the Lark broker at startup. The guard now reads `GATEWAY_WORKERS or WEB_CONCURRENCY or "1"` and still fails closed on multi-worker or non-numeric values; the broker falls back to its defaults. ([#5840]) - **skills:** Strip section anchors from code-span resource references in the skill review resource graph. Markdown-link targets already dropped `#fragment` before resolving, but a code-span reference like `references/faq.md#pricing` kept the anchor, resolved as a literal path, and raised a false `resource.missing` warning even though the file exists and is referenced. Code-span tokens now split the fragment the same way and strip trailing sentence punctuation; dotted filenames such as `references/v1.0.md#notes` keep their extension dots. ([#5841]) - **skills:** Keep the skill review markdown-link scan linear on adversarial input. The extractor re-scanned the same target run from every candidate opener, so a long run of unmatched `[` or a `](`-dense invalid target drove the review quadratic — #5714 measured 19 s on 256 KiB of `[`, and the resource graph scans every text member with `PackageLimits.max_file_bytes` at 64 MiB, so a single large SKILL.md is squarely in scope. The scan now walks candidate openers once, reproducing `finditer` exactly (verified by differential fuzzing against the base regex), and blanks matched spans by position instead of `str.replace`, which could blank an unrelated later copy of the same text. ([#5884]) - **config:** Sign the bytes the config cache parsed, not a second read. The app-config loader opened `config.yaml` twice — parse once, then hash a fresh read to record the `(mtime, size, sha256)` signature — so a write landing between the two reads cached the older content under the newer signature and `get_app_config()` never reloaded it, silently serving a stale config until some later edit. The loader now reads once through a shared `file_signature.read_config_with_signature()` helper that signs exactly the returned bytes; a racing edit can only cost one extra reload. ([#5848]) - **dev:** `make stop` / `make dev` can now reclaim dev ports held by a sibling worktree whose path contains spaces. `serve.sh` built its worktree-root list with `awk '{print $2}'` over `git worktree list --porcelain`, whose paths are unquoted, so `.../deer flow two` was recorded as `.../deer`; a Gateway or frontend started from that worktree was never recognised as deer-flow's and the start aborted with "port already in use". The whole path is kept now. ([#5856]) - **uploads:** A malformed `files[*].size` in a run's message metadata no longer fails the whole run. `UploadsMiddleware` validated every other field of a client-supplied file entry fail-soft but passed `size` straight to `int()`, so a value such as `"abc"` or a list raised out of `before_agent` before the model was called — and again on every edit or regenerate of that message, since the entry is carried over verbatim. The size only feeds the human-readable line in ``; unusable values now fall back to `0`, the same as a missing size, while numeric strings keep working. ([#5855]) - **release:** Bumping the version no longer leaves `backend/uv.lock` behind. `scripts/bump_version.sh` rewrote `backend/pyproject.toml`, `frontend/package.json` and the Helm chart, but the lockfile records the root package's own version too (uv keeps its PEP 440 form, so `2.1.0-rc0` is stored as `2.1.0rc0`). The documented release step therefore produced a commit whose lock CI rejects: `uv lock --check` fails on the stale lock and `uv sync --locked` refuses the tree, and with pre-commit installed it broke a step earlier on the `uv-lock-check` hook. The script now refreshes the lock with `uv lock` and exits before editing anything when `uv` is missing, instead of leaving a half-bumped working tree behind. Only the root package's version line moves. ([#5859]) - **sandbox:** Stop `glob` and `grep` from returning nothing when the search root — or one of its ancestors — matches an ignore pattern such as `build`, `dist`, `logs`, `node_modules`, `coverage` or `target`. The remote sandboxes applied `should_ignore_path` to the absolute path, which tests every segment, so one ignored name anywhere up the tree hid the whole result and the agent was told "no matches" for a directory `ls` had just listed. Ignore patterns are now applied to the path relative to the search root, as `list_dir` already did: an ignored name still hides its own descendants, but searching an ignored root — or a path below an ignored ancestor — returns its contents. ([#5667]) - **sandbox:** The temporary sandbox lease acquired by the HTTP upload route is now released. In remote/provisioner deployments, `POST /api/threads/{id}/uploads` temporarily acquires the thread sandbox to sync uploaded files into non-mounted sandboxes, but the lease was never released — not on success, 413, or failure — and Feishu file sync held the same non-releasing pattern, so the acquired sandbox (a Pod plus NodePort Service) never returned to the warm pool. Both paths release after sync, and a release only parks the sandbox in the warm pool once the last holder exits: when the same thread already has an active run sandbox, releasing the temporary lease just decrements it instead of parking the sandbox in use. ([#3261]) - **frontend:** Stop retrying the initial run-creation request after an ambiguous failure. The LangGraph SDK retries failed HTTP requests by default, and `POST /runs/stream` is not idempotent: an intermediary could return an ambiguous gateway error such as `504` after the backend accepted the request, and the retry then created the same run twice, duplicating user-visible work. Run creation is now issued with retries disabled, while reads, explicit stream joins, and SSE recovery — the `GET` to the server-provided `Location` carrying the `Last-Event-ID` cursor — keep their normal retry behavior. ([#4725]) - **gateway:** A stream-bridge failure no longer cancels a healthy run as if the client had disconnected. Both creator-side consumers — the SSE `POST /runs/stream` response and `POST /wait` — applied the creator's `on_disconnect` policy (cancel) from an unconditional `finally`, so any exit from `StreamBridge.subscribe()`, including an infrastructure exception or the subscription exhausting before a terminal event, was treated as a client disconnect and could cancel a running agent. The policy now applies only to observed disconnects (`request.is_disconnected()`, SSE generator close, request-task cancellation); bridge exceptions propagate without cancelling the run, and premature subscription exhaustion raises a server error. ([#5623]) - **logging:** Redact credential-bearing header values from urllib3's "Failed to parse headers" warning. The dump carries the raw header block following the first malformed line, and `UrlRedactionFilter` had no pass shaped for it, so `Set-Cookie`/`Authorization`/`WWW-Authenticate`/`Location` values reached the log whole — twice, since the `exc_info` traceback repeats them. New passes, gated on urllib3's own literals, split the dump on its logical breaks and collapse only credential-bearing field values, keeping field names and non-sensitive fields so the record still says what failed; the exception text runs the same passes via `exc_text`, which `JsonTraceFormatter` now honors instead of re-rendering exceptions itself (`logging.enhance.format=json` previously kept leaking). ([#5752]) - **uploads:** The link-failure cleanup arm in the upload staging helper (`_link_staged_no_overwrite`) no longer removes a staged name its caller still owns. The arm did a hard `os.unlink()` tolerating only `FileNotFoundError`; on the conversion path (`unlink_staged=False`) the caller keeps a descriptor on the staged inode, so on Windows the removal raised `PermissionError`, which replaced the original `os.link` failure the caller needs to see and left the `.part` behind. The arm now removes the staged name only when this call owns it, through the best-effort `_remove_staged_file`, so a removal failure can never mask the link error; with `unlink_staged=False` the caller cleans up via `_abort_upload_temp`. ([#5763]) - **events:** A cancelled JSONL snapshot no longer releases the per-thread lock while its file scan is still running. `find_latest_ai_message_run_ids()` takes the per-thread write lock for one complete thread-log read, but the scan ran through a bare `await asyncio.to_thread(...)`: cancelling the caller cancelled only the awaiter, so the lock was released while `_read_thread_events()` was still reading files and a writer could enter mid-snapshot. The scan now drains through the `_await_owned_task()` helper (task name `jsonl-snapshot:{thread_id}`); the first caller `CancelledError` stays primary and a read failure is chained as its cause. ([#5795]) - **sandbox:** Hold the AIO cross-process flock until its lifecycle worker settles, even when the caller is cancelled. `_discover_or_create_with_lock_async()` dispatched discovery, registration and creation through bare `asyncio.to_thread()`; cancelling the caller released the flock while a worker was still mutating the sandbox lifecycle, letting another process enter the same discover/create critical section. Sync steps under the lock now drain through `run_sync_lifecycle_operation()`, the whole async create runs inside `await_drained()` so a created sandbox still reaches readiness and registration, and the flock is retried with cancellable non-blocking attempts so a cancelled waiter exits promptly. ([#5820]) - **helm:** The chart now plumbs the optional lark-cli sandbox runtime images into the provisioner. The provisioner reads `LARK_CLI_INIT_IMAGE` and `LARK_CLI_BROKER_IMAGE` and Compose and the root README document them, but the chart had no corresponding value, so an operator following the README had nothing to set and the sandbox silently launched without `lark-cli`, failing only later with `command not found` inside the sandbox. New values `provisioner.larkCliInitImage` and `provisioner.larkCliBrokerImage` (broker supersedes init) render the matching env var on the provisioner Deployment only when set; unset, they are omitted entirely so existing releases render an unchanged Pod spec. ([#5835]) - **tenki:** A Tenki `sticky` setting supplied through an environment reference (e.g. `sandbox.sticky: $TENKI_STICKY` with `TENKI_STICKY=false`) no longer enables host pinning. Environment substitution leaves this provider-specific field a string, and the previous `bool(...)` conversion counted any non-empty string — `false`, `0`, `off`, `no` — as `True`, so the provider asked `Client.create` for sticky sandboxes the operator had disabled. Values are now parsed with Pydantic's boolean validator: case-insensitive `false`/`0`/`off`/`no` disable pinning, `true`/`1`/`on`/`yes` enable it, omitted or `null` stays disabled, native YAML booleans are unchanged, and an invalid value fails provider initialization. ([#5849]) - **config:** `stream_bridge.recovered_stream_cleanup_delay_seconds` now rejects booleans, non-finite values, and anything above 86400 at load time, like its guarded sibling `heartbeat_interval_seconds`. The field only carried `ge=0`, so a hand-edited `config.yaml` with `…: true` was silently coerced to 1.0 — collapsing the reconnect grace window to one second — and `…: .inf` or `1e30` was accepted as-is, making the post-END cleanup task sleep forever and keeping an orphaned run's Redis stream until the rolling `stream_ttl_seconds` (default 86400) expired it instead of the configured grace delay. `0` stays valid ("delete as soon as END is published") and the documented default of 60 is unchanged. ([#5850]) - **community:** DDG `web_search` now warns and falls back to the default of 5 results when `max_results` in `config.yaml` is a boolean or a fractional number, as the README already documents and as `image_search` has done since #5807. `web_search`'s `_coerce_max_results` went straight through `int()`, which truncates `3.5` to 3 and counts `true` as 1, so the tool quietly narrowed its result set instead of flagging the invalid value. Integral floats such as `4.0` and numeric strings keep their behavior. ([#5852]) - **llm:** `strip_think_blocks()` no longer slows quadratically on model text with many unclosed `` tags. The helper cleans model text used by goal evaluation, suggestions and input polishing, and its complete-block regex retried the remaining suffix at every opening tag, so 12,000 repeated `` tags took about 3.1 seconds with the cost still growing. It now scans opening and closing tags forward in one pass, consuming each matched block once and stopping at the first unclosed opening tag; case-insensitive matching, opening-tag attributes, whitespace before the closing `>`, and both `truncate_unclosed` modes are unchanged. ([#5854]) - **sandbox:** `ls /mnt/skills` no longer fails with "Directory not found" when only category mounts exist. In the default skills projection the provider mounts `/mnt/skills/public`, `custom`, `legacy` and `integrations` but maps no `/mnt/skills` root, so `LocalSandbox.list_dir` resolved the root to its literal host path and the host scan raised `FileNotFoundError` before the virtual sub-directory overlay could list the categories. `list_dir` now treats a missing host path as an empty listing when at least one mapping is mounted inside the requested container path; paths with nothing mounted inside them still raise `FileNotFoundError`. ([#5857]) - **examples:** The Jev fetched-content screening example no longer lets a misbehaving endpoint or an unusual deployment past its fail-open and data-egress guarantees. A deeply nested answer body (`RecursionError`) or a huge `noul` integer (`OverflowError`) escaped the fail-open path and, inside the per-message task group, cancelled the other messages' screening requests and dropped every flag of the tool call — parsing now runs inside the fail-open block, which catches both. The 16 KiB response cap counted decoded bytes, so a compressed body could expand past it first; requests now send `Accept-Encoding: identity` and drop encoded responses. A plain-HTTP loopback endpoint, the only plain-HTTP form the validator accepts, followed `HTTP_PROXY` and `ALL_PROXY` and sent the bearer key and excerpt to the proxy in cleartext; such endpoints now build the client with `trust_env=False`, while HTTPS endpoints keep proxy settings. The root README also no longer says the excerpt is always PII-redacted: it is sanitized, and redacted only when `pii_redaction` is enabled. ([#5858]) - **scheduler:** One scheduler's late completion write can no longer clobber another scheduler's fresh claim of the same run. `ScheduledTaskRunRepository.update_status` guarded `expected_lease_owner` (and the `protect_terminal` backfill) on a plain `session.get`, so when an expired launch lease was requeued and re-claimed by another scheduler (`lease_owner=B`) between that read and the write, the stale guard passed and the first scheduler's late write cleared B's lease fencing and wrote `failed` over its `launching` claim. The row is now re-read under the parent task's writer lock — the same task → run lock order every other mutating path in this repository uses — with `populate_existing=True` so the identity map serves the fresh row; Postgres gains a matching `FOR UPDATE` row read. ([#5860]) - **tools:** A synchronous call on a bound `task` or `batch_task` tool now goes through the bound runtime's own submitter and execution capacity instead of the process-global fallback. `get_available_tools` wraps the shared tool singletons in place with a sync `func`, but `bind_task_tool` and `_bind_batch_tool` rebound only the `coroutine` of their copies, so each copy inherited a sync wrapper around the unbound coroutine: its sync path fell through to the process-global submitter (the exact fallback the binding contract rules out) or raised `NotImplementedError` in a fresh process where the singleton had never been wrapped. Both binders now rebind `func` through the same `make_sync_tool_wrapper` helper, so both invocation paths of a bound copy run the bound coroutine. ([#5861]) - **models:** CLI credential files whose token fields are not strings no longer load as "successful" credentials. The loaders in `credential_loader.py` guarded container fields, `expiresAt`, and the Codex `account_id`, but not the tokens, so a hand-edited or script-rewritten file with a number, list, or object in `accessToken`/`access_token` passed the truthiness check — a malformed Claude override from `$CLAUDE_CODE_CREDENTIALS_PATH` masked the valid `~/.claude/.credentials.json` fallback, and a bogus Codex token became a garbage `Authorization: Bearer …` header ending in a confusing 401. Such tokens are now skipped (strings are stripped of padding, blanks count as missing), and a non-string `refreshToken` degrades to empty like `account_id`. ([#5864]) - **mcp:** Durable MCP task status and cancel calls now authenticate as the task's persisted owner on HTTP/SSE servers with `user_auth` enabled. A task that submitted fine inside an authenticated Agent turn then failed every background call: `McpTaskToolCaller` never exposed the stored owner to the credential interceptor, so without a live run/request context credential selection resolved to `default`, the service retried that authentication failure instead of observing the remote task, and a service rebuilt from SQL failed the same way. Background HTTP/SSE calls with enabled `user_auth` now bind an ID-only owner through the existing user ContextVar and restore the previous context in `finally`; submit, stdio, and calls without `user_auth` keep their original context, concurrent owners stay isolated, and background calls still never touch `headers_from_context` request secrets. ([#5870]) - **agents:** Loop detection no longer hard-stops paginated or content-varying calls to generic tools. The per-call key kept only the first salient argument (`path`/`url`/`query`/`command`/...), so an MCP `fetch` resuming with `start_index`, a search paged with `page`, or `list_uploaded_files` following its `cursor` collapsed onto one key, and the fifth distinct call ended the run with `stop_reason=loop_capped`. Keys now cover every argument; only the UI-narration `description` of `bash`/`ls`/`glob`/`grep` stays excluded (rewording it still cannot dodge detection), while `description` elsewhere remains keyed because it can be the payload. ([#5872]) - **middleware:** Input sanitization no longer strips message metadata from sanitized user messages. `InputSanitizationMiddleware` rebuilt each sanitized `HumanMessage` from four selected fields, so anything outside content/id/name/`additional_kwargs` — `response_metadata` carrying gateway provenance in particular — was dropped in both the text and multimodal paths. Sanitized messages are now copied from the original with only `content` and `additional_kwargs` replaced, so metadata, identity fields, and the message subclass survive while the original request message stays untouched. ([#5875]) - **community:** InfoQuest timeout options in `config.yaml` are now normalized the way the sibling search/fetch providers normalize theirs. `deerflow.community.infoquest.tools` handed the five documented numeric options (`timeout`, `fetch_time`, `navigation_timeout`, `search_time_range`, `image_search_time_range`) to `InfoQuestClient` unvalidated, and a `$VAR` reference is substituted verbatim from the environment — so `timeout: $FETCH_TIMEOUT` arrived as a string and the client's `> 0` comparisons raised `TypeError`, turning `web_fetch`, `web_search` or `image_search` into a hard failure whose message pointed at the client instead of the config key. Values now pass through one coercion step: an integer, an integral float or an integer-form string is used as configured, while a boolean, a fractional value, a blank/non-numeric string or `None` falls back to the documented `-1` (that filter is disabled) with a warning naming the rejected key. An already-valid integer config produces a byte-identical request. ([#5883]) - **sandbox:** Keep AIO acquire serialization until a started acquire worker settles, even when the caller is cancelled. Inside the per-`(user_id, thread_id)` serializer region, in-process cache reuse and warm-pool reclaim ran as bare `asyncio.to_thread()` calls, so cancellation released the scope while the worker was still running and a retried acquire for the same user/thread could overlap it. Both steps now run through a helper that cancels work still queued (the old behavior) but keeps serializer ownership and drains a started worker across repeated cancellation; the flock wait, readiness polling, and the skills-projection step remain cancellable. ([#5888]) - **skills:** Managed integration skill discovery stops descending at each skill package boundary. Unlike the public and custom skill loaders, the integration walk kept going after finding a `SKILL.md`, so a nested evaluation fixture carrying its own `SKILL.md` could surface as a runtime skill — or, with a matching frontmatter name, replace the real public skill. Provider and namespace directories without a `SKILL.md` of their own still expose their nested skills. ([#5890]) - **models:** Claude threads no longer die with HTTP 400 after a few turns when prompt caching is on. `ClaudeChatModel._apply_prompt_caching` wrote its `cache_control` markers into blocks the request shares with the thread — langchain-anthropic passes Claude-native blocks (an image or document with a `source`, a search result) by reference — so markers were checkpointed with the messages, and from the third turn every request exceeded Anthropic's four-breakpoint limit with no way to recover the thread. Markers now land on copies, and every request first strips markers already present, with caching on or off, so a thread whose checkpoint carries stale markers works again on its next turn without a migration. ([#5892]) - **subagents:** A durable batch no longer stays `running` forever when its last active item dies from an expired lease on its final allowed attempt. `claim_items` marked such an item `failed` but never refreshed the parent batch, so a batch whose final item ended this way kept `running` with no `completed_at` and no terminal result to observe. Lease recovery now refreshes the batch in the same transaction: a batch with no successful items becomes `failed`, one with partial success becomes `completed`, failed item counts stay visible, and an explicit item retry reopens the batch as before. ([#5897]) - **agents:** A provider that reuses a `tool_call_id` across runs no longer corrupts the durable delegation ledger. Entries were deduplicated by the raw ID with the earlier `run_id` carried forward, so a later run's delegation with the same ID could replace the earlier entry — its task vanished from durable context and `max_total_subagents` undercounted that run — and its reply could be mistaken for one to an older orphaned task. The ledger now keys entries by `(run_id, tool_call_id)` for capture, the retained window, and terminal-status protection; orphan closure on a new user turn is bounded by that run's opening message, and a resumed run without a new user message keeps an already-answered entry. ([#5898]) - **sandbox:** Sandbox network-policy mutations now drain across caller cancellation. The async wrappers for `consume_network_policy_events()`, `deny_pending_network_policy_events()` and `decide_network_policy_request()` offloaded the stateful trusted-proxy mutations with bare `asyncio.to_thread()`, so cancelling the awaiting tool/agent task left the lifecycle boundary while the policy state was still changing. All three now route through the cancellation-safe `run_sync_lifecycle_operation()` helper: the admitted worker settles first, then the original `CancelledError` propagates. ([#5899]) - **tracing:** Langfuse traces now carry the model that actually runs. The `model:` tag was injected before model resolution, so a default-model run could carry no model tag at all, and a fallback run kept the name of the request that was rejected. `LeadAgentAssembly` now exposes the resolved model as `effective_model`; the Gateway worker injects trace metadata after assembly, and the embedded client resolves its agent before injecting, so both tag the selected default or fallback model while caller-provided metadata keeps precedence. ([#5902]) - **runtime:** Image-only user input is now journaled once per run. `RunJournal` used the optional text summary to decide the input was already captured, and a textless message leaves that summary `None`, so every later model call appended another `llm.human.input` event and inflated `message_count`; a batched callback could even record an older prompt after the latest image-only turn. Capture is now tracked independently of the nullable summary, and the batch scan stops once input is recorded; the media content and the empty display text are preserved. ([#5903]) - **mcp:** Make MCP task notification recovery rollback-safe ahead of the atomic launch reservation. The worker reused the batch scan timestamp after asynchronous run lookups and launches, so retry delays started too early and repository expiry predicates compared against a stale time, and unexpected per-record failures were handled only after the whole batch `gather()` finished, tying one record's retry timing to its slowest sibling. Lookup and launch outcomes now use a completion time bounded not to precede the claim, per-record failures are released inside each child task, and repositories can read, recover, poll, and settle a `launching` notification — preserving its snapshot and idempotency key — while no production path writes that state yet, so an older Gateway stays compatible after a rollback; the frontend keeps polling while a notification is `launching`. ([#5908]) - **subagents:** Cancelled or timed-out sub-agent executions keep token usage already reported by completed model calls. A model can finish and report usage while model middleware is still awaiting, before the graph publishes its next `values` frame; cancellation in that window bypassed the result-handling branches, so the outer timeout/cancellation wrapper finalized the result from the older progress snapshot — two completed responses costing 15 and 30 tokens reported only 15. After stream cleanup drains, the executor now publishes the token collector's final cumulative snapshot before the terminal status is chosen; first-terminal-write semantics, cancellation propagation, and parent-journal deduplication are unchanged. ([#5910]) - **paths:** IDs with a trailing newline are now rejected by the path isolation validators. Python's `$` also matches just before a final newline, and `_validate_user_id`, `_validate_project_id`, and `_validate_integration_id` used `.match()`, so a caller-supplied ID like `"alice\n"` passed the documented restricted-charset check and produced a filesystem bucket the strict grammar never intended. All three now require a `fullmatch`, so only documented characters through end-of-string are accepted. ([#5911]) - **uploads:** An upload filename containing a NUL character no longer fails the request with a 500. Normalization let the embedded NUL through to `os.lstat()`, which raised an unhandled `ValueError` (`lstat: embedded null character in path`) and aborted the upload. `normalize_filename` now rejects such names up front; in a multi-file request the invalid file is skipped and the remaining valid files are still stored. ([#5915]) - **goal:** The agent now sees the active `/goal` on every model call. `ThreadState.goal` previously reached only the goal evaluator, so a goal set through `PUT /goal`, `DeerFlowClient.set_goal()` or the TUI was ignored, and compaction could take the goal away with the message that stated it. `DurableContextMiddleware` now opens its hidden `` message with an `` element — HTML-escaped, capped at 4,000 characters like the goal routes, and PII-redacted when redaction is on — and the durable-context contract gains a static exception letting the agent work toward that element at user priority, with no system or developer authority. `active_goal` joins the input sanitizer's tag denylist, so user input and remote results cannot forge it; subagents, whose state carries no goal, are unaffected. ([#5917]) - **frontend:** A fence line with an info string (`` ```python ``) no longer closes the open code block. CommonMark requires a closing fence to be at least as long as the opening one and to carry no info string, but of the three fence scanners only the Mermaid normalizer checked both halves — the render path (`stripLeakedSystemTags`) and the export path (`stripInternalMarkers`) compared just fence character and length, so the block ended early and the rest was treated as prose: render stripped the system-marker tags inside sample code and exposed their content, and thread export deleted the whole ``/`` span, losing user text. Both scanners now share the `isClosingFence` predicate that moved into `core/streamdown/fences.ts`. ([#5919]) - **skills:** Skill discovery no longer rescans the same tree through symlink cycles. `load_skills()` followed directory links but never detected a link back to an ancestor, so two cyclic links in a namespace repeatedly expanded the same tree — real scans exceeded a three-second diagnostic deadline where ordinary external links completed in under a millisecond. A shared `walk_skill_directories` helper now prunes a directory whose resolved path is already on the current traversal branch, across local and user-scoped public/custom/integration/legacy discovery; independent aliases of an external tree remain discoverable. ([#5921]) - **backend:** asyncio subprocesses work again on native Windows. Since #3236, a `backend/sitecustomize.py` switched every Python process with `backend/` on `sys.path` to `WindowsSelectorEventLoopPolicy`, whose loop cannot spawn subprocesses: the `invoke_acp_agent` tool failed instantly with an empty error (a bare `NotImplementedError`), and stdio MCP servers and local-sandbox command execution hit the same wall. The policy's stated premise (async psycopg) no longer holds — the postgres driver is asyncpg, which ships no Windows wheels, and the default SQLite backend runs on either loop. The file is deleted, restoring Python's default Proactor loop on Windows; the postgres benchmark script now sets the selector policy only for its own postgres cases. ([#5937]) - **sandbox:** Non-finite OpenSandbox timeouts are rejected at provider construction instead of failing the first sandbox acquire. These options are provider-specific extras, so YAML `.nan` and `.inf` survived `SandboxConfig` validation and the provider's sign-only checks accepted them, deferring the failure to SDK or `timedelta` conversion at acquire time with a low-level runtime error. `request_timeout` and `ready_timeout` must now be finite positive numbers and `sandbox_timeout` finite non-negative (`0` still means explicit cleanup); `config.example.yaml` documents the requirement. ([#5944]) - **subagents:** Context snapshots no longer drop Anthropic-native `document` blocks. With `context_mode="snapshot"` delegation, `document` was missing from the recognized media types, so the block was silently discarded and a child could lose a requirement the parent had. Serializable documents are now kept as historical user-role data, with the existing reserved-tag and user-input neutralization applied at capture time to their textual fields (`title`, `context`, text-source `data`, content-source text blocks, and citation prose such as `cited_text`), `cache_control` removed, and nested copies isolated from the parent. Neutralization runs at capture because hidden snapshot messages skip the input-sanitization middleware; an unserializable document falls back to the existing historical-media omission notice. ([#5948]) - **sandbox:** The middleware path that applies a network-approval response now drains across cancellation. `SandboxMiddleware.abefore_agent()` offloaded `_apply_network_policy_response()` with bare `asyncio.to_thread()`, so cancelling the awaiting agent task could leave the middleware boundary while a started `decide_network_policy_request()` worker was still mutating sandbox network-policy state — bypassing the provider wrapper that #5899 already made cancellation-safe. The async bridge now routes through `run_sync_lifecycle_operation()`; the synchronous `before_agent()` path is unchanged. ([#5949]) - **runtime:** Subagent step-event flushes now survive host cancellation. A flushed batch was removed from `_pending` before `RunEventStore.put_batch()` was awaited, and only ordinary failures were re-buffered — `CancelledError` bypassed the `except Exception` fence, so cancellation could unwind the flush while persistence was in flight and a later store failure silently lost the batch. Each admitted write now runs in its own shielded task and absorbs repeated cancellation; if the drained write fails, the batch is re-buffered before the original `CancelledError` propagates, and ordinary store failures still log and re-buffer. ([#5950]) - **context:** Two distinct `task_note` additions in one model response can no longer silently delete an existing note. Each call read the same state snapshot and merged its own update, so with seven existing notes the merged result kept only the last eight keys while both calls still returned `saved`. Remaining slots are now reserved for distinct new keys in the batch's tool-call order: excess additions return `note_capacity` before emitting a state update, existing notes are preserved, repeated writes to one new key share a slot, and structurally invalid sibling calls do not consume capacity. Slots freed by sibling deletions or failed calls become available in the next batch. For deployments with `task_continuity.enabled`, this changes the documented last-eight retention behavior for parallel overflow. ([#5954]) - **mcp:** Deleting a thread now also tears down its persistent MCP sessions. `DELETE /api/threads/{id}` removed checkpoints, runs, events, feedback, and the live browser session but never the thread's persistent stdio MCP sessions — `close_scope` had no production caller — so a stateful server such as Playwright kept server-side state a recreated thread with the same id could inherit (stale pages, cookies), while the leaked owner task and its subprocess held memory and file descriptors. Deletion now calls the new `MCPSessionPool.close_thread_scope()` best-effort after the browser-session step, matching the legacy scope and every incarnation of that user/thread (established entries and in-flight creations) while leaving other threads untouched; a failing teardown still deletes the thread. ([#5956]) - **tools:** Community search tools no longer fail on an infinite `max_results`. The coercion guards caught only `TypeError` and `ValueError`, but `int(float("inf"))` raises `OverflowError`, and both `yaml.safe_load` (`.inf` is a legal YAML float) and `json.loads` (accepts `Infinity`) can hand the guard an infinite value — instead of falling back to the default, the coercion blew up and SearXNG's `web_search_tool` returned an error payload for every search. The guards in brave, serply, serper, searxng, groundroute, and sofya (`max_results`, plus Sofya's content limit) now also catch `OverflowError`, so an infinite value falls back to the default like any other invalid input. ([#5957]) - **models:** Whitespace-only Codex CLI credentials are now rejected at model initialization instead of producing an invalid `Authorization` header. The loader accepted any string token, so an `auth.json` with `"access_token": " "` constructed `CodexChatModel` successfully and the misconfiguration only surfaced when a provider request failed. String tokens are trimmed before the existing type and empty checks, blank or whitespace-only values count as missing credentials (the existing "Codex CLI credential not found" error), and a valid token keeps working with surrounding whitespace normalized away. ([#5959]) - **agents:** An agent name differing only by a trailing newline is now rejected with 422 instead of a 500. The Gateway router validated `AGENT_NAME_PATTERN` with `.match()`, and Python's `$` matches before a final newline, so `POST /api/agents` with `{"name": "reviewer\n"}` passed the router and crashed in `FileAgentStore.create()`'s `fullmatch` — the actionable message stayed in the server log while the caller got an internal error. DeerMem's `validate_agent_name` accepted the same names, letting `agent_facts_directory()` build a bucket its own validator then refuses to read back. Both validators now use `fullmatch`; nothing previously valid changes. ([#5960]) - **gateway:** Gateway shutdown now closes pooled MCP sessions instead of abandoning them. The lifespan teardown closed the browser session manager but never `MCPSessionPool`, although a pooled session's owner task is the only thing that runs the transport's `__aexit__` — once the event loop stopped, stdio MCP subprocesses and SSE/HTTP connections got no orderly close at all. Lifespan shutdown now awaits `MCPSessionPool.close_all()` immediately before the browser-session step, bounded by the shared shutdown-hook timeout and best-effort: a timeout or failure is logged and the remaining teardown proceeds. ([#5961]) - **tui:** The headless `deerflow --print` and `--json` runners no longer leak a Python traceback when a run fails. Session creation, thread resolution, and stream iteration ran without an error boundary, so any raised exception — including the documented `CheckpointModeMismatchError` — escaped `main()` with no stable non-zero exit code. `--print` now prints one `Error: ` line to stderr and exits 1; `--json` appends a terminal `{"type": "error", "data": {"message": ...}}` record — every stdout line stays valid NDJSON even on a mid-stream failure — and exits 1. `KeyboardInterrupt` and `SystemExit` still propagate. ([#5963]) - **mcp:** MCP task notification launches are now reserved atomically before an Agent run is created. The worker used to launch the run before persisting any durable launch phase, so if ownership expired or was reclaimed during that side effect, another worker could launch the same notification concurrently. The reservation is a single conditional `UPDATE` (`begin_notification_launch`) fenced by lease owner, per-claim token, lease expiry, and dispatch version; a stale unstarted snapshot facing a newer event is rejected before any launch side effect, while a recovered `launching` row keeps its original event and idempotency key. Deterministic launch rejections (400, 401, 403, 404, a bare 409, 422, 501) dead-letter immediately, and ambiguous 408/429/500 outcomes or cancellation preserve the `launching` snapshot for reconciliation without consuming the retry budget. No schema migration. ([#5965]) - **mcp:** MCP connections created in the web editor now belong to the signed-in user instead of the deployment. The editor wrote deployment-wide configuration, so a connection added with a user's credentials became a shared Agent capability while ordinary users could not maintain their own. New owner-only CRUD lives at `/api/mcp/personal/config`, persisted under `.deer-flow/users//integrations/mcp.json`; the capability center shows deployment guides and shared connections under "Platform provided" and personal ones under "My plugins"; assembly combines shared tools with only the current user's connections (personal discovery stays outside the global tool cache), and durable tasks record the owning scope so submit, poll, and cancel keep it even when a deployment server name collides with a personal runtime name. Personal HTTP/SSE endpoints may connect only to public-network-vetted addresses with the original Host, TLS SNI, and certificate verification preserved, and privileged personal definitions recheck current administrator authority at discovery, invocation, and durable submit/poll/cancel (demotion fails closed). `config.yaml` tools and existing `extensions_config.json` entries remain shared. ([#5966]) - **middleware:** PII redaction no longer empties `response_metadata` on the user messages it rewrites. The middleware hand-built the redacted `HumanMessage` from content/id/name/`additional_kwargs`, so a message carrying gateway provenance or other metadata reached the model with that field silently reset whenever redaction changed its content; the redaction itself was already correct. The rebuilt message now copies the original and replaces only `content` (deep-copied, since untouched blocks are retained by reference) and `response_metadata`, keeping `id`, `name`, and the message subclass without sharing mutable state with the original. Placeholders and redaction behavior are unchanged. ([#5968]) - **agents:** Caller-attached `response_metadata` on the user turn now survives dynamic-context injection. `DynamicContextMiddleware` rebuilt the injected copy as a fresh `HumanMessage` carrying only `content`/`id`/`name`/`additional_kwargs`, silently dropping every other field — including `response_metadata` — from the model-bound message list, while the sibling PII-redaction and thread-data middlewares already used `model_copy`. The copy is now built with `original.model_copy(update={"id": ...})`, keeping the documented `{id}__user` id swap; `UploadsMiddleware`'s user-message rewrite gets the same treatment. ([#5977]) - **persistence:** A literal `+` in a PostgreSQL DSN query string survives schema injection. When `database.postgres_schema` is set, `dsn_with_search_path()` round-tripped existing parameters through `parse_qsl`/`urlencode`, and `parse_qsl` decodes `+` as a space — an HTML-form rule libpq does not apply — so `application_name=web+app` reached the server as `web app`, and `options=-c timezone=UTC+8` decayed into `-c timezone=UTC 8`, no longer one `-c` setting. The helper now carries existing parameters through byte-for-byte and only appends (or merges into) a percent-encoded `options=-c search_path=`; keyword-form DSNs and `postgresql+asyncpg://` normalization are unchanged. ([#5978]) - **goal:** An active goal now survives a failed artifact delivery. A run could write and present an output, receive a satisfied goal evaluation, and delete the goal — and only afterwards would delivery verification mark the same run as an error (a terminal delivery-receipt write failure could produce the same inconsistency), leaving the thread without its goal despite unfinished work. A satisfied evaluation is now only a completion candidate: the worker clears the goal after delivery, receipt persistence and terminal cancellation arbitration succeed and successful status persistence is confirmed, re-checking the goal, the visible conversation and the checkpoint under the existing checkpoint-write reservation so a newer admitted run wins. Any failure retains the original goal and counters without an extra model call; normal completion, including runs without changed outputs, still clears the goal. ([#5980]) - **config:** `extensions_config.json` saved as UTF-8 with a byte-order mark now loads. Both the runtime loader (`ExtensionsConfig.from_file`) and the raw read-modify-write reader behind Gateway skill toggles opened the file as plain UTF-8, so an editor-written BOM failed with `Unexpected UTF-8 BOM (decode using utf-8-sig)` — blocking MCP/skill configuration loading and preventing a toggle from saving an otherwise valid configuration. Both readers now accept an optional leading BOM; writes remain UTF-8 without one and raw `$VAR` references are still preserved. ([#5983]) - **agents:** A non-finite `read_file` line bound no longer crashes the run inside loop detection. A model-emitted number such as `start_line: 1e999` parses to `float("inf")`, and `_coerce_line_number()`'s `int()` conversion raised an uncaught `OverflowError` on the `after_model` path, ending the run with an opaque internal error instead of letting `read_file`'s own schema validation return a normal tool error. The coercer now treats `OverflowError` like its other unusable inputs: the bound falls back to `None` and the call keys as the existing open-ended read; integral bounds behave exactly as before. ([#5990]) - **integrations:** Completing a Lark authorization no longer flips the integration panel's runtime card to "not ready" in remote provisioner mode. The mutation response verified authorization but never probed sandbox runtime readiness, and the frontend cached it wholesale, so the panel showed a state a page refresh would contradict. Install, configuration completion, credential switching, and authorization completion now share one mutation-status builder probing runtime readiness consistently with the `GET /lark/status` route, built after the per-user credential lock is released (the probe previously ran while holding that lock and could block other credential operations for a network timeout). The status API exposes `sandbox_runtime_probed`, letting the frontend keep previously confirmed runtime fields only for unprobed mutation responses. ([#5994]) - **channels:** Feishu and DingTalk inbound attachments are now readable by the sandbox. In AIO/Docker sandbox mode the sandbox runs as a non-root user over the bind-mounted thread directory, and these two channels persist inbound files themselves at `0o600` — the agent got a `/mnt/user-data/uploads/` path that read as empty, so attachments from these channels were silently unusable. Both now grant group/other read right after their own write, matching the channel manager and HTTP upload paths (Telegram was already covered by #5581). Local sandboxes are unaffected. ([#5998]) - **database:** `DatabaseConfig` now validates `pool_size`, `pool_recycle`, and `command_timeout` strictly. YAML booleans were silently coerced to `1`/`0`, so `pool_size: true` limited the pool to a single connection and `command_timeout: true` imposed a 1-second statement timeout; `pool_size` also accepted non-positive values (`0`, `-1`), and `command_timeout` accepted `inf` (via YAML `.inf` or `"1e999"`), which never times out. `pool_size` and `pool_recycle` now enforce positive integers, and `command_timeout` rejects booleans and non-finite floats while `null` still disables the timeout. ([#6009]) - **community:** The Firecrawl tools now close the pooled async HTTP client each call constructs. Since the async migration, `_get_firecrawl_client()` builds a fresh `AsyncFirecrawlApp` per call, and that app eagerly creates an `httpx.AsyncClient`-backed pool with no public teardown, so every `web_search_tool` / `web_fetch_tool` call abandoned one unclosed pool. Both tools close it in a `finally` through a best-effort `_aclose_firecrawl_client` helper: a client without the internal `_v2_client.async_http_client` handle degrades to a no-op, and close failures are logged without masking the tool's own result or error. ([#6013]) - **capabilities:** Administrators can manage shared MCP servers again from the Capability Center. After #5966 the Platform provided section rendered the same read-only projection for admins and ordinary users, so add, edit, enable/disable and delete were impossible even though the admin-only `/api/mcp/config` endpoints still supported them. Administrators (outside static mode) now get the MCP editor under Platform provided, writing the shared deployment configuration that applies to all users; ordinary users keep the read-only view, and My plugins continues to manage personal configuration. MCP queries distinguish both scope and user ID, and control accessible names carry the localized ownership section, so same-named shared and personal connections are distinguishable. ([#6018]) - **gateway:** Drain managed subagent store mutations across request cancellation. The create/update/delete endpoints of `/api/subagents` offloaded persistent store writes with bare `asyncio.to_thread()`: cancelling the request stopped only the awaiter, so an already-started file/SQL store mutation could keep committing after the request lifecycle had unwound. All three writes now route through the existing `await_drained()` helper (wrapped as one `_run_store_mutation` helper), so an admitted mutation reaches terminal state before the caller's cancellation propagates; catalog reads and validation keep their existing cancellation semantics. ([#6023]) - **gateway:** Drain the admin-only managed-model save across request cancellation. `PUT /api/managed-models` persisted the shared encrypted model catalog through bare `asyncio.to_thread(_save, body)`: cancelling the request stopped only the awaiter, so an already-started `ManagedModelStore.save()` could keep committing catalog state after the request lifecycle had unwound. The save now drains through the existing `await_drained()` helper, and a worker failure after cancellation is logged with the exception type only before the consumed result discards it — storage errors can carry credentials. Catalog reads and connection probes keep their existing cancellation semantics. ([#6024]) - **config:** Numeric config fields that accepted silently-broken values now carry range bounds and boolean rejection, mirroring the earlier stream-bridge and database-pool guards. `sandbox.port` is bounded to 1-65535 instead of surfacing as an opaque Docker bind error; `circuit_breaker.failure_threshold` requires at least 1 (a threshold of 0 tripped on the check itself); `summarization.trim_tokens_to_summarize` requires at least 1 instead of `max(1, ...)` quietly making 1-token summaries; `mcp_servers.*.tool_call_timeout`/`session_init_timeout`, `models.*.stream_chunk_timeout`, and `run_events.max_trace_content` reject `0`/`inf` values that aborted every call or stream or truncated traces to nothing; `mcp_servers.*.oauth.refresh_skew_seconds` rejects negatives. YAML `true` no longer coerces to `1` on any of them; documented-valid values such as `sandbox.idle_timeout: 0` and `null` timeouts are unchanged. ([#6026]) - **frontend:** On Windows, `pnpm dev` now binds `127.0.0.1` by default. The dev server otherwise listens on `0.0.0.0`, and when Hyper-V/WSL2/winnat reserved the port range covering 3000, startup failed with `Error: listen EACCES: permission denied 0.0.0.0:3000` while a loopback bind on the same port succeeded. An explicit `-H`/`--hostname` is passed through, so LAN access remains available via `pnpm dev -- --hostname 0.0.0.0` (with `DEER_FLOW_DEV_ALLOWED_ORIGINS` as before). The default `make dev` flow is unaffected — the dev nginx upstream already targets `127.0.0.1:3000` — and macOS/Linux behavior is unchanged. ([#6034]) - **gateway:** The bundled-interpreter repair hint no longer shows a backslash-doubled interpreter path on Windows. The HTTP 400 detail for a bundled MCP server pointing at a different Python interpreter interpolated the path with `{sys.executable!r}`, and `repr()` escapes backslashes, so the hint read `'D:\\...\\python.exe'` — wrong to paste into a terminal and noisy in the JSON it tells you to edit. The detail now renders the path as a JSON string for the server config and prints the raw path alongside for terminal use; POSIX paths only change in where the quotes come from. ([#6040]) - **channels:** Inbound Slack text now decodes the three entities Slack always escapes, so `if a < b && c > d: print("R&D")` reaches the agent as typed instead of `if a < b && c > d: print("R&D")`. `<`, `>` and `&` are decoded after bot-mention stripping — a real mention is a raw `<@U…>` while a user-typed `<@U…>` arrives escaped and stays plain text — with `&` decoded last so an escaped entity is decoded exactly once. Previously the corrupted text drove the agent's reasoning, could leak `<` into files it wrote, and was escaped a second time when quoted back into Slack. Outbound escaping is unchanged. ([#6045]) - **sandbox:** Glob patterns with a directory prefix are now root-relative, and `**` is recursive. `path_matches` — behind the `glob` tool and the `grep` `glob=` filter on every provider — used `PurePosixPath.match`, which on Python 3.12+ matches from the right and treats `**` as a single-segment `*`: `src/**/*.py` missed `src/top.py` and deeper files, while `docs/*.md` also matched `vendor/docs/b.md`. Patterns containing `/` now match segment by segment from the search root, with `**` spanning zero or more directories; basename-only patterns still match at any depth. ([#6046]) - **cli:** `deerflow --print` and `--json` now exit 1 when a run ends in an LLM error fallback. Provider failures are not raised — `LLMErrorHandlingMiddleware` turns them into an AI message flagged `deerflow_error_fallback` — so a headless run with, say, an expired credential printed the fallback text and exited 0, and the calling script treated the run as a success. Both modes now follow the raised-exception failure contract: `--print` still writes the fallback text to stdout but adds `Error: LLM request failed (error_type=…, error_reason=…)` on stderr; `--json` appends a terminal error record after the streamed events. A mid-run fallback followed by a real final answer still exits 0. ([#6056]) - **frontend:** The run duration now labels the reasoning disclosure instead of sitting in a separate footer row. A completed answer showed a generic reasoning header above the answer and a duration line under the hover-only action toolbar, which left an unexplained gap whenever the toolbar was hidden. The disclosure now reads `Took 31s` (`用时 31 秒`) with a compact chevron, stays expandable by mouse and keyboard with a localized visually hidden reasoning cue in its accessible name, and is bound to the owning run in answer bubbles, processing groups, and subagent groups; the separate duration footer is gone. Streaming and history entries without a duration keep the generic label, restored reasoning starts collapsed, and live reasoning still closes one second after completion. ([#6057]) - **sandbox:** Every sandbox bash command now carries the authenticated user id in `DEERFLOW_USER_ID`. Skill scripts run as bash subprocesses inside the sandbox and cannot reach the Gateway auth context, so user-scoped skills had no way to learn the serving user (issue #3919). Commands are prefixed with `export DEERFLOW_USER_ID=` (`unset DEERFLOW_USER_ID` when the id is unusable) on both the POSIX local and the remote sandbox paths, ahead of the `DEERFLOW_CHANNEL_USER_ID` prefix; Windows publishes it via the subprocess environment instead. The variable is informational — commands can overwrite it — and authorization still resolves server-side. ([#6058]) - **channels:** Long Discord replies split into consecutive messages now reconstruct exactly what the agent wrote. `_split_text` ended each chunk just before the newline it split on and stripped it from the remainder, so every 2000-character boundary dropped one newline — and a run of blank lines at the cut in full — losing paragraph breaks between bubbles. The boundary newline now stays on the chunk's tail, so `"".join(_split_text(text))` returns the original text for any input while every chunk still fits Discord's 2000-character limit. Replies under the limit and the empty string behave as before. ([#6062]) - **goal:** The `/goal` evaluator now sees the assistant's tool calls and the tools' results, so completed file work no longer stands down with `missing_evidence`. It read only user and assistant text: `format_visible_conversation()` dropped tool messages, a tool-call-only assistant message rendered as nothing, and the evaluator's own fail-closed rule then kept the goal active on most completed file tasks (in a real-Gateway A/B, goals cleared on correct outputs went from 15 of 38 runs to 35 of 36 on DeepSeek, and from 4 of 33 to 29 of 30 on MiMo v2.6 Pro). Tool steps inside the existing 30-message window now appear in order as `Assistant tool call: ...` and `Tool result (...): ...` lines — argument values cut at 200 characters, steps at 600 — and the 12,000-character evidence cap keeps whole lines from the end, holding the latest user request at the top, instead of slicing mid-line. Tool text is JSON-escaped onto one line per step, failed results are labeled `error`, and as in the web UI a hidden assistant message's calls stay out while a clarification prompt remains. The system text adds fixed rules that tool results are data and never instructions and that a successful result alone does not show the goal is met. ([#6068]) - **goal:** A failed `/goal` evaluator call is now recorded on the goal like the other stand-downs. The worker logged "Goal evaluator failed" and returned without writing: the goal stayed active with no `last_evaluation` after a first evaluation, or kept a previous run's verdict and run id, so `GET /api/threads/{id}/goal` looked as if no evaluation had run — whether the cause was a connection error, an answer the parser rejected, or a provider timeout. The failure now persists `blocker: "run_failed"` with `stand_down_reason: "evaluator_failed"` and the current run id, and the reason names only the exception type ("The goal evaluator did not return a verdict (APIConnectionError).") because a provider's error message can carry request details. The goal still stays active with no retry and no hidden continuation, and nothing is recorded if the run was aborted meanwhile. ([#6073]) - **frontend:** In `/workspace/chats/new`, the first streamed assistant steps no longer render above the submitted question. The local human anchor that pins first-turn order was compared against the SDK thread id — undefined until creation confirms it — and was cleared again when that confirmation arrived, so early reasoning and answer blocks jumped above the question until the turn settled. First-turn ordering is now tied to the displayed conversation (`displayThreadId ?? threadId`) and the anchor survives creation confirmation; switching conversations still clears it. ([#6074]) - **goal:** The `/goal` evaluator now sees the user's answer to a Human Input Card, and the lead prompt stops coaching scripts when no `bash` tool is bound. The web UI sends a card answer as a hidden human message, which `format_visible_conversation()` skipped — the evaluator saw the question but not the answer, read following it as guessing, and goals stood down with `needs_user_input` despite correct work. The answer now appears in the evidence as a `User (Human Input Card answer): ...` line, whitespace-collapsed and cut at 2,000 characters, kept at the top over the cap. Separately, the default local sandbox has host bash off, but the system prompt still explained how to write scripts — in measured runs each model wrote a helper script nothing could run in 38 of 120 runs. `apply_prompt_template()` gains `bash_available`, the lead agent and embedded client pass `has_bash_tool(authorized_tools)`, and without bash the script guidance becomes one line telling the model to work out results directly and write them with `write_file` while the subagent section drops its `bash` examples. With bash bound, the prompt is unchanged. ([#6082]) - **gateway:** Drain the agents router's persistent writes across request cancellation. `POST /api/agents`, `PUT /api/agents/{name}`, `DELETE /api/agents/{name}` and `PUT /api/user-profile` ran their store writes through bare `asyncio.to_thread`: a disconnect that cancelled the handler either cancelled a still-queued worker — the write silently never happened — or detached from a running one, silently dropping its failure because the handler's `except` (500 plus error log) never ran. All four writes now go through a `_drained_write` helper built on the existing `await_drained()` helper, so an admitted worker finishes before the caller's cancellation propagates; a lost failure is logged with the exception type only, and expected domain errors such as `AgentExistsError` still reach the connected caller as its 409. Reads stay cancellable: abandoning them loses nothing. ([#6087]) - **frontend:** APNG, AVIF, and WebM artifacts now show media icons in the workspace artifact list and viewer instead of the generic document icon. All three formats are previewable in the browser, but `getFileIcon` had no case for their extensions: `apng` and `avif` now map to the image icon and `webm` to the video icon, matching the existing preview support. Unknown extensions keep the document fallback. ([#6089]) - **frontend:** Saved `Dockerfile` and `Makefile` artifacts now open as editable source instead of falling back to the unsupported-file download view, even though both names were already in the language map. `getFileExtension` split the full path on `.` instead of taking the basename first, so an extensionless name — or one under a parent directory that contains a dot — never resolved to its map entry. Detection now runs on the basename; existing suffix and hidden-file mappings are unchanged, and streaming write-file previews keep their own text fallback. ([#6091]) - **mcp:** Drain personal MCP configuration writes across caller cancellation. Personal config writes ran the durable file mutation through bare `asyncio.to_thread(_mutate, ...)`, so a request cancelled after the worker started could unwind while the admitted persistent mutation kept running without request-side lifecycle ownership. The mutation is now drained through the cancellation-safe `await_drained()` helper: the route stays alive until the started write settles, then the original `CancelledError` propagates. The read-only path keeps its existing cancellation semantics. ([#6093]) - **sandbox:** The local container backend pins `encoding="utf-8"` and `errors="replace"` on every text-mode docker/container-runtime subprocess call. On hosts whose locale is not UTF-8 (e.g. cp936 Chinese Windows), platform-default decoding previously lost entire output streams — `stdout`/`stderr` came back `None` — crashing JSON-parsing call sites or silently returning empty results. ([#5905]) - **uploads:** Cancelling an upload now waits for an already-running document conversion worker to finish before removing its temporary source file, so cleanup can no longer delete a file the converter is still reading. Cancellation can therefore take as long as the conversion; it is still propagated and no Markdown is written for the cancelled conversion. ([#5999]) - **agents:** The read-before-write block message now includes the existing file's line count and a concrete `read_file` range hint instead of a vague "read the last ~30 lines", and an inverted range returns `(invalid line range: start_line must be <= end_line)` rather than the misleading "no lines in range" wording that models misread as a short file. In-band no-content `read_file` results no longer stamp a read mark that would open the write gate. ([#6020]) - **frontend:** Artifact editing now requires complete file content: a truncated byte-range preview no longer becomes the editing baseline (it can carry the same ETag as the full file), fixing reverted edits that stayed "unsaved" and deleted tails that silently returned. Existing drafts survive partial reloads, and Exit editing stays available while a reload is loading, truncated, or failing. ([#6036]) - **models:** The vLLM provider falls back to the legacy `reasoning_content` wire field when `reasoning` is absent or null on non-streaming responses, streaming deltas, and follow-up tool-call turns, so reasoning from older vLLM-compatible endpoints is preserved; a payload carrying both fields keeps `reasoning`. ([#6048]) - **discord:** When `discord.py` is missing, the Discord channel's startup error now points at the optional dependency extra (`uv sync --extra discord`) instead of `uv add discord.py`, which would rewrite `pyproject.toml` in a tree that startups re-sync with `uv sync --locked`. ([#6054]) - **telegram:** Outbound Telegram messages are now split (and streaming previews truncated) by UTF-16 code units — the unit Telegram measures its 4096-character limit in — instead of Python code points. Emoji-heavy replies previously passed the code-point check yet exceeded the real limit, got a deterministic 400 "Message is too long", and were dropped after retries; a non-BMP character is also never split across two chunks now. ([#6067]) - **skills:** Custom-skill mutations (edit, delete, rollback, install, and enable/disable) now drain their persistence tail across request cancellation: the write, its history entry, and the skills prompt-cache refresh settle together before cancellation propagates, so a cancelled request can no longer leave a skill file without a matching history record or a stale prompt cache. ([#6078]) - **subagents:** Tool results from `return_direct=True` tools (including tools contributed by extension middleware) now reach the caller instead of being lost to the preceding assistant message: direct-return outputs are returned in tool-call order, and a failed direct tool marks the task failed through the existing `subagent_error` contract while preserving the batch outputs, including successful siblings. ([#6083]) - **memory:** Memory mutation endpoints (clear, fact create/update/delete, and import) now drain their persistence write across request cancellation via `await_drained`, so an admitted write settles before cancellation propagates instead of the request unwinding while the storage worker is still running. ([#6092]) - **paths:** Creating host-visible paths now rejects Windows reserved device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`) and segments ending with a space or dot, on every OS, across upload filenames, local sandbox tool and bash paths, and custom-skill support paths. Existing files stored under such a name remain readable and editable. ([#6102]) - **frontend:** Web-fetch result titles no longer show trailing closing Markdown hashes: `# Release Notes ###` now labels the workspace link "Release Notes". Literal hashes are preserved (`C#`, escaped hashes, hashes followed by non-ASCII whitespace), and extraction uses a backward suffix scan to avoid quadratic backtracking on long titles. ([#6103]) - **agents:** Externalized tool outputs are now published atomically: content is written to a sibling temp file and renamed into place, so a write that fails part-way (a full disk, an interrupted request) no longer leaves a truncated file under the final name. Externalized filenames now derive from the sanitized tool call id instead of a random suffix, so host and sandbox paths agree on one name per call and re-externalizing the same output no longer leaves two files behind. ([#6109]) - **auth:** OIDC login no longer fails with a 500 when the provider's ID token carries a non-ASCII or non-string `nonce` claim. Such claims are rejected through the normal validation path, so the callback redirects to `sso_failed` as with any other nonce mismatch; a matching non-ASCII nonce is still accepted byte-for-byte. ([#6115]) - **sandbox:** In Lark broker mode, fresh AIO Bash runs close inherited pipe stdin, so commands that read default input get immediate EOF instead of hanging; persistent Shell commands are untouched, and explicit pipelines, heredocs, and file redirections still supply input. The broker shim forwards pipe/file stdin only after EOF and aborts with exit 124 without executing when input idles past its window (`DEERFLOW_LARK_BROKER_STDIN_GRACE_SECONDS` / `DEERFLOW_LARK_BROKER_STDIN_TAIL_SECONDS`, default 2 seconds); broker logs record only argument count, exit code, and elapsed time, never argument values. ([#6117]) - **uploads:** Filenames matching the reserved staging pattern `.upload-*.part` are rejected with a 400 and a rename hint before any file in the batch is written or a sandbox acquired, so a reserved name can no longer leave a partial upload that collides with temporary staging files. The embedded SDK validates the whole batch before copying (`ValueError`), and project shelf documents follow the same restriction; attaching a legacy shelf document with a reserved name now returns 400 with a rename hint instead of 500. **Behavior change:** filenames that previously uploaded fine now require a rename, and older thread uploads matching the pattern are not migrated. ([#6122]) - **frontend:** A re-persisted message keeps its original position in the thread. Deduplicating a history row re-persisted under a later journal sequence used to push the message towards the tail; the surviving copy is now re-anchored to the earliest sequence its identity held, preferring the earliest visible copy so a hidden control row cannot move its visible twin. Content still converges to the newest copy. ([#6128]) - **frontend:** `.jl` files resolve to the Julia language again. The code-file extension map keyed Julia by the language name instead of the `jl` extension, so Julia files missed code-file detection and the editor received the extension `jl` as the language rather than `julia`; the map now carries both `jl` and `julia` keys pointing at `julia`. ([#6130]) - **persistence:** `RunEventStore.list_messages` honors both cursors at once: with `after_seq` and `before_seq` supplied, the memory, JSONL, and database backends now apply both exclusive bounds before `limit` and return the first messages of that window in ascending order. The JSONL backend previously ignored `after_seq` whenever `before_seq` was set and returned the tail of the window; keep `before_seq` fixed and advance `after_seq` to page forward through a bounded history range. ([#6136]) - **paths:** Uploads, new skill support files, and new local sandbox paths reject Windows superscript device names on every platform — Windows treats `COM¹`/`LPT¹` through `COM³`/`LPT³`, including extensions such as `com².txt`, as reserved devices, so files created under those names were unusable on Windows. Ordinary names like `COM⁴.txt` or `COM¹notes.txt` remain allowed, and read/removal paths keep their existing exemptions. ([#6148]) - **uploads:** The reserved staging-name check now also rejects Win32 aliases: names that become `.upload-*.part` after trimming trailing dots and spaces or case-folding — such as `.upload-notes.part.`, `.upload-notes.part `, or `.UPLOAD-NOTES.PART` — are refused with a 400 before the batch touches disk, since Windows would otherwise open them as the staging file itself. New project shelf names apply the same check on upload, promotion, and attach; legacy alias files already on disk stay visible and are not swept. ([#6149]) - **agents:** Concurrent externalizations of the same tool output no longer share one temporary file. Each writer creates a unique sibling `.tool-output-*.tmp` with exclusive creation and publishes it by atomic rename after close, so racing writers cannot truncate or delete each other's pending output and the last successful publisher wins with complete bytes. Published files keep the Gateway's normal umask-derived permissions, so mounted sandboxes running under another UID keep read access; an unclean shutdown can still leave `.tool-output-*.tmp` leftovers, which have no automatic sweeper. ([#6150]) - **frontend:** Artifact files named `constructor`, `__proto__`, or dotted forms like `data.constructor` are no longer misclassified as code files: language detection now checks only the language map's own entries instead of its inherited properties, so these filenames keep the existing download fallback rather than failing to open an editor. ([#6152]) - **models:** The Codex provider returns the completed response as soon as the `response.completed` SSE event arrives instead of reading until the connection closes, so a stream the server keeps open can no longer turn a finished answer into a read-timeout failure. `response.failed`, `response.incomplete`, and `error` events now raise with the provider's error code/message or incomplete reason, and partial output is never returned as a successful answer. ([#6155]) - **nginx:** Opening the stack at `http://127.0.0.1:2026` or `http://[::1]:2026` now redirects (301, safe methods only) to the same URL on `localhost`, so the login session created at one loopback spelling is visible at the other. Browsers scope cookies per host, which previously split the session across the two origins. API calls and WebSocket handshakes pass through untouched, and non-loopback hostnames never match. ([#6158]) - **config:** The shipped Gemini example for Google's official OpenAI-compatible endpoint now uses the standard reasoning contract (`thinking: required`, `dialect: none`, supported `reasoning_effort` values) on `gemini-3.1-pro-preview`, instead of a top-level `thinking` field that the endpoint rejects with HTTP 400 `Unknown name "thinking"`. Migration notes cover profiles copied from the previous example. ([#6161]) - **sandbox:** The Tenki cloud sandbox provider now requires SDK 1.4.0+, which can return `timed_out=True` together with exit code zero. Timed-out commands keep their partial output and report `Error: command timed out` with `Exit Code: 124` instead of appearing to succeed; they are not retried and the sandbox stays usable. Warm-pool health probes reject timed-out or failed probes even when the output contains an `ok` line. ([#6162]) - **uploads:** Converted upload outlines and previews now validate the source's modification timestamps in addition to device, inode, and size, so rewriting an uploaded document in place with different bytes of the same length no longer serves an outline describing the old content. **Behavior change:** legacy ownership records without source timestamps are rejected — re-upload the source with `uploads.auto_convert_documents: true` to restore conversion; files stay available and the unvalidated Markdown shows as a standalone file in the historical listing. ([#6165]) - **tui:** The TUI composer is now a multiline editor, so pasted code, stack traces, and multi-paragraph prompts keep their line breaks and indentation, and `Enter` sends the complete document. Up/Down move the cursor inside the input and fall back to input history on the first/last visual row (wrapped lines included), leaving the slash palette and PageUp/PageDown transcript keys unchanged. ([#6168]) - **config:** `tool_output` character/count budgets and every per-tool `tool_overrides` value now require non-negative integers: YAML booleans are rejected instead of being coerced to `0`/`1`, and negative overrides — which previously, for example, silently disabled externalization for one tool via `tool_overrides: {web_fetch: -1}` — fail configuration loading with the field and tool named. Numeric strings, integer-valued floats, and explicit zero keep working; a zero per-tool override still disables only externalization. ([#6172]) - **telegram:** The rich-message length check now measures the reply in UTF-16 code units, the way Telegram enforces its cap, instead of Python code points. Emoji-heavy replies that fit by code points previously passed the check, were rejected by Telegram, and degraded to chunked plain text, losing the reply's markdown formatting and wasting an API round trip. ([#6174]) - **community:** The Firecrawl and fastCRW web-search tools normalize a configured `max_results` before handing it to the search client. The raw YAML value previously went into the request's `limit` unvalidated; booleans, fractional values such as `3.5`, zero, negative numbers, non-numeric strings, and an explicit null now log a warning and fall back to the default of 5, while integer strings such as `"8"` are accepted. ([#6175]) - **frontend:** HTML artifact previews ignore literal `` text that is not a real element. Injecting the artifact base href was previously skipped by a regex that also matched a base tag in a comment, in script text, or inside a `