* fix(extensions): isolate team workers and rotate admissions
* fix(extensions): index and batch pending team snapshots
* fix(agent-teams): retain unfinished owner rotation rounds
Carry the remaining owner queue across planning passes, including exhausted owners and newcomers. Read legacy rows completely before updating their pending projection and distinguish dispatch reservations from sorted entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(channels): add channels.leader_lease and gate it under multi-process signals
IM channels other than GitHub connect outbound to their platform. With more
than one Gateway instance each one opens that connection with the same bot
credentials: Telegram rejects the second poller with 409, Discord answers every
message twice and per-process ChannelManager state diverges. The multi-process
startup gate said nothing about channels; the only guard was a comment in the
Helm values.
Add the `channels.leader_lease` section (`enabled: auto|true|false`,
`lease_seconds`, `grace_seconds`; startup-only, registered in the reload
boundary) that the per-provider leader lease will read, validated by a Pydantic
model from the non-schema `channels` mapping. The gate now refuses
`enabled: false` under a multi-process signal while a non-GitHub provider is
enabled under `channels.*` or connectable through `channel_connections`, naming
the providers and the rollback; a malformed block is refused on every topology.
`auto` follows the multi-process signal, so a single instance is unchanged.
ChannelService strips the block before its per-provider loops and exposes it
as `leader_lease`; `ensure_ready_channels` only visits registered providers.
The two-Gateway harness overlay leaves the block alone. config_version 60 in
the example, the Helm chart and its README; docs replace the comment-only
"keep 1 replica" guidance with the gate.
* docs(changelog): link #6746
* fix(channels): stop recommending the unimplemented leader lease as a remedy
Review on #6746: the refusal told operators to switch channels.leader_lease
from false to auto/true "so one instance owns each provider's connection", but
the transport arbitration ships separately, so that change only bypassed the
clause and every worker still started the same bot.
The refusal now offers only the remedies that work today (a single instance, or
disabling the channels) and states that auto/true are reserved for the lease
that ships separately. A regression helper in the gate tests asserts the
remedies start with the rollback and that auto/true are never offered. The
config example, Helm values/README, CONFIGURATION.md, README, the deployment
guide (en/zh), the model docstrings and both changelogs describe auto/true as
reserved settings rather than an ownership guarantee.
* fix(channels): warn on an enabled unknown channel key; reword the changelog entry
Review on #6746: the registry guard in ensure_ready_channels skipped a
misspelled provider key (channels.telegam) silently, where it previously
reached _start_channel and logged 'Unknown channel type'. An enabled dict
block that is neither a registered provider nor leader_lease now logs a
warning that lists the registry.
The changelog entry claimed the chart and docs drop the "keep 1 replica"
guidance; they keep it (auto/true do not make several replicas safe yet) and
only stop relying on the comment alone.
* fix(dev): disable UI channel providers in multi-instance overlay
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): read the complete scope in the substring search fallback
_search_substring iterated list_facts without paginating, so the
adapter-less fallback only ever saw the default page of 100 facts.
With 150 matching facts in a scope (max_facts allows up to 500),
memory_search returned 100 results for top_k=150 and silently dropped
every match past the first page; DeerMem.search keeps these results
whenever they are non-empty, so its uncapped fallback never ran. Read
the complete scope instead, as the scoped index rebuild already does.
* test(memory): drop in-page fact-100 from the past-page assertion
fact-100 was the last id inside list_facts' old first page
(``matched[0:100]`` in insertion order), so its presence does not
distinguish the fixed search from the pre-fix page; fact-101 and
fact-150 (plus the ``len(results) == 150`` check) carry the regression
signal. Keep a boundary note so the remaining ids are not misread.
* feat(extensions): add example guide and packaging checks
* fix(extensions): align packaging checks with installer
Reject Windows drive-qualified wheel paths before extraction and reuse the installer entry-point validator. Cover unsafe paths, dotted attributes, and valid dotted modules in regression tests.
* fix(extensions): skip unused wheel extraction
Only extract wheels when browser manifests need validation, with a backend-only regression test. Link the three remaining example READMEs in the developer guide.
* docs(frontend): trim guidance intro below size budget
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): keep non-core fact categories instead of rewriting to other
_normalize_category rewrote any category outside CORE_CATEGORIES to
"other" and parked the original label in a write-only
categoryExtension field, so memory_add(category="personal") -- an
example label in the tool's own docstring -- persisted as "other" and
memory_search(category="personal") ("Only facts with this exact category
are returned") never matched it. get_memory documents kept reporting
"other" as well.
Keep the caller's label as the stored category so it round-trips
through search, documents, and the retrieval index.
* fix(memory): finish migrations interrupted across the category change
A pre-native-category Gateway committed a per-agent legacy JSON migration
as category="other" + categoryExtension=<label> and could stop before
deleting the legacy JSON (crash / failed unlink). The retained JSON holds
the label natively, so _migrate_locked compares "personal" against its
own committed "other"/"personal" alias, raises MemoryStorageCorruption,
and fails every subsequent read, reload and migration retry for that
agent (HTTP 500 at the Gateway) -- a recoverable state the previous
build recovered from.
Fold the legacy alias into _migration_equivalent's category comparison
("other"/<label> equals <label>/no-extension) so retries recognize and
complete recovery, while a genuinely different label still stops the
migration with the legacy file preserved.
Also drop a stale categoryExtension when a fact is normalized away from
"other": _normalize_fact deep-copies unknown fields through, so a legacy
fact updated to its native category kept surfacing the leftover
extension, contradicting the Gateway contract that the field is only
set "when category is 'other'".
Both regressions are pinned by replaying the historical writer in tests.
---------
Co-authored-by: BlueX888 <BlueX888@users.noreply.github.com>
* docs(agents): trim restated rationale to restore AG002 chain headroom
The agents/middlewares AGENTS.md chain crossed the 96 KiB AG002 hard
limit at 3f7a0769 (98441 > 98304 bytes); that push produced no lint-check
run, and later pushes compare equal to their base, so main stayed green
while a full-worktree check_agent_guidance run reports an error. Trim
restated rationale:
- backend/AGENTS.md: the Feishu button-card sentence restated semantics
app/channels/AGENTS.md already owns (originator-validated, one-time,
fail-closed clicks); keep the flag visibility and point at the module
guide.
- agents/middlewares/AGENTS.md: drop the membership-probe rationale
duplicated by the model_response.py paragraph, the ToolOutputBudget
ordering narration, and the lead-only append note restated by the
lead-only middlewares heading.
Chain: 98441 -> 98198 bytes (106 under the hard limit).
* docs(channels): own the Feishu card fail-closed invariant in the module guide
Review follow-up on #6759: the backend/AGENTS.md trim dropped two things
app/channels/AGENTS.md item 7 does not own - the fail-closed-across-workers
guarantee and the owning file's name - so the invariant left every
instruction chain and the pointer dead-ended one level above the
implementation. State both in item 7, paid by dropping the therefore-clause
restated by the single-active-owner invariant above it (the partial unique
index and the losing-writer retry already imply deterministic resolution).
channels/AGENTS.md: 49134 -> 49129 bytes (AG001 headroom 18 -> 23).
---------
Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(qq): parse allowed_users with the shared fail-closed allowlist
QQChannel built its allowlist with set(config.get("allowed_users") or []).
A single OpenID, which is what an `allowed_users: $QQ_ALLOWED_USERS`
reference resolves to, became a set of its characters, so the configured
user was silently dropped. A mapping authorized every key, including one
mapped to false, and a numeric scalar raised TypeError while the channel
was being constructed.
Parse it with the shared parse_allowed_users helper that Telegram and
WeChat already use: unset, null, empty collections and a blank string
allow everyone; a scalar OpenID is one entry; entries that are not
non-blank strings (such as numeric QQ account numbers) are dropped with a
warning, and a mapping is rejected as a whole; a configured value with no
valid OpenID denies every sender and logs an error. A scalar containing commas or whitespace warns
that it stays one literal ID. Binding codes still precede the allowlist.
* test(qq): gate group messages by member OpenID in allowlist tests
Every allowlist test sent a C2C frame, and the shared event() helper puts
the sender in both user_openid and member_openid, so a group-path
regression in sender resolution would pass unnoticed. admits() now takes
a kind; group frames carry only member_openid and a group_openid that
differs from the sender. The listed-OpenID test runs for both kinds, and a
new test shows that listing the group's OpenID does not admit its members.
Document that a blank or whitespace-only allowed_users string allows every
sender, like an unset value or an empty list.
* refactor(channels): share scalar allowlist separator warnings
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(client): convert the captured upload source
* test(uploads): cover ACL sources and scope capture failures
* fix(uploads): preserve concurrent uploads from one captured source
Combine the embedded client's source capture with no-replace publication for
first-time filenames and Markdown companions. Hold one private capture across
collision retries, retain original descriptor metadata, and keep replacement
behavior for names present when the call starts.
Port concurrent-upload and staging-publication regressions from PR #6687.
Extend source-edit/removal and real XLSX cases to both publication paths, and
cover edits or removal during two filename collisions in ordinary and event-loop
calls. Document capture, retry, publication, and cleanup contracts.
Validation: related suites 469 passed, 24 skipped; full offline suite 28,788
passed, 488 skipped; blocking-I/O suite 245 passed, 1 skipped; Ruff, guidance,
and diff checks passed. Nine race/collision cases failed on the #6632 baseline.
Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
* docs: keep agent guidance within CI budgets
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
* fix(sandbox): decode PowerShell native-child output with the locale fallback
Fixes#6646
## What
The Windows PowerShell branch of `LocalSandbox.execute_command` passed `encoding="utf-8"` to `_run_windows_command` with no `fallback_encoding`. The guarded console-UTF-8 preamble covers PowerShell's *own* output, but native children it runs (`python.exe`, CLI shims) inherit the pipe and keep writing the host code page, so the pipe decoder's `errors="replace"` turned every such byte into U+FFFD. This passes `fallback_encoding=locale.getpreferredencoding(False)` there, mirroring what the MSYS branch above already applies (#6548).
## Why
On Windows hosts whose ANSI code page is not UTF-8 (GBK on zh-CN hosts, cp932, cp949, cp1251, ...), non-ASCII tool results printed by native children reach the model garbled (#6646). PowerShell's own output stays correct because the preamble pins it to UTF-8, so the fallback only engages when a buffer is not valid UTF-8 — exactly the case where a native child wrote host-code-page bytes. Pure-ASCII and PowerShell-emitted UTF-8 buffers never touch the fallback, matching the tradeoff the MSYS branch already accepts.
## How checked
Reproduced on a real zh-CN Windows host (ANSI code page 936):
- Red on `main` (3f7a0769): a native `python.exe` child printing CJK text through `powershell.exe` / `pwsh.exe` returned `'\ufffd\ufffd\ufffd \ufffd\u0571\ufffd\ufffdZ\n'` — the new regression test failed on both shells:
```
FAILED tests/test_local_sandbox_encoding.py::test_windows_powershell_native_child_cjk_roundtrip[powershell.exe]
FAILED tests/test_local_sandbox_encoding.py::test_windows_powershell_native_child_cjk_roundtrip[pwsh.exe]
```
- Green with the fix: both pass, and the child's text round-trips exactly (`'\u4f60\u597d \u65e5\u672c\u8a9e\n'`).
- `tests/test_local_sandbox_encoding.py`: 34 passed, 1 skipped (Windows 11, CPython 3.13, `uv sync --frozen` environment). The two pre-existing PowerShell unit tests were extended so their `_run_windows_command` fakes accept the new `fallback_encoding` kwarg and assert it is the locale code page.
- `ruff check` and `ruff format --check` pass on both changed files.
* fix(sandbox): decode fallback must survive Python UTF-8 mode and mid-character captures
Follow-up to review feedback on #6727.
- Use ``locale.getencoding()`` (3.11+, repo floor is 3.12) instead of
``locale.getpreferredencoding(False)`` for both fallback call sites
(MSYS and the native-child guard): ``getpreferredencoding`` reports
``"utf-8"`` whenever the Gateway runs under Python UTF-8 mode
(``PYTHONUTF8=1``, ``-X utf8``), silently disabling the fallback and
leaving #6646 unfixed on those hosts. ``getencoding()`` deliberately
ignores UTF-8 mode and returns the code page the child's CRT used.
- After a failed strict UTF-8 decode, retry once without a trailing
incomplete multi-byte sequence, so a capture cut mid-character (the
10MB capture-cap boundary, or a timeout kill interrupting the child
mid-write) cannot cascade the whole buffer to the locale fallback.
Complete non-UTF-8 buffers still fail the retried attempt and reach
the fallback untouched.
- Tests: the Windows roundtrip probes now run under ``PYTHONUTF8=1``
with ``locale.getencoding`` pinned, so a green run proves the fallback
source ignores UTF-8 mode; added POSIX-runnable unit tests for the
cap-split and timeout-kill partial tails.
Test results (Windows host, ACP=936, repo venv via ``uv sync --frozen``):
``tests/test_local_sandbox_encoding.py`` 36 passed, 1 skipped, both with
``PYTHONUTF8=0`` and ``PYTHONUTF8=1``; ``ruff check`` and
``ruff format --check`` clean on both files.
* fix(sandbox): trim only on cut captures, preserving complete locale tails
Willem-bd's follow-up review pinned a real P2: on a cp1252 host the MSYS
branch receives b'caf\xe9' (a complete e-acute), the trailing E9 is trimmed
as an incomplete UTF-8 lead, and the ASCII prefix is accepted as valid UTF-8
- so the locale fallback never runs and the byte is silently dropped. No
capture cap or timeout is needed to trigger it.
Fix: the strict-retry trim is now authorized only when the capture may
actually have been cut mid-character - a byte-cap cut (signalled by the
capture's own truncated state) or a timeout kill (new mark_split_tail(),
called from _run_windows_command right after the process tree is killed). A
complete buffer, even one ending in a byte shaped like a bare UTF-8 lead,
now goes to the fallback with every byte intact.
Tests:
- new: cp1252 complete-tail roundtrip (red on b62ef75: returned 'caf').
- new: the same partial-shaped tail without a cut signal must fall back
intact instead of being accepted as a truncated UTF-8 prefix.
- new: POSIX-runnable _run_windows_command timeout integration test - the
child writes a partial UTF-8 sequence, hangs, is killed at the 1s
timeout; the trimmed prefix survives (this goes red if the kill path
forgets to authorize the trim, since the whole buffer cascades to the
fallback).
- updated: the timeout-kill unit test now marks the capture explicitly,
mirroring the real call site.
Evidence: Windows 11, Python 3.13.12 (repo venv), pytest
tests/test_local_sandbox_encoding.py -> 39 passed, 1 skipped under both
PYTHONUTF8=0 and PYTHONUTF8=1; ruff check + ruff format --check clean.
Red run against b62ef75: the three new/updated tests fail exactly as
described above.
* fix(sofya): report a format error for results with no usable objects
`_response_results` skipped non-object entries but returned the filtered
list even when it was empty, so a non-empty `results` array holding no
objects fell through to "No results found" -- telling the agent the query
legitimately matched nothing instead of surfacing the malformed payload.
Treat a non-empty list with no object entries as malformed, matching the
containment rule #6603 documents for this provider family (brave #6550,
groundroute #6603, tavily #6645, webz). Both `web_search` and
`web_fetch` share this helper, so both now report the provider's format
error. Missing, null, and empty `results` keep returning "No results
found", and mixed lists still keep their valid entries in order.
* test(sofya): cover mixed fetch result entries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(dingtalk): preserve Markdown table cell boundaries
DingTalk's sampleMarkdown fallback re-splits table rows on every pipe, so an
escaped pipe (\|) inside a cell is read as a column separator and every value
after it shifts one column to the left. The strip("|") wrapper also removes an
empty leading or trailing cell, dropping that value's column label.
Split rows on unescaped pipes only, and drop at most one outer border cell per
side, so escaped pipes and empty edge cells stay under the right label.
Adds six parameterized regressions plus one full fallback-adapter regression;
all seven fail on the base commit and pass after the fix.
* docs(dingtalk): document escaped-backslash boundary in table row splitter
Document the residual boundary case flagged in review: the
single-character lookbehind in _split_markdown_table_row cannot
distinguish an escaped pipe (\|) from an escaped backslash followed
by a real separator (\\|), so a cell ending in a literal backslash
absorbs the next cell. Full GFM backslash counting is out of scope
for this helper.
* fix(uploads): convert the same source version the upload saved (#6600)
DeerFlowClient.upload_files copied the caller's file into the uploads
directory, then reopened the original path for Markdown conversion. A
save landing between the two reads left the saved upload at version A
while the companion registered version B, and resolve_companion still
accepted the mismatched Markdown.
Snapshot convertible sources into a private temp dir up front and run
both the upload copy and the conversion from that one read. The snapshot
keeps the #5611 invariant (conversion never reopens a name inside the
sandbox-writable uploads dir), preserves metadata via copy2, and keeps
the re-upload SameFileError rejection through an explicit same-file
probe before the snapshot.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* fix(uploads): pin the snapshot copy to one held source descriptor
Review on #6621 found two gaps in the snapshot step: stat-by-name then
copy2-by-name split identity from content across two path resolutions,
and copy2 drops ACLs so a source readable only via ACL made the private
snapshot unreadable. Open the source once, fstat it for the same-file
rejection, and copyfileobj out of the held fd; restore the mtime from
that same stat so the upload keeps the source's timestamps.
Tests: the version-parity regression's injection now also fires when a
future regression reads the caller's path during conversion, and a real
macOS ACL case (mode 000 + read grant) uploads and converts cleanly.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* test: catch upload conversion source regressions
---------
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read subagent batch exports in small pages
The results.jsonl export loaded 500 full rows per page before streaming
them. A row can hold max_result_chars of report plus as much evidence
JSON (result_artifact), so with the default limit one request peaked
around 280 MB and decoding a page's JSON columns stalled the event loop
for about half a second; at the 1,000,000 ceiling a page was roughly
1.7 GB.
Read 10 rows per page instead. Output and ordering are unchanged; a
full export of 1,000 default-sized rows now peaks at 6 MB with a 46 ms
worst stall and takes the same time.
* docs(changelog): reference #6706
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): keep the thread list offset past hidden side chats
Primary thread lists hide sidecar threads on the client, so a page of 50
visible chats can consume more server rows. fetchInfiniteThreadsPage kept
that server offset in a Symbol property on the page array, but TanStack
Query's structural sharing (replaceEqualDeep) rebuilds a changed array
without symbol keys, and the list cache writers (rename, unread, pin,
delete, upsert) rebuild pages with map/filter. After the first refetch or
cache write, getInfiniteThreadsNextPageParam fell back to the sum of the
visible page lengths, so the next page started at 50 instead of 53 and
re-fetched rows already loaded. The same fallback treated a full page
that lost a row to a local delete as the end of the list.
Each cached page is now { threads, hiddenCount, hasMore }. The next offset
is the sum of visible and hidden rows over the loaded pages, so local
deletes and inserts still move it with the server list, and the end of the
list comes from the server response rather than the page length.
useInfiniteThreads projects the pages back to thread arrays with select,
so list components are unchanged.
* test(frontend): pin the sidebar's next-page offset past side chats in E2E
Three side chats in the first 50 server rows push the sidebar's second
page to offset 53. After a rename through the sidebar menu, scrolling to
the sentinel must request offset 53, not 50. On main this test fails with
the request at offset 50.
* docs(changelog): reference #6702
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): static-website gate and deep-link fallback for user management (#6641 follow-ups)
Close the two leftovers from the #6641 review:
- user-settings-page now gates on isStaticWebsiteOnly() like model management
(the static demo user is nominally an admin, but there is no Gateway to
query), and the users adminOnly copy gains the same 'unavailable in demos'
sentence the models copy already carries.
- the settings dialog derives the active section from the nav actually offered
to the current user, so a ?settings=users deep link (or a stale dialog
store) for a non-admin falls back to the first section instead of rendering
an orphan panel with no highlighted entry.
Tests: static demo loads nothing (mutation-verified red), non-admin deep link
falls back to models, admin deep link lands on user management; full unit
suite green.
* test(frontend): pin the static-on x admin matrix cell at the dialog level
Dialog-level coverage for the real demo path (review follow-up on #6691):
admin + isStaticWebsiteOnly() + ?settings=users keeps the Users nav entry
(the splice keys on the role only) while the page-level gate shows the
admin-only note and issues no query. Mutation-verified: adding the mode
flag to the nav splice turns this test red and only this test.
* feat(channels): render Feishu option clarifications as button cards
When the agent's ask_clarification carries options, the Feishu channel
now renders the question as an interactive card with one button per
option instead of plain text. This is the outbound half: card
construction plus a process-local pending registry that a later
card-action callback handler will validate clicks against.
- New feishu_card_interaction module owns request extraction, button
card rendering, and the pending registry (30-minute TTL, matching the
typed-reply clarification window)
- Manager attaches the clarification's structured human_input payload to
the final outbound's metadata so channels can render richer
affordances without touching the Gateway protocol
- Feishu channel renders the button card only when
card_interaction_enabled is set (default off); free-text, form, and
over-four-option clarifications keep the plain-text path
- Button cards bypass the markdown wrapper via a raw_card_content flag
on the card send/patch helpers; plain-text call signatures unchanged
* feat(channels): close the Feishu button-card click loop
The inbound half of interactive clarification cards: a click on an
option button validates, resolves the clarification, and marks the
card in place.
- Register the card-action callback only when card_interaction_enabled
is set; clicks arrive as WS EVENT frames through the SDK's callback
processor map, and the synchronous return value becomes the toast ack
- consume_if_originator atomically validates operator identity against
the pending entry: non-originators get a refusal toast and the entry
survives; unknown or expired requests are toast-only and dropped
- A validated click consumes once, updates the card to a terminal
resolved state, and injects a hidden inbound carrying the
human_input_response so the run resumes on the originating thread
with Web-card parity
- Manager translates the inbound's human_input_response metadata into
the human message's additional_kwargs with hide_from_ui set
* docs(channels): document Feishu interactive clarification cards
- README / README_zh: optional card_interaction_enabled setup, calling
out that card.action.trigger lives under the console's callback
configuration tab (separate from event subscriptions) and requires
publishing a new app version
- config.example.yaml: commented flag with the same console caveat
- backend/AGENTS.md: module ownership note for feishu_card_interaction
* fix(channels): close Feishu card-click routing and ack gaps from review
Address review on #6670:
- Carry the routing identity (topic_id, connection_id, owner_user_id)
on the pending entry and the synthetic inbound, so clicks resolve to
the clarified thread in topic-scoped chats and pass bound-identity
admission instead of opening a new thread or being rejected
- Validate option_value against the registered options at consume time:
an unknown value is treated like a stale card and the entry is kept
- Return a warning toast when a click cannot be dispatched, and flip
the card to its resolved state only after the inbound is durably in
the bus — never display a choice that was not recorded
- Drop the dead mark_sent/consume registry APIs and pass the payload's
own option ids through to the buttons
- Add an end-to-end test through the manager's thread resolution
pinning that a card answer reuses the clarified thread
* fix(channels): carry workspace_id on the Feishu card-click inbound
The connections-enabled admission path re-reads the binding with the
message's workspace_id, and Feishu connections are bound with
workspace_id = chat_id. The synthetic inbound set connection_id and
owner_user_id but never workspace_id, so the re-read missed and every
click was rejected as unbound after the entry was consumed.
- Set inbound.workspace_id from the entry's chat_id (Feishu's
chat-as-workspace binding convention)
- Extend the card-click e2e to require_bound_identity=True, driven
through the channel's real _resolve_card_click production
* fix(channels): stabilize Feishu clarification delivery and routing
Option cards could fail through the real reply SDK, resume another conversation,
or leave a stale text hint after the answer was accepted.
Unify raw-card delivery and persist trusted thread aliases before showing buttons.
Keep protocol request IDs separate from bounded, one-time callback tokens; validate
originators and option pairs, and retire only the accepted card's text/button state.
Treat valid hidden human answers as turn boundaries so old replies and artifacts
do not leak into the next answer. Cover consecutive clarifications and /new routing
through real SDK request models, stores, and the run journal.
Validation: 147 focused tests and 29,421 offline tests passed (405 skipped);
strict blocking-I/O passed 245 tests (1 skipped). Ruff and guidance checks passed.
* fix(channels): preserve clarification context in Feishu cards
Button cards replaced the full clarification text but omitted its background,
leaving users without the context needed to choose an option.
Carry optional context from request metadata and render its original Markdown
before the question. Keep question-only cards for absent, blank, or invalid context.
Cover rendering and all real SDK delivery paths, and update both user guides.
Validation: 156 focused tests, 245 strict blocking-I/O tests, Ruff, and guidance
checks passed. The offline run recorded 29,381 passed, 49 failed, and 405 skipped;
48 import failures reproduced before the fix. All 49 cases passed on both versions
in isolated environments, and the Feishu-to-export order check passed 13 tests.
* fix(skillscan): match fork bombs by shape, not spelling
`resource-fork-bomb` and the fork-bomb branch of `shell-destructive-command`
only matched the canonical `:(){ :|:& };:` rendering, so renamed or respaced
payloads produced no findings and passed the blocking gate.
Match the general shape instead - a shell function whose body recurses through
a pipe into a background job - keeping the pre-LLM static gate at least as
wide as the runtime sandbox audit middleware it backstops.
Closes#6651.
* fix(skillscan): bound the fork-bomb pattern and require self-recursion
Addresses the three review findings on #6689:
- Bound every quantifier in the shared fork-bomb pattern so a whitespace-free
blob costs O(n) instead of O(n^2) (measured ~0.15s at 200KB; the previous
spelling took ~5.5s at just 50KB).
- Require the function name to recur after the pipe (backreference), so a
benign pipeline such as `backup() { rsync -a src/ | logger & }` no longer
blocks as a CRITICAL finding.
- Keep both gates byte-identical again: skillscan's _FORK_BOMB_PATTERN and the
sandbox_audit_middleware fork-bomb entry, pinned by a parity test.
- The code comment now describes exactly what the pattern checks; pipe-less
self-backgrounding recursion and the spaced `|&` spelling are documented as
out of scope.
- New regression tests: near-miss pipelines in both suites, the parity
invariant, and a 200KB single-token scan-time ceiling.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): show a date-only run_at at midnight in the task's zone
A chat-created one-time task can store run_at as a bare date
("2026-12-07"). The server reads it with datetime.fromisoformat as
midnight in the task's timezone. onceRunAtInstant instead returned it
unchanged: its offset check matched the trailing "-07" as a UTC offset,
and its local-time pattern needed a time anyway. new Date() then read it
as UTC midnight, so the schedule line, chat card and edit form showed
08:00 in Asia/Shanghai and the previous evening west of UTC.
A bare date is now matched first and converted as midnight in the task's
zone, the same way a time-bearing local run_at is.
* docs(changelog): reference #6692
* test(frontend): pin the clock in the date-only run_at E2E
The detail page formats the schedule line against the real clock: it
drops the year inside the current year and switches to relative labels
in the final week. The exact-text assertion therefore failed throughout
2027 ("Once, Dec 7 00:00 ...") and again as "Once, Tue 00:00 ..." in the
week before the run. Fix "now" at 2026-10-01, the same instant the unit
test uses, so the assertion stays deterministic.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scheduler): preserve unchanged once schedules near execution
* docs: keep schedule guidance within the size limit
* style(scheduler): use English for the once-edit comment
* docs(scheduler): keep guidance within directory budgets
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): return to the opened workspace page after signing in
Opening a workspace page without a valid session sent the user to a bare
/login: the workspace layout called redirect("/login"), and a layout
cannot read the request URL, so the path and query were both lost. A
scheduled-task notification link (/workspace/scheduled-tasks?task_id=X)
opened after the session expired therefore landed on the default
workspace after sign-in.
For an unauthenticated verdict the layout now renders
WorkspaceLoginRedirect, which replaces the page with
/login?next=<path+query> from the browser. It renders nothing else, so
the workspace pages under it never mount.
AuthProvider.refreshUser and useRequireAuth kept the path but dropped
the query. They now share currentReturnPath() with the fetcher, which
already kept both.
* docs(changelog): reference #6681
* fix(frontend): keep the fragment in the sign-in return path
currentReturnPath() kept the path and query but not location.hash, so
an anchored deep link (/workspace/chats/x#section) lost its anchor across
the login round trip. It is now the single source for all four bounce
sites, so include the fragment. The server never receives the fragment,
so only the browser-side bounce can keep it. validateAuthNextPath, the
backend's validate_next_param and its quote() into /auth/callback all
carry a '#' through unchanged.
The models API test stubbed window.location without hash; a real
Location always has one, so the stub now carries it.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(acp): resolve an absolute Windows agent command before spawning
A command configured as an absolute path without an extension -- npm's global
shim layout, e.g. C:\npm\mcode beside mcode.cmd -- was handed to the ACP SDK
unchanged and always failed: create_subprocess_exec applies no PATHEXT, so it
ran npm's extensionless shell script and died with WinError 193 (or raised
FileNotFoundError when only the .cmd exists). The separator guard from #6598
was too broad: only relative paths need to stay with the spawn, because their
meaning is owned by the ACP-workspace cwd, while an absolute path does not
depend on the cwd.
Absolute paths now go through the same shutil.which lookup as bare names: the
lookup searches the command's own directory and probes PATHEXT, and the
configured value is kept when nothing matches so the not-found remediation
still fires. Relative paths (bin/agent, ./bin/agent) still go to the spawn
unchanged.
Docs: README.md and backend/packages/harness/deerflow/tools/AGENTS.md now
describe the bare/absolute vs relative split.
AI disclosure: this change was drafted with AI assistance at my request, under
my direction and review.
* fix(acp): keep already-absolute lookup results unchanged
Review feedback on #6656 (willem-bd): os.path.abspath collapsed a `..` that
follows a symlink, so an absolute POSIX command like
`/srv/current/../shared/agent` (with `current -> releases/v1`) was rewritten to
`/srv/shared/agent` -- a path that either does not exist or names a different
launcher. The anchoring to the Gateway cwd exists only for relative PATH
lookup results, where shutil.which cannot know the cwd the spawn will use; an
already-absolute result needs no rewrite.
Regressions: a platform-independent stub-lookup test pinning the literal
parent-segment path, and a POSIX-only test that builds the symlink layout and
asserts the configured command is returned unchanged.
* fix(runtime): mark cancel takeovers with the orphan-recovery stop reason
A cross-worker cancel that takes over an expired-lease run writes error but
left stop_reason unset. `orphan_recovered` is the only signal that lets a
store-only SSE/`wait` consumer synthesize END after a heartbeat, because the
producer is known to be gone. Without it, a subscriber that outlives the
crashed owner waits forever for an END no surviving worker publishes; periodic
reconciliation ignores the row because it is already terminal.
Mark the takeover like periodic orphan recovery: claim_for_takeover already
accepts the stop reason and the reconcile path already passes it. The new
tests pin the takeover row's marker and prove a real takeover wakes a waiting
consumer; three existing takeover test doubles now forward the store argument.
* docs(gateway): keep guidance within the 48KB budget
The takeover stop-reason sentence pushed backend/app/gateway/AGENTS.md over its 48KB hard limit, failing agent-guidance and the backend guidance-budget test. Trim the sentence; the rationale stays in the manager.py comment.
* fix(gateway): complete streams after scheduler orphan recovery
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): stop resolving sandbox and ACP env values a second time
AppConfig.resolve_env_variables already replaces every $VAR when
config.yaml loads. The AIO, E2B and OpenSandbox providers and the ACP
tool then resolved the result again, so a secret that itself starts with
$ (a bcrypt hash, a generated password) was looked up as a variable name
and reached the sandbox or agent as "" -- or as an unrelated host
variable's value when that name existed.
Pass the loaded values through unchanged, as BoxLite and the local
provider already do. The removed helpers' str() coercion was dead: both
fields are typed dict[str, str] and reject non-strings at load.
* docs(changelog): reference #6673
* docs(config): pin when sandbox and ACP env values are resolved
ACPAgentConfig.env and the SandboxConfig docstring said $ values are
resolved from the host environment without saying when, which reads as
"at spawn time" and invites a consumer-side second pass again. Both now
say it happens when config is loaded, matching SandboxConfig.environment.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
test_skillscan_env_launcher.py and test_channels.py put payloads of up to
52 KB directly in parametrize values, so pytest's generated item ids exceed
the 32767-character Windows environment-variable limit: PYTEST_CURRENT_TEST
raises ValueError at setup and teardown even though the products under test
behave correctly, and the giant ids flood -q reports.
The skillscan linearity test additionally spawned one child process per
chain. A fresh child spends 9-18 s importing the skills stack before the
4-168 ms scan, so its fixed 10 s timeout measured the import instead of the
scan and flipped to subprocess.TimeoutExpired on a loaded host.
Give the oversized cases short explicit ids, scan every chain in one child
process fed through stdin, and enforce the linearity bound on each scan
inside that child (5 s) while a 60 s outer timeout covers the import.
* fix(lark): publish the skill file and manifest atomically
_write_skill_file and _write_manifest wrote their final paths with a truncating
write, while the runtime files in the same module already go through
_publish_bytes_atomically. A partial skill file or manifest is unparseable, and
the manifest carries the content hash the install is verified against, so a
half-written one cannot be repaired. Route both through the existing helper.
* fix(lark): address skill publication review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agents): skip content blocks with unhashable type values
has_visible_content and ParentContextSnapshot.from_state classified a
content block's type with set membership (`in {"text", "output_text"}`
plus the _MEDIA_BLOCK_TYPES frozenset). `type: []` / `type: {}` are
legal JSON from a provider, and hashing an unhashable value raises
TypeError instead of skipping the block:
- has_visible_content runs on every model response through the
llm-error-handling (_raise_for_empty_response), terminal-response and
model-length middlewares, so one malformed block turned a completed
response into a failed run instead of a skipped block;
- from_state builds the history every background subagent dispatch is
seeded from, so the same block failed the dispatch.
Probe the type by equality with tuples so a non-string type simply fails
the probe, mirroring #6614's fix for extract_response_text and the
isinstance guard clarification already uses for form field types. The
media constant becomes a tuple too, so the second probe in the same loop
cannot move the crash one line down.
Tests pin the RED-to-GREEN cycle at both call sites: unit-level
has_visible_content, middleware-level empty-response classification,
and the dispatch snapshot across human/ai/tool messages.
Fixes#6663
Signed-off-by: yetuge <2219677952@qq.com>
* fix(agents): warn when a snapshot skips a non-string-typed block
Review follow-up on #6664: with the malformed block now skipped
instead of raising, a provider that systematically emits non-string
types would degrade the child's history with nothing in the logs to
explain it. The elif chain cannot log on fall-through —
reasoning/signature and tool-use exclusions are deliberate and must
stay quiet — so the signal keys on the type not being a str, matching
the guard the review suggested.
has_visible_content keeps its silence on purpose: the empty-response
retry already surfaces the symptom there.
Fixes#6663
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
* fix(tavily): contain malformed web search and fetch payloads
web_search and web_fetch normalized the SDK's decoded 200 body without
validating its shape: a `results`/`failed_results` container of the wrong
type, a non-object entry, or a missing leaf field escaped as a raw
TypeError/KeyError/AttributeError instead of the structured format error
brave, serper, serply, sofya, tencent_wsa, unbrowse and groundroute return.
Validate the container with one shared helper, skip non-object entries while
preserving valid objects in order, normalize missing leaf fields to empty
text, and coerce a non-string raw_content in web_fetch before truncating.
Diagnostics carry only the malformed container's name and type, never the
query, URL, key or payload values.
backend/tests/test_tavily_response_shapes.py drives the real tools and SDK
over an offline HTTPX MockTransport; it fails 32 of 36 cases on main and
passes with this change, while test_tavily_tools.py stays green.
* docs(readme): note Tavily payload containment
Follow the Brave and GroundRoute README entries in the setup wizard step; the containment contract itself lives in community/tavily/AGENTS.md.
* fix(tavily): normalize null search fields and test mixed fetch lists
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
_write_lark_cli_runtime_from_archive and _write_lark_cli_sandbox_launcher wrote
their file with a truncating open and chmod 0o755 afterwards, so a crash mid-write
left an executable fragment behind - for the extracted binary that file can be
large. Publish through a temporary file plus os.replace, as the flow-state writer
in the same module already does.
* fix(tools): publish the default agent's SOUL.md atomically
setup_agent persisted the default agent's global SOUL.md with a
truncating write_text. The open truncates the previous file before the
new content lands, so an interrupted setup — process kill, disk full,
an exception while flushing — leaves a truncated persona file, and the
prompt builder reads SOUL.md on every run, so the agent then silently
runs with part of its instructions missing.
The same file is already published atomically everywhere else: the
custom-agent store stages it to a temporary and commits with
os.replace, and #6618 replaced the identical truncating pattern for
the Lark CLI runtime files the same day. The default agent's copy was
the one remaining write of this file that could tear.
Publish through a staged temporary plus os.replace, mirroring the
store's _stage_temp commit, and clean the temporary up when the
publish fails so no partial artifact survives.
Tests: a failed publish (os.replace raising) keeps the previous
SOUL.md byte-for-byte and leaves no temporary behind, a control pins
that the old truncating write loses the content in the same failure,
and the happy path writes the full file.
Fixes#6667
Signed-off-by: yetuge <2219677952@qq.com>
* fix(tools): keep SOUL.md's mode and clean the temporary on staging failure
Review follow-up on #6668, both non-blocking suggestions that guard the
publish itself:
- NamedTemporaryFile creates the temporary with mode 0600, so the first
publish made the global SOUL.md 0600 (the old write_text produced 0644
& ~umask) and every re-publish clobbered a mode an operator had set.
Stat the target and chmod the temporary to that mode (0644 for a first
publish) before the replace — the order #6618 codified for the Lark
runtime files and atomic_write_extensions_config already follows.
- A failure inside the staging block (handle.write or the implicit
flush/close, e.g. ENOSPC) raised before the try started, orphaning the
delete=False temporary in base_dir. One try/finally now spans staging
and publishing, so neither phase can leave a temporary behind.
The exact POSIX mode semantics were verified outside the Windows host
(WSL Ubuntu: mkstemp is 0600, first publish is 0644, a re-publish keeps
an operator-set 0640, and both failure phases leave no temporary); the
exact-mode tests are therefore POSIX-gated, with a cross-platform test
pinning that the chmod lands on the temporary before the replace and a
staging-failure test that is red on the previous head.
The shared-helper suggestion is left as a follow-up: folding six
independent publishers into one helper is a cross-module refactor, not
part of this bug fix.
Fixes#6667
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
* fix(tui): keep a refused message in the composer
on_composer_input_submitted cleared the composer before _send_to_agent
decided whether it could start a run. A prompt typed during an active run,
while an interrupted run on the same thread was still stopping, or when the
worker failed to start was replaced by a notice and survived only in input
history.
_send_to_agent now reports whether it started a run, and the submit handler
puts the exact submitted text back with the cursor at its end, so Enter
resends it. Accepted messages and local slash commands clear the composer as
before.
* fix(tui): keep a launch message that fails to start in the composer
on_mount sent the launch message through _send_to_agent and ignored the
result, so a worker start failure lost the message: it never reached the
composer or input history. Route both the launch message and composer
submissions through one _restore_draft helper.
* docs(changelog): reference #6666 for the TUI refused-message fix
* fix(tui): keep the palette closed over a restored slash draft
Restoring a refused bare skill activation such as /tdd posted
TextArea.Changed, which reopened the command palette, so Enter filled the
skill instead of resending it. Remember the restored draft and keep the
palette closed until it is edited. Restore the history copy of the text too,
so a trimmed slash command matches what Up recalls.
Refresh the locked lark-oapi from 1.5.5 to 1.7.3 without touching the
declared >=1.4.0 constraint, which already admits the new release.
Deployments syncing from the lockfile move off the 1.6.x line, where a
server-side protocol drift reportedly left long-connection clients
connected but receiving no event pushes. Keeping the constraint as-is
avoids shutting out downstream consumers pinned to older but working
releases (1.5.5's WS event routing, including the callback processor map
used by register_p2_card_action_trigger, is verified intact in source).
- Refresh uv.lock only: lark-oapi 1.5.5 -> 1.7.3, specifier unchanged
- Feishu channel, channel-connections, and thread-binding suites pass
on 1.7.3 (111 passed, 1 skipped); ruff clean
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(authz): admin user-management UI and disabled-account copy (#4063 gap 3)
Frontend slice of the account lifecycle backend (#6566):
- Settings gains an admin-only Users page (core/auth/admin-users.ts ->
GET/PATCH /api/v1/admin/users): email, read-only role, disabled state,
and a suspend/restore toggle per row; the Gateway's rejection detail
(last-active-admin 409) renders verbatim. The nav entry is hidden for
non-admins and the page guards independently; ?settings=users joins the
deep-link whitelist (now an exhaustive Record over SettingsSection).
- A session that turns out disabled maps account_disabled to a login
redirect that states the reason on all three surfaces: the SSR
AuthResult tag (consumed exhaustively by the workspace/artifacts/auth
layouts), the shared fetcher's 401 interception, and
AuthProvider.refreshUser. No next target is preserved - a disabled
account cannot sign back in. Login failures surface the localized
account_disabled copy when the code is present.
- userSchema stays disabled-optional (absent = not disabled); zh/en
locales and docs updated in the same change.
* fix(authz): address review on admin user UI and disabled copy (#4063 gap 3)
Review follow-ups on PR #6641:
- Direct /login visits with a suspended session now surface the SSR
account_disabled verdict through AuthDisabledContext instead of
discarding it (willem-bd): the auth layout wraps pages with the
provider and the login form seeds its reason from it — no redirect,
so no loop. Regression tests cover both verdict states.
- admin-users.ts parses both endpoints against userSchema /
z.array(userSchema) (zhfeng): malformed payloads fail the load
instead of rendering phantom rows; toggle error copy is scoped to
GatewayApiError with a localized fallback for anything else.
- The localized account_disabled copy is login-only (zhfeng): sign-up
keeps the backend message verbatim so a suspended account's email
cannot be probed through the register surface.
- Settings page folds the load banner and toggle error into a single
alert region (zhfeng); adds coded-envelope 409, malformed-row, and
load-failure tests; fetcher.ts documents the 401 body-consumption
contract.
- prettier across touched files (CI format gate).
The admin-users query key keeps user?.id, matching the
model-settings-page precedent (converge later per review).
* test(local-dev): normalize the upgrade script's MSYS path rendering on Windows
nginx-local-conf.sh computes its repo root with pwd -P, which inside
Git Bash yields the MSYS mount form (/tmp/... for the Windows temp dir)
— correct for its bash-native consumers (serve.sh, nginx.sh) but never
equal to the test's Windows-form Path.resolve(). Map the rendering back
through cygpath -w before comparing, so the tests read and compare the
rendered config on Git Bash hosts too.
Fixes#6657
* test(local-dev): harden Git Bash path normalization
---------
Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(tui): keep every word of an unquoted --print/--json message
--print and --json took a single optional value and never read the
positional words, so `deerflow --print summarize this repo` sent only
"summarize", `deerflow summarize this repo --print` exited 2 asking for a
message on a terminal, and with piped stdin the typed words were ignored.
Both flags now take every following word and fall back to words given
before them, as --cli does; a typed message wins over piped stdin. A
message split around the flag is refused with exit 2, because argparse
loses the word order between the two parts.
* docs(changelog): reference #6636
* fix(tui): refuse --print and --json together
Only one headless mode runs, so the other flag's words were dropped:
'--print hello --json world' ran with 'hello', and '--print --json hello'
asked for a message. Put both flags in a mutually exclusive group so argparse
exits 2 for every combination.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(community): add You.com web search provider
Adds You.com as an opt-in community `web_search` engine, mirroring the
existing community engines (tavily, brave, exa, firecrawl, groundroute).
You.com runs its own web index and returns extracted page snippets per
hit rather than a single meta description, so a research loop gets usable
context without a follow-up fetch.
The provider picks its endpoint by whether a key is configured, so it
works out of the box and scales up when the operator adds one:
- no key -> /v1/agents/search, keyless free tier
- YDC_API_KEY -> /v1/search with X-API-Key, full Search API
Endpoint and headers are always chosen together; the keyless endpoint
rejects an auth header, so a stale key can never be sent there. Both
return the same body shape, so one normalizer covers both.
httpx only, no new dependency and no pyproject extra, so uv.lock is
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(community/youcom): review follow-ups — drop stray .pr-body.md, guard OverflowError, add 429 hint
* fix(doctor): make the You.com free-provider detail true with or without YDC_API_KEY
The doctor detail returned before any key check, so operators who had
set YDC_API_KEY still saw 'set YDC_API_KEY for the full Search API'.
Reworded to match the sibling free-provider entries and stay accurate in
both states.
* fix(search): sweep youcom max_results onto the shared owner
The single-owner gate that landed with #6563 flags this branch's private
_coerce_max_results as a newly hand-rolled copy, so backend-unit-tests
now fails after merging main. Follow the coordination note from review:
delegate to deerflow.community.search_max_results.coerce_max_results,
which owns the rejection profile (booleans, non-integral floats,
OverflowError), and keep only the 1-100 mirror of You.com's server-side
count clamp on top — clamping policy stays per-provider.
The .inf regression test now goes through the public tool path and also
pins the bool and non-integral-float shapes of the shared bar.
* docs(tools): use the bare you.com platform link in config and docs
Match every other provider entry: no other provider in config.example.yaml
or the en/zh tools.mdx uses campaign-tagged URLs, so drop the utm params
from the three you.com/platform links. Integration attribution stays in
the identifying User-Agent sent to You.com hosts.
* test(youcom): cover shared max_results ownership and bounds
Register You.com among the shared-owner providers and assert default handling and the 100-result cap through outbound request parameters.
---------
Co-authored-by: Brian Sparker <brainsparker@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: valueadd-lgtm <339269770+valueadd-lgtm@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>