* 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(gateway): name the agent problem when run admission rejects agent_name
Since #5238, run admission loads the named custom agent's config for
every run, with or without a knowledge scope, and folded every failure
into "knowledge_scope assistant configuration could not be resolved".
Messaging a chat whose custom agent was deleted, or passing a malformed
agent_name, pointed callers at the knowledge base instead of the agent.
The 422 status is unchanged; the detail now says which problem it is:
- Agent '<name>' not found
- Invalid agent_name '<value>': must contain only letters, digits, and
hyphens after normalization. (shared with custom_agent_name(), which
gains a field= argument; existing callers keep the default)
- Invalid agent_name: expected a string, got <type>.
- Agent '<name>' configuration could not be loaded
* docs(changelog): reference #8087 for the run admission agent_name detail
* fix(gateway): bound the agent name echoed in run admission errors
Address review on #8087: admission now echoes the caller's raw
agent_name, which start_run merges from config/context without a size
check, so an oversized value was reflected verbatim into the 422 detail
and any log line that records it. Cap the echoed name at 200 characters
in the invalid-name message (shared with assistant_id validation) and in
the not-found / unreadable-config messages.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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(wecom): raise the image upload cap to the platform's 10 MB
`send_file` capped images at 2 MB and files at 20 MB. 2 MB is the *voice*
limit in WeCom's aibot media-upload protocol; images are allowed up to 10 MB
(developer.work.weixin.qq.com/document/path/101917). Every 2-10 MB image was
therefore dropped before upload, and the turn arrived without its chart.
Raise the image cap to 10 MB. The 512 KiB chunk size keeps a 10 MB upload at
20 chunks, well under the protocol's 100-chunk limit.
* docs(changelog): record the WeCom image-cap fix
Adds the Unreleased -> Fixed -> channels entry for #8174 to CHANGELOG.md and
CHANGELOG_zh.md.
* 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>
* fix(helm): make the gateway init containers' image and resources configurable
init-home and init-extensions hard-coded busybox:1.36 and set no resources.
Clusters that pull only from a mirror could not start the gateway, and a
namespace ResourceQuota on limits.* rejected the pod, although every main
container already has a configurable image and requests+limits.
Add gateway.initContainers.image (repository/tag, default busybox:1.36, same
convention as nginx.image) and gateway.initContainers.resources (modest
requests+limits by default, matching the chart README's resource defaults).
Add a helm-template test for the defaults and the overrides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(helm): explain mirroring gateway init images
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
WeCom leaves the bot mention in group message text, so `_publish_ws_inbound`
handed "@RobotA /new" to `is_known_channel_command`, which requires a bare
leading slash. The turn was classified as chat and the command never ran.
Strip leading mentions for the command lookup only, mirroring Feishu (#4229)
and DingTalk (#4724): ordinary chat keeps its mentions, and the stripped form
is what reaches ChannelManager so it parses `/new`. The comment spells out that
this drops any leading mention, not only the bot's -- WeCom text mentions carry
display names, so `aibotid` cannot be matched against them.
Recorded in CHANGELOG.md / CHANGELOG_zh.md under Unreleased -> Fixed.
Fixes#8099
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Both entry points ignored their arguments entirely, so the first thing a
contributor tries ran the real work: `python scripts/check.py --help` ran the
full dependency probe (and, on a host with a missing tool, exited 1), and
`python scripts/setup_wizard.py --help` entered the wizard -- which on an
existing config.yaml asks "Do you want to reconfigure?" and then rewrites
config.yaml and .env. Unknown flags were silently ignored the same way.
Parse arguments in the __main__ block, following scripts/doctor.py: main()
stays directly callable for the in-process unit tests, --help prints usage and
exits 0 before any probe or prompt, and an unknown flag exits 2 with
"unrecognized arguments". The no-argument invocations behind `make check` and
`make setup` are unchanged.
Adds subprocess CLI-contract cases in a disposable checkout next to the
existing script tests, modelled on backend/tests/test_doctor_script.py.
* 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>
* test(utils): cover goal-objective and bash-exit input boundaries
* test(utils): make the string-comparison boundary case actually pin the contract
Review follow-up: 400 digits is below CPython's default int-max-str-digits cap (4300), so an implementation regressed to int(match.group(2)) would still pass. Derive the digit count from sys.get_int_max_str_digits() + 1 instead. Also document how this file differs from test_tool_result_meta.py (direct return contract vs message normalization). No production code changes.
---------
Co-authored-by: jack12178 <jack12178@users.noreply.github.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(frontend): copy share links on the current origin
The sidebar Share action rewrote the origin to the hosted demo
(https://deer-flow-v2.vercel.app) whenever the page ran on localhost or
127.0.0.1, which are the default make dev and Docker entry points. The
copied link never opened the thread, because the thread only exists on
the local deployment, and opening it sent the thread id (and a custom
agent name) to a host the operator does not control.
Always build the link from window.location.origin and pathOfThread().
* docs(changelog): reference #8126 for the share link origin fix
The frontend, gateway, provisioner, Postgres and Redis pods include
deer-flow.imagePullSecrets, but the nginx Deployment did not. With
nginx.image mirrored to a private registry, the nginx pod stayed in
ImagePullBackOff while every other pod started.
Add a helm-template test that renders every workload and checks that
each one uses the configured pull secrets and none sets them by default.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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(gateway): reject unusable skill_context entries at the input boundary
merge_skill_context indexes entry["path"] and calls entry.get(), so a
caller-supplied non-object entry or one without a usable path raised during
the state merge and failed the run with a 500. Validate the list shape at the
run-input and thread-state boundaries, mirroring the todos and delegations
channels.
* Normalize entry by ensuring 'name' is a string
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.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>
* feat(benchmark): add offline memory vs project-context comparison suite (part of #7884)
* fix(benchmark): route prompts through DynamicContextMiddleware, flatten rows, and share test run
* refactor(benchmark): run through local Gateway and simplify first-pass suite (#7884)
* docs(agents): keep context memory benchmark guidance within chain budget
* fix(benchmark): verify DeerMem scope rejection
---------
Co-authored-by: Totoro <msy626836554@gmail.com>
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>