3226 Commits

Author SHA1 Message Date
tiammomo
4501c76b0f
feat(scheduled-tasks): browse paginated run history (#5363)
Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
2026-09-12 12:59:52 +08:00
tiammomo
b9d6b16084
fix(uploads): recognize valid ATX headings in document outlines (#5316)
* fix(uploads): recognize valid ATX headings in document outlines

Exclude hashtags and indented code from uploaded document outlines; normalize valid ATX heading markers.

Fixes #5313

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* test(uploads): format long-heading regression command

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

---------

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
2026-09-12 12:50:22 +08:00
Hyeonsang Cho
bc4a33aba7
fix(skills): stop persisting resolved secrets when toggling skills (#5357)
Toggling a skill wrote resolved secrets into extensions_config.json. The
Gateway skill toggle and DeerFlowClient.update_skill loaded the file with
ExtensionsConfig.from_file(), which replaces every "$VAR" string with the
environment value (and an unset variable with ""), then serialized that
model back through to_file_dict(). A "$GITHUB_TOKEN" reference was
persisted as the plaintext token, and an unset reference was erased for
good. DeerFlowClient.update_mcp_config had the same flaw for every key
other than mcpServers.

Every writer now does a raw read-modify-write, the way the MCP router
already did: read_raw_extensions_config reads the on-disk JSON,
set_raw_skill_enabled changes only the target entry, and
validate_raw_extensions_config checks the candidate the way the runtime
will load it before the atomic write. When the file does not exist yet,
the Gateway seeds it with the cached skill states only, never the
resolved cached model. The MCP router's raw loader and candidate
validation delegate to the same helpers, so the rule lives in one place,
and to_file_dict() is removed so the unsafe serialization has no entry
point left.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 11:24:46 +08:00
yang rui
ac2b6415ea
feat(agents): support Unicode display names for custom agents (#5324)
* feat(agents): support Unicode display names for custom agents

* fix(agents): preserve and validate Unicode display names

* fix(agents): tolerate invalid stored labels and reject invisible names

* fix(agents): identify agent in invalid display name warning

* style(frontend): format agent display name fallback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 11:09:41 +08:00
yeejhyang
5e85f8a183
fix(runtime): add deterministic executor starvation regressions (#5335)
* test(runtime): add deterministic executor starvation regressions

* docs(tests): document executor starvation test contract

* fix(runtime): address executor starvation review feedback

* docs(tests): clarify executor cleanup guidance

* docs(tests): clarify executor cleanup guidance

* docs(tests): clarify executor cleanup guidance

* test(runtime): apply remaining ruff formatting fix

* test(runtime): register executor test guidance path

* test(runtime): bound dedicated executor regression wait

* test(ci): align guidance budget test with checker semantics
2026-09-12 10:59:31 +08:00
wutongyuonce
4470932118
feat(extensions): allow constructor kwargs on config-declared middlewares (#5312)
* feat(extensions): allow constructor kwargs on config-declared middlewares

extensions.middlewares entries may be a class-path string or {class, kwargs}.
String entries keep the zero-argument constructor. Unknown fields and blank
class paths fail at config validation. Constructor errors still fail at
agent creation.

Fixes #5311

* fix(extensions): coerce middleware kwargs to JSON types

YAML timestamps became datetime objects while JSON kept strings, so
constructors and to_file_dict() json.dump saw different types. Validate
kwargs as JSON types at config load, stringify dates, reject NaN and
other non-JSON values, and cover the raw-dict loader branch.

* style(extensions): wrap middleware Field description for ruff E501

make lint failed: the middlewares description was 289 chars (limit 240).
Wrap it and run ruff format on the two files this PR last touched.

* docs: compact configured middleware guidance to satisfy size limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 10:57:40 +08:00
Jun
76d072c584
fix(sandbox): drain skill sync before cancellation (#5350)
* fix(sandbox): drain skill sync before cancellation

* test(sandbox): fence cancelled skill sync

* test(sandbox): make repeated cancellation explicit

* test(sandbox): assert cleanup starts after skill sync
2026-09-12 10:54:39 +08:00
hataa
ec0ac474c4
feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063) (#5294)
* feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063)

Consume the effective route permissions surfaced by #5228 so the UI hides
actions the caller's role cannot perform:

- threads:delete hides the sidebar thread-row Delete menu item and the
  sidecar panel delete button (every useDeleteThread consumer)
- runs:cancel disables the composer stop affordance; all three stop entry
  points converge on one check inside handleStopStreaming

hasPermission treats an absent/null/unresolved permission list as
permissive, so a mixed old-backend/new-frontend deploy never hides actions
the caller can still perform. The Gateway @require_permission guards
remain the single enforcement point.

* fix(authz): review follow-ups for stop gating (comment accuracy, a11y, tests)

- Correct the defense-in-depth comment: the submit-button click is the
  only live entry into handleStopStreaming (handleSubmit returns early
  with the pleaseWaitStreaming toast while streaming, so the kind==="stop"
  branch is unreachable); the handler gate stays as future-proofing.
- Explain the disabled stop affordance with aria-label + title (Radix
  tooltips don't fire on disabled buttons), with en-US/zh-CN strings.
- Add the composer stop-gating DOM tests (disabled + onStop never fires +
  permissive default) and the sidebar delete-menu gating tests, so all
  gated surfaces carry wiring tests.

* fix(authz): stop conditional aria-label from stripping the submit name

The stop-gating follow-up (1612855b) explained the disabled stop
affordance with aria-label/title but passed explicitly-undefined
values in the non-denied case. PromptInputSubmit declares its default
aria-label="Submit" before {...props}, so the undefined key landed in
the spread and clobbered the default: React omits the attribute
entirely and the submit control lost its accessible name in every
state, which broke the sidecar e2e layout helper (it locates the
button by its "Submit" label).

Spread the attributes conditionally so they only attach when
stopDenied, and lock the invariant with a DOM test asserting the base
"Submit" name survives when stop is not denied (mutation-verified:
reverting the conditional spread turns the new test red).

* test(authz): drop unused rerenderWith helper, guard accessible name by role query

Address the review nit on the stop-gating DOM tests: the
rerenderWith helper was never called, and a second render() would
append a composer instead of updating the first one anyway — drop it
(the sidecar-delete-gating tests already demonstrate the correct
rerender pattern if a granted->denied flip test is ever needed).

Also resolve the accessible-name regression guard through
getByRole("button", { name: "Submit" }) so it fails exactly the way
e2e and assistive tech consume the control (mutation-verified: the
explicitly-undefined aria-label form turns it red).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 10:40:54 +08:00
Yufeng He
3aa1ac477d
fix(channels): cap WeCom outbound content at the 20480-byte protocol limit (#5148)
* fix(channels): cap WeCom outbound content at the 20480-byte protocol limit

Both _send_ws paths sent unbounded text. Stream replies now clip on a
character boundary with a truncation marker (one stream carries the whole
reply and cannot split mid-way), and proactive pushes split into
sequential markdown messages at newline boundaries. Measured in UTF-8
bytes, matching the documented protocol cap.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* test(channels): pin emoji boundary behavior in the WeCom content limit

Review on #5148 raised 4-byte emoji cut points. Probes show the split
path already carries a byte-split character into the next chunk and
terminates on all-emoji input; these tests pin that behavior so a later
refactor cannot regress it.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): preserve the delimiter when splitting WeCom pushes

The boundary newline was stripped by lstrip, so the sequential markdown
messages lost one delimiter per split and could not rebuild the original
response. Keep it on the chunk's tail and assert the exact round trip in
the tests, including leading blank lines.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): serialize WeCom proactive chunk batches per chat

Each chunk send awaits, so two manager workers pushing long texts to the
same chat could interleave markdown messages (A1, B1, A2, B2) and break
the sequential-message contract. Hold a per-chat lock across the whole
split batch; different chats still send concurrently.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): keep WeCom split advancing and cap the chunk batch

Two edge cases in _split_for_byte_limit left after the delimiter fix:

- A limit narrower than one whole character made the decode window
  empty, so the hard cut became 0 and the loop appended empty chunks
  forever. Take the character anyway when the window decodes empty, so
  the loop always advances.
- A single oversized push produced one message per 20480 bytes with no
  ceiling, flooding the chat and holding the per-chat lock for the whole
  drain. Cap one push at 10 messages: keep the first nine verbatim and
  collapse the rest into one clipped tail carrying the truncation
  marker, with a warning log when the cap trips.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(channels): reclaim completed WeCom send locks, cap the split before it does the work

Two leftovers from the last review round:

- _ws_send_locks kept one lock per chat forever. A guard-locked refcount
  now reclaims an entry only when no sender is queued on it, so a waiter
  can never land on a fresh lock mid-batch for the same chat. Pinned by
  three reclamation tests (single push, capped batch, 20 concurrent chats).
- _split_for_byte_limit built every chunk and then joined the discarded
  tail to clip it — quadratic work on pathological pushes. The batch cap
  now applies inside the loop: once the kept chunks are full, the
  remainder is clipped whole. A spy test pins that the clipper receives
  the unsplit remainder.

tests/test_wecom_content_limit.py 25 passed, plus tests/test_wecom_ws_text.py.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* docs(channels): describe the WeCom push cap, pin the staggered-lock race

The channels guide still described proactive pushes as an uncapped split. Also add the staggered-start regression the reviewer asked for: a waiter queuing while the holder's cleanup runs must share one lock, keep batches contiguous, and leave both lock registries empty.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-09-12 09:59:58 +08:00
shawn
bec0acf6b5
fix(subagents): make acceptance checks portable on Windows (#5162)
* fix(subagents): make acceptance checks portable on Windows

* fix(subagents): reject drive-root path escapes

* fix(subagents): preserve drive-root containment

* fix(subagents): use Windows path casing rules

* fix(subagents): reject drive-relative cd paths

* fix(subagents): reject shell-dependent cd targets

* fix(subagents): reject shell-dependent runner paths

* fix(subagents): harden cross-shell acceptance checks

* fix(subagents): reject ambiguous shell tokenization

* fix(subagents): reject tokenizer segment drift

* fix(subagents): reject ambiguous PowerShell syntax

* fix(subagents): include all PowerShell quote delimiters

* fix(subagents): fail closed on cross-family paths

* fix(subagents): reject ambiguous Windows aliases

* fix(subagents): reject PSDrive alias exclusions

* fix(subagents): reject PSDrive-relative aliases

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 09:28:46 +08:00
qian
9f4a7823e2
feat(title): use filenames for attachment-only conversations (#5304)
* feat(title): use filenames for attachment-only conversations

* docs: trim upload guidance to fit inherited size budget

* fix(title): bound attachment-only fallback titles

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 09:22:27 +08:00
blue
1ee93cd186
feat(frontend): add expandable generic tool details in debug mode (#5309)
* feat(frontend): add expandable generic tool details in debug mode

* fix: address tool call details review feedback

* fix: preserve existing ellipsis keys in tool previews

* fix(frontend): preserve tool preview property names

* fix(frontend): keep bounded tool previews structurally complete

* fix(frontend): avoid repeated array preview truncation markers

* fix(frontend): preserve precision in tool result previews

* fix(frontend): coalesce generated array tail markers
2026-09-12 09:16:37 +08:00
Hyeonsang Cho
806a5bd427
fix(gateway): serve XML artifacts as attachments to block same-origin script (#5353)
* fix(gateway): serve XML artifacts as attachments to block same-origin script

GET /api/threads/{id}/artifacts/{path} forced only text/html,
application/xhtml+xml and image/svg+xml to download. Every other XML
document was served inline from the application origin: `.xml` guesses
to text/xml or application/xml depending on the host's mime.types, and
both fell through to the inline text branches. Browsers render any XML
MIME type as a document and run an XHTML-namespaced <script> inside it,
so a report.xml written by a prompt-injected agent and opened from a
chat link executed with the viewer's session: the HttpOnly access_token
rides same-origin fetches, and the double-submit csrf_token cookie is
JS-readable, so state-changing calls are reachable as well.

Treat HTML plus every WHATWG XML MIME type (text/xml, application/xml,
any +xml subtype) and text/xsl, which Blink also renders as XML, as
active content. A single helper owns the rule for both the regular-file
and the .skill-archive-member branches. The artifacts panel already
previews .xml as code through a ranged fetch, so preview and editing
keep working against the attachment response.

* docs(frontend): name XML among the artifacts the Gateway downloads

Review follow-up on #5353: resolveArtifactOpenURL's comment still named
only HTML/SVG as the active content the Gateway serves as a download.
XML documents now join that bucket, so the frontend note matches the
Gateway rule. Comment-only; no behavior change.
2026-09-12 09:15:51 +08:00
Parthiban Sivakumar
a2011996d8
fix(scripts): detect the ollama extra from configured models (#5318)
* fix(scripts): detect the ollama extra from configured models

make dev synced without --extra ollama, uninstalling langchain-ollama from a working setup. Same failure #2754 hit with postgres; ollama predates the detector added in #2767 and never got a rule.

* fix(deps): declare the ollama extra on backend, pin model use: matching

Review follow-ups.

`ollama` was the only extra in the detector's map that the root `backend`
project did not declare, so any consumer syncing without
`--all-packages` failed outright:

    $ cd backend && uv sync --locked --extra ollama
    error: Extra `ollama` is not defined in the project's
    `optional-dependencies` table

`serve.sh` and `docker/dev-entrypoint.sh` both pass `--all-packages` and
were unaffected, but `backend/Dockerfile` does not, and
`config.example.yaml` documents `UV_EXTRAS` as an image build-arg — so
`UV_EXTRAS=ollama docker compose build` would have hard-failed on a
value this branch makes first-class. Declaring
`ollama = ["deerflow-harness[ollama]"]` alongside the other delegating
extras closes that, and `uv.lock` is regenerated to match.

The `use:` match also accepted any nesting depth inside `models:`, so a
`use` in a sub-mapping was read as the model's provider:

    models:
      - name: doubao
        use: deerflow.models.patched_deepseek:PatchedChatDeepSeek
        when_thinking_enabled:
          use: langchain_ollama:ChatOllama

That yielded `--extra ollama` despite the model's own provider pointing
elsewhere, and `when_thinking_enabled` appears fifteen times in
config.example.yaml, so the shape is common rather than contrived. Pin
matching to the list item's own key indent, mirroring how
`section_value()` pins `child_indent` and documents deeper nesting as
ignored on purpose. Covered by a regression test that fails on the
looser parser.

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

* chore(deps): drop unrelated standard-aifc marker churn from uv.lock

Review follow-up. Regenerating the lock with a newer local uv (0.11.19)
also rewrote the `standard-aifc` entry, adding
`python_full_version >= '3.13'` markers to its `audioop-lts` and
`standard-chunk` dependencies. Unrelated to this change, so restore
upstream's lines and keep the lock diff to the `ollama` extra.

`uv lock --check` passes with these lines under both the CI- and
Dockerfile-pinned uv 0.11.1 and uv 0.11.19.

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

* fix(scripts): detect ollama in unindented model lists and after nested sequences

Review follow-ups on the models parser.

`yaml.safe_dump` — used by the setup wizard (`make setup`) and
scripts/config-upgrade.sh — writes list items unindented:

    models:
    - name: qwen3-local
      use: langchain_ollama:ChatOllama

The parser treated any column-0 line as the end of the `models:` section,
so the first model ended it and nothing was detected. That is the layout
new users get from the recommended setup path, so `make dev` still synced
without `--extra ollama`. Model entries are now recognised before the
section-end test, the same ordering `tools_include_name()` already uses
for the unindented tools list (#4367).

Separately, every sequence item reset the key indent, including items
inside a model option. With `stop:` / `- END` before `use:`, the key indent
jumped to the nested item's and the model's own `use` was skipped, so
detection depended on key order within the model.

The first sequence item under `models:` now fixes the model-list indent;
only items at that indent start a model and set where its keys sit.
Nested sequence items and deeper mappings are skipped without moving it.

Regression tests cover the real setup-wizard output via
`build_minimal_config()`, a hand-written unindented list, and `use:`
after a nested `stop:` list — all three fail on the previous parser — plus
a guard that a `- use:` nested inside a model option is still ignored.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:10:47 +08:00
Shxiao
e92c2c62f7
test(wechat): skip the POSIX mode-bit half of the auth-state tests on Windows (#5366)
Two tests in test_wechat_channel.py assert that the persisted WeChat auth
state is owner-only via stat().st_mode & 0o777 == 0o600. Windows has no POSIX
mode bits: Path.chmod() only toggles the read-only attribute, so st_mode
reports 0o100666 and the assertion can never hold there (same class as the
skill-permission assertions handled in #5244).

Skip only the mode-bit assertion on Windows from a single-sourced reason
constant, and keep every platform-independent assertion running: the QR login
flow, the persisted JSON content, and the "no *.tmp residue" atomicity check.
In the tightening test the mode assertion moves after those checks so the
Windows run still verifies that the atomic owner-only temp-file path leaves no
residue behind.

Verified on Windows: both tests red on upstream main (assert (33206 & 511) ==
384), skipped after the change; the file is 32 passed, 2 skipped; ruff
check/format clean.
2026-09-12 09:00:48 +08:00
Shxiao
572744975d
fix(tools): run tool assembly off-loop at async entry points (#5224)
* fix(tools): run tool assembly off-loop at async entry points

get_available_tools() may block on MCP cache initialization while it is
called on async agent-assembly paths (task_tool, durable batch execution),
stalling the calling event loop for the full discovery duration.

Dispatch the (unchanged, synchronous) assembly call to a worker thread via
asyncio.to_thread at the two async entry points so the loop keeps processing
requests, SSE frames, cancellations, and timers.

Fixes #5172

* fix(tools): offload lead-agent assembly off-loop and pin with blocking-io anchors

Review follow-up for #5224:

- run_agent now dispatches agent_factory(...) through asyncio.to_thread, so
  lead-agent assembly (including both get_available_tools call sites in
  _assemble_lead_agent) runs off the event loop — the Gateway headline
  scenario from issue #5172.
- _ensure_sync_invocable_tool takes a double-checked threading.Lock, making
  the in-place tool.func wrap on the shared tool singletons explicitly
  single-shot now that assembly can run concurrently on worker threads.
- Add backend/tests/blocking_io/test_tool_assembly_offloop.py: blocking-probe
  anchors for task_tool and SubagentBatchService._execute_item under the
  strict Blockbuster gate, plus a meta-check proving the gate trips on the
  exact syscall class (ExtensionsConfig.from_file on the loop). Verified the
  anchor goes red when the offload is flattened back to a plain call.

* fix(gateway): build checkpoint state accessor off-loop; anchor run_agent offload

Review follow-up for #5224:

- Add abuild_checkpoint_state_accessor (asyncio.to_thread around the
  unchanged sync builder) and switch every async call site to it: the
  stateless_wait route, thread_runs, both threads call sites, and the
  build_thread_checkpoint_state_accessor boundary. The agent-factory
  assembly re-enters get_available_tools() and may block on MCP cache
  initialization; repeat calls hit _state_accessor_graph_cache and only
  pay the thread hop.
- Add a third blocking-io anchor driving the real run_agent with minimal
  RunManager/bridge stubs; the factory performs a real production blocking
  read (ExtensionsConfig.from_file()) and the test asserts assembly never
  runs on the main thread. Verified the anchor goes red when the run_agent
  offload is flattened back to a plain call.
- Adapt the test_threads_router checkpoint-builder patch sites to the new
  async name.

* refactor(tools): carry assembly offloads on a dedicated bounded pool

Review follow-up for #5224:

- Add utils/assembly_io.py: a dedicated ThreadPoolExecutor (default 8
  workers, DEER_FLOW_ASSEMBLY_WORKERS-overridable, mirroring
  utils/file_io.py and tools/sync.py) with run_assembly(), which copies
  contextvars explicitly. A hung stdio MCP server parks its worker for
  the full MCP timeout; carrying assembly hops on the loop's default
  executor would let a few parked assemblies queue every other
  to_thread/run_in_executor(None, ...) caller behind them.
- Switch all four offloads (run_agent, task_tool, batch _execute_item,
  abuild_checkpoint_state_accessor) to run_assembly().
- State the cold-path behavior in the accessor docstring: the graph
  cache validates factory identity, so non-identity-stable factories may
  duplicate lead-agent assembly across concurrent readers (MCP discovery
  stays process-wide single-flight); the pool bounds the duplicates.
- Add a fourth blocking-io anchor driving build_thread_checkpoint_state_
  accessor with a per-resolution fresh factory (always a cache miss) and
  the real production blocking read; enumerate all four offloads in the
  gate's module docstring. Verified the anchor goes red when
  abuild_checkpoint_state_accessor is flattened back to a plain call.

* fix(subagents): revalidate batch item before launch; make assembly pool observable

Review follow-up for #5224:

- _execute_item() revalidates the durable state right after assembly and
  before executor.execute_async(): renew_item_lease() returns valid=False
  when cancel_batch() terminalized the item or the lease was lost while
  assembly was parked, and the launch is skipped (the canceller already
  finalized the item). Previously the launch was unconditional and the
  poll loop's cancellation checks only started after execution began.
- Regression test driving the real SQLite repository: a blocking assembly
  probe parks _execute_item, cancel_batch() lands, and the launch is
  skipped with the item staying cancelled. Verified the test goes red
  when the revalidation is removed.
- run_assembly() tracks pending assemblies and logs a throttled WARNING
  once the pending count exceeds the worker count, so assembly starvation
  (workers parked on a hung MCP server) is distinguishable from idle.
- The run_agent blocking-io anchor now binds a sentinel extension
  snapshot via ctx.extensions and asserts the factory observed it through
  get_agent_build_extensions(), pinning run_assembly()'s ContextVar
  propagation. Verified red when ctx.run is dropped.
- Document the assembly pool in backend/AGENTS.md.

* fix(utils): decrement the assembly pending count on the pool thread

The pending-assembly counter behind the starvation warning decremented
from the asyncio future's done callback, which never fires once the
submitting loop is closed while its worker is still running: the count
ratcheted up permanently and eventually fired the starvation warning
with no starvation behind it (reproduced at 97dc9bec by review).

Decrement instead from the dispatched work item: run_assembly() wraps
func so a finally drops the count under the pending lock on the pool
thread, and the done callback is gone.

Pin the counter with tests/test_assembly_io.py: a healthy call returns
the count to zero, and an abandoned loop (stopped while the worker is
parked) does not wedge it — the abandoned case goes red against the old
done-callback decrement.

* docs(utils): fix the pending-counter comment after the decrement move

The comment still described the removed done-callback decrement,
contradicting _work()'s own comment; state the actual mechanism
(increment on the loop before dispatch, decrement from the dispatched
work item's finally on a pool thread).

* test(gateway): retarget checkpoint-accessor stubs to the services seam

thread_runs and runs now call abuild_checkpoint_state_accessor, so the
upstream wait-reader, regenerate-prepare, and idempotency tests must stub
the sync builder where abuild resolves it (app.gateway.services); stubbing
the removed router re-exports fails with AttributeError at setup. The
async seam semantics are unchanged: run_assembly invokes the stubbed
sync builder off-loop and propagates its return values and exceptions.

Move the agent/tool assembly off-load note from backend/AGENTS.md to
deerflow/utils/AGENTS.md (next to assembly_io.py) so the effective
instruction chain for agents/middlewares no longer grows past the AG002
hard limit.

* fix(runtime): serialize same-key accessor assembly and release queued-cancel slots

Address the three review follow-ups on the assembly off-load:

- assembly_io: a job cancelled while still queued never runs its work
  item, so the dispatched finally never fired and _pending_assemblies
  stayed elevated until a false starvation warning. Exactly-once cleanup
  now rides the concurrent future's cancelled() state — cancel() only
  succeeds before the executor starts the item, so cancelled() is true
  precisely when the finally will never run — plus a submit-failure
  release; the one-worker queued-cancellation case is pinned red/green.
- services: overlapping cold readers sharing one cache key could both
  run full agent assembly. _state_accessor_graph now serializes per key
  through a thread-side KeyedLockTable (pool threads, no running loop)
  and re-validates factory/app-config identity under the lock, so the
  factory runs exactly once while identity changes still rebuild. Cache
  dict access is lock-guarded now that construction runs off-loop.
- guidance inventory: register deerflow/utils/AGENTS.md in
  EXPECTED_GUIDANCE_PATHS so test_repository_has_the_approved_scoped_
  guidance_shape matches the relocated assembly note (CI shard 4).

* test(keyed-lock): pin KeyedLockTable reclamation and waiter bypass directly

Thread-side counterparts of the async table's own tests: overlapping
hold() calls serialize (a late arrival joins the live entry instead of
creating a second lock that bypasses a queued waiter), the last check-in
pops the entry, and many unique keys leave the registry empty. Both
regressions verified red — popping unconditionally trips the late-arrival
test, never reclaiming trips the many-keys test.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 07:41:24 +08:00
Totoro
cd0e74edaf
fix(scheduler): reconcile stuck once tasks from committed run outcome (#5035)
* fix(scheduler): reconcile stuck once tasks from committed run outcome

Restart recovery (cancel_stuck_once_tasks and the multi-instance
reconcile_stuck_once_tasks) blindly flipped every stuck once-task to
'cancelled'. When handle_run_completion crashed between its two
transactions, a once-task whose run had already committed 'success'
was permanently reported as cancelled.

Both reconciliation paths now read the latest scheduled_task_runs row
without a status filter and finalize the parent to match:
success -> completed (last_error cleared),
failed -> failed with the run's error,
interrupted -> cancelled with the run's error when present,
skipped -> cancelled (no work performed).
Active occurrences (queued/launching/running) are left untouched — a
concurrent completion or a later recovery pass will finalize them once
the run reaches a terminal state.
Tasks without a terminal run row keep the previous generic cancellation.

Review follow-ups (willem-bd / Huixin615):
- Extract _finalise_once_task_from_run() so both recovery paths share one
  outcome mapping (no more drift between single- and multi-instance paths).
  Returns bool (True = finalised, False = active/no-op) for explicit
  counter management at call sites.
- Fix a no-op (`run_row.error or None` -> `run_row.error`) in the skipped
  branch.
- Drop the unused `status` parameter from the test task helpers.
- Use TERMINAL_RUN_STATUSES / ACTIVE_RUN_STATUSES constants (local copies
  to avoid circular import; kept in sync with scheduled_task_runs.sql).
- [P1] Read the latest run AFTER acquiring the parent task row lock, not from
  a pre-lock batch snapshot. The latest-run lookup now runs per task under
  the lock with populate_existing so a concurrently committed status is read
  back fresh.
- [P2] Race tests now use monkeypatch to actually enter the race window:
  _intercepted_fetch commits success in a separate session at the moment the
  per-task fetch fires, so a reverted pre-lock batch implementation fails the
  test, while the current post-lock implementation passes.
- [P1] Do not finalize parent for active occurrences. A non-terminal
  scheduled occurrence means the run is still in progress — the parent must
  be left untouched until the completion path or a later recovery pass
  establishes a terminal outcome.
- [P2] Add cancel_stuck_once_tasks to the single-instance poll loop so
  stuck once-tasks are not left permanently "running" when the startup sweep
  fails (mirrors multi-instance _reconcile_active_state behavior).
- Fix stale docstrings in cancel_stuck_once_tasks and _fetch_latest_run.

Adds regression tests for multiple historical runs (older success +
newer skipped/active) on both paths, monkeypatch-based race tests that
prove a concurrent completion committing success is reflected as
completed, and active-run tests that verify the parent is left
unchanged. Documents the behavior in AGENTS.md.

Fixes #5034

* fix(scheduler): address review comments on completion-consistency fix

- _fetch_latest_run: drop arbitrary id DESC tie-break; order by
  scheduled_for DESC (deterministic recency on schedule position)
- _finalise_once_task_from_run: annotate bool return type
- Centralize TERMINAL/ACTIVE_RUN_STATUSES in scheduled_tasks/model.py;
  stop duplicating them in scheduled_tasks/sql.py and
  scheduled_task_runs/sql.py (removes stale circular-import workaround)
- cancel_stuck_once_tasks: run unconditionally in single-instance poll
  loop (remove try/except swallow)
- tests: pin created_at/scheduled_for in _create_run so recency ordering
  is actually exercised; correct docstrings that described the
  active-occurrence branch as 'generic cancel' instead of 'left unchanged'

* fix(scheduler): correct finalizer return annotation

* fix: order scheduled task runs by creation time

* fix(scheduler): stabilize latest run reconciliation ordering

* fix(scheduler): order latest runs by creation time

* test: update trace scheduler stub

* fix(scheduler): clarify reconciliation diagnostics

Signed-off-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>

* fix(scheduler): fail closed on startup recovery

Keep single-instance parent reconciliation at startup so it cannot race manual admission. Propagate recovery failures through the Gateway lifespan before channel startup, preventing a half-started scheduler.

Tests cover both recovery failure stages and a queued occurrence that survives startup before the ordinary poll drain launches it.

Signed-off-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>

* fix(scheduler): order occurrences and fence stale parent writes

Allocate per-task occurrence sequences under the parent lock and guard parent projection across launch, recovery, completion, and queue failure paths. Track launch accounting separately so stale occurrences are counted once without replacing newer results. Commit completion and accounting atomically, preserve legacy history, and cover migrations and reordered execution on SQLite and PostgreSQL.

* fix(scheduler): tighten completion projection and launch fencing diagnostics

Share the once-task outcome mapping between completion and both recovery paths, validate the terminal status before opening the completion transaction, leave cron parent status untouched on completion, log the fenced launch update when an occurrence does not belong to the launched run, and drop the README capability line.

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

* fix(scheduler): compare caller time only against unsequenced occurrences

Among sequenced rows the parent-locked occurrence_seq is the only recency key. An unsequenced row can only be legacy history or an admission by a pre-upgrade Gateway writer, so recovery prefers it over the sequence winner only when its caller timestamp is later, which is the previous ordering for that pair. A rolling upgrade therefore degrades to the pre-sequence behaviour instead of ranking every pre-upgrade admission below every sequenced one. Document that boundary instead of requiring every Gateway writer to stop before the upgrade.

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

* fix(scheduler): gate once-task recovery on the same projection rule

Recovery now finalises a once-task parent only from the occurrence that can_project() accepts: the highest sequenced occurrence whenever one exists, or the timestamp-latest row for a task whose history is entirely unsequenced. An unsequenced row admitted by a pre-upgrade writer can no longer cancel a parent whose sequenced occurrence is still live, nor stall finalisation of a parent whose sequenced occurrence already completed. Document that pre-upgrade instances project their own admissions during a rolling upgrade.

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

* fix(scheduler): defer once-task recovery while any occurrence is live

uq_scheduled_task_run_active allows one non-terminal occurrence per task, so a live row is the task's newest admission whatever its caller clock and whether it carries a sequence. Both once-task recovery paths now probe for any active occurrence after the fresh latest-run read and leave the parent untouched while one exists; cancel_stuck_once_tasks also locks the parent row so admission cannot insert a queued occurrence between that probe and the commit. Once no occurrence is live, the sequence winner decides and a terminalised unsequenced row never overrides it.

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

* test(persistence): follow the local head past canonical 0019

Main's forward-revision tests assumed 0019_thread_incarnations was the local chain head. With 0022_scheduled_occurrence_seq chained after it, seed the canonical-0019 shape explicitly, assert the real head where a database is upgraded, derive the 0020 rollback binary's revision set from the ancestors of its head, and step the PostgreSQL restart scenario back to canonical 0019 before the rollback binary restarts.

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

* docs(migrations): describe the chain through 0022_scheduled_occurrence_seq

The rolling-forward section still ended the local chain at canonical 0019; it now names 0022_scheduled_occurrence_seq as the head and lists it among the revisions the 0020 rollback-floor binary does not know.

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

* test(scheduler): accept CI's sync Postgres URL in occurrence fixtures

CI hands over TEST_POSTGRES_URI as postgresql://...?sslmode=disable. The occurrence, ordering and 0022 migration fixtures built async engines from it directly, so SQLAlchemy chose psycopg2, which is not installed. Normalize the scheme to postgresql+asyncpg and drop libpq-only query keys, matching the existing 0019 migration tests.

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

* docs(scheduler): keep the backend AGENTS.md chain within its budget

The middlewares guidance chain was already above the hard limit on main, so any added byte in backend/AGENTS.md fails the agent guidance check. Leave backend/AGENTS.md identical to main and record the recovery projection rule in the 0022 migration entry, which already describes the occurrence fields.

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

---------

Signed-off-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 07:35:07 +08:00
wutongyuonce
9f17bbeec7
feat(tools): filter list_uploaded_files by name and extension (#5341)
* feat(tools): filter list_uploaded_files by name and extension

Add optional query and extensions so historical upload discovery can
find older matching files instead of dropping them behind the default
20-item mtime cap.

Fixes #5339

* fix(tools): strip glob stars from list_uploaded_files extensions

Model-supplied tokens like *.pdf were prefixed to .*.pdf and never
matched Path.suffix. Also run ruff format so the backend format gate
passes.
2026-09-12 07:25:19 +08:00
Shxiao
e20b36b3c7
test(artifact-archive): make the archive suite platform-correct on Windows (#5365)
Two tests in this suite failed on Windows hosts for POSIX-shaped reasons:

- test_archive_download_contains_only_presented_files wrote its CSV fixture
  with write_text(), so Windows text mode translated the literal "\n" bytes to
  CRLF; the archive then legitimately contained CRLF while the assertion
  compared against the LF spelling.
- test_archive_rejects_a_path_replaced_during_read swaps the source file with
  os.replace() while the archive still holds it open, which Windows refuses
  with WinError 5, so that rename-under-read race is POSIX-only.

Write the fixture as bytes so its content is platform-independent, and skip
only the rename-based race on Windows: the same-size content change during
read (test_archive_rejects_same_size_content_change_with_restored_mtime) still
runs there and exercises the same mid-read revalidation.

Verified on Windows: both tests red on upstream main, green after the change
(one skipped); ruff check/format clean; the file is 28 passed, 7 skipped.
2026-09-12 07:12:37 +08:00
Nan Gao
3f0b6ecc81
feat(agents): elide blocked write payloads from model-bound requests (#5329)
* feat(agents): elide blocked write payloads from model-bound requests

A write_file / str_replace call rejected by the read-before-write gate never
runs, yet its payload (up to 80 KB for a non-append write, unbounded for
append) stayed verbatim in every later model request: nothing in the chain
rewrites AIMessage tool-call arguments, and ToolOutputBudgetMiddleware only
budgets ToolMessage output. The gate demands a re-read plus a fresh call, so
the model re-emits the content anyway and the original is pure dead weight.

- ReadBeforeWriteMiddleware stamps `deerflow_write_block` on the blocked
  ToolMessage and, in wrap_model_call, replaces the paired call's payload
  fields (content / old_str / new_str) with a short deterministic placeholder
  in the model-bound request only. state["messages"], receipts, loop
  detection, and the run journal keep the original arguments; nothing is
  externalized to disk, since a file reference to content the model must
  re-derive after reading the target would only invite bypassing the gate.
- New `tool_call_args` helper rewrites every provider surface together
  (structured tool_calls, raw additional_kwargs.tool_calls, tool_use content
  blocks, tool_call_chunks) so strict providers never see them disagree; the
  gate only supplies the policy (which calls, what placeholder).
- `read_before_write.elide_blocked_payloads` (default on) and
  `read_before_write.elide_min_chars` (default 2000) configure it; the
  runtime builder passes the config through and the middleware declares it
  via release_policy_parameters.

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

* docs(agents): condense middleware guide entry 11 to fit the guidance budget

The agent-guidance CI check failed: the effective AGENTS.md chain for
agents/middlewares was 99673 bytes against a 98304-byte hard limit. The
chain already sat at 98459 on main, so the ReadBeforeWrite entry could not
grow. Rewrite entry 11 so it states the same facts (gate, lock scope,
fail-open, authorization scope, blocked-payload elision, shared
tool_call_args helper) in 1229 bytes instead of 2640; the chain is now
98262 bytes.

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

* fix(config): bump config_version for the read_before_write elision keys

Review follow-ups on #5329:

- `read_before_write.elide_blocked_payloads` / `elide_min_chars` are new
  user-settable YAML keys, i.e. a config schema change, so bump
  `config_version` 40 -> 41 in config.example.yaml; without it an existing
  config.yaml gets no outdated-config warning and `make config-upgrade` has
  nothing to signal.
- Say in the `elide_min_chars` description (and the example comment) that the
  threshold and the placeholder's size figure are Python character counts,
  not tokens: the same value spans roughly 3-4x in real context cost between
  ASCII and CJK text.
- The builder wiring test now asserts only the wired `elide_min_chars` value
  instead of the whole `ReadBeforeWriteConfig` dump, so future knobs do not
  have to edit an unrelated test.

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

* chore(helm): bump chart config_version to 41

validate-chart's config_version drift check failed after config.example.yaml
moved to 41 in ef9ee267. Bare bump of the chart's embedded `config:` block
and the README example; the chart does not mirror the read_before_write
section, so no field changes are needed.

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

* fix(agents): rewrite Responses and v1 content-block arguments too

Review finding on #5329 (P2): the content rewriter only handled Anthropic
`tool_use` blocks. With `use_responses_api=true` and
`output_version='responses/v1'`, AIMessage.content carries `function_call`
blocks whose `arguments` still hold the full write payload, and
langchain_openai's Responses input builder emits that block instead of the
rewritten structured call whose `call_id` it already carries. Standard `v1`
`tool_call` blocks likewise keep `extras.arguments`, which the v1->Responses
translator prefers over the structured args. So the blocked payload was
still sent on every later Responses API request.

`tool_call_args` now rewrites every content dialect that carries its own
copy of the arguments: Anthropic `tool_use` (input, drop partial_json),
Responses `function_call` (arguments, matched by call_id, `fc_...` item id
and status preserved), and v1 `tool_call` / `tool_call_chunk` (args plus
`extras.arguments`). Tests assert against the real adapter serializers:
`_construct_responses_api_input` for responses/v1, v1, and v0 messages,
`_convert_message_to_dict` for chat completions, and Anthropic
`_format_messages` for native and v1 content, plus an end-to-end probe
through the gate's wrap_model_call.

* fix(agents): pair blocked writes per call occurrence and defeat Responses chaining

Two review findings on #5329:

- Tool-call ids may repeat across assistant turns (DanglingToolCallMiddleware
  pairs them with per-id queues). The gate matched blocked results against a
  history-wide id set, so a successful write sharing an id with a later (or
  earlier) blocked one also lost its payload and was labelled as blocked.
  `_blocked_call_occurrences` now pairs ToolMessages with call occurrences
  the same FIFO-per-id way and the selector keys on (message, call id).

- With `use_previous_response_id`, the OpenAI adapter sends only the messages
  after the last AIMessage carrying a `resp_` response id and lets the server
  rebuild the rest from its stored copy, which still holds the original
  arguments and cannot be edited; every later response chains back to it.
  `rewrite_messages_tool_call_args` now drops every `resp_` id from the
  model-bound copy whenever it rewrote anything, so the adapter replays the
  full rewritten history (the `use_previous_response_id=False` request
  shape). OpenAI bills chained input tokens as input either way, so replay
  costs no more; the state keeps its ids.

Tests cover success-before-block and block-before-success histories through
the Chat Completions serializer, and chaining through
`ChatOpenAI._get_request_payload` with `use_previous_response_id=True`:
unrewritten history chains and omits the call, rewritten history is replayed
with the placeholder and no `previous_response_id`.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 19:05:48 +08:00
dong
f09e824460
fix(scripts): prefer Windows .cmd shims for pnpm (#5305)
* fix(scripts): prefer Windows cmd shims for pnpm

* fix(scripts): address pnpm Windows review feedback

* docs: reduce inherited agent guidance size
2026-09-11 18:41:24 +08:00
tiammomo
b809b7bc7d
fix(web-fetch): resolve relative URLs in extracted Markdown (#5310)
* fix(web-fetch): resolve relative URLs in extracted Markdown

Pass the request URL through Jina and Browserless extraction and resolve
link/image destinations before Readability removes document base tags.
Preserve the optional legacy API and fallback text behavior.

Fixes #5307

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* fix(web-fetch): address provider and base URL review feedback

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* fix(web-fetch): preserve HTML source when resolving destinations

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

---------

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
2026-09-11 18:36:58 +08:00
Willem Jiang
ed8a3ad77e
fix(deps): upgrade transitive xmldom for Dependabot alert 395 (#5352) 2026-09-11 16:30:15 +08:00
Nan Gao
0cf1762638
docs(config): note use_previous_response_id in the Responses API example (#5359)
`use_previous_response_id` is not a DeerFlow config field; it reaches
`ChatOpenAI` only because `ModelConfig` allows extra keys and the model
factory forwards them. It is easy to mistake for `use_responses_api`, but the
two are not equivalent: the former picks the endpoint, the latter switches
the Responses API from replaying the full history each turn to chaining on
`previous_response_id` with server-side state. Chained context is still
billed as input tokens, and client-side history rewrites are only honored
when the history is replayed. Document the key next to the Responses example
so operators know what it changes. Comment only, no schema change.
2026-09-11 16:22:26 +08:00
Ryker_Feng
f52818fe5e
feat(skills): export custom skill packages with revision-bound preview (#5332)
* feat(skills): export custom skill packages with revision preview

* docs(gateway): keep export guidance within size budget

* ci: retry checks after transient uv setup download failure

* docs: focus skill export agent guidance on maintenance invariants

* fix(skills): handle export disconnects and bound archive transfers

* docs(gateway): remove redundant export guidance to fit merged budget

* fix(skills): reset export idle deadline after transfer progress
2026-09-11 16:21:23 +08:00
PeaceMaker-best
36ce7590b7
fix(agents): isolate loop detection state by run (#5344)
* fix(agents): scope loop detection state per run

* fix(agents): harden loop scope fallback

* docs: move loop lifecycle detail out of inherited guidance

---------

Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-11 13:45:52 +08:00
Vicki
c35022e18b
fix(frontend): show agent skill badges with empty tool groups (#5326)
* fix(frontend): show skill badges with empty tool groups

* test(frontend): cover agent skill badges with empty tool groups

* test(frontend): cover agents without badge content
2026-09-11 11:37:56 +08:00
Hyeonsang Cho
556975f284
fix(gateway): gate github_token and disable_clarification on internal callers (#5338)
* fix(gateway): gate github_token and disable_clarification on internal callers

`non_interactive` is honored only for internally-authenticated callers
because it strips `ask_clarification` from the lead-agent toolset. The two
sibling run-context keys reproduced that effect without the gate.

`merge_run_context_overrides` forwarded `_CONTEXT_RUNTIME_ONLY_KEYS`
regardless of `internal`, and `strip_internal_context_keys` scrubbed only
`_CONTEXT_INTERNAL_CALLER_KEYS` -- so any session or PAT caller could set
`disable_clarification` through `body.context`, or through the free-form
`body.config` that `build_run_config` copies verbatim. That is not a milder
flag than `non_interactive`: ClarificationMiddleware answers every
clarification -- `risk_confirmation` included -- with "proceed without
asking" instead of interrupting, and SandboxMiddleware reads the two keys as
the same non-interactive signal. `github_token` rode the same path into
`runtime.context`, where the bash tool exports it as `GH_TOKEN`/`GITHUB_TOKEN`,
and a copy smuggled through `body.config['configurable']` reached the
checkpoint store the context-only rule exists to avoid.

Both keys are produced server-side by the channel run policies, which reach
the Gateway over the internally-authenticated request channel, so gate them
the same way: forward them only when `internal=True`, and scrub the union
`_INTERNAL_ONLY_CONTEXT_KEYS` from both config sections for every other
caller. Destination stays an orthogonal axis -- `_CONTEXT_RUNTIME_ONLY_KEYS`
still land in `context` alone, never in checkpoint-persisted `configurable`.

Regression coverage in tests/test_gateway_services.py pins both smuggling
surfaces and replays the real start_run assembly order for a session caller
and for an internal one, so the GitHub channel keeps carrying its minted
token.

* docs(changelog): record the internal-only run-context key gate (#5338)

* docs(agents): keep the run-context note inside the AGENTS.md budgets

The AG002 inherited-chain check failed at this head. The new backend section
and the root scheduled-task sentence added 993 B to the root and backend
guidance both the sandbox and middlewares chains inherit, pushing sandbox 6 B
over the 98304 B hard limit and growing the middlewares chain, which main
already exceeds by 155 B. An already-over chain is only tolerated while it does
not grow, so the shared ancestors had to come back to their base size.

Condensed the new material and removed prose the root file was duplicating:

- The trust-boundary section keeps both gated surfaces, both helpers, the
  trust-vs-destination split, and the disable_clarification note in half the
  space.
- The root scheduled-task bullet names all three internal-only keys and both
  smuggling surfaces while staying under its previous size.
- Dropped the root `scheduler.recursion_limit` bullet, which restated
  backend/AGENTS.md:18 almost verbatim; its one unique fact (a YAML edit needs
  no Gateway restart) moved to that bullet.
- Deduplicated the nginx routing sentence, which already deferred to the
  backend routing table, and tightened the waiver note's sequencing tail.

Root and backend guidance now sit 50 B under their combined base size, so the
sandbox chain returns to 97310 B and the middlewares chain no longer grows.
Every file stays under its AG001 soft budget.
2026-09-11 11:33:52 +08:00
auyua9
452d09b96b
fix(frontend): localize Settings load errors (#5337)
Co-authored-by: ming <silverchris@foxmail.com>
2026-09-11 07:45:16 +08:00
Undermoon1412
c9c7076ba7
fix: localize Chinese docs links (#5275)
* fix: localize Chinese docs links

* fix(docs): correct layout import order

* test(docs): narrow localized link e2e locator

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

---------

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 21:29:21 +08:00
XIIRUAN
c65737025b
fix(mcp): enforce session pool capacity during promotion (#4962)
* fix(mcp): enforce session pool capacity during promotion

* test(mcp): cover concurrent session promotion

* docs(mcp): document promotion-time capacity check

* fix(mcp): align capacity eviction with owner promotion

* fix(mcp): detach promotion eviction teardown from new owner

* test(mcp): keep eviction teardown regression focused

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: XIIRUAN <253657638+XIIRUAN@users.noreply.github.com>
2026-09-10 21:21:18 +08:00
wutongyuonce
48a8978b7b
feat(scheduler): add interval schedule type (#5291)
* feat(scheduler): add interval schedule type

Allow scheduled tasks to fire every N seconds from last dispatch, not
only wall-clock cron or a single run_at. Cadence is UTC now+N with no
missed-beat catch-up, bounded by min_once_delay_seconds and 30 days.

* fix(scheduler): let interval tasks create, edit, and keep next run

Create/edit now keep every_seconds. Unchanged interval spec no longer
resets next_run_at, including timezone-only PATCH.

* fix(scheduler): keep non-minute intervals on edit

Stop rounding every_seconds to whole minutes in the form. Values that
are not whole minutes or hours now use a seconds unit so edit/duplicate
round-trips the stored cadence instead of rewriting it and resetting
next_run_at. Document that min_once_delay_seconds is also the interval
floor.

* fix(scheduler): clamp interval seconds to the default 60s floor

The new seconds unit allowed 1–59, which the API rejects under the
default min_once_delay_seconds. Clamp the form to >= 60 and show the
floor next to the preview. Also mention interval in the scheduler
field_doc, matching config.example.yaml.

* fix(scheduler): do not clamp interval amount while typing

Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could
not leave 60. Keep the raw field text and apply the 60s floor on blur
and emit only.

* test(scheduler): cover interval input editing

* fix(frontend): preserve saved interval cadence until edited

* style(tests): format scheduled task router tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 16:46:01 +08:00
Jun
3a6e681dee
fix(view-image): read active sandbox images from sandbox (#5306)
* fix(view-image): read remote sandbox images from sandbox

* docs(tools): clarify view_image sandbox behavior

* fix(view-image): address sandbox lifecycle review

* fix(view-image): preserve image provenance across sandbox replacement

* fix(view-image): address provider recovery review

* fix(view-image): drain cancelled tool reads
2026-09-10 15:48:55 +08:00
wutongyuonce
69f0f483eb
feat(scheduler): let scheduled tasks pin a custom agent (#5288)
* feat(scheduler): let scheduled tasks pin a custom agent

Create and update accept optional assistant_id, defaulting to lead_agent.
Custom names are normalized and must already exist for the task owner.
The workspace form exposes the same choice, and duplicate copies it.

Fixes #5286

* fix(scheduler): keep assistant-id PR free of interval tests

Drop the six interval tests that belonged to the interval schedule PR
and fail here because this tree still only accepts once/cron.

Treat lead_agent case-insensitively so LEAD_AGENT / lead-agent store
as the default. Omit unchanged assistant_id on edit so a deleted custom
agent does not 422 unrelated PATCH (rename, reschedule).

* fix(scheduler): format task page and browser tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 15:36:49 +08:00
Hyeonsang Cho
8e86729aa0
fix(gateway): confine artifact PUT to /mnt/user-data/outputs after path resolution (#5321)
* fix(gateway): confine artifact PUT to /mnt/user-data/outputs after path resolution

The outputs-only guard on PUT /api/threads/{id}/artifacts/{path} was a
string-prefix check on the raw path. A percent-encoded `..`
(`outputs/%2e%2e/uploads/x.txt`) survives nginx's variable proxy_pass
untouched, is decoded by Starlette, passes the prefix check, and the
resolver only confines the result to `user-data/` -- so an owner could
overwrite a sibling upload or workspace file in their own thread.

Collapse dot segments before the prefix check, and re-check the resolved
host path against the resolved outputs root so a symlink planted inside
`outputs/` cannot redirect the write either. The normalized virtual path
is what the response echoes and what non-mounted sandboxes receive.

* refactor(gateway): share the outputs-confinement rule with channel attachments

Review follow-up on #5321: the "only under /mnt/user-data/outputs" rule was
implemented independently by the artifact editor and by IM-channel
attachment delivery, and the two copies had already drifted.

Move it into app/gateway/path_utils.py as normalize_outputs_virtual_path
(collapse `..` before the prefix check) and resolve_outputs_confined_path
(re-check the resolved host path against the resolved outputs root, which
also catches a symlink planted inside outputs/). PUT /artifacts and
ChannelManager._resolve_attachments both call the helper; artifact_archive
keeps its stricter ZIP-member rules layered on top.

Tests that previously stubbed resolve_thread_virtual_path for the editor now
stub resolve_outputs_confined_path, and the channel attachment tests patch
path_utils.get_paths, which the helper binds at import like the other
consumers. The confinement itself is pinned by tests/test_gateway_path_utils.py.
2026-09-10 15:01:56 +08:00
kbsama
3e1349576e
fix(agents): filter assembly descriptor subagent policy by allowed_subagents (#5205) (#5262)
* fix(agents): filter assembly descriptor subagent policy by allowed_subagents (#5205)

* test(agents): ensure non-vacuous subagent catalog in assembly descriptor test

---------

Co-authored-by: 1747687484-collab <229902011+1747687484-collab@users.noreply.github.com>
2026-09-10 14:51:42 +08:00
hataa
37b03a3811
fix(channels): bound Discord outbound cross-loop awaits and restart dead clients, fixes #5226 (#5227)
* fix(channels): bound Discord outbound cross-loop awaits and restart dead clients, fixes #5226

* docs: reduce inherited agent guidance size

* fix(channels): stop half-started channels before discarding them

_start_channel now tears the instance down (stop + untrack) whenever start()
raises or the channel never reaches is_running, so an outbound listener
subscribed before the transport was confirmed cannot outlive its channel.
Addresses the review on #5227.

* fix(channels): retain half-started channels until failed-start cleanup completes

Ownership in _stop_and_discard_channel now mirrors ChannelService.stop(): the instance is dropped only after its stop() completes. A cancellation arriving mid-cleanup (or a stop() that raises) leaves it tracked, so a retried readiness attempt stops it again before replacing it and service shutdown can still reach it — untracking first orphaned resources nobody could clean up. Addresses the round-3 review on #5227.

* fix(channels): defer replacement when a retained channel fails to stop

The pre-retry stop in ensure_channel_ready popped unconditionally, so a
retained instance whose second stop() raised was untracked with its
outbound listener still subscribed — the same orphan one hop later.
restart_channel (del after failed stop) and remove_channel (pop before
stop) had the same shape. All three now route through
_stop_and_discard_channel and decline the operation for that round when
the instance is retained, so _start_channel can never overwrite a
still-listening channel. Addresses the review on #5227.

* fix(channels): enforce the retention guarantee inside the readiness attempt loop

A failed attempt whose cleanup retained the instance used to let the next
attempt (attempts=2 is the production default) construct a fresh instance
and overwrite the retained one via _start_channel's unconditional
assignment — orphaning the first instance's subscribed listener one hop
earlier than the cross-round guard covers. The guard now lives at the
mechanism: _start_channel refuses to install while the name is still
tracked, and ensure_channel_ready ends the loop on retention. The shared
discard helper's log message is path-neutral. Addresses the review on
#5227.

* fix(channels): make the retained-instance guard message path-neutral

The guard can fire for any still-tracked instance, not only failed
cleanup, so the message must not assume the cause.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 14:36:55 +08:00
tiammomo
0d4925305a
fix(infoquest): bound HTTP connect and read waits (#5315)
Apply an explicit 30-second connect/read inactivity timeout to InfoQuest reader, web-search and image-search calls.

Fixes #5314

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
2026-09-09 16:31:41 +08:00
RongJie G
06c827903a
feat(persistence): add expand-phase thread incarnation storage (#5216)
* feat(persistence): expand thread incarnation storage

Add nullable thread and MCP task incarnation columns while preserving mixed-version writes. New thread records receive stable incarnation IDs, and new task rows copy the matching owned or shared thread incarnation without changing any read, claim, session, or deletion behavior.

* test(persistence): pin incarnation rollback compatibility

* test(api): pin internal thread response boundary

* fix(persistence): rebase incarnation rollout after projects

---------

Co-authored-by: CorgiBoyG <CorgiBoyG@users.noreply.github.com>
2026-09-09 15:52:09 +08:00
wutongyuonce
3c7d3303d3
feat(gateway): paginate thread run history (#5283)
* feat(gateway): paginate thread run history (#5282)

GET /api/threads/{thread_id}/runs stays a bare array of the newest 100
runs so LangGraph SDK clients keep working. Add GET /runs/page with a
(created_at, run_id) keyset cursor so callers can walk older history.

* fix(gateway): reject one-sided run history cursors

RunManager.list_by_thread now raises if only one of before_created_at
or before_run_id is set, matching the HTTP 422. Document the per-page
sort cost on the SQL keyset query, and add the missing CHANGELOG
[#5282] link definition.

* fix(gateway): round-trip run page cursors through query strings

Emit next_before_created_at with a Z suffix so '+' is not decoded as a
space. Accept that space, and Z, when parsing. Treat blank cursor
fields as absent and reject a non-ISO before_created_at in RunManager
so a harness caller cannot silently restart at the newest page.

* style(gateway): ruff-format run page cursor files

Collapse the one-sided cursor ValueError and the two before_created_at
asserts so ruff format --check passes at line-length 240.
2026-09-09 14:56:31 +08:00
Zeren Wang
05432f4b43
fix(frontend): preserve trusted message positions through content merge (#5293)
* test(threads): add red R3/R4 merge ordering regressions

* fix(threads): preserve trusted seq positions through content merge (R3/R4)

Extract the message ordering/identity logic into a pure
core/threads/message-order.ts module. Each normalized identity now tracks
latest visible content and trusted position separately: content replacement
no longer drops deerflow_seq/run_id/turn_duration (R3), and a seq-carrying
live message is placed by the ascending seq skeleton instead of the next
shared identity anchor (R4: 1,3,2,5 -> 1,2,3,5).

buildVisibleHistoryMessages converges repeated identities to the earliest
visible feed row (mirroring backend get_message_seqs), and the
summarization transient bridge plus rendered ledger share the same
position priority: trusted seq outranks anchor weaving, bridge refreshes
keep known seqs, and hidden control copies never contribute a visible
position.

* test(e2e): add long-thread ordering regression with compaction and pagination

Add tests/e2e/thread-ordering.spec.ts: a deterministic 68-row, 33-turn
fixture with two hidden compaction summaries, a paginated /messages/page
mock, and a live compaction during submit (real SSE frame shapes). Asserts
DOM group order at stage barriers, outline/scroll navigation across the
virtualized list, tool-card association, and order stability across reload.

Also close three mock gaps in mockLangGraphAPI (token-usage, mcp-tasks,
workspace-changes): unmocked they fell through to the absent gateway and
the 401 redirected thread pages to /login, breaking every thread-page spec
in a gateway-less Playwright environment.

* test(threads): address review on ordering regression coverage

- e2e: actually expand the collapsed web_search step and assert the
  intermediate result payload (realistic JSON array fixture); assert the
  new turn's DOM relative order via compareDocumentPosition instead of
  racing viewport coordinates; add a Custom Agent route regression sharing
  the same paginated fixture.
- Add a unit test for the hidden-control-only seq fallback path.
- Keep isNonEmptyString in hooks.ts (message-order.ts does not use it).
- Document the seq-first position authority contract in frontend/src/AGENTS.md.

* test(threads): type run_id fixtures via getMessageRunId accessor

* test(e2e): exercise the real collapsed-steps region for the tool payload check

The previous toolStep.click() was a no-op: as the last tool call, the
web_search step rendered unconditionally. Add a second tool call to the
turn-30 fixture so web_search falls into the collapsed moreSteps region,
assert the intermediate result payload is hidden while collapsed, then
click the "1 more step" button and assert it becomes visible.

* docs(frontend): prettier-format AGENTS.md merge contract

* fix(frontend): anchor mixed-sequence message segments

* test(auth): include project permissions in me contracts

* fix(frontend): anchor trailing steps to positioned live results

* fix(frontend): preserve prefixes before rescued sequence anchors
2026-09-09 11:32:06 +08:00
Sami Belhareth
a3848ef155
fix: expose summary_text in embedded client values events (#5249)
* fix: expose context summary in embedded values events

* test: cover summary values in mode-tagged streams

---------

Co-authored-by: Sami Belhareth <6599699+belharethsami@users.noreply.github.com>
2026-09-09 10:17:11 +08:00
wutongyuonce
d8ed8160c9
fix(sandbox): stop list_dir from reporting failures as empty (#5264)
* fix(sandbox): stop list_dir from reporting failures as empty

Remote providers swallowed find/client errors as [] and 2>/dev/null
missing paths as empty stdout. ls_tool then told the agent the
directory was (empty). Raise OSError/FileNotFoundError instead so
the tool returns Error.

* fix(sandbox): list_dir raises on missing local paths and uses find -H

Empty stdout is not a missing path when find's start point is a
symlink (E2B /mnt/acp-workspace). Dereference only the start point
with find -H. LocalSandbox now raises FileNotFoundError for a
non-directory root, matching remote providers. AIO maps a missing
result.data to OSError rather than FileNotFoundError.

* fix(sandbox): group AIO list_dir find type predicates

Without parentheses, find PATH -maxdepth N -type f -o -type d applies
-type d without maxdepth and can drop files from the listing.

* fix(sandbox): distinguish list_dir command failure from missing path

Tenki, Boxlite, and OpenSandbox treated any empty find stdout as
FileNotFoundError, so a missing find binary (exit 127) or SDK error
looked like a missing directory. Raise OSError when find status is
outside (0, 1); keep FileNotFoundError for the find-ran-but-empty case.

* fix(sandbox): apply list_dir exit-status contract to AIO and E2B

Same gap as Tenki/Boxlite/OpenSandbox: empty find stdout with exit 127
was FileNotFoundError. Raise OSError when the status is outside (0, 1).

* fix(sandbox): classify list_dir by find status not head status

find | head under sh -lc reports head's exit code, so a missing find
binary (127) became FileNotFoundError. Record find's own status after
the bounded listing, treat SIGPIPE 141 as truncation success, and add
a shell-level regression test.

* test(auth): include projects permissions in /me contract pins

#5265 added projects:read/write/delete to the registered route set.
The /auth/me tests still pinned the pre-projects list, so CI failed
after merging main.

* fix(sandbox): do not treat missing list_dir marker as success

The generated script ended on `rm -f`, so process status was 0/1 even
when find's marker never landed. Both codes are in _FIND_OK, and the
parser fallback then classified an empty listing as FileNotFoundError —
the 127 misclassification this helper was meant to close.

Exit with find's status (126 if unknown). A missing marker is now
OSError unless the process status is already a non-OK failure.

* test(sandbox): emit list_dir status marker in provider fixtures

Parser now requires __DF_FIND_STATUS__ and refuses marker-less stdout.
Update AIO/Boxlite/E2B stubs and OpenSandbox/Tenki find fakes so listings
carry :0 and missing paths carry :1 with matching exit codes.

* style(sandbox): format list dir test fixture

* style(sandbox): format remote list dir helper

* docs(sandbox): keep guidance within the tested size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-09 10:12:45 +08:00
Shxiao
fa89a12526
fix(sandbox): mask output tails into POSIX-style virtual paths (#5247)
* fix(sandbox): mask output tails into POSIX-style virtual paths

The output maskers slice the matched path tail from the original
output. With separator-agnostic matching, a Windows-spelled nested
tail kept its backslashes and was spliced into the POSIX-style virtual
path, so glob results and masked read output showed mixed paths like
/mnt/user-data/workspace/pkg\util.py or
/mnt/skills/integrations/lark-cli\lark-doc\SKILL.md. Virtual paths are
always POSIX-style, so normalize nested tails to forward slashes the
same way depth-1 tails already end up. Depth-1 tails and the callable
replacer (LocalSandbox._reverse_resolve_path) were unaffected.

Pin the nested-tail contract in test_sandbox_path_patterns; the
previously failing glob-tool and skills-masking regressions now pass
on Windows hosts.

* refactor(sandbox): share the mask tail-splicing rule; guard it on Linux CI

Review follow-up for #5247:

- hoist the tail-splicing rule (slice off the base, strip leading
  separators, normalize the rest to "/") into
  path_patterns.normalize_mask_tail and import it at both call sites,
  so the two maskers can only drift in their matching logic, not in
  the splice;
- add test_mask_local_paths_normalizes_windows_spelled_skill_tails,
  which spells the skills host root and the output with Windows-style
  strings so the nested tail keeps backslashes on every platform.
  Reverting the mask_local_paths_in_output-side normalization now goes
  red on Linux CI too, not only on Windows hosts.
2026-09-09 10:09:28 +08:00
Wenchao An
0b3dadbc9b
feat(subagents): add acceptance checks to durable batch items (#5289)
* feat(subagents): check and persist durable batch acceptance

Carry optional per-item criteria into native subagents, reuse the deterministic checker, and expose separate verdicts through item queries and exports. Preserve execution and retry semantics, renew leases during checks, and migrate existing batch rows with nullable acceptance fields.

* fix(subagents): align batch acceptance normalization and sandbox admission

* test(auth): include project permissions in the full-stack contract
2026-09-09 08:45:20 +08:00
Parthiban Sivakumar
f5e51b1d4f
fix(tests): await future completion before asserting done() (#5299)
* fix(tests): await future completion before asserting done()

`test_run_on_isolated_subagent_loop_survives_caller_loop_teardown`
signals from inside the coroutine:

    async def deferred_work() -> None:
        completed.set()

`run_on_isolated_subagent_loop` is `asyncio.run_coroutine_threadsafe`,
whose `concurrent.futures.Future` is marked done by the loop only after
the coroutine returns. The main thread can therefore wake from
`completed.wait()` while the future is still pending, and
`assert handles[0].done()` fails:

    assert False
     +  where False = done()
     +    where done = <Future at 0x7f62241b22d0 state=pending>.done

Observed on main at a2808e82 (shard 2) and on an unrelated PR at
852a94dd (shard 4) sixteen seconds apart, so it tracks runner load
rather than any change under test.

Assert the result first — `Future.result(timeout=10)` blocks until the
future completes — then assert `done()`. Both assertions keep their
original meaning and no sleep is introduced.

Fixes #5298

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

* test: drop the now-redundant done() assertion

Review follow-up. Once `result(timeout=10)` has returned normally the
future is guaranteed to be FINISHED, so the `done()` assertion below it
could no longer fail — it documented intent rather than checking
anything.

`result()` alone proves both halves of what the test is about: that the
coroutine body ran after caller-loop teardown, and that the future
resolved. The `completed.wait()` guard above still covers the "work
never ran" case with a descriptive message.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:05:10 +08:00
JeffJiang
dfaeef3772
fix(frontend): support standalone demo APIs and runtime GitHub stars (#5302)
* fix(frontend): support standalone demo APIs and runtime GitHub stars

* Update API origin URL to use environment variables

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

* test(frontend): align static demo tests with runtime origin

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 00:00:56 +08:00
Parthiban Sivakumar
065f84f711
fix(doctor): skip tool checks when tools block is empty (#5301)
* fix(doctor): skip tool checks when tools block is empty

Follow-up to #5296, which fixed this for `models:`. The same defect
remains for `tools:`: `.get("tools", [])` returns None when the key is
present but empty, because the default only applies when the key is
absent. Iterating that None raises TypeError, which the surrounding
broad handler renders as a check result:

    ! web search configured  ('NoneType' object is not iterable)
    ! web fetch configured  ('NoneType' object is not iterable)
    ! web capture configured  ('NoneType' object is not iterable)
    ! image search configured  ('NoneType' object is not iterable)
    ✗ sandbox configured  ('NoneType' object is not iterable)

Line 476 is reached by all four web/image checks through the shared
check_web_tool helper, and line 645 by check_sandbox.

Unlike the models case, a default install does not hit this: `make
config` ships ten real tool entries, so a user has to empty or comment
out that block first.

The web checks now fall through to their normal "no tool in config"
warning and the sandbox check evaluates normally. Parentheses on the
comprehension are for readability; `or` already binds correctly there.

Regression tests use the commented-out `tools:` shape that reproduces
the failure, matching the tests added in #5296.

Fixes #5300

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

* fix(doctor): skip non-mapping tool entries, tighten regression tests

Review follow-ups on the line this PR already changes.

A `tools:` list holding a scalar (`tools:\n  - web_search`) reached
`t.get("name")` and raised AttributeError, which the broad handler
rendered as the check result:

    ! web search configured  ('str' object has no attribute 'get')

That is the same leakage this PR removes for the null case, so it is
fixed here rather than deferred. `check_sandbox` already guards the same
way via `isinstance(tool, dict)`.

The empty-tools test asserted that "NoneType" was absent from the
detail, which pins the failure mode rather than the behaviour — it would
still pass if the detail became some other internal error text. Both
tests now assert the expected message directly.

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

* test(doctor): assert sandbox outcome instead of the failure mode

Review follow-up, same class as the web-tool assertion fixed earlier in
this PR. The sandbox regression test still asserted that "NoneType" was
absent from the detail, which pins the failure mode rather than the
outcome — it would keep passing if some other internal error text leaked
out of the broad handler.

On this config the path is deterministic: an empty `tools:` means no
bash tool, so exactly one result. Assert the fields directly
(`CheckResult` has no `__eq__`, so whole instances cannot be compared by
value).

Verified against `main`'s scripts/doctor.py, where the same config
yields status=fail and detail="'NoneType' object is not iterable", so
the new assertions are red there and green here.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:29:52 +08:00
Willem Jiang
062273f850
chore(doc): update the CHANGLOG with the latest changes. (#5297)
* chore(doc):updated the CHANGELOG.md with latest changes

* chore(doc):updated the CHANGELOG_zh.md with latest changes
2026-09-08 20:13:18 +08:00
Parthiban Sivakumar
611801d5c0
fix(doctor): skip LLM checks when models block is empty (#5296)
`config.example.yaml` ships a `models:` key with every entry commented
out, so it parses as None rather than an empty list and the `[]` default
in `.get("models", [])` never applies. Iterating that None raised
TypeError, which the surrounding broad handler rendered as a check
result:

    ✗ LLM API key check  ('NoneType' object is not iterable)
    ✗ LLM auth check  ('NoneType' object is not iterable)
    ✗ LLM package check  ('NoneType' object is not iterable)

Every fresh install hit this before configuring a model, turning one
actionable error into four and hiding the real "models configured" hint
behind internal exception text.

Fall back on a falsy value at the three iteration sites so the checks
return no results when nothing is configured. `check_models_configured`
gets the same treatment for consistency; it was already correct because
it tests truthiness rather than iterating.

The existing tests missed this because they use `models: []`, an
explicit empty list, which iterates fine. The added regression tests use
the commented-out shape that `make config` actually produces.

`make doctor` now reports 1 error instead of 4 on a fresh clone.

Fixes #5295

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-08 20:01:39 +08:00