* fix(memory): reject Windows reserved device names in an agent memory scope
`_management_agent_name_or_501()` validated the `agent_name` query parameter
with `AGENT_NAME_PATTERN` (`^[A-Za-z0-9-]+$`) only, which admits `NUL`, `CON`,
`COM1` and the rest of the Windows reserved device names.
An agent scope is not only a label: DeerMem resolves it to
`{storage}/users/{uid}/agents/{name}/facts` and creates that directory on the
first write. On Windows the path never materialises, so `create_fact` raised
`FileNotFoundError`, which the endpoint's `OSError` branch turned into
`500 Failed to create memory fact`.
Run `windows_incompatible_segment()` at the same boundary -- the check agent
directories, thread IDs, uploads, sandbox writes and skill support files
already go through -- and answer `422`. The `for_creation` flag scopes the rule
to the two routes that create the scope, `POST /memory/facts` and
`POST /memory/import`; read and delete routes stay permissive so a scope that an
older release stored under a reserved name can still be exported and cleared.
Closes#7940
* fix(memory): address review feedback for Windows reserved agent names
Cite PR #7941 in CHANGELOG instead of issue #7940; pin the clear route's permissive behavior for a legacy reserved-name scope with a regression test; cross-reference the shared validate_agent_name() reserved-name check so the two copies cannot drift.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(middleware): scan PII emails in linear time
The email detector's pattern restarts at every position inside a
whitespace-free run and consumes the rest of the run before failing to
find '@', so a scan is quadratic in the run length. PiiRedactionMiddleware
re-redacts every user message on every model call, on the event loop, so
one pasted 100 KB hex dump stalled the Gateway for ~6 s per model call.
Scan from run starts only (a local part must reach the end of its run),
and match anchored where the previous match ended so glued addresses
such as 'a@b.cc+c@d.org' keep both matches, exactly as before.
* docs(changelog): reference #8006 for the linear PII email scan
* fix(middleware): group the embedded email pattern and pin every detector's linearity
Wrap the pattern behind the run-start lookbehind in a group so a future
top-level alternative in _EMAIL_PATTERN cannot escape the lookbehind, and
add one whitespace-free payload per other detector to the linearity test
so the AGENTS.md rule is enforced for all of them.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(subagents): reject Windows reserved device names in managed subagent creation
* Reject Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
in normalize_managed_subagent_name when for_creation=True via
windows_incompatible_segment.
* Validate subagent names on creation in FileManagedSubagentStore.create
and /api/subagents POST gateway router before attempting disk writes.
* Keep read, update, and delete paths permissive for backward compatibility.
* Add unit tests for store validation and router endpoints.
* Update CHANGELOG.md under Fixed.
* fix(subagents): reject reserved names in SQL managed-subagent create
Apply the same for_creation check used by the file store so both
backends refuse Windows device names. Pin mixed-case Nul in tests and
import the shared normalizer at module level.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scheduler): pause a task whose schedule no longer computes
Admission validates every schedule, but a stored one can stop computing
later: a zone the installed tz database no longer has, or a cron
expression a newer croniter rejects. The scheduler recomputes next_run_at
inside the shared poll, and that raise escaped:
- dispatch_task recomputed it in its except block after a successful
launch, so run_once aborted the claimed batch and left the occurrence
in "launching" with no last_error;
- once the launch lease expired, recover_expired_launch_claims found the
durable run and raised again for every poll, before claim_due_tasks,
so no other scheduled task was admitted for the rest of the process.
Runtime cursor writes (launch, launch repair, queue timeout, launch
failure, finalization) now use stored_next_run_at, which returns the
reason instead of raising. A live recurring task is paused with
"schedule can no longer be computed: ..." as its last_error and no next
run; a once task keeps its normal outcome. Resume re-validates the
schedule, so it reports the problem until the schedule is fixed.
run_once also isolates the lease-recovery pass, like the terminal-run
reconciliation next to it, so a failed pass no longer skips admission.
* docs(changelog): reference #7993 for the unschedulable task fix
* fix(skills): reject Windows device names when creating a skill
A skill name is a `custom/<name>` directory component, but the hyphen-case
grammar admits Windows reserved device names (`nul`, `con`, `com1`, ...).
`skill_manage` create and `.skill` archive install accepted them: on Windows
`mkdir("nul")` reports success without producing a directory and the first
write inside it fails with FileNotFoundError; on Linux the install succeeds
but the tree can never be copied, mounted or exported to a Windows host.
Creation now rejects those names through `windows_incompatible_segment()`, the
check uploads, sandbox writes, skill support files, thread IDs and agent names
already use. Archive extraction also rejects member segments that cannot be
created on every host. Reads and deletes stay permissive so a skill an older
release created on a Linux host can still be inspected and removed.
Fixes#7945
* test(skills): pin the extraction guard and the tool-level create wiring
Address review findings on the skill-name portability change:
- Isolate the extraction guard: an archive with a portable folder and a
portable frontmatter name but a reserved support-file segment
(`my-skill/references/nul.md`) now fails with the member-path error, so
deleting the guard turns this suite red again.
- Pin the `skill_manage` wiring: `create` of a device name raises through the
tool, while `edit`/`delete` keep reaching the existing "not found" path.
- Reword the `safe_extract_skill_archive` docstring: members before the
offending one are already written into staging, so what the check
guarantees is that the install never commits under `custom/`.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(community): strip provider API keys before they reach a header
GroundRoute (from GROUNDROUTE_API_KEY), InfoQuest, Tavily, Exa, Jina,
Firecrawl, fastCRW and Crawl4AI put the configured or environment key into
an HTTP header as read. A key read from a file keeps its trailing newline
(a Kubernetes Secret created with --from-file and mounted through envFrom),
and the transport refuses it with an error that quotes the whole header:
h11's "Illegal header value b'Bearer <key>\n'" or requests' "... in header
value: '<key>\n'". The providers returned that message as the tool result,
so the key reached the model and the checkpointed conversation, and
GroundRoute, InfoQuest, Jina and Crawl4AI also wrote it to the error log.
Resolve each key through provider_credentials.resolve_api_key, as the other
bundled providers already strip theirs. Tavily, Exa and Firecrawl now read
their environment fallback themselves instead of leaving it to the SDK,
which would read the raw value again. A blank configured key now falls back
to the environment, as it already does for GroundRoute and the stripping
providers.
* docs(changelog): reference #7981 for the provider API key fix
* fix(community): keep fastCRW from sending the Firecrawl key
fastCRW builds the Firecrawl SDK client, and the SDK reads
FIRECRAWL_API_KEY whenever api_key is None. Once a blank configured key
resolved to None, a fastCRW config with api_key: "" and no CRW_API_KEY sent
the Firecrawl key to the fastCRW endpoint. With no api_key configured at
all, main already did the same.
Pass "" when fastCRW resolves no key of its own; the SDK falls back only on
None and sends no Authorization header for an empty key. A loopback test
pins the empty, blank and absent cases with FIRECRAWL_API_KEY set.
* fix(community): keep a blank environment key away from the SDKs
Tavily, Exa and Firecrawl resolved their environment fallback themselves,
but passed None when it was blank, and each SDK then re-read the same
variable unstripped. Tavily and Firecrawl now pass "", which the SDKs
treat as a missing key; Exa sends "" as is, so it fails before any
request instead.
The fastCRW comment records that only the Firecrawl SDK's v2 client
honours "", and the fastCRW regression test now covers web_fetch as well
as web_search, so a move to the v1 client would fail it.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(docker): keep local config out of the image build context
backend/Dockerfile copies the backend tree wholesale (COPY backend ./backend),
and frontend/Dockerfile does the same for frontend/. .dockerignore excluded
.env files and runtime data, but not the local configuration: config.yaml,
the config.yaml.bak that make config-upgrade writes beside it,
extensions_config.json and legacy mcp_config.json. The config resolvers still
read these from the legacy backend/ location, so on a checkout that keeps them
there every locally built gateway image (dev and prod) carried the model API
keys and MCP credentials in its layers, under the file Compose mounts over at
runtime. frontend/.env.local, the documented place for BETTER_AUTH_SECRET in
local development, was copied into the frontend image the same way, where
next build and next start also read it.
Exclude those files at the root and in backend/, and .env*.local at any depth.
None has tracked content; the build's file list is otherwise unchanged.
* docs(changelog): reference #7958 for the Docker build-context fix
* fix(docker): keep local HTTPS keys and snapshot credentials out of images
frontend/.gitignore also ignores *.pem, the mkcert key pair for local HTTPS,
and COPY frontend ./frontend shipped it into the dev and prod frontend images.
Exclude frontend/*.pem.
The trailing !backend/extensions/sources/** re-inclusion wins over every
earlier rule, so a hand-edited extension snapshot could still carry .env files
or keys into the gateway image. Exclude, after the re-inclusion, exactly the
files the extension manager already refuses to snapshot; a test pins that set
to the manager's own _SENSITIVE_FILENAMES and _SENSITIVE_SUFFIXES.
The test's matcher now translates patterns as Docker does (** across segments,
* within one, character classes, parent-directory matches), so it can model
the mid-path ** rules; its predictions agree with BuildKit on every test path.
The README and changelog now say that a locally built frontend image no longer
inlines NEXT_PUBLIC_* values from frontend/.env.local.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agent): stop todo reminders after run hard limits
Respect loop_capped and token_capped before scheduling completion reminders, and include the behavior in the assembly policy fingerprint.
Cover sync and async agent graphs, blocked post-stop tools, preserved Todo state, and continuation from the same thread checkpoint. Validation: 316 targeted tests passed.
* docs(agent): explain todo behavior after hard stops
Document that loop and token hard stops suppress Todo completion reminders while retaining unfinished tasks for later runs.
* docs(changelog): record todo hard-stop fix
Add English and Chinese Unreleased entries referencing issue #7953.
* fix(agent): mark safety stops with partial responses
* fix(agent): honor provider stops before todo retries
* docs(agent): clarify todo behavior after provider stops
* test(agent): isolate todo safety-chain runtime dependencies
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
* fix(channels): keep Feishu cards within the card size limit
Card text was unbounded and ASCII-escaped, so an answer of about 4,400 CJK
characters made the card request body exceed Feishu's 30 KB limit (230025).
The final patch and its reply fallback were both rejected, the answer was
lost and the running card stayed on its last streamed text.
Cards are now serialized without ASCII escapes and budgeted by the bytes
their text adds to the request body. A streamed update shows what fits; a
longer final answer continues in up to 10 cards replied to the same source
message, split at line breaks, and a retry resumes after the cards already
delivered.
* docs(changelog): reference #7798 for the Feishu card size fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): key WeCom group conversations by chatid, not the sender
Group messages used the sender's userid as chat_id and topic_id, the same
key as the sender's private chat. Wherever no group connection kept them
apart (auth-disabled mode, require_bound_identity: false, legacy group
bindings), a member's private chat and every group they wrote in ran in one
DeerFlow thread. Groups are now keyed by chatid with one thread per member.
A group-scoped connection carries its sender-keyed thread over once; a
legacy binding, which spans every group of the bot, does not.
* docs(changelog): reference #7794 for the WeCom group conversation fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(parsing): keep literal think tags inside JSON string values
Structured parsers ran strip_think_blocks over the whole response before
json.loads. A model answer that quotes the tag -- "Explain <think>example
</think>" -- lost the quoted text, and an unclosed literal tag inside a
string cut the JSON document mid-string, so suggestions returned None and
goal evaluation failed to parse.
Strip reasoning only outside JSON string literals: leading blocks are still
removed whole (a JSON example inside reasoning is never taken as the
answer), and blocks after the payload starts are removed only outside
quotes. Suggestions and the goal evaluator both use the shared helper.
* fix(parsing): keep think-tag stripping complete outside the JSON payload
The string-aware walker only covered the payload itself: a reasoning block
after prose and before the payload was copied verbatim, so brackets inside
it became the parsers' find/rfind targets, and an unclosed block in
trailing prose was kept where strip_think_blocks used to truncate.
Find the payload start while stepping over think blocks, strip the head with
strip_think_blocks, keep strip/truncate semantics after the top-level
payload closes, and truncate at an unclosed block outside a string. Adds a
CHANGELOG entry for the user-visible parsing change.
* fix(sandbox): defer fresh AIO skill-policy acquisition
Prepare the authorized thread skill view before the model runs without
acquiring an AIO container for a fresh lazy run. Keep existing sandbox
bindings and upload-based providers on the eager rebind/sync path.
Add sync/async regressions for first-tool authorization, strict rebinding,
filtered read-only AIO mounts, and policy-scoped warm reuse.
AI assistance: Codex; the account owner confirmed human review of the
complete seven-file patch and responsibility for the change.
* docs(sandbox): keep lazy-isolation guidance within inherited budget
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(subagents): continue native batches and honor item dependencies
* docs: link native batch follow-up changelog entries
* test(migrations): pin isolated history-seed upgrade to 0050
* fix(subagents): preserve follow-up scope, blockers and fair scheduling
* test(memory): freeze capacity evaluation time
* fix(subagents): guard follow-up admission and retain run controls
* fix(subagents): enforce runs:cancel on tool cancellation of follow-up batches
* style(tests): format test_batch_followup_host with ruff
* fix(subagents): drop the admission checkpoint check and keep SDK cancel compatible
The checkpoint check inside run admission waited on the checkpoint saver's
lock while holding the SQLite write lock, which could make another chat's
checkpoint write fail. Remove it; dispatch still checks the conversation
before admission.
A follow-up waiting for an unanswered question now keeps its claim until the
lease expires instead of rereading the conversation every poll. Cancel passes
the caller's run authority only to submitters that take it, so existing SDK
submitters keep the two-argument call. Document that retrying a prerequisite
by hand does not requeue its blocked items, and list 0052 as the migration head.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(harness): reject Windows reserved device names in thread_id validation
A caller-supplied thread ID of NUL (any casing) passes
validate_thread_id() but Paths.ensure_thread_dirs() then fails on
Windows hosts with FileNotFoundError (WinError 3), because NUL is a
reserved device name in every directory.
Reject the reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
case-insensitively in the shared validator, including the ".ext"
form kept legal by recent Windows versions, so thread IDs stay safe
for every filesystem backend regardless of host OS.
* style(harness): apply ruff format to thread_id validation
* refactor(harness): reuse windows_incompatible_segment for thread ID check
Address review feedback on #7892:
- Drop the private _WINDOWS_RESERVED_DEVICE_NAMES copy and route the
thread-ID check through the canonical windows_incompatible_segment()
from deerflow.utils.host_paths, which uploads, sandbox writes and
skill support files already use. The canonical list also covers
CONIN$/CONOUT$ and the superscript-digit COM/LPT variants.
- Remove the unreachable stem-split: the thread-ID pattern cannot
contain a dot, so test whole IDs and pin the reserved-name branch
with the dedicated 'reserved Windows device name' message; the
unreachable 'NUL.txt' case is removed from the parametrization.
- Document the default-behavior change (Linux-host threads with
reserved-name IDs will fail validation after upgrade; deletion is
unaffected) under ### Fixed in CHANGELOG.md.
* feat(extension-api): add task identity fields to CompactionEvent (0.2.10)
CompactionEvent carried the transform, its hashes and its counts, but
nothing that names the task, run or thread the compaction happened in.
An observer that wants to relate a compaction to the run that produced it
had to match hashes across sessions, which is unreliable.
Append three optional fields, task_id, run_id and thread_id (str | None,
default None). Existing keyword and positional construction is unchanged,
so already-released extensions keep working. The host fills them in the
next commit.
Bump deerflow-extension-api to 0.2.10 and the harness's exact pin with it.
* fix(extensions): hand compaction observers the live task store and its identity
The summarization middleware already recovers the live task store from
the runtime to observe its summary system call, and the registry
allocates that store precisely because a compaction observer is
registered. notify_context_compacted() then discarded it and handed every
observer a detached store, with a docstring claiming there was no live
task.
- The lead run worker and the subagent executor now seed every task store
they allocate with the task's TaskInfo, not only when a lifecycle
contributor is registered; the lifecycle notification reuses the seeded
object. A subagent without a parent run_id still gets no TaskInfo.
- compact_state() and acompact_state() pass the store from
task_store_from_runtime() to _record_compaction().
- notify_context_compacted() takes an optional task_store. A live store
goes to the observers and fills the event's task_id/run_id/thread_id
from its TaskInfo; without one (manual /compact runs outside any run)
observers still receive a detached store and the ids stay None.
For a background subagent the store keeps its public scope id (the
provider tool_call_id) while TaskInfo.task_id stays the execution id, as
lifecycle contributors already see it; the event follows TaskInfo.
* docs(extensions): document compaction event identity and seeded TaskInfo
The extensions guide and the developer manual still said compaction
observers always receive a detached store because no task is live at the
call site. Describe what the host does now: lead runs and subagents with a
parent run seed every task store with TaskInfo, compaction observers get
the live store, and CompactionEvent's task_id/run_id/thread_id come from
it; only manual /compact falls back to a detached store with None ids.
Adds the 0.2.10 row to the contract version table (PR number pending).
* docs(changelog): note compaction observers receive the live task identity
* docs(extensions): list compaction observers among task-store triggers
* docs: reference #7831 for the compaction event identity change
* docs(extensions): backfill contract history rows 0.2.5 to 0.2.9
The version table jumped from 0.2.4 to 0.2.10 although 0.2.5-0.2.9
shipped. Add those rows, attributing names merged without a bump to
the first release that carried them (#5796 in 0.2.5, #6920 and #5645
in 0.2.8, #7729 in 0.2.10). The page intro now says it documents
0.2.4 plus the 0.2.10 CompactionEvent fields, and points to the
history for the rest.
* fix(channels): deliver the DingTalk reply when an AI card request fails
dingtalk-stream's AICardReplier logs a failed card request and returns
as if it succeeded; its create call returns the card ID it generated
before the request. The channel stored that ID, so neither the running
reply's text fallback nor send()'s sampleMarkdown fallback could run,
every streamed update failed silently, and the final answer never
reached DingTalk.
Send createAndDeliver and the streaming update through the channel's
httpx client with its token, using the bodies the SDK sends, and raise
on HTTP and transport failures so both fallbacks run. The SDK replier,
its per-card map and the token shim it needed are removed.
* docs(changelog): reference #7781 for the DingTalk AI card fallback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(harness): keep a cancelled batch cancelled when retrying a failed item
retry_item only kept a paused batch as it was, so retrying a failed item
of a cancelled batch reset the batch to queued and the worker claimed and
ran the item. A cancelled batch now rejects the retry (409), re-checked
under the write lock because SQLite ignores FOR UPDATE, and the workspace
no longer offers Retry there.
* docs(changelog): reference #7792 for the cancelled batch retry fix
* fix(agents): store an explicit null description as empty
PUT /api/agents/{name} accepted "description": null and wrote it into the
agent's config, but AgentConfig.description is a str. Every later load of
that agent failed validation: the PUT itself, GET and repair PUTs returned
500, the file backend dropped the agent from the list, and the db backend
failed the owner's whole list.
Store an explicit null as the empty default, and have parse_agent_config
read a stored null description (already written by this route, or a bare
YAML key) as empty so affected agents load again.
* docs(changelog): reference #7638 for the agent null description fix
* docs(agents): document that a null description resets it to empty
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(clarification): keep each question when a provider reuses a call ID
The clarification ToolMessage ID, which is also the human-input
request_id, was clarification:{tool_call_id}. Providers that number
calls per response reuse call_0 across turns, so a later question took
the earlier one's ID: add_messages replaced the earlier question in
place and left the new call without a result.
Add a digest of the issuing AIMessage ID. A retry of the same call comes
from the same AIMessage and keeps its ID; without that message in state
the ID keeps its previous form.
* docs(changelog): reference #7762 for the clarification call ID fix
* fix(projects): keep referenced originals when the trash sweep walks a symlinked tree
The storage reconciliation protects each row's namespace by its resolved
path (project_document_path resolves symlinks) but walked the unresolved
documents directory. With a symlinked .deer-flow (no DEER_FLOW_HOME),
users/, users/{id}/ or projects/, no walked directory matched a protected
namespace, so the startup sweep and the lazy trash-listing sweep unlinked
live document originals older than 24 hours as orphans.
Walk each project's resolved documents directory so both sides compare
the same paths.
* docs(changelog): reference #7732 for the trash sweep symlink fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(runtime): add Queue and Steer for active Web runs
* style(frontend): format Queue and Steer controls
* fix(runtime): retain user Steer controls and order active inputs
* docs: add Chinese changelog entry for Queue and Steer
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read the live config off the loop for thread token usage
build_context_usage resolves the model capacity through deps.get_config,
which revalidates config.yaml and extensions_config.json on every call, so
each /threads/{id}/token-usage request paid that file IO on the shared
Gateway loop.
* docs(changelog): record the thread token-usage config read move
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scripts): include nginx logs in dev_multi_instance.sh logs all
`logs all` selected only the two Gateway logs, the same as running
`logs` with no argument, so nginx's error and access logs never showed
up although the help lists all next to nginx. `all` now adds the nginx
logs, and the nginx selection also covers nginx.log, where the start
command's own output goes and which no option printed before.
* docs(changelog): reference #7723 for the dev_multi_instance logs fix
* test(scripts): pin dev_multi_instance logs when nginx never started
Cover the layout start_nginx leaves behind when it bails on a missing
binary or a busy port: only gateway-*.log exists, so `logs all` prints
the two Gateway files and `logs nginx` prints nothing, both exiting 0.
* fix(memory): mask backend secrets in the memory config responses
GET /api/memory/config and /api/memory/status returned
memory.backend_config as loaded. Config loading has already resolved
$VAR references by then, so a DeerMem extraction model api_key or a
Honcho api_key reached every caller with memory:read in plaintext,
which with authorization disabled (the default) is every signed-in
user.
MemoryConfigResponse now masks credential-named values (api_key,
*_token, password, authorization, ...) at any depth as "***". The
key is matched on its end, so knobs such as token_counting and
guaranteed_token_budget and the api_key_env variable name stay
readable, and unset values stay null or empty. The loaded config the
backend uses is not changed.
* docs(changelog): reference #7665 for the memory config secret masking fix
* refactor(gateway): share config secret masking between MCP and memory
The memory config masking forked the MCP router's key normalization
and recursive masker. Both now use app/gateway/secret_masking.py, and
each router keeps its own key rule: MCP matches credential terms
anywhere in the key, memory matches them at the end and keeps unset
values. MCP masking is unchanged.
The memory rule now also covers plural and cookie keys (api_keys,
secrets, passwords, cookies). Plural tokens stays out because
max_injection_tokens is a DeerMem knob. A secret-named container is
still masked whole, since its keys can be arbitrary names; the helper
docstring and a test now say so.
* docs(changelog): move the #7665 entry to Unreleased
Both change logs mention [#7022] in the workflow-turn-cap entry but neither
defines it, so the released page renders the literal text "[#7022]" next to a
working link for the fixing PR. #7022 is an issue, not a pull request, so the
definition targets issues/7022.
Add an offline guard for the whole class: every [#n] reference token needs a
definition, and every definition must aim at the same number in this repository
(commit permalinks, as used for deleted PRs, stay allowed).
* chore(changelog): Sync the change log information of 2.2.0
* chore(changelog): update the change log information of 2.2.0
* chore(changelog): sync the change log information of 2.2.0
* Add entry for pull request #7585 in CHANGELOG_zh.md
* fix(tui): show picker labels as written instead of parsing markup
SelectScreen passed thread titles and model display names to Textual as
plain str, which Textual parses as content markup. A thread title such as
'Fix [/bold] in parser' raised MarkupError and exited the app whenever
/threads opened, and '[a]' vanished from the list. Wrap the title and
option labels in Rich Text so they render literally.
* docs(changelog): reference #7631 for the TUI picker markup fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): give DingTalk AI cards the channel token, not the SDK fetch
AICardReplier's async card calls read dingtalk_client.get_access_token()
inline, and dingtalk-stream refreshes it with requests.post and no
timeout. Card creation and card streaming both run on the Gateway loop,
so the first card and every SDK refresh after it (about every two hours)
blocked the loop for the whole token request, without bound if DingTalk
stalled.
Hand the replier a _CardTokenClient that serves the channel's own
httpx token (10s timeout), set before card creation and before every
streamed update. A token failure while streaming now raises into send's
existing sampleMarkdown fallback instead of being skipped silently.
Pin it with a strict blocking-I/O anchor over a real DingTalkStreamClient
and AICardReplier, a gate meta-check, and unit tests for token rotation
and failure. LocalHttpServer also records each request's method, path
and headers.
* docs(changelog): reference #7597 for the DingTalk AI card token fetch
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scripts): read the checkout .env as data in serve.sh
serve.sh sourced the root .env under set -a, so bash ran it as a script:
$$ became the shell's PID, backticks and $(...) ran commands, and an
unquoted value with a space ran its second word as a command (exit 127
under set -e, stopping make dev). The exports also beat the Gateway's
own load_dotenv(override=False), so e.g. a $$ in AUTH_JWT_SECRET changed
the signing secret on every launch.
Parse .env with a stdlib-only scripts/dotenv_exports.py on the host
Python and eval its shell-quoted export lines. It tokenizes like
python-dotenv, so values match what the Gateway reads, and keeps the
launcher's precedence over inherited variables and its $NAME /
${NAME:-x} / leading-~ expansion that support_bundle.py mirrors. $$ is
now one token in both. Unparseable lines and invalid names are skipped
with warnings that never echo values; a non-UTF-8 file stops the
launcher.
Tests run serve.sh's real .env block: no execution, parity with
python-dotenv, precedence, warnings, the non-UTF-8 stop and a stdlib-only
run; the support-bundle launcher cases now exercise the parser.
* docs(changelog): reference #7606 for serve.sh reading .env as data
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): resolve reply artifacts off the event loop
_handle_chat_on_thread and _handle_streaming_chat called
_prepare_artifact_delivery inline. For every presented file it resolves
the virtual path (realpath), resolves the outputs root, and checks the
file with is_file and stat, all on the Gateway loop that also serves
every other channel and request.
Run it through asyncio.to_thread on both reply paths, and anchor both
through the real manager under the strict blocking-I/O gate.
* docs(changelog): reference #7594 for the off-loop reply artifact resolution
* docs(changelog): drop the duplicate #7580 link definition in the zh changelog
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): write each Feishu thread alias once per streamed reply
Every outbound to a Feishu running card re-sent set_thread_id for all of
the reply's aliases (source message, card, topic, root, parent and Feishu
thread ids), although only updated_at changed after the first chunk.
That was about five store writes per streamed chunk: a full store.json
rewrite under the store lock each, or a database upsert.
Remember the aliases written for each in-flight reply and skip them on
non-final chunks. A failed write is not remembered, so the next chunk
retries it, and the final outbound still writes every alias, so the
store ends up as before.
* docs(changelog): reference #7593 for the Feishu streamed thread alias writes
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(wizard): replace retired Claude Sonnet 4 default with claude-sonnet-5-5
The setup wizard's Anthropic default model is now `claude-sonnet-5-5` instead of the retired `claude-sonnet-4-20250514`. A new per-model `model_omitted_config` field makes the wizard leave out the extended-thinking (`budget_tokens`) and thinking-disabled templates for that model only, since Sonnet 5.5 rejects them; the other Claude models are unchanged. The Anthropic examples in `config.example.yaml` and `configuration.mdx` (which used `claude-3-5-sonnet-20241022`) now use `claude-sonnet-5-5` without those settings, and the change also adds a regression test and a CHANGELOG entry.
Notice anthropic-retired-models-2026-10-27 about api.anthropic.com's API describes the change. It was read from a file and is not signed by the provider.
* Update CHANGELOG with PR reference #7585
Added a reference to pull request #7585 in the CHANGELOG.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(runtime): present outputs before a loop wrap-up and explain step-limit stops
Loop-detection warnings now ask the agent to present the files it already
created in /mnt/user-data/outputs before its final answer, when
present_files is available.
A run that exhausts recursion_limit now ends with stop_reason
recursion_capped and a plain message that lists outputs it created but
did not present. An active goal stands down without an evaluator call.
The /wait error envelope keeps the GraphRecursionError name, and IM
channels reply with the step-limit message instead of a generic failure.
* docs(changelog): note run-end delivery and step-limit stops (#7588)
* test(runtime): stop pinning LangGraph's step count in the step-limit test
The produced paths and the message already follow the observed model
calls, so the exact count only pinned LangGraph's per-step accounting.
Also note on the step-limit goal branch that its caller passes no
evaluator factory, so it has to stay ahead of the evaluator section.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): keep IM binding codes on screen and report goal state in replies
Connecting Slack, Discord, Feishu, DingTalk, WeCom, Buzz or QQ now opens a
dialog that keeps the /connect command on screen with a copy button, its
expiry time and the binding status, instead of a toast that disappears.
The bots' /connect replies and the IM /goal replies now use the owner's
web UI language (else channel_connections.notification_locale), and /goal
reports the goal's state as the web goal bar does: in progress, why it
stopped, or that it was met.
* docs(changelog): note the IM binding dialog and localized IM replies (#7591)
* test(channels): check IM reply tables against every notification locale
The connect-reply check compared a fixed en-US/zh-CN pair, and the /goal
reply table had none. Both now match NOTIFICATION_LOCALES with the same
keys in each, like the existing _GOAL_REASON_TEXT check, so a new locale
fails here instead of raising KeyError at reply time.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
* fix(community): resolve async tool config off the event loop
Agent runs execute on the Gateway event loop, and the async community
tools resolved their tool config through get_app_config() inline. That
call stats and hashes config.yaml and extensions_config.json on every
call and reparses them after an edit, so each tool call held the loop
for it, up to three times per call: Tavily, SearXNG and Firecrawl read
the config again to build their client, InfoQuest read it three times,
and the browser tools read the launch config for every action.
Resolve it through asyncio.to_thread once per call, as image_search and
groundroute already do, and build the client from that same read. This
covers jina_ai, crawl4ai, browserless, tavily, searxng, firecrawl,
infoquest, ragflow, lightrag and browser_automation.
* docs(changelog): reference #7580 for the off-loop community tool config reads
* docs(changelog): use the existing community-tools label in the zh entry for #7580
* fix(community): resolve the jsonify dataset_rows config off the event loop
dataset_rows_tool is async and called _load_settings() inline, so every
call ran get_app_config()'s stat + sha256 revalidation on the loop.
Read it through asyncio.to_thread like the other async community tools,
and add the tool to the off-loop anchors.
* docs(changelog): include Jsonify in #7580 and drop trailing whitespace
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): read the live config off the event loop on public auth routes
/register, /setup-status, /providers, /oauth/{provider} and
/callback/{provider} read the live auth config through get_app_config()
inline. That call stats and hashes config.yaml and extensions_config.json
on every request and reparses them after an edit, and these routes are
unauthenticated, so any client could keep that file IO on the event
loop. /login/local already reads its throttle policy in a worker.
Read the registration gate through asyncio.to_thread, and the SSO
routes' auth.oidc section through a shared _oidc_config() helper that
does the same.
* docs(changelog): reference #7576 for the off-loop auth route config reads
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): leave SIGTERM/SIGINT teardown to the Gateway lifespan
AIO and E2B providers built on the event-loop thread installed SIGTERM/SIGINT
handlers that force-destroyed every sandbox synchronously, before uvicorn
started the run drain. The Gateway lifespan now holds sandbox shutdown
ownership from its first step, so providers skip those handlers and the
existing hook closes the provider after run drain and MCP teardown. SIGHUP
keeps the provider fallback. AIO acquire now refuses once shutdown began.
* docs(sandbox): document Gateway-owned sandbox shutdown and AIO admission fence
* test(gateway): inspect the lifespan body now that lifespan wraps it
* docs(changelog): reference #7560 for the sandbox SIGTERM drain-order fix
* ci: retrigger sandbox image smoke after a registry pull timeout
* fix(frontend): return SSO sign-ins to their next path
The (auth) layout redirected every signed-in request to /workspace. The
Gateway sets the session cookie on the same redirect that lands on
/auth/callback?next=..., so the callback page never rendered and every SSO
sign-in landed on the workspace instead of the page that asked for it. A
signed-in visit to /login?next=... lost its target the same way.
Layouts cannot read searchParams, so the layout now renders the page for a
signed-in session and each page sends the user on: the callback and login to
the validated next path, setup to the workspace. The login page shows a
short wait message instead of the form while it redirects, and uses
router.replace so Back does not return to it.
* docs(changelog): reference #7467 for the SSO next-path fix
* fix(auth): reject control characters in the next redirect path
Browsers delete tab, LF and CR from a URL before parsing it, so a next of
"/\n//evil.example" passed both validators' "//" check and then navigated
to //evil.example. The login page's post-sign-in push already followed it,
and the signed-in login and SSO callback redirects reach it too.
validateAuthNextPath and validate_next_param now reject every C0 control
character and DEL. Percent-encoded forms are left alone: URL parsers do
not decode them, so they stay on the local origin.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read the route authorization config off the event loop
AuthMiddleware resolves route permissions for every authenticated
request, and resolve_route_permissions read the hot-reloaded
authorization config inline. get_app_config() stats and hashes
config.yaml and extensions_config.json on every call and reparses them
after an edit, so that file IO ran on the event loop per request.
Read it through asyncio.to_thread, like the plugin authorization path
already does. The context copy keeps runtime config overrides visible.
* docs(changelog): reference #7557 for the off-loop route authorization config read
* docs(config): note the get_app_config read cost where it is documented
Move the off-loop note from gateway/AGENTS.md, which is 12 bytes under
its hard limit, to the Config Caching section that documents what each
get_app_config() read costs.
* fix(memory): flush pre-compaction memory off the event loop
The async compaction path fired the before-summarization hooks inline.
memory_flush_hook hands the summarized messages to add_nowait, which is
sync HTTP for mem0 and Honcho (inherited default -> add) and OpenViking
(its own override), so every automatic compaction with an HTTP memory
backend blocked the Gateway event loop for the whole request.
acompact_state now runs the hooks through asyncio.to_thread, as
MemoryMiddleware.aafter_agent already does for its writes. Ordering,
failure isolation, summary-first timing and context propagation are
unchanged; the sync compact_state path is untouched.
* docs(changelog): reference #7530 for the pre-compaction memory flush fix
* test(memory): anchor the OpenViking pre-compaction flush off the event loop
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): honor the configured recursion limit in IM runs
Every IM channel run asked the Gateway for recursion_limit 100, because
the channel manager seeded each run config with it. The Gateway honors a
request value, so the top-level recursion_limit never reached IM runs,
and a budget of 100 could stop a run after a handful of tool-calling
turns (six in one measurement with the default lead-agent middleware;
the count changes with the middleware chain).
IM runs now send no recursion_limit of their own and get the Gateway's
configured default, clamped by max_recursion_limit, like Gateway API
runs. A session config value still takes precedence, and GitHub channel
runs keep their floor of 250. The README and config.example.yaml IM
examples no longer pin session.config.recursion_limit to 100.
* docs(changelog): reference #7521 for the IM recursion-limit fix
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): derive the AIO sandbox ID off the event loop in async acquire
_acquire_internal_async offloads every acquire layer except the sandbox ID
derivation, which ran inline. _sandbox_id_for_thread checks the thread's
skills_view directory with exists(), and resolving it goes through
Paths.base_dir, so each async thread acquire made filesystem syscalls on the
event loop. The strict blocking-I/O gate raises on it.
Run the derivation through the same owned-worker helper as the reuse and
warm-pool layers around it, and anchor it in the blocking-I/O suite for both
the default and the policy-scoped ID.
* docs(changelog): reference #7507 for the AIO async acquire sandbox ID fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>