93 Commits

Author SHA1 Message Date
luo jiyin
da3feb3863
fix(sandbox): fail E2B bootstrap safely (#4325)
* fix(sandbox): fail E2B bootstrap safely

* test(sandbox): cover E2B bootstrap cleanup

* docs(backend): document E2B bootstrap failure

* fix(sandbox): discard E2B bootstrap failures

* test(sandbox): cover E2B reconnect bootstrap failures

* docs(backend): clarify E2B bootstrap recovery

* fix: preserve E2B bootstrap errors

* fix: reject falsey E2B bootstrap errors
2026-07-21 22:22:20 +08:00
Ryker_Feng
fa496c0c8d
feat(browser): add agentic browser control (#4187)
* feat(browser): add agentic browser control

* fix(frontend): format browser view changes

* fix(browser): keep browser optional and isolate sidecar layout

* fix(browser): address PR review security and IME findings

- Nginx: add a browser-stream WebSocket location before the generic
  /api/threads regex so Live upgrades instead of downgrading to HTTP
  (both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
  navigate, and tear down the browser session on thread deletion so a
  later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
  context-level route guard covering redirects, popups, iframes, and
  subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
  CJK candidate with Enter no longer submits the remote page form.

Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.

* fix(frontend): smooth streaming in long tool threads

* Revert "fix(frontend): smooth streaming in long tool threads"

This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.

* fix(browser): address review security and lifecycle findings

- Reject cross-origin WebSocket upgrades on the live browser stream
  (Origin allow-list reuse of CORS/same-origin helpers) to close a
  WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
  off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
  (browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
  and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
  can't permanently stall after the cumulative attempt cap.

* fix(frontend): reduce long tool thread render stalls

Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.

* fix(browser): keep live control responsive during continuous input

Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").

What:
- Frontend forwards one `click` per physical click instead of also emitting
  `down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
  start a rate-limited background refresh loop (leading frame + bounded cadence)
  that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
  inputs coalesce, and continuous input keeps refreshing before it stops.

Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.

* fix(browser): harden worker and session lifecycle

* fix(browser): address latest review feedback

* fix(frontend): preserve optimistic new-chat message

* test(e2e): preserve mocked message run ids

* fix(browser): address capability review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-21 11:46:33 +08:00
ChenglongZ
ca16b64b26
feat(agent): support config-declared lead middlewares (#3964)
* feat(agent): support config-declared lead middlewares

* fix(agent): preserve configured extension middlewares

* fix(agent): address middleware extension review

* fix(agent): tighten middleware extension docs and tests
2026-07-21 09:39:44 +08:00
Tianye Song
8511fa6aa3
fix(memory): consolidated facts inherit expected_valid_days from sources (#4225)
* fix(memory): consolidated facts inherit expected_valid_days from sources

Consolidation (#3996) and per-fact expected_valid_days (#4143) were both
authored by the same contributor but never connected: the consolidated
new_fact carried the newest source's createdAt but no expected_valid_days,
so _effective_fact_staleness_age fell back to the global staleness_age_days.
A merge of several 200-day-old stable facts (each evd=3650) would land with
no evd, read as a 90-day window, and re-enter the staleness candidate set on
the very next cycle - the merge discarded the lifetime signal of the
underlying information and contradicted consolidation's premise (these are
stable, related facts worth synthesising).

Fix: the merged fact inherits expected_valid_days set so it is re-reviewed at
the EARLIEST source review deadline (min(createdAt + expected_valid_days)
across sources, relative to the merged fact's createdAt = the newest source's).
A merge combines details from every source, so a volatile sub-detail (evd=7)
must not inherit a stable source's 3650-day window and escape staleness review
for years - staleness KEEP/REMOVE is the only path that re-validates a merged
fact, so biasing toward the soonest deadline keeps uncertain merges re-checked
sooner. A source already past its deadline yields a minimal positive window
(review next cycle) rather than the global fallback, which would defer an
overdue review. Capped at the creation-time staleness_max_lifetime_multiplier
like any new fact. Omitted when no source carries a valid evd (legacy facts
fall back to the global age at read time, matching pre-feature behaviour).

DRY: extract _read_expected_valid_days(fact) -> int | None, the shared type
rule (int/float, reject bool, coerce to int BEFORE the > 0 guard) previously
inlined in four places - _normalize_memory_update_fact,
_effective_fact_staleness_age, the newFact creation cap, and consolidation
inheritance. All four call the single helper. Coercing before the guard
matters for values in (0, 1): 0.5 passes a raw > 0 check but truncates to 0,
which would violate the helper's "positive int or None" contract; the order
now matches the original _normalize_memory_update_fact rule.

No prompt/schema change: consolidation's prompt does not surface source evd to
the LLM, so asking the model to assign a merged lifetime would be guessing
without signal. Source inheritance is deterministic and always available.

Tests: consolidation evd cases now use time-stable createdAt (relative to now
via a _days_ago helper) covering - earliest-deadline selection, creation-cap
clamp, omit when no source evd, volatile source governs the deadline (and
re-enters staleness next cycle), overdue source clamps to a minimal window,
float coercion. Plus TestReadExpectedValidDays / TestEffectiveFactStalenessAge
regression cases for the (0, 1) coercion-order fix.

* fix(memory): reject non-finite expected_valid_days before int coercion

The shared _read_expected_valid_days helper (introduced when consolidating the
evd type rule across four call sites) coerces with int(raw) before the > 0
guard - reversing the original _normalize_memory_update_fact order so that a
fractional 0.5 does not leak as 0. But int(raw) raises for non-finite floats:
int(nan) raises ValueError and int(inf)/int(-inf) raise OverflowError. Python's
JSON decoder accepts NaN / Infinity as floats by default, so a single malformed
expected_valid_days in a hand-edited memory.json would abort staleness selection
or consolidation instead of falling back to the global lifetime.

On main, _effective_fact_staleness_age checked raw > 0 first, so NaN fell back
safely (nan > 0 is false) - but inf did NOT (inf > 0 is true, so main also
crashed on inf). This helper is now the persisted-fact read path for both
staleness and consolidation, so the regression (and the pre-existing inf crash)
must be closed here.

Fix: require math.isfinite(float(raw)) before the int() coercion, then keep
the existing positivity check and fallback. NaN / +/-inf all return None, so
callers fall back to the global staleness_age_days. Normal int/float values
(including large ones) are unaffected - isfinite is a no-op for them.

Tests:
- TestReadExpectedValidDays.test_rejects_non_finite_values - NaN, inf, -inf
  return None (not raise).
- TestEffectiveFactStalenessAge.test_falls_back_for_non_finite_values - the
  persisted-fact read path returns the global age for each, no raise.
- test_consolidation_with_non_finite_source_evd_does_not_raise - end-to-end:
  a NaN-evd source merged with a stable source does not abort consolidation;
  the NaN source's effective lifetime falls back to the global 90 and its
  deadline participates in the earliest-deadline computation.

* test(memory): hoist _select_stale_candidates import + tidy deadline docstring

Two review nits from the latest pass:

- `_select_stale_candidates` was imported inline inside three test methods;
  hoisted to the module-level import block so the dependency is declared once.
- `test_consolidated_evd_volatile_source_with_equal_created_at_future_deadline`
  had an abandoned calculation in its comment ("3 + 7 = 10 ... minus 3 already
  elapsed = 7? No:") that could mislead future readers into thinking
  elapsed-since-creation factors into the inherited window. Collapsed to a
  single clear line stating the window is relative to the merged createdAt,
  regardless of the source's current age.

* fix(memory): reject huge-int expected_valid_days above timedelta.max.days

_read_expected_valid_days routed every numeric value through float(raw) for the
math.isfinite guard, but Python's JSON decoder parses an integer literal with
no decimal point as an arbitrary-precision int (unlike 1e400, which decodes to
float inf). So a hand-edited memory.json carrying "expected_valid_days": 10**400
raised OverflowError in float(raw) before math.isfinite was ever called -
exactly the malformed-field-aborts-everything scenario the helper's docstring
claims to prevent.

The earlier non-finite fix only closed the float cases (NaN / +/-inf / 1e400).
A huge int below the float limit but above timedelta.max.days (e.g. 10**12)
would pass the helper and raise OverflowError downstream in
timedelta(days=evd) during staleness selection or consolidation - the same
crash fancyboi999 flagged for extend_by_days, just reached via a stored evd.

Fix: branch on type so an int never passes through float() (matching the
reviewer's suggestion), AND cap the returned int at timedelta.max.days
(999999999) so the downstream timedelta(days=evd) call cannot overflow either.
The float branch keeps the isfinite + int() coercion. Both branches share the
0 < evd <= timedelta.max.days positivity/range check.

Normal values are unaffected - any legitimate expected_valid_days is far below
the cap (the config ceiling staleness_max_extension_days tops out at 36500).

Tests (all three layers):
- TestReadExpectedValidDays.test_rejects_huge_int_above_timedelta_max - 10**400,
  10**12, 10**9, timedelta.max.days+1 return None; timedelta.max.days itself
  is accepted.
- TestEffectiveFactStalenessAge.test_falls_back_for_huge_int_above_timedelta_max
  - the persisted-fact read path returns the global age, no raise.
- test_consolidation_with_huge_int_source_evd_does_not_raise[1e400|1e12|1e9] -
  parametrized end-to-end: a huge-int-evd source merged with a stable source
  does not abort consolidation; the bad source falls back to the global 90.

* fix(memory): guard datetime arithmetic, not just timedelta construction

The huge-int fix capped _read_expected_valid_days at timedelta.max.days, but
that only proves timedelta(days=evd) can be constructed - adding it to a real
fact timestamp still overflows datetime.max. @fancyboi999 reproduced it: a
source with expected_valid_days=timedelta.max.days raises
"OverflowError: date value out of range" at dt + timedelta(...) in the new
consolidation deadline calculation. capping at timedelta.max.days was another
patch chasing the next overflow boundary, not a real close.

Root cause: the helper was doing datetime-range validation, but the safe bound
depends on the datetime the evd is added to, not on the evd alone. So the
responsibility moves to the arithmetic site, with try/except as the terminal
guard - no concrete upper bound to be wrong about.

Changes:
- _read_expected_valid_days returns any positive int (huge ints included, not
  routed through float). Its job is type/positivity validation only.
- New _safe_add_days(dt, days) -> datetime | None wraps dt + timedelta(days),
  returning None on OverflowError/ValueError. This is the terminal guard -
  there is no further boundary to overflow because try/except catches any
  datetime-range failure regardless of magnitude.
- _select_stale_candidates uses _safe_add_days(now, -effective_age); a None
  result means the window is unrepresentably large, so the fact cannot yet be
  stale and is skipped (not selected).
- Consolidation computes each source's deadline via _safe_add_days; a source
  whose deadline overflows falls back to the global staleness_age_days deadline
  (same treatment as a legacy no-evd source), so one malformed field cannot
  abort the merge.

Normal values are unaffected - any legitimate expected_valid_days is far below
the overflow boundary (the config ceiling staleness_max_extension_days tops
out at 36500).

Tests:
- TestSafeAddDays: normal/negative shifts; 10**400/10**12/10**9 return None;
  timedelta.max.days (the exact reproduced value) returns None, not raises.
- TestSelectStaleCandidates.test_huge_evd_does_not_abort_selection: a fact with
  a huge evd is skipped, not selected, and selection does not raise.
- test_consolidation_with_huge_int_source_evd_does_not_raise now parametrized
  over [1e400, 1e12, 1e9, timedelta.max.days] - the last is the value that
  constructs a valid timedelta but overflows datetime arithmetic.
- Helper/read-path tests updated to assert huge ints are returned as-is (the
  overflow guard is no longer in the helper).
2026-07-21 09:36:09 +08:00
Aari
5eb59cb130
fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes (#4221)
* fix(sandbox): stop multi-worker orphan reconcile from killing peer sandboxes

Docker sandboxes are shared across gateway workers, but each worker kept its
own in-memory warm pool. Startup reconciliation adopted every running
container, so a peer idle reaper could destroy sandboxes another worker still
owned and tool calls hit 502 / Connection refused.

Add file-based ownership leases under sandbox-leases/, only adopt true
orphans, refuse idle/replica/shutdown destroy while a foreign lease is live,
and renew the lease on create/get/release/reclaim.

Fixes #4206

* fix(sandbox): close lease fail-open, hot-path IO, and check→destroy race

Address review of the multi-worker orphan lease (#4206):

- read_lease returns None only for a genuinely-absent lease and raises
  (CorruptLeaseError/OSError) when a lease is unreadable or corrupt, so the
  ownership check fails closed instead of mistaking an unprovable peer lease
  for a free container. clear_lease still removes a stuck/corrupt file.
- get() no longer renews the lease (blocking mkdir/fsync/os.replace on the
  event loop path used by ensure_sandbox_initialized_async); active leases are
  renewed off the event loop from the idle checker (_renew_active_leases).
- The ownership check and container stop run under a per-sandbox flock guard
  (lease_ownership_guard); every lease write takes the same guard so a peer's
  touch cannot interleave with a destroy. Same-host multi-worker scope, not a
  multi-pod distributed lock.

Also fixes the ruff format lint on the branch. Adds regression tests: corrupt
and unreadable lease fail closed, a tests/blocking_io anchor keeping get()
non-blocking on the event loop, and a peer-touch/destroy interleave test.

* fix(sandbox): share container ownership across gateway instances

Rework of the #4206 fix per review: ownership state is shared through a
third-party service instead of being maintained per gateway instance,
following the stream_bridge precedent (sandbox.ownership.type:
memory | redis). The file lease and its same-host flock guard are
deleted, not ported — they only covered workers on one host, while the
deployment that hits #4206 is a load-balanced multi-instance gateway.

A lease answers "who reaps this container", not "who may use it".
Containers are deterministic per (user, thread), so consecutive turns
legitimately land on different instances: take() transfers ownership on
acquire, while claim() gates every adopt/reap path.

Leases carry a state — own: or del: — so a takeover is refused against a
teardown in progress. Without it an unconditional take() would overwrite
a destroyer's claim and the peer's container stop would land on a
sandbox the new owner had already handed to an agent.

renew() distinguishes a lapsed lease from one a peer took; only the
latter drops the sandbox. Collapsing them meant a Redis restart evicted
every in-flight sandbox on every instance at once.

Renewal runs on its own thread with a TTL derived from its interval,
never from idle_timeout: renewal used to ride the idle checker, which
does not start at idle_timeout: 0, so leases silently lapsed on a
supported config.

Ownership establishment is fail-closed: a sandbox whose ownership cannot
be published is never handed out, and a just-created container is
destroyed rather than leaked as an adoptable orphan. Every destroy path
claims before untracking.

The memory store is single-instance only and says so; the resolver reads
app_config.stream_bridge and the env var in the bridge's own order, so
deployments already using Redis get a redis ownership store without
extra config.

* fix(sandbox): wait out a recovery grace before adopting a keyless container

An absent ownership lease meant two opposite things on two paths. Renewal
reads it as LAPSED and re-establishes it: nobody took the lease, so the
container is still ours. Reconciliation read the same absent key as "orphan"
and adopted on sight.

After the store loses its keys (a Redis restart without persistence, or
eviction under maxmemory) every owner is alive and merely pre-renewal-tick.
Whichever instance reconciled first therefore adopted every live container;
each real owner's next renewal reported LOST and dropped a sandbox it was
serving mid-turn, leaving it for the adopter to idle-destroy — #4206 through
the back door, in the very case the LAPSED handling was added to make safe.
Not limited to startup: an already-running instance hits the same window from
the idle checker's periodic reconcile.

_adoptable_after_grace requires an untracked container to be seen unowned
across a full lease TTL before it can be adopted. That rebuilds the delay the
state loss erased: a live owner republishes within one renewal interval,
shorter than the TTL by construction, while a crashed owner never does, so its
containers are still adopted one grace later rather than leaking. A republished
lease resets the grace; a pausing-only timer would still expire over a live
owner's lease. The peek is read-only — the atomic claim still gates adoption.

The grace is skipped when the store cannot coordinate across processes: no peer
can hold a lease such a store would show us, so single-instance deployments
keep instant orphan cleanup, and a grace could not help a multi-worker gateway
on memory anyway.

* fix(sandbox): hold the teardown lease for as long as the container stop runs

claim(..., for_destroy=True) wrote the del: marker with the ordinary lease TTL
and nothing refreshed it. renew() extends only own: and deliberately reports a
teardown as LOST, and the destroy paths drop the sandbox from the maps the
renewal loop iterates — so a container stop that outlived the TTL let the marker
lapse, a peer's take() succeeded against the still-running container, and the
stop then landed on the turn that had just been handed it. That is the exact
window the del: state exists to close, reopened by its own expiry.

The two lease states alone never made the per-sandbox flock redundant, as I
claimed when deleting it: a held lock cannot expire, a lease can. The exclusion
has to be held deliberately rather than assumed to outlast the work it guards.

_held_teardown_lease wraps both _backend.destroy() call sites and re-claims the
marker every renewal_interval_seconds until the stop returns. No store change is
needed: claim(for_destroy=True) already refreshes an existing del: marker on
both backends.

Reachable without an abnormal backend. The schema bounds only
renewal_interval_seconds (> 0) and ttl_multiplier (>= 2), so a legal config puts
the TTL below a normal container stop; and LocalContainerBackend._stop_container
passes no timeout to subprocess.run, so a wedged daemon blocks unbounded even at
the default 120s TTL.

The TTL stays finite on purpose: the heartbeat dies with the process, so a
destroyer that crashes mid-stop still releases the container one TTL later
instead of marking it undestroyable forever.

* fix(sandbox): hold the teardown lease on every del: stop, and pin the claims that had no test

90936b49 said `_held_teardown_lease` wrapped "both" `_backend.destroy()` call
sites. There are three. `_drop_unhealthy_sandbox` marks `del:` and then blocks on
the same unbounded stop, and it untracks *before* claiming, so `_renew_owned_leases`
cannot see the id either — nothing refreshed the marker. Reproduced against a real
redis: the peer's `take()` succeeds 1.0s into a 2.5s stop. Same window, third path.

That miss came from the habit the rest of this commit addresses: a property
asserted in prose, with no test that could falsify it. Auditing every load-bearing
claim in this feature — AGENTS.md, the store docstrings, the provider's design
comments — against the test that would go red turned up several more, each
verified by mutating the code and watching the suite stay green.

Tests that could not fail:

- `test_reconcile_fails_closed_when_ownership_unknown` reached the grace gate, not
  the claim. A bare MagicMock answers `owner()` with a truthy mock, so the
  container read as peer-owned and deferred; `claim()` was never called. It stayed
  green with `_claim_ownership` failing open. Adding the grace ahead of the claim
  is what hollowed it out — inserting a gate can silently disarm the tests for
  the gate behind it.
- `test_adoption_grace_restarts_when_a_live_owner_republishes` never distinguished
  reset from pause. Those diverge only on a *second* lapse, which it never drove,
  so it passed with the reset deleted.

Claims with no test at all, each now pinned (mutation → red, per test):

- `destroy()`, `_evict_oldest_warm`, `_reclaim_warm_pool_sandbox`,
  `_register_created_sandbox` and `shutdown()`'s warm loop were each the one
  untested sibling of an "every path does X" enumeration. `shutdown()` was never
  driven with a non-empty warm pool, so a loop bypassing the ownership claim —
  stopping a live peer's container on our exit — went unnoticed.
- Renewal's unknown-is-not-lost rule, the single deliberate exception to
  fail-closed. Inverting it drops every active and warm sandbox on every instance
  the moment the store blinks.
- Both hops of the stream-bridge redis inference. Deleting either left the suite
  green while every config.yaml-native multi-instance deployment silently fell
  back to memory — #4206 reopened on exactly the deployments the inference exists
  for.

Claims narrowed instead, because they promised more than the code delivers:

- "run against both backends ... cannot drift" — CI provisions no redis, so the
  merge gate runs the memory tier only and the Lua never executes there.
- "Every destroy path claims before untracking" — `_drop_unhealthy_sandbox`
  untracks first, deliberately, under its `expected_info` TOCTOU guard.
- "Atomic: concurrent claims from different instances cannot both succeed" — true
  via Lua on redis, vacuous on the single-instance memory store, and pinned by
  neither, since the contract suite drives sequential calls. A concurrency test
  against the memory store would make the claim look covered while the mechanism
  that carries it still never runs in CI.

* fix(sandbox): release the teardown marker when a destroy() stop fails

The three `del:`-marked stop paths disagreed on failure. `_destroy_warm_entry`
releases on both outcomes and says why: the stop failed, so the container is
probably still up, and a marker left behind refuses its own thread's `take()`
until the TTL lapses. `_drop_unhealthy_sandbox` does the same. `destroy()` had no
such guard — a raising backend propagated straight past `_release_ownership`, and
the thread could not re-acquire for a full TTL.

Fails safe rather than fatal: a stuck marker stops peers from touching the
container, it is not the cross-instance kill. But the paths must agree, and this
one is the odd one out.

Release, then re-raise. Swallowing would be the easier symmetry with
`_destroy_warm_entry`'s `return False`, but `destroy()` has no failure return and
`shutdown()` logs per sandbox off the exception, so swallowing would silently
narrow what callers can see.

Found by comparing the three paths after @fancyboi999 asked for release to be
handled "consistently with the other destroy paths" on the unhealthy path — which
0d2377b2 already does. This is the sibling that wasn't.

* fix(deploy): bump chart config_version to 27 for sandbox.ownership

config.example.yaml went to 27 with the new sandbox.ownership section, but
the chart embeds its own copy and stayed at 26, so validate-chart failed.

A bare bump: the chart already sets stream_bridge.type=redis, which is what
resolve_ownership_config infers a redis ownership store from, so no field
change is needed.

* fix(sandbox): release the teardown lease from its heartbeat, not the caller

`_held_teardown_lease` joined its heartbeat only briefly and the caller
cleared the `del:` marker right after the stop. A refresh `claim` still in
flight (`RedisOwnershipStore` had no socket timeout, so a round trip could
block) could land *after* that release and rewrite `del:` on a container
whose stop had already completed — refusing a fresh `take()` (or rolling
back a fresh create) until the TTL.

Move the release into the heartbeat's own `finally`, after its loop stops,
so no refresh can run after it. The three destroy paths no longer release
after the `with` (`destroy()`'s no-container branch still does, since no
lease was held there). Bound every store round trip with a socket timeout
so the in-flight refresh — and thus the deferred release — stays finite,
and broaden the heartbeat's `except` so an unexpected error cannot strand
the marker during a long stop.

Also fold in the review follow-ups: stop re-resolving an already-resolved
ownership config in the factory, document the Redis-outage-vs-TTL boundary
in config.example.yaml, and add a tests/blocking_io anchor pinning that
`release()`'s store round trip stays off the event loop.

* fix(sandbox): refuse a non-destroy claim that would unwind our own teardown

`claim(for_destroy=False)` against our own `del:` lease fell through and
overwrote it with `own:`, cancelling a teardown that was already in flight.
The container stop cannot be recalled, so downgrading the marker would let a
`take()` hand out a container that is about to die -- #4206, self-inflicted.

No caller does this today: the two non-destroy callers run against an absent
key (the LAPSED re-claim) or an unowned one (post-grace reconcile). The
contract has to forbid it rather than rely on that staying true.

Fixed in both backends. The redis rule lives in Lua and the memory rule in
Python, so fixing one only would let them drift silently -- and the shared
contract suite is what is supposed to catch that drift, so it now covers this.

Also adds a contention test for `claim`. The suite drove sequential calls
only, so it pinned the exclusion predicate but not the atomicity that
predicate depends on; eight instances now race for one container and exactly
one must win.

* fix(sandbox): bound the container stop so it cannot outlive its teardown lease

`_stop_container` passed no `timeout` to `subprocess.run`, so a wedged
container runtime blocks it forever. The `del:` marker is what keeps a peer
from re-acquiring the container while the stop runs, but a marker is a lease
and a lease can lapse: a store outage longer than the TTL frees it, a peer's
`take()` succeeds against the still-running container, and the stop then
lands on the turn that was just handed it -- the exact #4206 failure.

The teardown heartbeat already covers the case where the store stays
reachable. This bounds the worst case independently of the ownership layer,
which is the point: it holds even when the ownership layer is the thing that
failed.

A timeout is not swallowed like a `CalledProcessError`. That error means the
runtime answered "I could not stop it"; a timeout means we do not know, and
the container is probably still running -- returning normally would let
`_destroy_warm_entry` report a clean stop and drop the warm entry, leaking a
running container nothing tracks.

* fix(sandbox): exclude this instance's own reapers from its acquire path

An ownership lease excludes peers and nothing else. `claim()` and `take()`
both succeed against our own `own:` lease by design -- that is what lets a
destroy path claim what it already owns -- so `del:` says nothing to this
process's other threads. Meanwhile every reaper decides outside `_lock`,
because a store round trip must not be held under the lock that guards every
acquire. So each reaper acts on a decision its own acquire path may already
have invalidated, and the store cannot see the difference.

Six paths end in an irreversible act (a container stop, or closing a
host-side client) on a decision made outside the lock. All six reproduce:

  _evict_oldest_warm      re-checks warm membership, then releases the lock
  _reap_expired_warm      no re-check at all
  _cleanup_idle_sandboxes re-verifies idle, then releases the lock
  _renew_owned_leases     acts on a stale renew() -> LOST
  release()               same staleness on its own refresh
  _drop_unhealthy_sandbox untracks before claiming, opening discovery

Both warm reapers are a regression from the deferred pop this branch
introduced: `WarmPoolLifecycleMixin` popped under the lock, so a reclaim's
membership check failed and the race could not occur. Deferring the pop is
still right (popping first loses the container on a refused claim), so the
exclusion has to be made explicit instead. The idle path is pre-existing in
shape, but this branch widened it from a few instructions to a network round
trip by claiming ownership before untracking.

Two guards, because the two directions want opposite answers:

Reaping -- nothing may promote it. The reaper reserves the id, and every
promote path refuses a reserved id exactly as it refuses a peer's `del:`
(drop and cold-start). The "is this still reapable?" test travels with the
reservation as a predicate and runs in the same critical section, because
checking first and reserving second is the window, not a narrower version of
it.

Forgetting -- the peer legitimately wins, so the promote is what to detect.
`_publish_ownership` bumps a per-id acquire epoch; the callers that decide
from a store round trip snapshot it first, and the pop is skipped if it
moved. Object identity cannot substitute: the reuse path re-publishes
ownership while handing out the same tracked `AioSandbox`, so an identity
check sees nothing and the pop closes a client mid-turn.

`still_reapable` is required rather than defaulting to unconditional -- the
safe default is the one that makes a new call site think about it. That
diverges from the mixin hook, which is safe because this provider overrides
both mixin callers, and loud rather than silent if those are ever dropped.

Also closes a client leak on the discover path: "nothing to roll back" was
true of the container but not of the HTTP client constructed before the
publish, which the sibling create path already closes.

The shared-store test view rebound `owner_id` outside the store's lock, so a
concurrent claim could execute under the wrong id and read its own lease as a
peer's. Serialized, so the heartbeat-hold tests stop flaking.

* fix(sandbox): mark acquire intent before the ownership round trip

A guard must become visible no later than the transition it guards. The
acquire epoch cannot manage that for `take()`: the takeover is durable before
`take()` returns -- redis has committed the SET while the reply is still in
flight -- and the epoch can only be written afterwards. In that interval the
store already says the container is ours while the epoch still reads as it
did when a renewal decided `LOST`, so the stale forget walks through, drops
the maps and closes the client the acquire is about to hand back. Acquire
then returns an id the provider no longer tracks and `get()` answers `None`
for the rest of the turn.

`_publish_ownership` now publishes an intent mark under `_lock` before the
round trip; the epoch keeps covering the other half, "an acquire completed
since you decided". `_forget_lost_sandbox` honours the intent mark
unconditionally rather than only when an epoch is supplied -- today's
epoch-less callers cannot reach the window, but "no epoch" reading as "no
guard" is how the next caller of a dangerous primitive gets written.

The same invariant had four more instances, all reproduced:

  reuse returns a decision the forget already invalidated -- before the mark
    is set a `LOST` is both current and correct, so the forget legitimately
    runs and the entry reuse decided to hand out is gone. Re-check after
    publishing and fall through to discovery instead.
  reclaim installs an entry a reaper reserved after its check -- the warm
    entry is still visible during the stop, and the reaper's claim succeeds
    because reclaim's own take() just made the lease ours. Re-check likewise.
  the reservation was released before the entry was removed -- the pop
    belonged to the caller, leaving a gap where the container is stopped, the
    entry is still in `_warm_pool`, and nothing marks it.
    `_destroy_warm_entry` removes it itself, inside the reservation; the pop
    stays deferred relative to the stop, just not to the reservation.
  reconcile adopts a container this instance is tearing down -- adoption is a
    promote and needs the same reservation check as the others. Neither
    existing guard excludes it: the claim succeeds because the lease is ours,
    and on `memory` the recovery grace is skipped outright.

The pre-round-trip checks in reuse and reclaim are kept as early-outs, since
they skip a health check and a store round trip on a doomed entry, and are
pinned to that job rather than to a correctness role they no longer hold.

The teardown reservation predicate runs under `_lock`, so it must not touch
the lock. Documented rather than engineered around: making the lock reentrant
to tolerate it would trade a loud hang for a quiet class of re-entrancy bugs
across the rest of the provider.

* fix(sandbox): honor local teardown after ownership publish

* fix(sandbox): clear a stale warm entry when an id becomes active

Active and warm are exclusive states, and the two register paths were the
only place that could hold both: they inserted into `_sandboxes` without
popping `_warm_pool`, so one container ended up with two reapers.
`_reap_expired_warm` judges an entry by its warm timestamp and never
consults `_last_activity`, so it stops a container an agent is actively
using while `_sandboxes` still hands out its client.

Reachable because `_reconcile_orphans` adopts an untracked-but-running
container into the warm pool inside the register's publish -> track
window, and on the `memory` store it adopts on sight:
`_adoptable_after_grace` short-circuits when `supports_cross_process` is
False, so an id carrying this process's own lease reads as adoptable.
That window is new to this branch -- on main the track was a single
locked insert with nothing before it.

Both register paths now pop the warm entry inside the same locked
section that installs the active one.

* fix(sandbox): harden ownership renewal teardown

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-21 09:09:40 +08:00
Ryker_Feng
24a45a4e68
feat(tui): add clear command (#4306) 2026-07-20 23:56:37 +08:00
Daoyuan Li
474a0fd6b7
fix(gateway): correct GitHub auto-retry claim in webhook route docs (#4289)
While investigating a review on PR #4274 (which corrected the same
misconception in the channel manager's dedupe-TTL comment), I found a
more elaborate version of the same factual error in this route,
predating that PR: the docstring claimed "GitHub retries 5xx deliveries
with exponential backoff (up to ~5 attempts over ~8 hours)". No such
behavior exists. Verified directly against GitHub's documentation:
failed deliveries (5xx, timeout, connection error alike) are simply
recorded as failed and never automatically redelivered, for both
repository and GitHub App webhooks
(https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries).
Redelivery is always explicit: the "Redeliver" button, the REST API, or
an operator's own recovery script (GitHub's own recommended pattern for
the latter runs on a 6-hour cron, illustrating this is opt-in operator
tooling, not a GitHub-side mechanism).

The 503-vs-200 status code choice itself was already correct and is
unchanged: 503 keeps a transient failure recorded as failed so it can be
recovered via one of the explicit paths above, while 200 correctly
marks permanent/non-retryable conditions (disabled channel, unknown
event, missing service) as done so no pointless redelivery is invited.
Only the rationale text describing *why* was wrong.

- github_webhooks.py: reworded the route docstring and four inline
  comments/log messages that repeated "GitHub retries" / "so GitHub
  retries" framing, citing the actual documented behavior.
- test_github_webhooks.py: renamed
  test_dispatch_failure_returns_503_so_github_retries to
  test_dispatch_failure_returns_503_not_200 and reworded its docstring
  plus two nearby docstrings/comments with the same framing. No
  assertions changed - same status codes and response bodies checked.
- AGENTS.md: corrected the GitHub Webhooks router table row to match.

All 44 tests in test_github_webhooks.py pass, plus the full
test_github_dispatcher.py / test_github_channel.py /
test_github_registry.py / test_channels.py suites (313 total). ruff
check and ruff format --check are clean on both touched Python files.
2026-07-20 08:01:18 +08:00
Aari
a8bf54cbbb
feat(skillscan): detect exfil through instance/dataflow network clients (#4265)
* feat(skillscan): detect exfil through instance/dataflow network clients

* fix(skillscan): resolve client handles with Python lexical scopes

_walk_client_nested_scope copied every live handle into a nested scope after
excluding only parameters. That is not Python's name resolution: a class
namespace is not a closure scope for its methods, a function-local binding
shadows an enclosing name across the whole body, and comprehensions bind their
targets in a scope of their own. Benign skills were therefore blocked as
python-env-dump-exfil (CRITICAL) even where the outbound-looking call provably
cannot run on the tracked client.

Derive each scope's bindings lexically instead:

- a class body reads its enclosing scope, but the names it binds are not passed
  into the methods defined in it;
- comprehensions are their own scope, so only the outermost iterable is
  evaluated outside and every for-target shadows first;
- a function-local prepass excludes names the body binds anywhere, with
  global/nonlocal opting back out.

Detection is unchanged where the client really is reachable: a comprehension
calling an unshadowed handle, a method closing over an enclosing function's
handle, and a global-declared module handle all still block.

* fix(skillscan): apply target rebinding in Python evaluation order

The generic ast.iter_child_nodes() fallback yields fields in declaration order,
which for `for`/`async for`, `:=` and `+=` puts the binding target ahead of the
expression Python evaluates to produce it. Visiting the target first dropped the
handle before the call that actually runs on it, so `for s in s.post(host, ...)`
reported nothing even though Python calls post() on the client before binding the
loop target. Match captures bind through a plain `name` string rather than a Name
node, so the Store branch never saw them and a rebound name kept a stale handle.
Assignment expressions inside a comprehension were applied only to the
comprehension-local map, leaving the containing scope stale as well.

Give every bind-after-evaluate construct its own branch:

- `for`/`async for` walk the iterable against the pre-loop binding, then rebind
  the target, then walk the body;
- `:=` walks its value first and binds into the containing scope too, since PEP
  572 puts a comprehension's walrus target there;
- `+=` walks its value before dropping the target;
- match captures drop the handle, matching how a rebind under `if`/`try` that may
  equally not execute is already treated.

The bypasses this closes are the reason detection widens here; the comprehension
walrus and match cases narrow it back where the client provably cannot be the
receiver.

* fix(skillscan): preserve scoped client handle semantics

* fix(skillscan): scan sinks inside assignment target expressions

The Assign/AnnAssign, AugAssign, For/AsyncFor, with, and comprehension
branches evaluated only the value and rebound the target, so a client
call placed in an attribute receiver or a subscript value/index -- both
evaluated at bind time -- was never scanned. os.environ could exfil
through a tracked client while python-env-dump-exfil reported nothing.

Add _walk_client_target_exprs to walk the executable parts of a binding
target (attribute receiver, subscript value+slice, recursing through
tuple/list/starred) in Python evaluation order, without treating Store
name leaves as reads. The AnnAssign annotation expression is walked too.
Name-leaf invalidation is unchanged.

* fix(skillscan): apply assignment targets and annotations in runtime order

The round of assignment-target scanning walked every target then rebound
the names in one batch, and always walked an AnnAssign annotation before
the target. Python instead binds chained and destructured targets left to
right (so `session = out[session.post(...)] = cfg` runs the subscript on
the already-rebound name), evaluates a variable annotation only in module
or class scope (never in a function, never under `from __future__ import
annotations`), and evaluates an executed annotation after the target.
The scanner therefore hard-blocked benign skills.

Bind each target left to right via _bind_client_targets (walk its
executable sub-expressions against the current bindings, then rebind
before the next target), and walk an annotation only for the nodes
_evaluated_annotation_nodes marks as actually evaluated, after the
target. Target-expression scanning and name-leaf invalidation are
otherwise unchanged.

* fix(skillscan): scan client sinks in evaluated function-signature annotations

A function's parameter and return annotations are evaluated at def time
in the enclosing scope, like its decorators and defaults, so a tracked
client sink placed in one is a real egress. _client_scope_prelude walked
the decorators and defaults but not the annotations, so os.environ could
exfil through `def f() -> session.post(host, json=dict(os.environ))`
while python-env-dump-exfil reported nothing.

Record function/async-function defs in _evaluated_annotation_nodes (their
signatures evaluate at def time unless from __future__ import annotations
postpones them) and, for those nodes, add the parameter and return
annotation expressions to the enclosing-scope prelude walk.

* fix(skillscan): match runtime evaluation order for annotations and except handlers

* fix(skillscan): scope try/match branches to their own selection state

* fix(skillscan): propagate branch bindings to everything that observes them

A branch's net effect has to be visible exactly where Python makes it visible.
The walker isolated `except`/`else`/`match` bodies into scope copies and then
discarded them, so a client created on the branch was invisible to `finally`,
to the code after the statement, and to anything defined inside the branch,
while a name the branch replaced stayed a sink receiver.

- `except*` clauses are sequential, not alternatives: thread one scope through
  body, clause types and clause bodies in source order instead of reusing the
  mutually exclusive copies ordinary `except` needs.
- Fold each `except`/`else`/`match` branch's net effect back into the scope that
  `finally` and the following code read, and make the branch scope what nested
  definitions close over.
- Keep a fallthrough scope across `match` guards, so a guard that returned false
  still hands its side effects to the next case, while pattern captures stay
  isolated to their own case.
- Keep an `as` target path-local: Python unbinds it only on the path that ran, so
  dropping it for every path erased a live handle where no such handler executed.

Adds a 14-case runtime-oracle regression covering both directions at every
branch site; each of the ten guards was deleted on its own to confirm the test
that pins it goes red.

* fix(skillscan): join alternative branches instead of overwriting one with another

Only one of a statement's alternative branches runs, but the walker folded each
one into a single destructive binding map. Whichever branch was visited last
therefore decided the state: a handler that rebinds the name erased a sibling
that leaves the client in place (missing a client Python really calls), and a
handler that builds one was credited on paths where it never ran (inventing a
CRITICAL sink). Alias targets had the mirror problem, since only key presence
was compared, so replacing `import x as name` on a live branch was ignored.

- Join alternatives into a may-state: a name stays a sink receiver when any
  feasible branch leaves it a client, and stops being one only when every
  feasible branch replaced it. Alias targets join toward the target that can
  still name a constructor.
- Treat each `except*` clause as optional rather than threading every clause
  body unconditionally, so a clause whose type never matched cannot erase a
  handle the next clause calls.
- Keep the fall-through state (no exception raised, no case matched) as one more
  alternative, and drop it only where the source decides the outcome: a literal
  always-raising body selecting one handler, a literal exception group choosing
  `except*` clauses, a wildcard or literal-equal `match` case.

Also corrects an existing match-capture test that asserted the non-exhaustive
case is benign: the runtime oracle shows the original client still takes the
call on the path where nothing matched. Adds runtime-oracle regressions for both
directions at every alternative site; each of the nine guards was deleted on its
own to confirm the test pinning it goes red.

* fix(skillscan): model feasible conditional client flow

* fix(skillscan): preserve feasible control-flow outcomes

* fix(skillscan): model expression evaluation paths

* fix(skillscan): narrow instance-client detection to lexical statement order

The construction-to-use signal had grown into a path-aware interpreter:
exception selection, except* subgroup consumption, match capture timing,
finally override, comprehension laziness, annotation evaluation order, and
may-state joins over feasible branches. That is the heavyweight analysis
RFC #2634 rules out of Phase 5, and because the signal feeds a CRITICAL
rule it hard-blocks skill installation, so every ambiguity it resolved by
over-reporting cost a benign skill instead of a human review.

Replace it with ordinary statement order over a one-level handle map: a
known constructor bound to a simple name (including `with ... as`), a
direct outbound method call on that name in the same lexical scope,
rebinding invalidation, and name-to-name alias propagation so `s = session`
does not shed the handle. A sink is recorded at the call, so a rebind after
the call cannot retract it.

Compound statements are not interpreted. Every name an if/try/except*/loop/
match may bind is dropped before its bodies are walked, and each body is
walked from an isolated copy. Dropping before rather than after is what
keeps a `finally` that runs after a handler rebound the name, a later
except* clause, and a second loop iteration from reporting a client the
runtime never calls. Bodies are still walked, or wrapping any construction
in `if True:` would be a universal bypass.

Lexical scoping is unchanged: class namespaces are not closures for their
methods, comprehension targets and function-local bindings shadow, and
alias visibility stays per scope.

The cases this gives up are false negatives by construction and are
recorded in #4296, pinned by a test that asserts the runtime really calls
the client while the scanner stays silent.

Verified: 102 SkillScan tests; full suite 8 failed / 7851 passed, the same
network-dependent web-fetch tests that fail on clean main; per-clause red
check 12/12 guards red; 925 repo-owned files scanned branch vs main with 0
new and 0 lost CRITICAL findings; #4158 bypass BLOCKED and #4153 false
positive allowed with 0 findings through the real enforce_static_scan gate.

* test(skillscan): pin closure boundary

* refactor(skillscan): narrow client handle analysis

* fix(skillscan): close client handle correctness gaps

* fix(skillscan): require proven client imports
2026-07-20 07:04:59 +08:00
Daoyuan Li
3ed2e1f1d9
fix(config): close out #4124 review follow-ups (shared signature helper, resolve_config_path None contract) (#4275)
* fix(config): close out #4124 review follow-ups

Extracts the (mtime, size, sha256) content-signature helper that was
duplicated between config/app_config.py and mcp/cache.py into a new
config/file_signature.py, and fixes ExtensionsConfig.resolve_config_path()
to return None instead of raising FileNotFoundError when an explicit
config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH points at a file
that has since been deleted -- the exact resolution mode Docker dev/prod
uses per AGENTS.md, so the MCP tools-cache staleness check could raise
instead of degrading to "not stale". Both were flagged by willem-bd in
review on #4124 and explicitly deferred there ("flagging for visibility",
"leaving the extraction as the follow-up you suggested").

* fix(config): keep explicit extensions-config paths fail-loud

resolve_config_path() previously turned every missing-file case into a
clean None, including an explicit config_path argument or
DEER_FLOW_EXTENSIONS_CONFIG_PATH (the exact mode Docker dev/prod uses).
That silently downgrades a bad Docker mount, typo, or deleted
production config to "no extensions" instead of surfacing the
misconfiguration, per fancyboi999's review [P1] and willem-bd's
follow-up notes on this PR.

Restores FileNotFoundError for the two explicit modes (config_path
argument, DEER_FLOW_EXTENSIONS_CONFIG_PATH); only the fallback search
mode (no explicit path/env var, nothing found in the usual locations)
still returns None, since that is the legitimate "extensions were
never configured" case.

The one caller that needs the old fail-soft behavior -- the MCP
tools-cache staleness check, which re-resolves the path on every
get_cached_mcp_tools() call -- gets a narrow, local catch instead
(deerflow.mcp.cache._resolve_config_path) so a config file going
missing mid-run still degrades the cache to "not stale" rather than
crashing a hot per-request path. Also hoists the double os.getenv()
read in the env-var branch into a local, per willem-bd's nit.

Adds resolver-level tests for both explicit-path and env-var raises,
a dedicated search-mode None regression test, and updates the
existing MCP-cache docstrings to describe the corrected split.
2026-07-19 22:12:30 +08:00
Aari
5b65d543b1
fix(channels): key inbound dedupe on chat-scoped workspaces (#4287)
* fix(channels): key inbound dedupe on chat-scoped workspaces

* fix(channels): release the inbound dedupe key on swallowed transient failures

_release_inbound_dedupe_key runs from _handle_message's generic exception
handler, but three sites handle their error in place and never re-raise, so
the key recorded on receipt survives the full dedupe TTL and the provider's
redelivery -- the retry that would have recovered the failure -- is dropped:

- _handle_streaming_chat swallows every stream error into its finally block
- the fire-and-forget runs.create busy branch
- the non-streaming runs.wait busy branch

Keying chat-scoped workspaces gives telegram/feishu/wechat/dingtalk a dedupe
key for the first time, which newly exposes them to this. The mechanism is
pre-existing, not introduced here: WeCom already had a key (streaming plus
aibotid) and shows the same black hole on main.

Also parametrize the dispatch dedupe test over all three chat-scoped
providers so the streaming path is covered end-to-end, and restate the
workspace-fallback comment in terms of what the chain actually guarantees --
both fallbacks are appended last and gated on every earlier source being
absent, so they can only turn "no key" into a key, never change one.

* fix(channels): publish final reply before dedupe release

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-19 20:01:52 +08:00
yym36991
0f088033fe
fix(gateway): prefer X-Trace-Id over metadata.deerflow_trace_id when header is set (#4283)
When TraceMiddleware binds a valid inbound X-Trace-Id, worker resolution
prefers that id over config.metadata.deerflow_trace_id so logs, response
headers, Langfuse, and runtime context stay aligned.
Also add trace-context marker reset coverage for the inbound-header flag.
2026-07-19 09:13:51 +08:00
Ryker_Feng
a028dfd5fb
feat(auth): add keep me signed in login option (#4255)
* feat(auth): add keep me signed in login option

* fix(auth): address remember-login review feedback
2026-07-18 22:17:15 +08:00
Ryker_Feng
f8bef42a04
fix(wechat): validate timing configuration (#4280) 2026-07-18 17:53:30 +08:00
Aari
a0e1d82ef4
fix(workspace-changes): offload blocking filesystem IO in text-cache lifecycle (#4268)
* fix(workspace-changes): offload blocking filesystem IO in text-cache lifecycle

capture_workspace_snapshot and record_workspace_changes offload their scans via
asyncio.to_thread, but ran the snapshot text cache's whole lifecycle on the event
loop: roots resolution (os.path.abspath), tempfile.mkdtemp, and shutil.rmtree on
both the capture-failure branch and record_workspace_changes' finally. That
finally runs on every agent run, including abort paths, so each run removed up to
max_files cached texts on the loop.

Offload the roots + mkdtemp prep through one _prepare_capture worker hop, and
route both rmtree call sites through _remove_text_cache_dir. Cleanup stays
best-effort: it swallows and logs, so a failing cleanup cannot replace the
exception or result already in flight. asyncio.shield is deliberately not used --
to_thread submits to the pool immediately, so cancelling the future does not stop
the running thread and the cache is still removed under single and repeated
cancellation. Externally observable behavior is unchanged.

Found via `make detect-blocking-io`: these were the last 2 HIGH findings in the
repo, which now reports none. The roots resolution is invisible to that scanner
(sync helper, cross-file call) but blocks the same async path, and the anchor
cannot reach the rmtree without it.

Add tests/blocking_io/test_workspace_changes_recorder.py, driving the
capture-failure branch and the record finally. Teeth verified per clause under
the strict Blockbuster gate: reverting each offload alone reddens its own call
(os.path.samestat for rmtree, os.path.abspath for roots/mkdtemp).

* fix(workspace-changes): make text-cache prepare handoff cancellation-safe

_prepare_capture creates the mkdtemp cache dir inside the to_thread worker, so a
run cancelled after mkdtemp but before the coroutine receives the path orphaned
the dir. Shield the prepare future and, on cancellation, reclaim its result to
remove the dir before re-raising. mkdtemp stays offloaded (the blocking-io gate
flags os.mkdir from deerflow code). Adds a deterministic cancellation regression.

* fix(workspace-changes): drain repeated cancellation in text-cache reclaim

The mkdtemp handoff guard reclaimed the shielded worker's result on the first
CancelledError, but the reclaim await was itself cancellable: a second cancel
landed there, slipped past `except Exception` (CancelledError is BaseException),
and skipped the reclaim while the shielded worker still finished — orphaning the
deerflow-workspace-changes-* dir. Repeated cancelled runs accumulate leaks.

Move reclaim+remove into a task the caller cannot abandon and drain repeated
cancellation until it completes, then restore the cancellation. A repeat cancel
interrupts the await, not the task, so the dir is never abandoned; the loop exits
only once cleanup has finished, leaving no pending task. Non-cancel paths are
unchanged.

Adds test_capture_workspace_snapshot_repeated_cancellation_leaks_no_text_cache
(double-cancel regression). make test-blocking-io: 43 passed.
2026-07-18 17:30:14 +08:00
Huixin615
c9b6131f8f
fix(skills): reload mounted skills without restarting Gateway (#4264)
* fix(skills): add admin-only reload endpoint

* fix(skills): preserve cache when reload fails
2026-07-17 23:22:16 +08:00
hataa
10890e10a8
feat(authz): propagate trusted authorization principal context (#4203) 2026-07-17 14:49:51 +08:00
Huixin615
f9340c1f08
test(mcp): cover passive skill tool visibility (#4247)
* test(mcp): cover passive skill tool visibility

* test(mcp): tighten deferred discovery coverage
2026-07-17 14:39:35 +08:00
Huixin615
65afc9b1d2
fix(skills): apply allowed-tools only to active skills (#4098)
* fix(skills): scope allowed-tools to active skills

* fix(skills): tolerate stale active skill paths

* chore: retrigger CI

* fix(skills): document policy activation limits

* perf(skills): reuse per-step tool policy decisions

* fix(skills): harden runtime tool policy contracts

* fix(skills): redact cached policy decisions

* fix(skills): make slash tool policy authoritative

* fix(skills): preserve policy-safe discovery tools

* test(skills): cover explicit task delegation policy
2026-07-16 14:12:02 +08:00
AochenShen99
94a34f382d
feat(context): record effective memory identity per run (#3556)
* feat(context): record effective memory identity per run

* fix(context): address memory identity review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-16 09:39:09 +08:00
Tianye Song
8da7cbf028
feat(memory): LLM-assigned per-fact expected_valid_days and staleFactsToExtend (#4143)
Re-ports this feature onto the pluggable-memory backend introduced in #4122
(the original #4143 was force-pushed clean by accident and auto-closed). The
#4122 refactor moved the staleness logic into the self-contained DeerMem
backend (backends/deermem/deermem/core/) and reverted it to the pre-feature
global-threshold version, so the per-fact lifetime work is re-applied here
against the new module layout + DI (MemoryUpdater is now (config, storage,
llm)-injected; config lives on DeerMemConfig, not host MemoryConfig).

**expected_valid_days (creation)**
The LLM assigns a per-fact review window when storing each new fact. The
prompt exposes five tiers (<=14 d transient -> >365 d very stable). The value
is capped at write time by staleness_age_days x staleness_max_lifetime_multiplier
(default 20.0 -> 1800 d ~= 5 years; range 1.0-100.0) so the model cannot set
an initial lifetime so long the fact is never re-evaluated. The default 20.0
makes the "> 365 d very stable" tier achievable out of the box (3.0 silently
clamped it to 270 d).

**staleFactsToExtend (review)**
During staleness review the LLM can emit extension entries for kept facts
whose window seems miscalibrated. new_evd = min(days_since_created +
extend_by_days, staleness_max_extension_days). Extensions use an absolute
ceiling (default 3650 d ~= 10 years; range 90-36500) rather than the creation
multiplier - they are deliberate review decisions that must be able to advance
the window beyond the initial cap, but the absolute bound prevents timedelta
overflow (a model-supplied extend_by_days of 10**9 previously crashed every
later candidate-selection pass with OverflowError) and LLM misfire.

**Invariant correctness**
- Read-time cap removed from _effective_fact_staleness_age; cap is write-time
  only so extensions actually advance the review window.
- proposed_remove_ids hoisted out of the removals sub-block and used to exclude
  from extension, so a cap-surviving proposed-removal fact is never extended.
- extend_by coerced to int before the > 0 guard (a fractional 0.9 would pass
  the float check then int() to 0, silently writing a zero-delta extension).
- days_since uses total_seconds() // 86400 (not .days truncation).
- staleness-section html.escape uses quote=False to match the prompt.py
  convention; only <, >, & break element-text structure.

**Tests**
test_memory_staleness_review.py was module-level skipped by #4122 ("full
unit-test migration is a follow-up"). This PR performs that migration: DI
construction via (DeerMemConfig, _FakeStorage), _build_staleness_section back
to the (candidates, config) signature, plus new coverage for per-fact
selection, EXTEND with the absolute cap, the overflow next-cycle regression,
the proposed-removal-not-extendable case, fractional extend_by skipping, and
the creation-time cap. 67 tests, all green.
2026-07-16 09:34:21 +08:00
Nan Gao
de55982c5a
fix(subagents): preserve parent checkpoint namespace (#4215)
* fix(subagents): preserve parent checkpoint namespace

* test(subagents): align stream isolation coverage
2026-07-16 07:03:08 +08:00
Daoyuan Li
45865e9f3f
fix(tracing): attach Langfuse trace metadata to the goal evaluator (#4202)
The goal evaluator (runtime/goal.py) runs from runtime/runs/worker.py after
the main graph run has already completed, so there is no graph root for it
to inherit tracing from. create_goal_evaluator_model was built with
attach_tracing=False, and evaluate_goal_completion invoked the model with a
bare config={"run_name": "goal_evaluator"} — no tracing callbacks, no
Langfuse session/user attribution. Every goal-evaluator LLM call went
untraced.

Same class of gap fixed by #2944 for the main agent graph and by #3902 for
memory_agent/suggest_agent: a standalone call site that invokes a model
directly instead of through a traced graph root must attach its own tracing
callbacks and inject Langfuse trace-attribute metadata itself.

- create_goal_evaluator_model: attach_tracing=False -> True, matching the
  other standalone non-graph callers (oneshot_llm.run_oneshot_llm,
  MemoryUpdater).
- evaluate_goal_completion: accept optional thread_id/user_id/
  deerflow_trace_id and inject Langfuse trace metadata onto the ainvoke
  config via the shared inject_langfuse_metadata() helper, mirroring
  oneshot_llm.py's pattern.
- worker.py: thread user_id (resolve_runtime_user_id(runtime)) and
  deerflow_trace_id through _prepare_goal_continuation_input into
  evaluate_goal_completion so the evaluator's trace groups under the
  triggering run's thread/session.

Updates the existing test that pinned attach_tracing=False as expected
behavior, and adds a regression test asserting the ainvoke config carries
Langfuse trace metadata when enabled.
2026-07-15 22:30:34 +08:00
Huixin615
3247f61750
fix(middleware): sanitize invalid tool call arguments (#4193)
* fix(middleware): sanitize invalid tool call arguments

* refactor(middleware): share tool argument parsing
2026-07-15 22:06:29 +08:00
Tu Naichao
959bf13406
fix(memory): flush memory queue on graceful shutdown to prevent loss (#4181)
* fix(memory): bounded shutdown flush via MemoryManager.shutdown_flush

Re-applies the memory-queue shutdown drain on top of the pluggable
MemoryManager abstraction (#4122): the old top-level MemoryUpdateQueue
singleton is gone, so the drain is now a backend contract instead of
host code reaching into the queue.

- MemoryManager ABC: shutdown_flush(timeout) -> bool. Every backend
  implements a bounded graceful-shutdown drain.
- DeerMem: queue.flush_sync (daemon-thread + Event.wait hard timeout
  for the uninterruptible sync LLM call; joins an in-flight worker
  first so contexts a debounce Timer already pulled out are not lost on
  exit; skips inter-item sleep on the drain path; per-item
  succeeded/failed count), exposed via shutdown_flush.
- noop: shutdown_flush is a clean no-op success.
- Gateway lifespan: call get_memory_manager().shutdown_flush(timeout)
  after channels/scheduler stop, via asyncio.to_thread, try/except
  bounded. No host-level pending/processing guard -- the backend
  short-circuits on an idle buffer, so the host cannot "forget" the
  in-flight case (structurally eliminates the guard race flagged on the
  prior revision).
- shutdown_flush_timeout_seconds added to the shared MemoryConfig
  (host-owned lifecycle budget, default 30, 1-300) + exposed on
  MemoryConfigResponse and the embedded client; config_version 25 -> 26.

Tests: queue flush_sync (7), lifespan drain incl. False-branch caplog
assertion + disabled gate (3), ABC contract noop/deermem (3).

* fix(chart): gateway grace period so memory drain is not SIGKILLed

K8s defaults terminationGracePeriodSeconds to 30s, shorter than the
Gateway's graceful-shutdown work (channel stop ~5s + memory queue drain
default 30s). Without an explicit grace period, K8s SIGKILLs the memory
drain mid-flight and silently re-introduces the loss shutdown_flush is
fixing (flagged on the prior revision).

- gateway pod: terminationGracePeriodSeconds (default 45, configurable).
- gateway container: preStop sleep (default 5, 0 disables) so the
  Service/ingress deregisters the pod before SIGTERM begins the drain.
- values.yaml + README: both configurable; README documents that the
  grace period must track memory.shutdown_flush_timeout_seconds.

* docs(memory): document shutdown_flush_timeout_seconds + lifespan drain

Add the host-shared field to the memory config list and Config Schema
summary in backend/AGENTS.md, noting the lifespan drain and the K8s
grace-period relationship.

* fix(chart): bump embedded config_version to 26

The chart's embedded `config:` block (values.yaml + README example) still
had config_version: 25 after commit f3ca8e9f raised config.example.yaml to
26, failing the validate-chart config_version drift check. Bump both to 26.
2026-07-15 20:25:41 +08:00
Daoyuan Li
cdefd4a8aa
fix(mcp): invalidate tools cache on config content + path, not just newer mtime (#4124)
* fix(mcp): invalidate tools cache on config content + path, not just newer mtime

The MCP tools cache invalidated only on a strict extensions-config mtime `>`
comparison and tracked no resolved config path, so `_is_cache_stale()` missed:

- content changes with an unchanged mtime (same-second edits; object-store /
  network mounts that do not bump mtime);
- content changes with a backward mtime (git checkout, cp -p / backup restore,
  tar / rsync preserving timestamps);
- a resolved-path switch to a different config file with mtime <= the recorded
  value (structurally invisible — no path was tracked at all).

On multi-worker (uvicorn/gunicorn) or stale-mtime deployments this leaves the
LangGraph-embedded runtime and every non-writer worker serving stale MCP tools
after `PUT /api/mcp/config`, breaking the module's documented promise that
changes made through the Gateway API are reflected in the embedded runtime.

Record the resolved config path and a `(mtime, size, sha256)` content signature
at initialization and invalidate when the path OR the signature differs (`!=`),
mirroring `config/app_config.py::get_app_config()` so the two runtime-editable
config files share one content-based staleness signal. The per-call stat was
already paid, and the small-file sha256 matches the cost app_config pays per
request. The "config missing / not yet initialized" no-op behavior and the
cache reset endpoint are preserved.

Adds backend/tests/test_mcp_cache.py covering all three failure modes plus
unchanged-file and forward-edit sanity cases. Also folds in an incidental
backend/AGENTS.md doc-sync: the restart-required field list was missing
`scheduler` and `run_ownership`, both present in
reload_boundary.py::STARTUP_ONLY_FIELDS.

* test(mcp): pin cache staleness contracts raised in review

Two review observations on the content-signature cache fix, both raised as
non-blocking design questions rather than bugs:

- Whether relying on mtime+size alone (skipping the sha256) could ever be
  "optimized" back in, reopening the narrow same-second / identical-length
  swap gap the signature was built to close.
- Whether the extensions config being deleted entirely after a successful
  init leaves the cache in a defined state, since current_signature flips
  to None and _is_cache_stale() returns False.

Neither is a behavior change: both were already the intended contract,
preserved verbatim from the pre-fix mtime-only code (which also returned
False once the file could no longer be stat-ed). Record the reasoning
inline and add regression tests that pin each contract so a future change
cannot alter either silently:

- test_same_mtime_same_size_swap_is_stale: a same-length server-name swap
  that leaves mtime AND size unchanged (the precise scenario from review,
  sharper than the existing same-mtime test, which also changes size) is
  still caught only because the sha256 is computed unconditionally.
- test_config_deleted_after_init_is_not_stale: deleting the config file
  after init keeps the cache serving its last-known-good MCP tools instead
  of invalidating into an unconfigured state.

Both new tests were confirmed to fail against a deliberately reintroduced
version of the regression they guard (hash short-circuit / removed
None-guard), then confirmed to pass against the real code.
2026-07-14 22:40:51 +08:00
AnoobFeng
446fa03801
fix(context): resolve context compress bug (#4065)
* fix(runtime): persist original human input outside model sanitization

* refactor(history): load thread messages by global event sequence

* fix(frontend): make summarization rescue a transient history bridge

* fix(frontend): old message not append tail

1. add identity anchor
2. add bridgeOrder

* fix(frontend): lint error fix

* fix: address review feedback and harden pagination coverage

- defer transient history ref writes until after render commit
- cover large middleware-only history scans
- verify infinite-query refetch recalculates page cursors
- document AI event types and anchor-weaving differences

* fix: harden message pagination and enrichment

- append unmatched live tails after canonical history
- warn and stop when pagination has_more lacks a cursor
- deep-copy restored UI messages to isolate model-facing content
- log invalid event sequence and non-advancing cursor errors
- pass user_id explicitly through event-store history queries
- cover middleware-only AI runs across memory, JSONL, and DB stores

* fix: address pagination review feedback

* fix(frontend): checkpoint has unknow redener content, optimize the anchor policy

* fix(frontend): unit test issue missed previously, remove the TanStack cache trimming

* fix(gateway): harden message history queries and provenance

- reject externally forged original_user_content metadata
- validate provenance metadata in upload and sanitization middleware
- make run lookups fail closed by default
- batch feedback queries by run ID
- align memory message filtering with persistent stores
2026-07-14 10:43:13 +08:00
Mohammed Ansari
4af6178358
feat(trace): add agent observability with Monocle (#4024)
* Add Monocle tracing

Enable Monocle (OpenTelemetry tracing for LLM apps) with one setup call plus the monocle_apptrace dependency. setup_monocle_telemetry auto-instruments the frameworks already in use and writes traces to .monocle/. Additive; no changes to application logic.

* Config-gate Monocle telemetry in the Gateway lifespan

Addresses review on #4024: moves setup_monocle_telemetry out of agents/__init__ import time into the Gateway lifespan, gated by MonocleTracingConfig (MONOCLE_TRACING env, default off). Warns on the Langfuse/global-OTel-provider conflict and relies on monocle_apptrace's own duplicate-setup guard and existing-provider attach. Pins monocle_apptrace>=0.8.8 (+ uv.lock), adds .monocle/ to .gitignore, adds tests (default-off / toggle-on / no import-time setup), and documents exporters, Okahu, and the VS Code viewer in README, config.example.yaml, and backend/AGENTS.md.

* Clarify Monocle/Langfuse single-provider guidance

Make the docstring, warning, and AGENTS.md consistent with the README: only one library can own the global OpenTelemetry provider; Monocle initializes at startup before Langfuse's per-run handler, so enabling both drops Langfuse's spans — enable one OTel tracer (LangSmith, a callback, coexists fine).

* Address review: optional extra, exporter validation, off-box warning, tests

Responds to the second review round.

- Make monocle_apptrace an optional extra (deerflow-harness[monocle], re-exposed
  as deer-flow[monocle]) following the boxlite/tui precedent, so a default
  install no longer pulls the OpenTelemetry stack. It stays pinned in the dev
  group for the tracing tests, and enabling MONOCLE_TRACING without the extra
  raises a clear install error.
- Warn loudly at startup whenever any exporter other than `file` is configured,
  since those move prompts, tool inputs/outputs, and completions beyond the
  local .monocle/ directory.
- Validate MONOCLE_EXPORTERS against the known exporter names and require
  OKAHU_API_KEY when okahu is selected, mirroring the Langfuse pattern.
  Validation runs from Monocle's own init (not validate_enabled) so a config
  typo can never fail agent runs; errors surface at Gateway startup instead.
- Grow the tests from 5 to 13: caplog coverage for the Langfuse-conflict and
  off-box warnings, exporter validation cases, a stronger import-time
  regression that asserts the global TracerProvider is not replaced, and a
  subprocess double-invoke test exercising the real check_duplicate_setup.
- Docs: config.example.yaml block retitled to a dedicated tracing header;
  README documents the [monocle] install and scopes tracing to Gateway runs.

* docs: align Monocle README section with the other tracing providers

Lead with what Monocle is and captures, drop the install step (the dev
group already ships monocle_apptrace via uv sync; unusual installs get
the RuntimeError), and point the missing-package error at the repo-native
command (uv sync --extra monocle / deerflow-harness[monocle]).

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

* Address review: verified Langfuse coexistence, lifespan test, scope docs

Responds to the third review round.

The Langfuse conflict claim was wrong, verified empirically against langfuse
4.5.1 in both init orders: whichever library initializes second reuses the
existing global TracerProvider and attaches its own span processor, so neither
side loses spans. Dropped the warning and its tests, corrected the README,
AGENTS.md, and config.example.yaml statements, and pinned the verified behavior
with test_coexists_with_langfuse (real monocle + real langfuse in a subprocess,
no mocks). One honest caveat documented: both processors see all spans, so
Monocle's exporters also capture Langfuse's spans when both are enabled.

Also from the review:
- Document the Gateway-only scope in AGENTS.md: the lifespan is the sole call
  site, so the embedded DeerFlowClient and TUI are not instrumented; embedded
  users call setup_monocle_tracing_if_enabled() themselves.
- Add test_gateway_lifespan_initializes_monocle pinning the lifespan wiring.
- Comment why MonocleTracingConfig.is_configured is intentionally coarser than
  LangSmith/Langfuse (composite validation lives in validate() at startup).
- Note that monocle_exporters_list takes the comma-separated string as-is.
- Module-level importorskip("monocle_apptrace") so minimal installs collect
  the test module cleanly.

* docs: reword Monocle intro sentence

* fix(tests): run the import-time regression in a subprocess

test_no_import_time_setup deleted deerflow.agents* from sys.modules and
re-imported to force __init__ to re-execute. The re-import creates new module
objects, and restoring the old sys.modules entries afterwards leaves the parent
package's attribute bindings pointing at the new ones, so any later test that
resolves a deerflow.agents.* dotted path (monkeypatch.setattr in
test_summarization_middleware, test_thread_data_middleware, and others) failed
with "module 'deerflow.agents' has no attribute ...".

Run the check in a subprocess instead: the import is genuinely fresh, the
assertion is stronger (the provider must still be the SDK-less proxy, proving
nothing was installed at any point), and no module identity leaks into the
rest of the suite.

* Address review: console warning scope, embedded hint, honest naming, doc alignment

Responds to the post-approval review round:

- Scope the off-box exporter warning to the remote exporters (okahu, s3,
  blob, gcs): console writes to local stdout and no longer trips it.
  config.example.yaml's data-handling note now distinguishes file /
  console / remote likewise.
- Rename MonocleTracingConfig.is_configured to is_enabled so the boolean
  reads as what it checks; the exporter-dependent credential check stays
  in validate(), run at Gateway startup.
- Hint on the embedded path: build_tracing_callbacks() logs a debug line
  when MONOCLE_TRACING is set but setup never ran in this process, so
  embedded DeerFlowClient/TUI users are not left with silent no-op
  tracing. Backed by a process-global setup flag.
- Re-export setup_monocle_tracing_if_enabled from deerflow.tracing,
  matching the package convention.
- Note the deliberate fail-open-at-startup contrast with
  LangSmith/Langfuse in the lifespan, and the OTel SDK-internals
  dependency in the coexistence test.
- Test hygiene: clear MONOCLE_* env in the tracing config/factory
  fixtures; reset the setup flag in the monocle test fixture; reword the
  README Langfuse-spans claim as the shared-provider inference it is.
- Document that .monocle/ trace files are never rotated or cleaned up.

* fix(tests): pin the factory logger level in the embedded-hint tests

configure_logging() from earlier tests in the full suite pins an explicit
INFO level on the logger hierarchy, so a root-level caplog.at_level(DEBUG)
never sees the factory's debug hint. Scope caplog to
deerflow.tracing.factory so the test is independent of suite ordering.

* Address review: co-export disclosure, lifespan failure test, exporter parse dedup

- Off-box warning now notes that Langfuse's spans are exported too when
  both providers are enabled and share the global OTel provider; pinned
  both ways by tests.
- Pin the lifespan fail-open contract: a raising Monocle setup is logged
  and the Gateway keeps serving (pragma dropped now that the path is
  exercised). README notes a config error is reported at startup and
  tracing stays off until restart.
- Hoist exporter parsing into MonocleTracingConfig.exporter_list so
  validate() and the off-box warning cannot diverge, and note the
  upstream coupling on the exporter allow-list.
- Reduce config.example.yaml's Monocle block to a pointer; the capture,
  retention, and data-handling detail lives in README's Monocle section.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-14 08:58:06 +08:00
heart-scalpel
b53c1ae0e0
fix(runs): cancel degrades to lease takeover for multi-worker (#4064)
* fix(runs): cancel degrades to lease takeover for multi-worker

Work item 4 of the multi-worker ownership epic
(https://github.com/bytedance/deer-flow/issues/3948).

Problem: POST /runs/{run_id}/cancel landing on a non-owning worker
returns 409 — the cancel button silently fails under GATEWAY_WORKERS>1
with no sticky routing. cancel() required the current worker to hold
the in-memory task/abort_event, which any non-owner pod cannot satisfy.

Changes:
- RunManager.cancel() returns CancelOutcome enum (cancelled /
  taken_over / lease_valid_elsewhere / not_active_locally /
  not_cancellable / unknown) instead of bool, so the router can map
  each outcome to the right HTTP response.
- New store primitive claim_for_takeover(): a single atomic
  conditional UPDATE that marks a run as error only when
  status IN (pending, running) AND (lease IS NULL OR
  lease < now - grace). Closes the stale-read / concurrent-heartbeat
  race — if the owner renews between our read and write, the UPDATE
  matches 0 rows and we surface lease_valid_elsewhere.
- HTTP cancel + stream-join endpoints route on CancelOutcome:
  cancelled -> 202 (or 204 with wait=true); taken_over -> 202
  immediately (no SSE streaming — the run is terminal on another
  worker, streaming would hang); lease_valid_elsewhere -> 409 +
  Retry-After header computed from lease_expires_at + grace_seconds.
- RunManager.grace_seconds exposed as a public property; the router
  no longer reaches into _run_ownership_config.
- _is_lease_expired extracted to a module-level function, shared by
  RunManager.cancel() and MemoryRunStore.claim_for_takeover().
- GATEWAY_WORKERS=1 + heartbeat_enabled=false is zero-regression:
  the non-local path short-circuits to not_active_locally, preserving
  the original 409 behaviour the existing tests pin.

Tests: 12 new (5 store primitive + 4 cancel-takeover unit + 3 HTTP
including a regression guard verifying POST /stream?action=interrupt
on a dead-owner run returns 202 instead of hanging on SSE).
244 directly-related tests pass; 36/36 blocking-IO gate pass.

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

* fix(runs): guard update_status and self-terminate on takeover

Two defenses close a split-brain window where the original owner
could overwrite a peer's takeover status:

- update_status (SQL + memory store) now guards on
  status IN ('pending','running'). When takeover already set
  the row to 'error', the owner's final status write matches
  0 rows and is dropped.

- _persist_status: when update_status returns False, check
  whether the row exists before attempting recovery via put().
  If the row exists (takeover by another worker), skip recovery
  instead of blindly upserting over the takeover.

- Heartbeat _renew_leases: when update_lease returns False
  (row no longer pending/running or owner changed), cancel the
  local task so wasted CPU is bounded to the next heartbeat
  tick (~10s) instead of the full task lifetime.

Also fix three reviewer feedback items:

- Re-fetch the store row when cancel() returns
  lease_valid_elsewhere, so Retry-After uses the owner's
  freshly-renewed lease instead of a stale value from
  request start.

- Fallback 'unknown' in takeover error message when
  owner_worker_id is NULL (pre-ownership data).

- Remove dead else-10 branch from grace_seconds property
  (unreachable — all callers are downstream of the
  heartbeat_enabled guard).

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

* test(runs): pin split-brain defences from update_status guard + heartbeat

Three tests lock down the takeover authoritativeness so a
late-running owner cannot overwrite a peer's claim:

- update_status must reject writes when the store row is already
  terminal (taken over by another worker).
- _persist_status must skip row-recovery via put() when the row
  exists but has been taken over.
- Heartbeat _renew_leases must cancel the local task when
  update_lease returns False (row claimed by another worker).

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

* fix(runs): precise outcome + log when local cancel loses to peer takeover

Two reviewer precision nits on the split-brain defence:

- _persist_status: branch the skip-reason log on existing["status"].
  error → WARNING "peer takeover" (anomalous); interrupted/success →
  INFO "local cancel/completion race" (expected when user hits stop
  as the run finishes). Stops noisy false-positive takeover warnings
  in operator logs.

- cancel() local path: when _persist_status returns False, re-check
  the store. If a peer's claim_for_takeover flipped the row to error
  between our in-memory cancel and the guarded update_status, surface
  taken_over instead of cancelled so the client sees a status
  consistent with the store.

Test: test_cancel_returns_taken_over_when_peer_claims_during_local_cancel
pins the race outcome.

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

* fix(runs): widen update_status guard, de-duplicate lease helpers, add coverage

Round 3 of reviewer feedback:

- Widen update_status guard to status IN ('pending','running','interrupted').
  The original guard blocked interrupted→error (the rollback finalize path),
  losing the "Rolled back by user" message. interrupted is now permitted
  while error/success stay locked — takeover protection unchanged.

- claim_for_takeover False now re-reads the store row to distinguish causes:
  owner renewed lease → lease_valid_elsewhere; row went terminal →
  not_cancellable; another worker already took it over → taken_over.

- Extract _raise_lease_valid_elsewhere() helper to de-duplicate the
  409+Retry-After block shared across cancel_run and stream_existing_run.

- Extract _lease_expired_or_null() in persistence/run/sql.py to
  de-duplicate the lease-expiry SQL WHERE clause shared by
  claim_for_takeover and list_inflight_with_expired_lease.

- 11 new tests: 5 SQL-layer claim_for_takeover (expired/valid/NULL/
  terminal/nonexistent), 3 _compute_retry_after unit (NULL/unparseable/
  normal), 2 claim re-read precision (terminal/takeover), 1 stream
  endpoint 409+Retry-After.

Not addressed (non-blocking, reviewer agreed):
- The 2–3 store.gets in the takeover cold path: optimizing the API to
  accept a pre-fetched record would couple the router to the manager
  more tightly than justified by the perf gain.
- The lease-expiry inline loop in MemoryRunStore.list_inflight_with_-
  expired_lease pre-computes cutoff once for all rows; switching to the
  shared _is_lease_expired helper would recompute datetime.now() per row
  with no real benefit.

260 related tests pass; 36/36 blocking-IO gate pass; ruff clean.

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

* fix(runs): de-duplicate lease-expiry helper, restore defensive fallback

Address final round of review feedback:

- Extract is_lease_expired to deerflow.utils.time (no _ prefix, public
  utility). Manager and MemoryRunStore now import from the same place
  instead of the store reaching backward into the manager for a private
  function.

- Restore defensive else-10 fallback in grace_seconds property (removed
  in an earlier round). The guard is unreachable for current callers but
  protects future ones from AttributeError.

- Comment the transient in-memory interrupted vs store error state when
  a local cancel is superseded by a peer takeover.

- Comment the max(1, ...) floor in _compute_retry_after — the floor is
  a lower bound, not a poll interval; clients should apply jitter.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: rayhpeng <rayhpeng@gmail.com>
2026-07-14 07:37:59 +08:00
DanielWalnut
4e209827f3
feat(agent): Add subagent total delegation cap (#4115)
* fix subagent total delegation cap

* fix embedded subagent run cap context

* fix subagent cap config consistency

* fix resumed subagent run cap boundary

* fix legacy resume subagent boundary

* address subagent cap review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-13 19:26:07 +08:00
黄云龙
f85ee590e1
docs(backend): add IM channel connections and GitHub agents architecture diagrams (#4112)
Adds two focused architecture docs and references from backend/AGENTS.md:
- backend/docs/IM_CHANNEL_CONNECTIONS.md — sequence + graph diagrams for user-owned
  IM channel connections: bind-code flow (/connect/<start), single-active-owner
  transfer, provider message routing, owner-scoped file storage.
- backend/docs/GITHUB_AGENTS.md — sequence + graph diagrams for GitHub event-driven
  agents: webhook fan-out, preferred_thread_id = UUID5, GH token lifecycle, race recovery.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 11:42:55 +08:00
Daoyuan Li
5edc7a889e
fix(security): neutralize prompt-injection tags in web_capture results (#4099)
The remote-content allowlist in ToolResultSanitizationMiddleware
(`_REMOTE_CONTENT_TOOL_NAMES`) covered web_fetch / web_search /
image_search but not web_capture, which was added later. The Browserless
web_capture tool embeds the target site's `X-Response-Status` reason
phrase — free-form text controlled by whatever server is being captured
(RFC 7230 §3.1.2) — into its result message via `_target_status_warning`.
A malicious page could therefore forge a `<system-reminder>` block (or a
`--- END USER INPUT ---` boundary marker) through web_capture that would
be escaped for web_fetch, letting attacker-influenced remote content reach
the model as authoritative framework context.

Add "web_capture" to the allowlist so its result is structurally
neutralized for parity with the other remote-content tools. This extends
the same defense introduced in #4002 to the one built-in remote-content
tool it did not yet cover.

Add regression tests that build the web_capture result the way
community/browserless/tools.py does (real `_target_status_warning` +
`BrowserlessScreenshotResult`) and assert the forged tags/boundary markers
are escaped, while a benign status warning is preserved unchanged.
2026-07-12 11:24:24 +08:00
Ryker_Feng
41658c5ff4
feat(skills): add skill review quality gate (#4037)
* feat(skills): add skill review quality gate

* fix(skills): skip review eval fixtures in CI

* fix(skills): ignore review eval fixtures in bundled scans

* fix(skill-review): harden review gate boundaries

* fix(skills): address skill review gate feedback
2026-07-11 15:58:07 +08:00
Huixin615
6389fc03fd
fix: ensure visible response after tool runs (#4033)
* fix: ensure visible response after tool runs

* fix: clean up terminal response recovery state

* fix: bound terminal recovery state
2026-07-11 15:53:14 +08:00
Nan Gao
aafd5077b2
feat(subagents): show effective model and token usage on task cards (#4049)
* feat(subagents): show runtime metadata on task cards

* fix(subagents): stop task-card render loop and dedupe model fetches

Address code review on the runtime-metadata cards:

- P1 render loop: the terminal ToolMessage is re-parsed on every
  MessageList render and always carries modelName/usage, so the
  presence-based setTasks condition fired a fresh state object each
  render -> "Maximum update depth exceeded". computeNextSubtask now
  returns a value-compared `changed` flag and a pure subtaskNotification()
  routes terminal transitions through the deferred after-render path
  while skipping no-op re-parses.

- Per-card useModels refetch: add staleTime: Infinity to the ["models"]
  query so every subtask card shares one /api/models fetch instead of
  refetching on each mount.

* make format

* refactor(subagents): dedupe token-usage validators + tidy event narrowing

Address PR review follow-ups:

- DRY: extract one shared token-usage validator per side. Backend
  status_contract.normalize_token_usage() now backs both the terminal
  ToolMessage metadata and the subagent.step/.end run events
  (step_events.py), and frontend messages/usage.normalizeTokenUsage()
  backs both the live task_running event (lifecycle.ts) and the terminal
  ToolMessage metadata (subtask-result.ts). Prevents the input/output/
  total_tokens validation from drifting across the four former copies.

- Nit: onCustomEvent narrows event.type once instead of re-checking the
  object shape per branch; the redundant task_started early-return
  (already validated by taskEventToSubtaskUpdate) is dropped.
2026-07-11 15:41:57 +08:00
Vanzeren
c2002d9fac
feat(memory): add memory tool sets (#4023)
* feat: add memory-as-tool mode alongside existing middleware mode

- Add memory.mode config field (middleware|tool, default middleware)
- Add search_memory_facts() for case-insensitive fact lookup
- Add 4 memory tools: memory_search, memory_add, memory_update, memory_delete
- Wire mode gating in factory.py and lead_agent/agent.py
- 256 memory tests passing, zero regressions

* fix: harden tool-mode memory scoping and docs

* fix: address memory tool mode review feedback

* fix(memory): address tool mode review feedback

* fix: update config_version to 22 in values.yaml
2026-07-11 15:28:16 +08:00
Nan Gao
bbb3deb231
fix(subagents): classify LLM error fallbacks as failed (#4042)
* fix(subagents): classify LLM error fallbacks as failed (#4041)

* fix(subagents): address #4042 review on LLM error fallback

- clarify _extract_llm_error_fallback docstring: tail-only scan is safe
  because subagents append their own terminal message, so the last
  AIMessage is never a stale parent-history marker (cross-ref worker.py)
- compute final_result and pop stop_reason only on the COMPLETED branch
  so the guard stop-reason pop no longer fires on the discarded FAILED path
- note the error_detail/"LLM request failed" fallbacks are defensive;
  the middleware always populates a non-empty content
- add regression test locking the stale parent-history marker invariant

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-11 09:25:15 +08:00
Nan Gao
8fbf101de3
fix(subagents): inject durable context before compaction (#4040)
* fix(subagents): inject durable context before compaction

* fix(subagents): coalesce system messages after durable-context injection

Address #4040 review:
- append SystemMessageCoalescingMiddleware innermost on the subagent chain
  so the SystemMessage(authority) DurableContextMiddleware injects is merged
  into one leading system_message; otherwise the durable fix trades #4039's
  assistant-first 400 for a duplicate-system 400 on strict backends
- add a two-system regression guard driving the real builder output through
  a strict model; assert exactly one leading SystemMessage
- assert single-leading-system in the compaction integration test too
- update the middleware count/last-element assertion (coalescer is now
  unconditionally last, removing the summarization-dependence ambiguity)
- compare _skills_root against posixpath.normpath(container_path)
- document the coalescer on the subagent chain in backend/AGENTS.md
2026-07-11 08:29:42 +08:00
Daoyuan Li
d300e3597f
Correct memory-config documentation to match the code (#3995)
Two memory-config docs disagreed with the actual defaults/behavior in
`config/memory_config.py` and `agents/memory/storage.py`:

- backend/AGENTS.md: `staleness_age_days` was documented as "default: 180;
  range: 1-3650" but the field is `default=90, ge=30, le=365`;
  `staleness_max_removals_per_cycle` was documented as "default: 5; range:
  1-20" but the field is `default=10, ge=1, le=50`.

- config.example.yaml: the `storage_path` comment said "Path relative to
  backend directory", but a relative `storage_path` resolves against the data
  `base_dir` (`get_paths().base_dir / p`), not the backend working directory,
  and an absolute path is what opts out of per-user isolation (matching the
  field's own description in `memory_config.py`).

Doc-only change; no behavior change.
2026-07-10 14:32:51 +08:00
Aari
04a85b307a
fix(sandbox): scrub abbreviated *_PASS vars and Postgres PGPASSFILE from skill env (#4026)
* fix(sandbox): scrub abbreviated *_PASS vars and Postgres PGPASSFILE from skill env

env_policy blocks the full PASSWORD/PASSWD spellings but not the ubiquitous
abbreviated _PASS form (DB_PASS, SMTP_PASS, MYSQL_PASS, ...), whose value is the
plaintext password itself, so every skill subprocess inherited it. Postgres's
file-based credential sources PGPASSFILE (.pgpass locator) and PGSERVICEFILE
(pg_service.conf, may carry a password) were likewise missed -- the psql analog
of the MYSQL_PWD/REDISCLI_AUTH no-flag sources #4018 added.

Collapse *PASSWORD*/*PASSWD* into a single *PASS* pattern (which subsumes both,
covers the abbreviated family, and catches PGPASSFILE) and add PGSERVICEFILE to
the exact-name denylist. *PASS* deliberately does not touch PWD/OLDPWD, which
carry no PASS substring. Capability-handle vars (KUBECONFIG, DOCKER_CONFIG,
GIT_ASKPASS, ...) are a broader, non-value-bearing class and are left out of
this fix.

test_secret_like_names_are_blocked already pins the contract; the new names are
added to its parametrize list (red before, green after) and the benign-names
test guards against over-scrubbing PWD/OLDPWD.

* test(sandbox): pin the *_ASKPASS over-scrub that *PASS* introduces

*PASS* also matches the credential-helper vars GIT_ASKPASS, SSH_ASKPASS and
SUDO_ASKPASS, which main inherits. Each names a program whose purpose is to hand
the caller a credential, so inheriting the pointer is the same leak class as
inheriting the value -- a skill can simply invoke it. Scrubbing them is intended;
pin all three in test_secret_like_names_are_blocked (red on main) so the
behaviour is a decision rather than a side effect of the pattern's shape.

test_benign_names_are_allowed could not have caught this: none of its 14 names
carries a PASS substring, so it passes identically before and after the
broadening and asserts nothing about it. Record that in its docstring -- the
absence of a benign PASS-bearing name is a decision, and PWD/OLDPWD are the
boundary the list does pin.

Also note the incidental COMPASS_*/BYPASS_* over-scrub in the module comment
(over-scrubbing is this module's fail-safe direction; required-secrets is the
escape hatch), and list PGPASSFILE alongside PGSERVICEFILE in AGENTS.md.
2026-07-10 14:05:47 +08:00
Tianye Song
9097642658
feat(memory): add memory consolidation to synthesize fragmented facts (#3996)
* feat(memory): add memory consolidation to synthesize fragmented facts

When a fact category accumulates many individual entries, the LLM
reviews them during the normal memory-update call (same invocation,
no extra API cost) and decides whether groups of related facts can be
synthesized into a single richer fact. This completes the memory
lifecycle: extraction → guaranteed injection → staleness review →
consolidation.

- Select fragmented categories by min-facts threshold, surface the most
  fragmented groups first; prompt-layer caps aligned with apply-layer
  guardrails so the LLM never sees groups it cannot act on
- Cap consolidated confidence at source maximum to prevent inflation;
  reject results below fact_confidence_threshold
- Double-consume protection prevents a fact from being merged into
  multiple consolidation targets
- Feature-gated at both prompt and apply time with per-cycle safety caps
- Add 26 tests covering candidate selection, normalization, apply
  guardrails, and prompt integration

* fix(memory): address consolidation correctness issues from PR review

Six fixes based on maintainer review of #3996:

1. Deduplicate sourceIds in normalization — ["f1","f1"] previously
   bypassed the ≥2-distinct-sources check; dict.fromkeys collapses it
   to ["f1"] which is correctly rejected.

2. Run consolidation after max_facts trim — previously, sources were
   deleted then the merged fact could be evicted by the trim, leaving
   no record of either. Moving consolidation last ensures source facts
   exist in the post-trim index before removal.

3. Fix count= attribute in consolidation prompt — advertised the full
   category size but listed only max_sources IDs; now uses
   min(len(group), max_sources) to match what the LLM can act on.

4. Exempt staleness_protected_categories from consolidation candidates
   — mirrors the existing staleness-review contract so correction facts
   are never surfaced for merging.

5. Strip and default category in consolidation normalization — "  " or
   " preference " are now normalised, matching _normalize_memory_update_fact.

6. Propagate sourceError from source facts into consolidated fact —
   correction context is no longer silently lost on merge.

* fix(memory): add apply-time guardrails and tests for consolidation

P1: mirror the staleness-pass defense-in-depth pattern — build
allowed_source_ids from _select_consolidation_candidates at apply time
so a protected-category or below-threshold fact proposed by the LLM is
rejected regardless of model behavior.

P2a: test that LLM-returned confidence is capped at max source confidence
and that a capped result below fact_confidence_threshold is rejected.

P2b: test that factsToConsolidate with consolidation_enabled=False is a
no-op at apply time (35 tests, all pass).

* fix(memory): address three correctness issues from second review round

1. Default consolidation_enabled=False — consolidation is lossy (source
   content is permanently replaced, only consolidatedFrom IDs preserved);
   new lossy features default to off. config.example.yaml updated to match.

2. Unify confidence coercion between prompt and apply — _build_consolidation_section
   now calls _coerce_source_confidence(fact) instead of an inline 0.0-default
   coercion, so a null-confidence fact renders with 0.50 in the LLM prompt and
   is capped at 0.50 at apply time (same value, same function).

3. Preserve staleness clock on merge — consolidated fact now carries the
   newest source's createdAt (not now) so aged information does not gain a
   fresh staleness-review window just by being consolidated; consolidatedAt
   is added as an explicit audit field.

Three regression tests added (default=false, null-confidence consistency,
createdAt policy); all guardrail tests now set consolidation_enabled=True
explicitly so they test the guardrail, not the feature flag. 38 tests pass.

* fix(memory): harden createdAt comparison and confidence handling

1. createdAt max via _parse_fact_datetime — replaces string max() which
   crashes on non-string createdAt (numeric unix timestamps) and sorts
   Z/+00:00 mixed formats incorrectly. Mirrors how staleness computes age.

2. Remove dead min(..., 1.0) — _coerce_source_confidence already clamps
   each source confidence to [0, 1], so max(source_confidences) ≤ 1.0
   by contract; the outer min could never bind.

3. Clamp raw_llm_conf to [0, 1] before applying the source cap — out-of-
   range values like 1.5 are safe today (pinned by the cap) but defensively
   clamped first so the invariant holds even if the cap is ever loosened.

4. Doc: expand the apply-time guardrails comment to call out the protected-
   category exclusion via allowed_source_ids — this is the central safety
   property ("explicit user feedback is never silently merged away").

5. Test: add test_confidence_fallback_to_max_source_when_llm_omits_field
   covering the else-branch (LLM omits confidence → uses max_source_conf).

6. Fix lint: reorder imports in test file (stdlib before third-party).

39 tests, all pass.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-10 11:31:08 +08:00
hataa
266883b3dd
fix(subagents): inherit summarization middleware and harden step capture (#3875 Phase 3) (#4009)
Phase 3 of #3875 — subagents previously inherited none of the lead's
context-compaction, so a deep-research subagent (max_turns up to 150)
could accumulate >1M cumulative input before max_turns/timeout/token_budget
engaged, even after Phase 2's budget capped the pathological tail.

- Gate the subagent runtime chain on the SAME ``app_config.summarization.enabled``
  switch the lead reads (per maintainer guidance in #3875), via the shared
  ``create_summarization_middleware`` factory. One config covers both chains;
  no separate ``subagents.summarization`` field. No-op when summarization is
  off (factory returns None).
- ``skip_memory_flush=True`` on the subagent path: the factory otherwise
  attaches ``memory_flush_hook`` (when memory.enabled), which flushes
  pre-compaction messages into durable memory keyed by thread_id. Subagents
  share the parent's thread_id, so without skipping the hook a subagent's
  internal turns would pollute the PARENT thread's durable memory
  (#3875 Phase 3 review point).
- Harden ``capture_new_step_messages`` to tolerate history contraction:
  summarization rewrites the messages channel via
  ``RemoveMessage(id=REMOVE_ALL_MESSAGES)``, shrinking len(messages) below
  the step-capture cursor. Without a reset, every step appended after the
  compaction point was dropped until length overtook the stale cursor (#3845
  interaction, maintainer validation point (a)). Cursor now resets to the
  new tail; id/content dedup prevents re-emitting pre-compaction steps.
- Couple the DEFAULT token-budget ceiling to ``summarization.enabled``
  (#3875 Phase 3 review point): 1M when compaction is on, 2M when off
  (preserves Phase 2's deliberate headroom for summarization-off
  deep-research runs that can exceed 1M). A user-set budget (global or
  per-agent) always wins regardless of the switch. Flagged tunable.

The summarization middleware does not implement ``consume_stop_reason``, so
the Phase 2 guard-cap stop-reason channel is unaffected.

Refs: https://github.com/bytedance/deer-flow/issues/3875
2026-07-10 11:17:35 +08:00
Zhengcy05
b06372b812
feat(backend): queue rapid same-thread messages and preserve topic card previews (#3988)
* feat(backend): queue rapid same-thread messages and preserve topic card previews

* fix: checkstyle

* refactor(channels): make feishu serialization policy-driven and fix queue cleanup
2026-07-10 11:01:39 +08:00
Ryker_Feng
ebc09ce130
feat(mcp): auto-promote deferred MCP tools from routing hints (#4019)
* feat(mcp): auto-promote deferred MCP tools from routing hints

When tool_search.enabled=true defers MCP tool schemas, PR1 routing hints
still require the model to spend a tool_search discovery round trip before
it can call the tool the routing metadata already points at. This adds a
McpRoutingMiddleware that matches the latest user message against PR1
routing keywords and promotes the matching deferred schemas before the
model call, removing that round trip.

Design (soft routing, opt-in, additive):
- Matches only the latest real HumanMessage (shared is_real_user_message
  helper, reused by SkillActivationMiddleware so the two cannot drift);
  case-insensitive substring match, no tokenizer dependency.
- Ordering: priority desc, then tool name asc; capped by the new global
  tool_search.auto_promote_top_k (default 3, clamped 1..5). Does not add or
  consume a per-tool auto_promote_top_k (PR1 schema unchanged); a per-tool
  value is ignored with a DEBUG note.
- Returns a plain {"promoted": ...} state update (not a Command) and relies
  on ThreadState.merge_promoted for union/dedupe, so auto-promote and a
  model-triggered tool_search converge on the same catalog hash.
- Installed before DeferredToolFilterMiddleware on every deferred-tool path
  (lead agent, subagent, embedded client, webhook via shared builders);
  a construction-time assert rejects the reversed order. catalog_hash is
  None / no routing index is a complete no-op, so bootstrap and ACP skip it.
- Privacy: never executes tools, never promotes policy-filtered tools, adds
  no routing keywords or matched tool names to trace metadata or INFO/WARN
  logs.

No behavior change when tool_search.enabled=false.

Tests: index construction, matching semantics, middleware state updates,
same-cycle deferred-filter interaction, lead/subagent/embedded-client
builder wiring + order invariant, config clamping, config.example.yaml
parseability, and privacy assertions.

* refactor(mcp): address auto-promote review nits

- executor: access app_config.tool_search.auto_promote_top_k directly to match
  the lead-agent and embedded-client paths (drop the over-defensive getattr that
  masked missing config); update the subagent test mock to carry tool_search.
- tool_search / mcp_routing_middleware: cross-reference the duplicated routing
  priority/keyword normalization between the builder and the middleware's
  defensive _normalize_index so they cannot silently drift.
- MCP_SERVER.md: document that auto-promote keyword matching is a case-insensitive
  substring test (not word-boundary), advising distinctive keywords.
2026-07-10 07:54:36 +08:00
Huixin615
66c0c641bf
fix(gateway): live-tail malformed redis reconnect ids (#4012)
* fix(gateway): live-tail malformed redis reconnect ids

* test: avoid redis live-tail heartbeat gaps

* fix: handle terminal malformed redis reconnect ids
2026-07-10 07:44:45 +08:00
Huixin615
c30c8ef759
fix: recover from empty tool call names (#4008)
* fix: recover from empty tool call names

* test: harden empty tool call recovery

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-09 16:40:10 +08:00
Ryker_Feng
5ba25b06ec
feat(mcp): add MCP routing hints (#4004)
* feat: add MCP routing hints

* test: isolate mcp routing prompt config

* fix: address mcp routing review feedback
2026-07-09 16:26:31 +08:00
Yi Deng
b36d7194d9
fix(security): neutralize prompt-injection tags in remote tool results (#4002)
* fix(security): neutralize prompt-injection tags in remote tool results

User input is already neutralized for framework/injection tags, but tool
results are not. Remote content fetched by web_fetch/web_search is equally
untrusted and can carry a forged <system-reminder> block that reaches the
model verbatim as authoritative context.

Extract a shared neutralize_untrusted_tags() primitive from
InputSanitizationMiddleware and apply it to remote-content tool results
(web_fetch/web_search/image_search) via a new ToolResultSanitizationMiddleware.
Local tool output (bash/read_file) is left untouched so legitimate code/file
content is never mangled.

* test: update subagent middleware count for tool-result sanitizer

The new ToolResultSanitizationMiddleware adds one entry to the shared runtime
chain (11 -> 12). Update the subagent count assertion, use a lazy import for
neutralize_untrusted_tags so the module loads even when tests stub the
input-sanitization module, and document the new middleware in AGENTS.md.

* fix(security): address review — sanitize bare str list items; document MCP scope

- Neutralize bare str elements inside a ToolMessage content list (previously
  only {type:text} dict blocks were rewritten), matching the str-in-list shape
  ToolOutputBudgetMiddleware._message_text already anticipates.
- Document the name-based allowlist limitation: MCP remote-content tools
  registered under arbitrary names (e.g. fetch_url) are not covered; a name
  heuristic is avoided to prevent mangling local tool output, with metadata
  tagging tracked as a follow-up. Add a regression test pinning this boundary.
2026-07-09 14:45:25 +08:00
hataa
c9fb9768d4
fix(subagents): unify guardrail caps on additive stop_reason + add token_budget (#3875 Phase 2) (#3980)
Phase 2 of #3875. Two guardrail axes can end a subagent run early — the turn
budget (GraphRecursionError) and the token budget (TokenBudgetMiddleware) —
and both now surface *why* through one additive `subagent_stop_reason` field
instead of a status enum.

This completes and course-corrects Phase 1 (#3949), which shipped the
turn-budget cap as a `max_turns_reached` status enum. The agreed Phase 2
design replaces that enum with an optional `stop_reason` field
(token_capped | turn_capped | loop_capped): a new enum value would break v1
consumers, while an additive field is ignored by older frontends and ledger
readers. `max_turns_reached` and SubagentStatus.MAX_TURNS_REACHED are removed.

- subagents.token_budget config (default enabled, 2,000,000 tokens, warn 0.7)
  with per-agent override; TokenBudgetMiddleware is now attached in
  build_subagent_runtime_middlewares so the cost-ceiling backstop engages for
  every subagent. The hard-stop does not raise — it strips tool_calls and
  lets the run finish with a final answer, recording the cap on a per-run
  consume_stop_reason() accessor.
- executor.py: on normal completion it reads consume_stop_reason() and stamps
  completed + token_capped when the budget fired; on GraphRecursionError it
  recovers the last AIMessage partial (completed + turn_capped) or, if nothing
  usable survived, failed + turn_capped. SubagentResult gains stop_reason.
- status_contract.py / contracts/subagent_status_contract.json (v2) /
  frontend subtask-result.ts: additive subagent_stop_reason field, pinned by
  test_status_values_match_contract / test_stop_reason_values_match_contract.
- task_tool.py + delegation_ledger.py: drop the max_turns_reached paths; the
  ledger captures stop_reason and renders model-facing "capped" guidance so
  the lead reuses a capped completion knowingly.

The 2,000,000-token default is deliberately loose (tighten to taste) — it
would have roughly halved the reported 4.4M burn while leaving legitimate
deep-research runs (max_turns=150) room. Subagent summarization is a follow-up.
2026-07-08 22:26:06 +08:00
Ryker_Feng
01dc067997
feat: add composer input polishing (#3986)
* feat: add composer input polishing

* Revert "Merge branch 'main' into feat/input-polish"

This reverts commit 5b6ceccf0db3092bc62fde3b05e7816829601756, reversing
changes made to 45fbc57fef5fa5fd878cf0176c37f3e3bc7ebef6.

* Merge main into feat/input-polish

* style(frontend): format input helper polish guard

* fix(input-polish): address composer polish review findings

Frontend
- Add a cancel affordance to the in-flight polish status pill that calls
  abortInputPolishRequest(), so a slow/hung provider no longer hard-locks the
  composer for up to stream_chunk_timeout with a page reload (and draft loss)
  as the only escape.
- Reset promptHistoryIndexRef/promptHistoryDraftRef when a rewrite is applied
  (and on undo), so a stale history-browse index can no longer let the next
  ArrowDown silently overwrite the polished draft.
- Disable polishing while an open human-input card is present, matching the
  frontend/AGENTS.md rule that composer entry points defer to the card so
  card-reply metadata is preserved.
- canPolishInput now reuses parseGoalCommand/parseCompactCommand instead of a
  third hardcoded reserved-command regex, and drops the phantom /help entry
  (no /help parser exists in the composer), so future builtins only need to be
  taught to the existing parsers.

Backend
- Extract the non-graph one-shot LLM path (build model + inject Langfuse
  metadata + system/user invoke + text extract) into
  deerflow.utils.oneshot_llm.run_oneshot_llm, shared by the input-polish and
  suggestions routers so tracing-metadata and invocation shape cannot drift
  between the two copies.
- strip_think_blocks gains truncate_unclosed (default True, preserving the
  suggestions/goal JSON-prep behavior); input polish passes False so a draft
  that legitimately contains a literal <think> substring is no longer
  truncated into a partial rewrite or a spurious 503.
- Validate the empty-check and max_chars boundary against the same stripped
  view of the draft that is sent to the model, so the user-facing length
  boundary and the model input can no longer disagree.

Tests / docs
- Backend: literal-<think> preservation, whitespace-only rejection, and
  normalized-length/model-input agreement cases; suggestions tests repoint the
  create_chat_model patch to the shared helper module.
- Frontend: helper unit tests updated for the /help/reserved-command change; a
  new Playwright case covers cancelling an in-flight polish request.
- backend/AGENTS.md documents the shared one-shot helper and the polish
  normalization/think-tag behavior.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-08 17:10:27 +08:00
Vanzeren
e3137b13cf
fix(provisioner): gate legacy skills mount by user visibility (#3985)
* fix(provisioner): gate legacy skills mount by user visibility

* fix(test): aio sandbox provider

* fix: use shared legacy skill visibility helper for sandbox mounts
2026-07-08 14:41:16 +08:00