* fix(agents): count each delegation once when a provider reuses its call id
Tool-call ids are unique per response, not per conversation. Some providers
number every response's calls from call_0, so two task calls in one run can
share an id.
The delegation ledger used (run_id, tool_call_id) as a delegation's identity:
- extract_delegations built one id -> entry map over the whole run, so a
later call_0 replaced the earlier delegation's description and took its
result while it was still running.
- merge_delegations replaced the same (run_id, id), keeping one entry.
- SubagentLimitMiddleware counted distinct ids, so the per-run cap
(subagents.max_total_per_run) never advanced past the first call_0 and a
run could launch any number of subagents one response at a time.
Each entry now also records dispatch_message_id, the AIMessage that issued the
call, and results pair through pair_tool_call_results, which answers a call
only from its own response. The reducer, DurableContext's change filter and
the cap use the extended identity. An entry without a dispatch id still
updates its run's entry for that call id, and a dispatch-scoped entry adopts a
ledger entry written before the field existed, so runs in flight across the
upgrade are not counted twice. The steer path names the dispatching AIMessage
from the tool request's state, so its delegation update and the graph's own
capture stay one entry.
* fix(agents): close an earlier run's dangling dispatch by its own response
_close_delegations_left_by_earlier_runs decided "answered" by
(run_id, tool_call_id). When a stopped run had dispatched call_0 twice,
the first reply kept the second, dangling entry in_progress, so the
lead kept hearing "already delegated" for work nothing would finish.
Entries that carry dispatch_message_id are now decided by pairing that
response's calls through pair_tool_call_results. Legacy entries and
dispatches no longer in history keep the id-based inference.
Also pin that two runs reusing call_0 stay two ledger entries, and
cross-reference the three delegation identity helpers.
* fix(agents): settle an approval-gated task delegation in the ledger
With `task` in `hitl_approval.tools`, the paused run records the dispatch
as in_progress and the resume run executes it. The resume run opens with
no HumanMessage, so its capture window starts after the pre-existing
messages and never contains the dispatching AIMessage: the result was
dropped and the entry stayed in_progress with no result_brief, forever.
DurableContext now pairs results the resumed window receives with the
last pre-existing dispatch and settles the issuing run's entry, keeping
its run_id instead of re-tagging it. HITL denials of `task` carry
`cancelled` subagent status, so a rejected or denied delegation settles
too.
* fix(agents): address review on the delegation ledger
- Steer path: hold each wait's dispatch AIMessage locally and pass it to
the observer, so a later response reusing the call id while the first
call is still observed cannot take over its args or dispatch id.
- New-turn close: a dispatch whose reply carries subagent status settles
from that reply instead of staying in_progress, covering a crash
between the tools checkpoint and the next capture.
- Reuse thread_state.delegation_key instead of a duplicate identity
helper in DurableContext.
- CHANGELOG: reference #8128.
* docs(changelog): drop the #8128 entry per review
* fix(memory): reject duplicate fact ids in imports regardless of entry order
A backup containing two entries for the same fact id was accepted or
rejected depending only on their order. The duplicate check in
_commit_changes_locked consulted the map of prepared writes, but an
entry whose normalized content matches the stored fact is skipped from
that map - so an unchanged entry followed by an edited duplicate passed
the check while the reverse order raised, and the saved snapshot ended
up with whichever entry came last.
Track every incoming id in a seen-set for duplicate detection, before
the unchanged-entry skip. The saved snapshot now survives the rejection
in both orders.
Fixes#8150
* fix(memory): validate duplicate import ids before capacity selection
Copilot review follow-up on #8153: the storage-level duplicate guard runs
after _select_for_capacity, and the capacity policy can drop one of two
same-id entries (e.g. lower-confidence copies trimmed to max_facts) - the
duplicate then never reaches the guard and the import outcome still
depends on entry order.
Validate all normalized incoming ids in import_memory_data before
capacity selection. The storage-level guard stays as defense in depth.
Regression test with max_facts=10 and 9 distinct high-confidence facts
plus two same-id copies.
* fix(memory): surface duplicate fact id errors with their own detail
willem-bd review on #8153, three follow-ups:
1. P2: the bare ValueError fell through to the router's generic catch, so
POST /api/memory/import answered with the facts-array message even
when every fact had non-empty content. Add
MemoryDuplicateFactIdError (same type as #8151) at the manager
boundary and map it to its own 400 detail that names the duplicated
id; router test asserts the exact detail.
2. P3: the duplicate check lived inside the apply_changes-override
branch, so the base MemoryStorage save() path bypassed both guards.
Move it before the branch split, right after normalize_memory_data,
matching #8151's validate-before-capacity posture.
3. P3: parametrize the order-dependence regression over the default
bucket (agent_name=None) and "researcher", and assert the seeded
snapshot survives the rejection in both orders.
* test(memory): pin the storage duplicate guard and fix the no-op assertion
willem-bd review follow-ups on #8153:
1. The snapshot-survival assertion compared storage.load against
get_memory_data, which is itself storage.load - both reads happened
after the rejected import, so a regression that partially applied the
duplicate would pass. Capture the seeded snapshot before the rejected
import and compare against it.
2. The updater gate rejects import duplicates before storage is touched,
so nothing exercised the storage-level guard in _commit_changes_locked
with an unchanged duplicate first - reverting it would keep the suite
green. Add a direct apply_changes regression test; verified it fails
with the guard removed.
* style(memory): apply ruff fixes after #8151 merge resolution
* test(memory): assert untouched store in the storage-guard pin; facade import
willem-bd review follow-ups on #8153:
1. The storage-guard regression test pinned rejection but not the
untouched-store half: capture the seeded snapshot right after seeding
and assert storage.load still equals it after the rejection, so a
regression that partially applies the first entry before raising
fails.
2. The router regression test imported MemoryDuplicateFactIdError from
the manager module directly; use the package-facade import consistent
with the other manager-error usage (the symbol is already re-exported
from deerflow.agents.memory since #8151).
* test(memory): drop the mock router duplicate-id test
ShenAC-SAC review on #8153: #8151 already drives the real import route
with duplicate IDs and asserts the same 400 response plus an unchanged
snapshot, and the mock test's docstring implied a detail the assertion
did not check. The direct apply_changes() regression is kept.
Also removes the blank line after the docstring that tripped
ruff format --check on the repo-pinned Ruff version.
* test(memory): remove the orphaned mock-router test body
ShenAC-SAC review: my earlier deletion removed the function definition
and docstring but left the test body running inside
test_clear_route_keeps_working_for_windows_reserved_agent_name. Remove
the whole block and the now-unused MemoryDuplicateFactIdError import;
#8151 already covers the duplicate-ID response through the real import
route.
* fix(subagents): apply tool policies to middleware-declared tools
* fix(subagents): address tool-policy review feedback
Normalize declaration conversion errors with middleware context, document
generated discovery exemptions, and add the Unreleased changelog entry.
Audit final explicit and middleware tool sets before graph creation, with
identity-based exemptions for generated discovery helpers and regressions.
* fix(subagents): reuse tool name resolution and clarify policy errors
Server-prefixed tool names are not unique across servers: server 'a'
exposing 'b_x' and server 'a_b' exposing 'x' both bind as 'a_b_x'. The
flatten step forwarded both declarations to the model, where the provider
silently keeps only one and the other server's tool becomes unreachable
with no diagnostic.
Deduplicate by bound name in enabled-server declaration order and drop
later duplicates with a warning.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(task_tool): introduce read-only preflight validation before delegation side effects
Add _preflight_delegation_checks() as a zero-side-effect preflight that runs before any delegation side effects (tool assembly, executor creation, background task registration). Existing inline checks for context_mode, bash host policy, and subagent_type config membership are centralized into this function.
A rejected preflight returns immediately with status='failed' and leaves state unchanged, so callers can safely retry with corrected arguments.
A reserved resume_task_id parameter is included for a future recovery feature, pointing to issue #7633 (validate before claim/cancel) to avoid the reject-after-claim anti-pattern.
* fix(task_tool): return config from preflight, drop dead resume_task_id param, soften side-effect comment
Fixes UnboundLocalError on successful delegation paths: the helper now returns (config, error) and callers destructure both. Previously config was scoped inside the helper so the happy-path references at lines 1044/1074/1082 raised UnboundLocalError. Verified against test_task_tool_core_logic.py.
Drops the unused resume_task_id parameter and its hardcoded rejection branch — dead code since the only call site never passes it, and a future recovery feature should plug real read-only recoverability checks here (see issue #7633).
Narrows the helper-side-effect claim in the caller comment: input_control.child(tool_call_id) already registers a benign retry-reusable child gate before the preflight runs, so the wording now scopes the zero-side-effect promise to the helper itself.
* test(task_tool): cover preflight abort and happy paths
Adds unit coverage for _preflight_delegation_checks (invalid context_mode, host bash disabled/enabled, subagent outside caller policy, no permitted subagents) plus a rejected-delegation test asserting no executor is created and no tools are assembled.
Adds a happy-path regression test that would have caught the UnboundLocalError: it asserts the config resolved by the preflight reaches SubagentExecutor, including the unconditional config.model == 'inherit' branch.
* fix(task_tool): resolve catalog inside preflight after guards
Copilot review flagged that catalog discovery (get_available_subagent_names) was hoisted out of the helper and ran before any guard, so an invalid context_mode could hit the managed-subagent store instead of returning status='failed'.
Now the helper takes only allowed_subagents and performs its own catalog lookup strictly after the context_mode and bash guards. The caller no longer calls get_available_subagent_names ahead of preflight.
Adds test_preflight_resolves_catalog_after_guards asserting get_available_subagent_names is never invoked when context_mode is invalid.
* test(runtime): use ToolRuntime for native task results
* docs(task_tool): translate preflight comments to English
Comment-only change; no behavior impact.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
* fix(models): keep the OAuth betas when a Claude request sends its own betas
ClaudeChatModel put the Claude Code OAuth betas (oauth-2025-04-20, ...)
only in the client's default anthropic-beta header. A request whose
payload carries betas is sent through beta.messages.create, and the
Anthropic SDK turns that list into a per-request anthropic-beta header
that replaces the default. So a configured betas list (even []),
per-call betas, or a server tool for which langchain-anthropic appends a
beta sent the request without oauth-2025-04-20, and Anthropic rejects
the Bearer token.
_get_request_payload now puts the OAuth betas first in any request betas
list before the thinking budget resolves the header. Requests without
betas keep the default header, and API-key models are unchanged. The
list is rebuilt because the payload's betas can be self.betas.
Fixes#8060
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): link the Claude OAuth betas fix to #8061
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(models): keep the OAuth betas in a caller's anthropic-beta extra header
The Anthropic SDK lets a request's extra_headers override both the
client default and the header built from betas. An anthropic-beta in
extra_headers, from model_kwargs or passed per call, therefore still
dropped the OAuth betas and Anthropic rejected the Bearer token.
_apply_oauth_betas now gives that header the same merge as betas: the
OAuth betas go first and the caller's betas follow without repeats.
The headers dict is copied because it can be the one in model_kwargs.
API-key models are unchanged.
Refs #8060
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
* fix(community): return an error for malformed URLs in the SSRF guard
validate_public_http_url and validate_delegated_backend_url document
returning an "Error: ..." string, but urlparse() raises ValueError for
malformed authorities such as http://[::1/api, so the exception escaped
to callers (web_fetch/web_capture, browser_navigate, personal MCP, and
the browser stream navigation check) instead of a clean validation error.
Parse through a small _parse_url helper that returns None for malformed
input (including non-string values), and treat a raw bracket in the
userinfo slot as malformed on every Python version, mirroring the
SkillScan host parser merged in #8017.
The only URLs newly rejected are userinfo values containing a raw
bracket, which RFC 3986 forbids; no valid host becomes unreachable and
fail-closed behavior is otherwise unchanged.
* test(community): share malformed URL cases and add changelog entry
Drive both validators' malformed-URL parametrization from one list, and
document the contract fix in the Unreleased Fixed section (#8079).
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(subagents): resolve the inherit-path app config off the event loop
task_tool's inherit-model fallback called get_app_config() synchronously
inside the async tool body. get_app_config() reads config and extension
files on every cache miss (and may fully reload them), so the fallback
path stalls the calling streaming event loop for the duration of the
disk IO - the same class of loop stall removed for tool assembly in
issue #5172. Every other config resolution in this tool already
dispatches through run_assembly; route this one through it too.
* chore: drop the CHANGELOG entry per maintainer request
Changelogs for merged PRs are created in batch upstream; keeping the
entry here only risks merge conflicts.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(opensandbox): add opt-in secure_access sandbox option
Forward a new `sandbox.secure_access` option (default false) to
SandboxSync.create so OpenSandbox can require a per-sandbox endpoint
token at the ingress gateway and server proxy. The server returns the
token as an endpoint header and SDK 0.1.15 already attaches endpoint
headers to execd and file requests, so DeerFlow needs no token handling.
Non-boolean values are rejected at provider startup so a quoted "false"
cannot silently enable it. Documented in config.example.yaml,
CONFIGURATION.md, the SandboxConfig docstring and the provider README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(opensandbox): validate server proxy boolean
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(client): scope memory management to the named agent's bucket
A named DeerFlowClient stored and read its memory in that agent's bucket:
MemoryMiddleware and the memory tools both pass agent_name. The management
methods on the client did not, so create_memory_fact wrote the fact into the
user's default bucket while the agent's injected context came from its own
bucket — the fact was persisted but never recalled by the same client.
Pass the client's agent_name through get_memory, export_memory,
import_memory, reload_memory, clear_memory and the single-fact CRUD calls.
An unnamed client keeps the default bucket, and a backend that ignores the
scoping keyword keeps raising its own unsupported-operation error.
Fixes#8142
* fix(client): refuse named memory calls a backend cannot scope
DeerFlowClient forwarded the agent name to every memory management call, but
a backend that declares no agent-scoped storage ignores the keyword and
answers normally. A named client then reported success while the call read,
cleared or exported the user's default bucket, the same silent
wrong-bucket behaviour #8142 is about. Check the capability flag before
forwarding the scope and raise an unsupported-operation error, as the
Gateway already does with 501.
Resolve the scope in reload_memory before the get_memory fallback so an
unsupported scope is not mistaken for a backend without reload support and
retried as a user-wide read.
* chore: drop the CHANGELOG entry
The changelog is generated at release time, and the hand-written entry points
at #8146 without a link definition, which breaks
tests/test_changelog_reference_links.py. Removing it keeps the file released-
generated, per the reviewer's request.
* docs(memory): explain scoped noop client behavior
---------
Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(browserless): reject timeouts httpx cannot use
`_coerce_timeout` forwarded any int, float or numeric string to
`httpx.AsyncClient(timeout=...)` unchanged, so a configured `timeout_s: 0`
or a negative value failed every fetch and capture with `ConnectTimeout`
against a healthy backend, `nan` raised `ValueError: deadline must not be
NaN`, and `inf` left the request without a deadline so it never completed.
Each of those silently disabled the provider.
Fall back to the documented 30s default with a warning when the number is
not a usable deadline. This matches `crawl4ai._coerce_timeout` after #7481;
`jina_ai._coerce_timeout` still forwards non-positive values.
* fix(browserless): address timeout review and format tests
Explain how HTTPX handles NaN and positive infinity. Document that
Browserless accepts positive finite timeouts without an upper limit,
and pin that behavior with a 1e308 test case.
Crawl4AI and Jina timeout validation are tracked in #7481 and #7823.
Remove the Browserless docstring's claim that Crawl4AI shares its
coercion behavior. Format the test file for the backend lint check.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
In long-running agent workflows with repeated context compaction, empirical gateway ablations across 132 real sessions revealed that standard boundary compliance remains robust, while semantic drift can occur when model-generated summaries rephrase literal approval phrases and action conditions.
This change converges PR #8114 into a clean built-in summary prompt enhancement:
- Replaces regex extraction, tag elevation, and heuristic revocation with an extended built-in DeerFlow summary prompt (_DEERFLOW_SUMMARY_PROMPT).
- Instructs the summarizer to faithfully record active user requirements, literal approval phrases, permitted actions, and later explicit revisions without paraphrasing.
- Treats summary text strictly as historical data, fully preserving the security boundary and control/data plane separation of DurableContextMiddleware.
- Unifies automatic and manual compaction factory paths to select the built-in template when unconfigured, while preserving explicit summary_prompt precedence.
- Adds comprehensive sync/async unit tests covering built-in and custom templates.
Co-authored-by: Totoro <Totoro-qaq@users.noreply.github.com>
Co-authored-by: Totoro <msy626836554@gmail.com>
* docs: turn README into an index and move reference detail to docs/guide
The README had grown to 393 KB because every change appended its notes there. The text moves verbatim into 18 topic guides under docs/guide/; only headings are re-leveled and relative links re-pointed. The README keeps the introduction, a quick start and a guide index. Contributor test notes move to CONTRIBUTING.md.
* docs: send user docs to topic guides and budget the root README
The rule 'update README.md for user-facing changes' is what grew the README. Guidance now points to docs/guide/, and check_agent_guidance.py fails when the root README exceeds 16 KiB (AG003).
* chore: remove unreferenced screenshots and finished plan notes
pr-build/ holds two PR screenshots (3 MB) from #1986. plans/ holds the plan for the merged #4049. docs/SKILL_NAME_CONFLICT_FIX.md is a February change note. Nothing references them.
* docs: route always-loaded AGENTS.md files to scoped guides
* docs(agents): cut scoped guides to scope, verify, traps, decisions
Each AGENTS.md keeps four kinds of content: the scope of the directory, the
verification commands, traps a local test run does not show, and decisions the
code cannot state. Values, field lists, function inventories, test names, issue
numbers and history are deleted, because the code, the tests and git state them.
Search and fetch providers share one community guide. Four guides with no
remaining content are removed. The size check now allows 6 KiB per file (hard
limit 8 KiB) and 24 KiB per chain (hard limit 32 KiB).
* style: format frontend guide table and guidance test imports
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(runtime): keep assembly evidence from reviving a run a peer reclaimed
In heartbeat mode every run-row write is conditional on this worker's
live lease except assembly evidence, which went through RunStore.put().
That upsert rewrote status, owner, lease, error and stop_reason from the
local record. When a peer had already reclaimed the run, it put the row
back to running and erased the takeover error. If the owner's local lease
was still live, its renewal then succeeded, the peer never reclaimed it
again, and the run finished as success after the peer had published its
terminal recovery event. If the write landed late, the row came back with
an expired lease and was reclaimed a second time.
Add RunStore.merge_owned_metadata(), which merges metadata keys only for
the owner holding a live lease on a pending/running row and writes no
other field. SQL follows the existing clock -> row lock -> conditional
UPDATE order; MemoryRunStore mirrors it. With heartbeat enabled,
record_assembly_evidence() uses it, refuses a locally fenced run before
any write, fences the run when the store rejects the merge, and fails
closed on stores without the method instead of falling back to put().
Single-worker mode keeps the full snapshot.
* docs(changelog): reference #8046 for the assembly evidence takeover fix
* test(runtime): scope the simulated lease expiry to the run's thread
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(hitl): never pause on an approval call without a usable id
A provider can emit a tool call with no id (langchain_core keeps `id=None`),
an empty id, or the same id twice in one response. `HitlApprovalMiddleware`
copied that id into the interrupt payload, but the Gateway resume validator
requires unique non-empty strings: every resume answered 422 and every new run
stayed 409 `approval_pending`, so the thread was wedged permanently — the
all-reject escape is validated after that check. Denial paths also built
`ToolMessage(tool_call_id=None)`, which raises inside `after_model`, so a
non-interactive run failed instead of denying the call.
Treat such calls as unaddressable: strip them from the model response with a
visible rejection note instead of pausing, and deny by removal whenever a
`ToolMessage` could not pair. The tool never executes and the thread stays
usable.
Fixes#8069
* docs(hitl): state the empty-id rule accurately and track legacy recovery
Review follow-up on the unaddressable-approval guard:
- An empty tool-call id was technically resumable end to end (langchain-core
accepts `ToolMessage("")`, and the Gateway validator accepts `""`), so only a
null or duplicated id can wedge a pause. The rule still denies empty ids
because strict providers reject them when the conversation is replayed; the
docstring, CHANGELOG and interaction policy now say exactly that, and
`_process_responses` uses the same `_addressable` predicate as the pause
guard so the two checks cannot drift.
- Add an operator note for pauses stored before this fix: they are not cleared
here, so continue in a new thread; in-place all-reject recovery is tracked in
#8071.
* fix(hitl): drop a colliding call id from every serialized surface
Review follow-up: `_strip_calls` removed the unaddressable approval calls from
`tool_calls`, but `clone_ai_message_with_tool_calls` filters the raw
`additional_kwargs["tool_calls"]` payload and content tool-call blocks by id.
A non-target call sharing a removed id kept that id in `kept_ids`, so the
removed calls stayed in the raw provider payload and the next request would
advertise calls that have no result.
Drop the whole colliding id group: an id shared with a denied approval call
cannot be answered unambiguously either. The regression covers a mixed response
(duplicate approval ids plus an ordinary call with the same id) and asserts the
raw payload, content blocks, execution and visible note.
* fix(hitl): reject an id shared with an ordinary call before pausing
Review follow-up: `_addressable(targets)` only asked whether a `ToolMessage`
can answer the targeted calls. A single approval-required call whose id is
shared by an ordinary call in the same response still paused, even though
approving or rejecting it would also answer the ordinary call by id.
Add `_unambiguous_ids`: every targeted id must also be unique across the whole
response. A collision denies the colliding group (strip + visible note) before
the pause, and the resume path applies the same check to a legacy pause. New
regression: one approval-required call and one ordinary call sharing an id.
* fix(hitl): strip a deny target whose id an ordinary call shares
`_deny_all` routed only on `_addressable`, which checks uniqueness among the
targeted calls. A target whose id an ordinary non-target call also carried was
therefore answered with a deny `ToolMessage` while the ordinary call stayed
serialized, so the next request advertised the id twice with two results.
`_deny_all` now requires `_unambiguous_ids` and strips the whole colliding id
group, matching the pause path. The tool still never executes.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(runtime): persist a rejected HITL call in the resume run's history
An approval pause and its resolution are two Runs. The paused Run dispatches
the gated call; the resume Run produces its result. An approved call executes
and reaches on_tool_end, but a rejection is written by HITL after_model with no
tool callback. Journal reconciliation only persists such middleware-written
results for calls dispatched by the current Run, so the rejection stayed in
checkpoint state and never reached the run-event history: /messages and the
resume run's messages had no llm.tool.result for it.
The worker already reads the durable interrupt payload to recover the gated
tool names. It now also returns each paused tool_call_id with its name, and the
resume journal adopts those calls, so the existing reconciliation persists the
rejection under the resume Run. Approved results keep their tool-callback event
and are deduplicated by message id; results from other earlier Runs are still
not re-attributed.
* fix(runtime): keep HITL replay names independent of call adoption
Derive the replay-gate tool names and the adopted call ids separately
from the matched pause's actions, so an action without a usable
tool_call_id still gates replay. Pin that the all-reject escape paths
(approval disabled, gated tools changed or cleared while paused) reach
the history feed under the resume run, check resume-run attribution in
the mixed-decision test, and add a CHANGELOG entry.
* refactor(runtime): drop unused _pending_hitl_resume_calls wrapper
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Re-uploading a file whose name collides with a compound archive suffix
(e.g. .tar.gz, .tar.bz2) previously inserted the dedupe counter inside
the suffix: a second report.tar.gz became report.tar_1.gz, which broke
file-type detection. Treat the compound suffix as a single extension so
the counter lands before it (report_1.tar.gz), matching single-suffix
behavior (report_1.tar, report_1.txt).
- Add _COMPOUND_SUFFIXES and split stem/suffix as one unit, sorted
longest-first with a closed, non-overlapping allowlist (.tar.br added).
- Expand tests to all 13 suffixes plus counter-escalation and plain .tar.
- Add a CHANGELOG [Unreleased] Fixed entry.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): recall CJK sub-phrases when jieba is not installed
Without the memory-zh extra, _tokenize fell back to whitespace splitting, so
the FTS5 index kept a whole CJK run as a single unicode61 token and every CJK
sub-phrase query returned no hits without any warning. The relevance ranker
already emitted CJK character bigrams for the same text, so the two retrieval
strategies disagreed on the same data.
Reuse relevance.cjk_token for CJK runs when jieba is unavailable and route
_preprocess_content through _tokenize, so the index and the query side agree.
Installs with jieba keep the existing jieba path.
Existing index rows are rebuilt on the first search or warm-up after the
upgrade; no schema or version change is involved.
* style(tests): format the CJK fallback regression test
`ruff format --check .` in the backend lint job reformats the new assertion in tests/test_memory_retrieval_adapter.py: the implicitly concatenated message is joined into one line. Apply the formatter output, no behavior change.
* fix(deermem): recall single CJK characters through an FTS5 prefix phrase
The fallback index stores CJK runs as character bigrams, so a one-character
query had no equal token and came back empty; `"重"*` is an FTS5 prefix phrase
that matches the bigram `重试` and still matches a standalone `重` token, while
multi-character tokens keep the plain quoted phrase. Also force the no-jieba
branch with a monkeypatch so the regression test runs on installs that have
the optional `memory-zh` extra, and cover the single-character and absent-
character cases.
* docs(deermem): scope the single-CJK prefix note to leading matches
The prefix phrase only reaches indexed tokens that start with the queried
character, so a character occurring only later inside a bigram (务 in 任务)
is not matched. Say that in the docstring and mark the residual gap as
accepted instead of widening the prefix to the run's trailing character.
* fix(agents): drop empty text items from sanitized list content
InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but only replaces the items it collected. Empty
bare strings and empty text blocks are skipped by the extractor (matching
message_content_to_text) and then re-emitted as-is, so a request whose
content was ["", {"type": "text", ...}] still carried a bare "" content
part. The Chat Completions schema only allows typed content-part objects
in a content list, so endpoints that validate against it rejected the
whole turn — and kept rejecting every later call that resent the message.
Empty text items are now recognised during extraction and dropped when the
content list is rebuilt: leading, interleaved and trailing positions. A
list made only of empty items collapses to one empty typed block, and an
image-only message still passes through untouched. Nothing else changes:
non-text blocks keep their positions, and the transform stays
request-scoped.
* fix(agents): drop empty text items when rebuilding sanitized content
InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but it only replaced the items it had collected.
Empty bare strings and empty text blocks were skipped during extraction
(matching message_content_to_text) and then re-emitted unchanged, so a
message whose content was ["", {"type": "text", ...}] still carried a bare
"" content part. The Chat Completions schema only allows typed content-part
objects inside a content list, so endpoints that validate against it
rejected the whole turn, and kept rejecting every later model call that
resent the message.
Empty text items are now collected during extraction (they are text-shaped,
so the rebuild is what has to drop them) and therefore disappear in all
positions: leading, interleaved and trailing. A list made only of empty
items collapses to one empty typed block instead of leaking the bare
string, and an image-only message still passes through untouched.
Non-text blocks keep their existing placement rule, and the transform stays
request-scoped.
* docs(middleware): state that empty typed text blocks are dropped
The #8038 scope note in middlewares/AGENTS.md still said empty typed text
blocks remain unchanged and that providers can still reject them. This
change drops those blocks too, so the note described the opposite of what
the code does. Rewrite the summary and that bullet to match, and keep the
remaining limits explicit: user content only, and an empty user turn can
still be rejected.
* test(middleware): keep the legacy cases out of the new class and pin keep-alive
Inserting TestEmptyTextBlockItemsAreDropped before the existing
already-wrapped, graph-bubble-up and fail-open cases silently moved those
three tests into the new class, changing their node IDs and putting them
under a docstring about empty typed text blocks. Place the new class after
test_fail_open_on_processing_error so the legacy tests keep their home.
Also pin the boundary the sweep must not cross: a whitespace-only part,
typed or bare, carries no words but still keeps the turn alive. Assert
both shapes survive next to an image so tightening not block to not
block.strip() fails here rather than turning an empty user turn into a
rejected one.
* docs(middleware): keep the empty-text note inside the guidance size limit
The added paragraph pushed the effective AGENTS.md chain 55 bytes past its hard limit. Condense the same facts instead of adding a new line to the budget.
---------
Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
* fix(feedback): return 409 instead of 500 when feedback is POSTed twice
POST /api/threads/{id}/runs/{rid}/feedback did a plain INSERT. A second
POST by the same user for the same run (a retry, or feedback on another
message of that run) hit uq_feedback_thread_run_user and the uncaught
IntegrityError surfaced as HTTP 500.
FeedbackRepository.create now rolls back and raises
FeedbackAlreadyExistsError on that collision, and the route maps it to
409 pointing callers at PUT, which already upserts.
* fix(feedback): only map the run/user unique key to 409; cover concurrent POSTs
Address review on #8064:
- create() now re-reads the (thread, run, user) row after an
IntegrityError and raises FeedbackAlreadyExistsError only when it
exists; any other violation (e.g. a feedback_id collision) propagates.
- Add a commit-barrier test where two first POSTs race: one 200, one
409, and the winner's row is kept.
- Add CHANGELOG.md / CHANGELOG_zh.md Fixed entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(browserless): resolve the token through the shared credential rule
Browserless read BROWSERLESS_TOKEN and the configured token raw, so a value
from a file kept its trailing newline and became the auth query parameter;
every request then failed authentication. A whitespace-only value was sent as
a token instead of counting as unset. Use provider_credentials.resolve_api_key
like the other search and fetch providers, with the blank-config fallback to
the environment.
* fix(browserless): keep an explicit blank token opt-out
An explicitly configured token ("" or null) is authoritative: a self-hosted
capture endpoint uses it to opt out of authentication, and falling back to
BROWSERLESS_TOKEN sent the cloud credential to that endpoint. Only an omitted
token key falls back to the environment. Regression tests cover the client
token and the outgoing capture query.
* docs(changelog): record the browserless token normalization
Match the sibling #7981 entry in both locales; the fix closes out the
credential-rule family for the bundled search and fetch providers.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(typesafe): build the per-evaluation HTTPX client off the event loop
TypeSafeClient.aask constructs httpx.AsyncClient on the running loop, and
that constructor loads the certificate store synchronously
(create_ssl_context -> ssl.create_default_context(cafile=certifi.where())).
aask is the path GuardrailMiddleware.awrap_tool_call awaits, plus both
memory paths, so every guarded tool call stalls the Gateway loop for the
duration of the trust-store load.
Move the construction into asyncio.to_thread, the shape backend/AGENTS.md
records for the other HTTPX clients and the one the merged Crawl4AI anchor
tests. Requests and cleanup stay on-loop, so the deadline scope, retry
loop, bounded body read and the error taxonomy are untouched.
* fix(typesafe): close clients built during cancellation
Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>
* fix(skills): read undecodable skill state as missing
`_read_manifest` and `_read_skill_states` document that an unreadable file
means "no manifest" / "no overrides", and both catch OSError and
JSONDecodeError. A file that is not valid UTF-8 raises UnicodeDecodeError,
which is a ValueError and matches neither, so the projection and the skill
state read raise instead of falling back.
Catch it alongside the existing exceptions.
* fix(skills): keep the reason when profile state cannot be decoded
Review feedback on #7963: the state reader dropped the exception, so a decode
failure that silently discards the user's disabled set left nothing to diagnose,
and the next toggle overwrites those bytes. Log the exception, and log a debug
line when an unreadable projection manifest forces a rebuild.
* docs(skills): state that undecodable skill state fails open
_read_skill_states already degraded a malformed file to {} before this change;
making undecodable bytes take the same path made that explicit, so say so next to
the sandbox check that documents the opposite verdict for a single named skill.
---------
Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
* fix(memory): select FTS5 matches by composite score
Score every SQL-filtered match before selecting the final composite top-k.
Keep candidate retention bounded with a streaming heap and preserve the
existing scoring formula and adapter-level filtering behavior.
Add regressions for confidence and time-decay reversals, category filters,
result prefixes, ties, and the public DeerMem storage path.
Fixes#8003
* fix(memory): report matched and retained FTS5 rows
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
remain from its review:
- `stored_next_run_at` caught the ValueErrors a dropped zone or a newer cron
parser raises, but a stored `schedule_spec` that is JSON null, a list or a
string fails on `.get` with AttributeError. The launch-failure handler
recomputes the schedule, so that escaped `run_once`'s claimed-task loop and
skipped the rest of the batch. Catch it too; the task is paused with the
reason like the other unusable schedules.
- `run_once` dispatched its claimed batch with a bare loop, so any per-record
raise in `dispatch_task` re-aborted the batch. Isolate each task's dispatch
and keep claim-level store failures loud above the loop.
Tests: `test_scheduler_poll_batch_isolation.py` (non-object specs, end-to-end
pause, per-task isolation). Red on `91f20a7`, green at head.
Refs #7998
* fix(threads): evict in-memory runs when a thread is deleted
RunManager.get() and list_by_thread() answer from the process-local _runs
registry before consulting the RunStore. DELETE /api/threads/{id} only
removed the durable rows, so a worker that had admitted the thread's runs
kept serving them: after the delete returned 200 and the thread itself
answered 404, GET /api/threads/{id}/runs still listed the run and
GET /api/threads/{id}/runs/{run_id} still returned 200 with the original
prompt intact in kwargs. The residue disappeared only on restart, which
is what made it look like a cache rather than a leak.
Add RunManager.discard_thread_runs() to drop this worker's copies for the
thread and call it from the delete path, next to the durable run
deletion. Internal thread-operation rows are left alone: the reservation
protecting the deletion is one of them and its own release path owns it.
Historical runs, the thread row, run events and checkpoints are unchanged.
* fix(threads): keep a live finalize tail attached when evicting runs
discard_thread_runs() popped every record it found, but worker.py clears
finalizing before the tail that publishes the terminal frame, closes the
journal and releases the sandbox lease. Popping mid-tail turns the tail's
manager-mediated writes into warnings on a record that no longer exists.
Skip records whose task is still running, mirroring what cleanup() already
does through shield(), and pin the guard with a unit test that seeds both
a finished run and a delete reservation. Document that a DELETE served by
one Gateway bounds, rather than removes, a peer's copies: they survive
until that peer's deferred cleanup(run_id) fires.
* fix(threads): hide a deleted run from reads during its finalize tail
A DELETE that arrives after worker.py clears `finalizing` but before the
worker task finishes cannot drop the record: the tail still publishes the
terminal frame, closes the journal and releases the sandbox lease through it,
so popping turns those writes into `set_status called for unknown run`.
Leaving the record readable instead keeps the run and its prompt reachable
through `GET /threads/{id}/runs` until `cleanup()`'s 300s delay fires, which
is exactly the window #8034 reports.
Records that a deletion reaches mid-tail are now flagged instead of skipped.
`get()`, `list_by_thread()`, `get_many_by_thread()`, the regenerate-source and
edit-replay reads skip a flagged record (and never rebuild it from a store row,
since the durable rows were deleted first), and the tail's own completion
evicts it — no second delete request and no timer.
* fix(threads): keep the tail eviction task referenced until it settles
The done-callback eviction is a separate task because it cannot await the
registry lock inline. The loop holds only weak references to tasks, so an
unreferenced eviction could be collected before it drops the record and the
deleted run would come back within `cleanup()`'s 300s window. Track it in a
manager-owned set and discard on completion.
* fix(threads): keep hidden runs hidden on every read path
The deletion flag set by discard_thread_runs is authoritative for "this run
is deleted", but two reads still treated it as a hint.
get() narrowed its store-less guard to a missing record, so a flagged
record on a store-less RunManager fell through to self._store.get() on
None; the AttributeError was swallowed by the broad except below while
every read in the flag window logged a hydration warning with a full
traceback. The record now settles the read before any store call, which
also keeps the read side-effect free as the comment intends. aget()
shares the path.
get_many_by_thread() rebuilt flagged runs from store rows. list_by_thread
already refuses that, because the durable row is deleted before the flag
is set and anything left is a stale or partial read; the batch read now
applies the same guard so the invariant is uniform.
---------
Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
* fix(subagents): keep tests_passed evidence per call when a provider reuses its id
Tool-call ids are unique per response, not per conversation. Some providers
number calls per response, so two turns can both issue call_0.
The bash evidence for tests_passed acceptance assumed one call per id in two
places:
- _harvest_bash_executions built one id -> command map over the whole
conversation, so every result under a reused id was labelled with the
last call's command. A passing subset run (pytest tests/unit) became
evidence for the full suite when a later call_0 ran pytest tests and had
no result yet.
- update_bash_executions merged values chunks by tool_call_id. A dict keeps
a key's first position, so a later run under a reused id overwrote the
earlier entry in place. With call_0 failing, call_1 passing and call_0
failing again, the merged list ended on the stale pass and the leaf held.
Pair results through pair_tool_call_results, which answers a call only from
its own response, and merge chunks by result message id, keeping the call id
for entries without one. Each run stays a separate entry in execution order,
so the latest run decides the leaf again.
* fix(subagents): key the summary cache per run and document id-less calls
- Cache full-output test summaries by result message id (call id only
without one), so reused call ids keep one cache slot per run.
- Update the bash_executions field doc and the subagents guide to the
result-id merge, and state that calls without an id record no
evidence (the leaf stays UNVERIFIED).
- Tests pin both: one classification per run under a reused id, and no
evidence for a call with a missing or empty id.
* chore(subagents): drop the stray empty deerflow/executor.py
The previous commit added a 0-byte module at the package root by mistake.
Nothing imports deerflow.executor; the real module is deerflow/subagents/executor.py.
* fix(skills): give a bracketed literal in the userinfo slot no host on every Python
`_http_host` answered from `urlsplit(url).hostname`, which decides the
ambiguous authority `http://[::1]@Example.COM/api` differently across
interpreters: 3.12 reads the bracketed part as userinfo and returns the host
`example.com`, 3.13 rejects the authority and `urlsplit` raises, so the helper
returns None. The same URL got two different answers depending on the runtime.
The answer is not cosmetic: `_is_outbound_url` compares it against
`_LOCAL_HTTP_HOSTS`, so whether a URL counts as local or outbound moved with the
interpreter. The test table added with the surrounding refactor (#7950) already
states the intended answer, None, which is why `tests/test_skillscan_native.py`
fails on 3.12 and passes on 3.13.
The bracketed-literal case is now rejected before `hostname` is read, so the
answer no longer depends on that URL-parsing change, and the shape keeps the
treatment the other malformed authorities get: no host, therefore outbound.
The test table also gains a bracketed literal with an IPv6 host and a port
(`http://[2001:DB8::1]@example.com:8443/api`), so a fix that only special-cased
the exact URL from the failure cannot pass.
On 3.12: `tests/test_skillscan_native.py` goes from 1 failed / 387 passed to 388
passed, and the skillscan suite (834 tests) passes. Reverting only the
orchestrator change puts both bracketed cases back to failing.
* fix(skills): reject raw brackets in URL userinfo
---------
Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agents): drop empty strings from rebuilt user content
* fix(agents): normalize empty content before sanitizer early returns
* docs(agents): clarify input sanitization provider limits