671 Commits

Author SHA1 Message Date
rayhpeng
a328af2917 refactor(schedule): wire the slice through adapters and delete the old path
The outer ring: SQL repositories, the run launcher, the thread lookup,
and the run-completion listener implementing the ports the domain
declared, plus the HTTP router and the poller driving them. All of it is
instantiated in one composition root, so no route or lifespan hook
builds an adapter of its own.

With the ports filled, the pre-hexagonal implementation is deleted
rather than left alongside: `app/scheduler/service.py` and its router
mixed policy, persistence, and HTTP into one class, which is why its
rules were only reachable through a live database.

Two bugs that shape hid are fixed here: a completion hook that replayed
a stale snapshot and rolled the launch write back, and a corrupt stored
row surfacing to the client as a 4xx. The concurrency invariants are
pinned by contract suites that run each port against both the fake and
real sqlite, plus dedicated dispatch-race tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:44:08 +08:00
rayhpeng
d856ae8573 feat(schedule): add the domain model, ports, and application service
The inner ring of the schedule slice, added on its own so it can be read
as domain modelling rather than as a diff against the old implementation:
two aggregates with their state machines, the policy value object, the
output ports the service depends on, and the errors it raises.

Nothing wires it up yet -- no existing code path changes. The service is
exercised end to end against in-memory fakes, which is what makes the
rules (overlap policy, lease handling, which write owns which timestamp)
assertable without a database at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:43:35 +08:00
rayhpeng
d9c7d1ccab docs(hexagonal): establish the layering spec and its enforcement
Introduces the ports-and-adapters standard new backend modules are
expected to follow, plus the two pieces that make it more than prose:
`deerflow/domain/` as the inner-ring namespace, and an AST test that
fails when anything under it imports infrastructure.

The spec is normative rather than descriptive -- the existing modules
predate it, so the test guards the namespace, not the whole backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:43:35 +08:00
Huixin615
133a82c6c2
fix: isolate MCP server toggles from invalid peer configs (#4577)
* fix: isolate MCP server toggle updates

* fix: write extensions config atomically

* fix: normalize MCP transport aliases
2026-07-31 08:32:39 +08:00
yjchen101
6fe6bad001
fix(mcp): ignore oversized path-like text (#4582)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:52:07 +08:00
Ryker_Feng
063d62c3c3
feat(persistence): support custom postgres schema (#3442)
* feat(persistence): support custom postgres schema

* fix(persistence): address CI lint/test failures and review feedback

- Map missing psycopg import to actionable POSTGRES_INSTALL guidance in
  sync/async schema-creation helpers
- Accept SQLAlchemy compound DSN schemes (postgresql+asyncpg) when
  injecting search_path, normalizing to a libpq-consumable DSN
- Guard keyword-DSN tests with importorskip so they skip without psycopg
- Set database=None in sync checkpointer none-fix test to avoid MagicMock
  backend resolution
- Apply ruff import sort and format

* fix(persistence): address pg-schema review feedback

- Restrict postgres_schema regex to lowercase-only so the quoted CREATE
  SCHEMA matches the unquoted search_path (PG case-folds it), fixing the
  mixed-case bug where tables silently fell back to public.
- Replace shlex.join/split with libpq-correct backslash escaping for the
  options parameter so values containing spaces survive intact.
- Add normalize_libpq_dsn() and route the async checkpointer pool through
  dsn_with_search_path() so a +asyncpg suffix is stripped and existing DSN
  options (e.g. statement_timeout) are merged instead of overridden.
- Extract shared ensure_postgres_schema()/ensure_postgres_schema_async()
  helpers (mapping missing psycopg to the install hint) used by all four
  provider sites.
- Tests: reject mixed-case schemas, preserve space-containing libpq option,
  cover normalize_libpq_dsn, and assert pool search_path via DSN.

* fix(persistence): align pg-schema test with merged store API

The main merge moved the sync Store factory to the single-path
_resolve_store_config/_sync_store_cm design, dropping the PR's
_sync_store_from_database helper. The integration test still imported
the removed symbol, breaking test collection (backend-unit-tests).
Resolve the store config from a DatabaseConfig and drive it through
_sync_store_cm instead.

* fix(persistence): address pg-schema review feedback

- reject trailing/leading whitespace in postgres_schema via re.fullmatch
  (a $-anchored re.match let "deerflow\n" through, silently landing tables
  in public)
- re-escape all whitespace (TAB/CR/LF) when re-joining libpq options so a
  caller's pre-existing options value round-trips losslessly
- re-validate the identifier inside create_schema_sql as defense-in-depth
  at the SQL-emitting boundary
- accept the postgres:// short scheme in the alembic search_path injection
- close the sync psycopg connection explicitly (psycopg3 __exit__ does not
  close()), mirroring the async path
- drop the partial checkpointer/store reset on a database config change;
  database is restart-required and the ORM engine is not rebuilt, so a
  partial reset would half-migrate the deployment

* docs(config): complete the postgres_schema migration checklist

Address PR review (P1): the documented `public`->schema migration only
moved runs, run_events, threads_meta, feedback, and users. That strands
every other DeerFlow-owned table -- the four channel_* tables, both
scheduled_* tables, agents, and (critically) alembic_version -- in
`public`. On restart bootstrap treats the partially-populated target
schema as unversioned, re-baselines it, and replays migrations while the
real rows stay invisible in `public`.

List the full owned set explicitly, call out alembic_version as required,
and keep the "discover the rest" query for version-drift safety.

* refactor(checkpointer): drop test-only _sync_checkpointer_from_database

Address PR review: the helper was only reached by the env-gated
integration test and re-implemented the DatabaseConfig->CheckpointerConfig
backend resolution that _resolve_checkpointer_config already owns, so a
future backend added there would silently miss this path. Mirror the store
side of the same test, which reuses the production path directly:
_resolve_checkpointer_config(...) + _sync_checkpointer_cm(...).
2026-07-30 13:51:17 +08:00
Minh Vu
904cee4a72
fix(sandbox): push read_file ranges into sandbox reads (#3824)
* fix read_file range validation and passthrough

* chore: format test_aio_sandbox.py

* test: cover e2b sandbox read_file line-range behavior

* fix(boxlite): support ranged file reads

* fix(sandbox): reject non-positive start lines

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-30 07:53:10 +08:00
Eilen Shin
9d915ca8ca
fix(agent): route subagents by net benefit (#4384)
* fix(agent): route subagents by net benefit

* fix(agent): refine subagent routing boundaries

* fix(agent): clarify routing limits and batches

* fix(agent): handle single-subagent routing
2026-07-30 07:21:55 +08:00
now-ing
2a96341889
sandbox: claim ownership before readiness-timeout destroy (#4248) (#4505)
* fix(sandbox): claim ownership before readiness-timeout destroy (#4248)

When a freshly-created sandbox fails to become ready within the 60s timeout, _create_sandbox / _create_sandbox_async destroyed the container with a bare self._backend.destroy(info) call. Ownership is published by _register_created_sandbox only after the readiness gate, so for the whole timeout window the container ran unowned — exactly the state a peer gateway startup reconciliation is built to adopt across. With the claim skipped, a peer could adopt the not-yet-ready Pod and this instance subsequent stop landed on the turn the peer had just handed it: a cross-instance kill of an active turn.

Route the destroy through a new _destroy_unready_sandbox helper that first claims the teardown lease (claim(..., for_destroy=True) writes the del: marker) and holds it via _held_teardown_lease for the duration of the stop, matching the ownership guard every other reap path already uses (_destroy_warm_entry, _drop_unhealthy_reserved). Fail closed if a peer already holds the lease or the ownership store cannot answer: leave the container for the peer own reconciliation rather than stopping it from underneath an active turn.

Fixes #4248.

* fix(sandbox): reserve local teardown around readiness-timeout destroy

Review feedback (AnnaSuSu) on #4505: the ownership claim is only the
cross-instance half of the guard — claim() succeeds against our own
lease by design, so in the window between the readiness timeout and the
claim a same-process _reconcile_orphans (idle checker, every 60s) can
adopt the unready container into _warm_pool; the subsequent claim still
succeeds and the stop lands on an entry this instance has just adopted,
leaving a dead warm entry for the next reclaim to hand out.

Wrap the still-untracked check, claim, and stop in
_reserve_local_teardown / _finish_local_teardown, with the predicate
checking the id is absent from the active and warm maps — the same
pairing _destroy_warm_entry already uses.

Add an interleaving regression test mirroring
test_reconcile_does_not_adopt_a_container_this_instance_is_tearing_down:
reconcile runs while the destroy thread is parked after reserving but
before its `del:` claim lands, and must not adopt. A mirror test
asserts a genuinely unowned container is still adopted when no teardown
is in flight, so the reservation cannot over-block reconciliation.

* style(sandbox): apply ruff format to readiness-timeout destroy changes

---------

Co-authored-by: now-ing <24534365+now-ing@users.noreply.github.com>
2026-07-30 07:12:07 +08:00
Huixin615
d726ae60c3
fix(skills): use managed integrations root for slash activation (#4570)
* fix(skills): use managed integrations root for slash activation

* refactor(skills): clarify integrations root getter
2026-07-29 22:39:48 +08:00
ly-wang19
4582a41347
fix(skills): offload blocking filesystem IO in update_skill and serialize writes (#3565)
* fix(skills): offload update_skill's blocking IO and share the config write lock

Re-applied on top of main rather than merged: update_skill has since gained
admin gating, user-scoped storage and a PUBLIC vs CUSTOM/LEGACY split, so the
offload is applied per path.

- PUBLIC: the extensions_config.json read-modify-write (path resolve, snapshot,
  merge, write, reload) moves to a worker thread via asyncio.to_thread. The
  payload is built from a snapshot so the cached singleton is never mutated in
  place while the write is still in flight.
- CUSTOM/LEGACY: set_skill_enabled_state is offloaded; the non-user-scoped
  fallback takes the same shared-file RMW path as PUBLIC.
- Both load_skills calls (and storage construction) are offloaded.

The RMW lock now lives next to reload_extensions_config as
get_extensions_config_write_lock() and is acquired by both the skills router and
the MCP router, which performs the same read-modify-write on the same file.
Previously each router held its own module-local lock, so once both sides
offloaded, a PUT /api/mcp/config could run inside a skill toggle's read->write
window and the later write would silently drop the other's change.

The lock is keyed by the running event loop rather than being a module-level
singleton: asyncio primitives bind to the first loop that awaits them, which
makes a plain module-level lock unusable in a process that runs more than one
loop.

Adds a cross-router regression anchor asserting a skill toggle and an MCP update
never overlap inside the RMW (max in-flight 1); it observes 2 when the routers
use separate locks.

* fix(config): own the extensions_config RMW lock from the worker thread

An asyncio.Lock held around `await asyncio.to_thread(...)` protects only the
awaiting task. If that task is cancelled the context manager releases the lock
immediately while Python keeps running the worker thread, so a second skills or
MCP writer could acquire it and operate on extensions_config.json concurrently
with the first worker — reopening the lost-update window this was meant to close.
The per-event-loop keying had a second hole: writers on different loops got
different locks and so did not exclude each other at all.

Replace it with a process-wide threading.Lock acquired *inside* the worker that
performs the RMW (`_write_extensions_skill_state` and `_apply_mcp_config_update`),
so ownership belongs to the thread doing the writing and is held until the write
and reload actually finish, regardless of what happens to the caller. A
threading.Lock also has no event-loop affinity.

Adds a cancellation regression: the skills worker is paused inside the lock, its
route task is cancelled, and the MCP writer is started — the MCP RMW must not
enter until the skills worker is released. Against the previous asyncio-lock
design this test fails with ['skills-enter', 'mcp-enter'].

The two existing serialization tests instrumented by replacing the worker
functions, which now bypasses the lock under test; they instead patch inside the
real workers (each module's reload_extensions_config, the last step under the
lock) so the production lock is exercised.

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-29 18:52:32 +08:00
Daoyuan Li
9bb8225079
fix(memory): harden OpenViking retries and watermarks (#4552) 2026-07-29 07:24:55 +08:00
Vanzeren
352f247a81
feat(memory): add mem0 HTTP memory backend (#4528)
* feat(memory): add mem0 HTTP memory backend

* fix(memory): address mem0 review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-29 07:11:20 +08:00
Yufeng He
8eb3be59bd
fix(sandbox): unwrap Overwrite-wrapped state in ensure_sandbox_initialized (#4429)
* fix(sandbox): unwrap Overwrite-wrapped state in ensure_sandbox_initialized

The same fork-restored wrapper that crashed after_agent also reaches the sandbox init path, where sandbox_state.get() on the Overwrite object raises AttributeError. Share the unwrap helper from #4381's follow-up module deerflow/sandbox/overwrite.py and apply it at both init sites.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(sandbox): note why discarding fork_restored at the reuse sites is safe

* fix(sandbox): unify the Overwrite unwrap helper and pin the fall-through

- middleware.py now imports unwrap_sandbox from overwrite.py instead of
  keeping a second local copy whose docstring had already drifted; the
  shared helper covers both crash forms (subscript TypeError and the
  .get()-form AttributeError)
- test the acquire fall-through: when the fork-restored id is gone from
  the provider, a fresh sandbox is acquired and the stale wrapped state
  is replaced by the plain acquired dict
- the reuse-path test now also asserts runtime.state["sandbox"] stays
  wrapped, pinning the don't-treat-as-owned contract after_agent relies on

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(sandbox): unwrap Overwrite state in the sibling sandbox readers

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-29 07:04:26 +08:00
RongfuShuiping
b3af8c9183
feat(memory): keep tool-mode fact recall explicit (#4521)
* feat(memory): keep tool-mode fact recall explicit

* fix(memory): clarify optional tool-mode context
2026-07-29 06:50:19 +08:00
阿泽
9c7cd4cad3
feat(sandbox): add thread data mount override for upload sync (#4536) 2026-07-28 23:41:14 +08:00
RongfuShuiping
2aaf74b0f8
feat(memory): add OpenViking HTTP backend (#4509)
* feat(memory): add OpenViking HTTP backend

* fix(memory): harden OpenViking lifecycle

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-28 23:36:25 +08:00
Aari
a5059b8284
fix(subagents): isolate callbacks and activate skills lazily (#4497) 2026-07-28 23:29:14 +08:00
Vanzeren
c48de5e70b
feat(checkpoint): make delta snapshot_frequency configurable (#4516)
* feat(checkpoint): make delta snapshot_frequency configurable

* fix(config): carry legacy checkpoint_delta_snapshot_frequency with warning

Addresses review on #4516: the rename from the flat
database.checkpoint_delta_snapshot_frequency key to nested
database.checkpoint_delta.snapshot_frequency silently dropped the old
value (pydantic extra="ignore"). Add a before-validator that maps the
legacy key onto the nested one with a deprecation warning (nested key
wins when both are set), plus a CHANGELOG breaking-change note covering
the rename and the 1000 -> 10 default change.

* fix(checkpoint): validate frozen snapshot frequency
2026-07-28 23:21:23 +08:00
阿泽
ea74367502
fix(runtime): honor LangGraph Server identity for user-scoped data (#4538)
* fix(runtime): honor LangGraph Server identity for user-scoped data

* fix(runtime): scope custom agent SOUL by resolved user
2026-07-28 22:59:14 +08:00
Ryker_Feng
aacb99cfd2
feat(lark): sidecar credential broker for sandbox lark-cli (Pattern B) (#4501)
* feat(lark): sidecar credential broker for sandbox lark-cli (Pattern B)

Removes the plaintext Lark credential mounts (appSecret + OAuth tokens)
from the sandbox container. A long-running broker sidecar owns lark-cli
and the per-user config/data dirs and serves the command surface over
Pod loopback; the sandbox gets only a forwarding shim on PATH, so the
raw credential files never exist in the sandbox filesystem.

- lark_broker.py: stdlib-only loopback broker (argv passthrough with
  shell=False, server-injected credential env, bounded I/O) + shim
  script constant + install-shim mode.
- docker/lark-cli-broker: init(install-shim) + serve image.
- provisioner: LARK_CLI_BROKER_IMAGE + provision_lark_cli_broker →
  shim init container + lark-cli-broker sidecar (config/data mounted
  sidecar-only); credentials dropped from the sandbox container;
  /api/capabilities reports lark_cli_broker_image. Broker supersedes
  the Pattern A init-container binary when both are configured.
- gateway: lark_cli_env_overlay(broker=True) omits config/data env;
  sandbox_lark_broker_active() TTL-cached mode resolver; broker added
  to sandbox_runtime_mode / readiness and the settings UI.

Opt-in and off by default (empty LARK_CLI_BROKER_IMAGE ⇒ no change).

Closes #4338

* fix(lark): address Pattern B broker review findings (#4501)

Follow-up to the sidecar credential broker addressing the PR #4501 review:

- shim: split the on-PATH lark-cli into a /bin/sh launcher + Python shim body
  so broker mode fails loudly (exit 127, actionable message) instead of ENOEXEC
  when the sandbox image ships no python3; interpreter pinnable via
  DEERFLOW_LARK_BROKER_PYTHON. Launcher bakes in the shim's absolute path since
  $0 is the bare command name when run off PATH.
- broker: drop the dead cwd payload field (broker can't see the sandbox FS) and
  document the command-surface-only / no-file-IO limitation.
- broker: return a structured 500 JSON on unexpected exec errors so the shim
  gets a meaningful message, not an opaque transport failure; set a handler
  socket timeout to bound slow/stuck connections.
- broker: add an opt-in DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS denylist that
  refuses secret-dumping subcommands before spawning the binary, forwarded from
  the provisioner sidecar.
- gateway: tighten the per-bash-call broker probe timeout (1.5s) and cache
  negatives longer (300s) so non-broker remote-provisioner users don't pay a
  latency hit; guard the mode cache with a lock; drop the dead
  _probe_provisioner_lark_cli_init_image wrapper.
- docs: remove the broken design-doc link from the broker README.

Adds tests for launcher python resolution, cwd omission, denylist enforcement,
500-on-error, hot-path probe timeout + negative caching, and provisioner
denylist-env wiring.
2026-07-28 22:54:44 +08:00
Aari
9a43d8276d
fix(gateway): replay edit and rerun from a settled checkpoint (#4534)
Editing the only turn of a thread reran the original prompt: the model
answered the question the edit was replacing while the UI showed the
edited text, and the edit vanished on reload.

The replay-base lookup decided whether a checkpoint predates the target
user message by message id alone. DynamicContextMiddleware re-keys the
first user turn to `{id}__user` mid-run, so every checkpoint written
before it holds the same prompt under an id the lookup cannot match. The
scan walked past those and anchored inside the run that produced the
turn — a checkpoint that still contains the original prompt and owns the
injection node's pending writes, which the replay then re-added after the
edited message.

Require the replay base to be a settled checkpoint (no pending tasks) in
both the lineage walk and the chronological fallback. That rule is
middleware agnostic: the first turn now anchors on the thread's empty
initial checkpoint and later turns on the previous run's tail, which also
drops the existing reliance on LangGraph discarding a stale `__start__`
write.

Edit replay additionally passes `head_checkpoint` so it resolves its base
lineage-first like regenerate does, and a replayed user message is
restored to its pre-swap id: replaying `{id}__user` into a state that has
no reminder yet makes the middleware treat the turn as already injected
and silently drops its date and memory block.

Frontend: a prepared replay masks the turn it supersedes, so the
optimistic-message baseline is taken from the post-mask human count. The
pre-mask count can never be exceeded when the replay puts exactly one
human message back, and on the first turn the runtime re-keys the
replacement message so identity comparison cannot stand in for the count.

Fixes #4531
2026-07-28 22:12:27 +08:00
MiaoRuidx
8a78c264b7
fix(runtime): cancel runs across live gateway workers (#4500)
* docs(runtime): design cross-worker cancellation

* fix(runtime): cancel runs across gateway workers

* fix(runtime): harden cross-worker cancellation races

让取消请求与 owner 终态写入通过持久化 CAS 决定先后,保证首次取消 action 在不同 worker 路由下保持一致。\n\n将 heartbeat 收敛为续租后仅发送本地中止信号,并补齐完成竞态与路由重试的回归用例。

* docs(runtime): drop implementation plan from PR

移除仅用于实现过程的跨 worker 取消设计记录,保留 README 和 backend/AGENTS.md 中面向最终行为的文档。

* fix(runtime): preserve local cancel fallback

* test(runtime): adapt worker run manager fakes

* docs(runtime): fix run cancel migration registry

---------

Co-authored-by: MiaoRuidx <12540796+MiaoRuidx@users.noreply.github.com>
2026-07-28 21:45:20 +08:00
ShitK
b1984cf4ab
fix(security): reject legacy MCP credentials in run metadata (#4448)
* docs: design run metadata secret admission

* docs: refine run metadata secret boundaries

* docs: plan run metadata secret fix

* fix(security): centralize legacy run metadata policy

* fix(security): reject secrets at run admission

* fix(security): hide legacy secrets from history APIs

* docs(security): migrate MCP credentials to secret context

* fix(security): redact legacy runnable config metadata

* fix(security): reject legacy config metadata credentials

* fix(security): hide legacy secrets from run kwargs

* docs(security): clarify config redaction boundary

* docs: keep issue 4416 planning local
2026-07-28 21:31:23 +08:00
阿泽
94003c1f47
feat(models): support cumulative vLLM stream usage (#4537)
* feat(models): support cumulative vLLM stream usage

* fix(models): preserve active cumulative usage streams
2026-07-28 19:56:40 +08:00
qin-chenghan
88f9f81877
fix(clarification): normalize dict options (#4527) 2026-07-28 19:43:56 +08:00
Ryker_Feng
0a9ce5d7e3
feat(suggestions): configure follow-up suggestion count (#4533) 2026-07-28 19:35:21 +08:00
ajayr
6456c35675
fix(browserless): accept the timeout config key and harden coercion (#4519)
`browserless` reads `cfg["timeout_s"]`, while its sibling web providers
`crawl4ai` and `jina_ai` read `cfg["timeout"]`. Tool configs allow extra
fields, so the unrecognised spelling is dropped without a diagnostic: someone
adapting one provider's config snippet for another silently gets the 30s
default instead of the timeout they set. (Observed in the other direction, on a
deployment whose crawl4ai entry carried `timeout_s`.)

Accept both keys, preferring the documented `timeout_s` when both are present.

While adding coverage, two pre-existing bugs in the same three lines surfaced,
both already guarded in crawl4ai/jina_ai but not here:

- `timeout_s: "30s"` (or any non-numeric string) raised ValueError out of
  `float(raw)` during tool construction rather than falling back.
- `timeout_s: off` -- YAML parses that as `False`, and `float(False)` is
  `0.0`, so every request timed out immediately against a healthy server.

`_coerce_timeout` now mirrors the sibling providers: booleans and unparsable
strings fall back to the default, with a warning for the string case.

Tests: five cases in tests/test_browserless_client.py covering both keys, the
precedence order, and both coercion bugs. Verified red before the fix (3 of 5
fail) and green after.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 07:56:04 +08:00
Aari
d455a1815e
fix(sandbox): allow grep to search a single file (#4512) 2026-07-28 07:49:13 +08:00
qin-chenghan
795af20a6b
feat(memory): built-in FTS5/BM25 retrieval adapter (#4360)
* feat(memory): integrate FTS5 retrieval adapter

* deps: add jieba as default dependency for Chinese tokenization

Without jieba, FTS5 unicode61 tokenizer treats entire Chinese sentences
as single tokens, making single-character or sub-phrase searches
impossible (e.g. '吃' or '油泼面' returns 0 hits against
'用户喜欢吃油泼面'). jieba segments Chinese text into meaningful tokens
before indexing.

* fix(memory): avoid treating hyphens as FTS5 operators

* feat(memory): make Chinese tokenization optional

* fix(memory): warm every requested retrieval scope

* fix(memory): close retrieval resources on shutdown

* fix(memory): close backend when shutdown flush fails

* fix(memory): recreate corrupt retrieval index

* fix(memory): tolerate partial retrieval rebuilds

* fix(memory): warm retrieval index in background

* fix(memory): preserve shutdown flush budget

* fix(memory): stop retrying partial lazy rebuilds

* fix(memory): close retrieval through storage

* refactor(memory): simplify retrieval scope limit

* docs(memory): clarify retrieval shutdown lifecycle

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 23:17:18 +08:00
Zhipeng Zheng
838037188e
feat(channels): share inbound webhook dedupe across pods via Postgres (#4210)
* feat(channels): share inbound webhook dedupe across pods via Postgres (#4120)

* ci: run cross-pod inbound dedupe integration tests in CI

Expose the job Postgres service via DEDUPE_TEST_POSTGRES_URL so the integration tests (issue #4120) actually execute instead of silently skipping. Normalize the URL for asyncpg (postgresql:// -> +asyncpg, drop libpq-only sslmode) and await the now-async _is_duplicate_inbound in test_github_dispatcher.
2026-07-27 23:07:40 +08:00
Ryker_Feng
fcbf0609b0
feat(chat): edit and rerun latest user turn (#4377)
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 22:46:51 +08:00
Vanzeren
6f53fd5e99
feat(runtime): enforce artifact delivery from workspace snapshots (#4494) 2026-07-27 22:27:16 +08:00
Diego Câmara
ac18f518c8
feat(sandbox): add Tenki cloud sandbox provider (#4382)
* feat(sandbox): add Tenki cloud sandbox provider

Adds deerflow.community.tenki, a SandboxProvider backed by Tenki cloud
microVMs, alongside the existing e2b_sandbox / boxlite / aio_sandbox
backends. Selected via `sandbox.use: deerflow.community.tenki:TenkiSandboxProvider`
(resolved by class path, so the change is purely additive).

The full Sandbox contract is implemented — execute_command plus
read/write/update/download_file and list_dir/glob/grep — with file ops run
as busybox-portable shell commands (cat / find / grep / chunked base64),
reusing deerflow.sandbox.search, mirroring e2b_sandbox and boxlite. Tenki's
SDK is synchronous, so unlike boxlite there is no event-loop bridge.

Tenki sandboxes run as an unprivileged user with /mnt root-owned, so the
/mnt/user-data virtual prefix is remapped under the writable home dir (like
e2b_sandbox); the provider also best-effort sudo-symlinks /mnt/user-data to
that home dir so agent shell commands using the literal path still work.
Sandboxes are pooled per (user, thread) with warm reclaim, a replica cap,
and an idle reaper via the shared WarmPoolLifecycleMixin. Transient
transport blips get one bounded retry; terminal session errors evict and
recreate.

Only the stable Tenki surface is used (create/terminate + exec/shell/fs) —
no volumes, snapshots, or template builds — so any stock base image works.
The tenki-sandbox SDK is an optional extra (deerflow-harness[tenki]) and is
imported lazily, so a default install and every other provider are
unaffected.

Tested: unit suite runs in CI without tenki-sandbox installed; a live
integration test and full-surface e2e were verified against real Tenki
sandboxes.

* fix(sandbox): remove unsafe auto-retry from Tenki exec

Pre-merge review caught that the transient-transport retry sat at the
universal _exec layer, so it retried every operation — execute_command and
base64 file-write chunks included. gRPC has no exactly-once guarantee: a
"socket closed" ack-drop after the server already ran the op means the retry
runs it twice, double-firing command side effects and duplicating a write
chunk mid-file (silent binary corruption on multi-chunk writes).

exec is not idempotent, so it must not be auto-retried. Reverts to the
boxlite/e2b behavior: a transient error surfaces to the caller (returned as
text by execute_command, raised by the file ops); a terminal session error
still evicts the sandbox so the next acquire rebuilds it. Verified live
end-to-end across 31 edge cases (empty/binary/unicode/chunk-boundary files,
shell-metachar content, error paths, list/glob/grep, warm-pool reclaim,
concurrency).

* fix(sandbox): address Tenki provider review feedback

- Use Tenki's native sandbox.fs API for all file transport (read_text,
  read_bytes, write_stream, mkdir) instead of cat/chunked-base64 over shell.
  Uploads stream in 1 MiB frames; append is read-modify-write because the
  write stream has no append mode (same approach as community/e2b_sandbox).
- download_file streams via fs.read_stream and enforces the 100 MB cap on
  bytes actually received, closing the TOCTOU window between the old
  wc -c size probe and the read.
- list_dir/glob/grep report paths back under /mnt/user-data instead of the
  sandbox-internal home dir, so results feed straight into the file APIs.
- Create with wait=False and await wait_ready() here: create(wait=True)
  raises with the session handle still inside the SDK, leaking a running
  microVM this provider could never terminate.
- Configure the sandbox lifetime (max_duration, default 4h) and expose
  sticky; without it Tenki reaps a reused thread's sandbox after ~30 min.
- close() terminates before marking the adapter closed and re-raises real
  failures, so a failed termination stays retryable instead of silently
  leaking a billed microVM; an already-gone session still counts as closed.
- Bump the optional extra to tenki-sandbox>=0.4.0 and commit backend/uv.lock.

* fix(sandbox): scope tenki grep() glob filter to its directory prefix

Mirrors #4168, which fixed the same defect in the E2B provider. The tenki
adapter reduced a directory-scoped pattern like "src/*.js" to its basename
before filtering, so the search silently broadened to every matching-extension
file in the tree. Post-filter grep's hits through path_matches() against the
path relative to the search root, the same way glob() already does, so both
agree on what a directory-scoped pattern means.

* fix(sandbox): address Tenki provider review — eviction, id width, write lock, grep -H

Four fixes from the upstream review:

download_file no longer swallows terminal transport errors. The broad
`except OSError: raise` re-raised ConnectionError/BrokenPipeError/EOFError
(all OSError subclasses that _is_terminal_failure treats as terminal) before
_note_failure ran, so a session that died mid-download was never evicted. Only
our own EFBIG size-cap now passes through without eviction.

Sandbox id widened from 32 to 64 bits (`[:8]` to `[:16]`), matching
community/e2b_sandbox. The warm pool is keyed by this id with no full-seed
fallback, so a collision could let one user reclaim another's parked sandbox on
a multi-tenant gateway.

_fs_op now holds the lock across the op, not just the fs lookup, so concurrent
calls on the same sandbox serialise over the SDK's shared connection. The
eviction callback runs after the lock is released to avoid a lock-order
deadlock with the provider. The append read-modify-write is serialised by a
dedicated _write_lock so two concurrent appends can't clobber each other.

grep passes -H so a search whose path resolves to a single file still prints
the filename; without it the file:line:text unpack dropped every match.

* fix(sandbox): address Tenki provider review round 2

- validate config `environment` at load time (_validate_extra_env) so a bad
  key fails fast instead of surfacing as an SDK error mid-command
- document the deliberate lock decision in download_file: the instance lock is
  dropped before streaming so a 100 MB download can't block every other tool;
  terminal transport errors still evict via _note_failure
- tighten the terminal-error comment to note ConnectionError/BrokenPipeError/
  EOFError are also treated terminal via isinstance
- document TenkiSandboxProvider in backend/AGENTS.md (provider detail, warm-pool
  destroy hook, community provider list)
- add a commented Tenki block to config.example.yaml for parity with AIO/BoxLite
- tests: config env validation, grep -F/case-sensitive flags, glob include_dirs,
  list_dir max_depth, bootstrap-failure warning branch

* fix(sandbox): make Tenki bootstrap non-interactive and time-bounded

The create-time bootstrap runs under the per-scope acquire lock, so a hang
would stall acquire for that scope indefinitely:
- use `sudo -n` so a password-requiring sudoers entry fails fast (swallowed by
  the existing `|| true`) instead of blocking on a tty password prompt
- pass a timeout to the bootstrap `remote.exec` so any other stall drops to the
  existing warning path rather than wedging acquire

Best-effort by design; the file APIs still work via the home remap on failure.

* test(sandbox): pin Tenki bootstrap timeout to its actual value

Assert bootstrap["timeout"] == _BOOTSTRAP_TIMEOUT instead of `is not None`, so
a regression to timeout=0 (treated as no timeout by some SDKs) or an unrelated
value is caught rather than passing a weaker non-None check.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 22:20:07 +08:00
Daoyuan Li
5ce3cecf2a
Fix concurrent thread metadata merges (#4489) 2026-07-27 22:18:02 +08:00
March-77
b22f85c686
fix(sandbox): reconcile E2B sandboxes safely (#4443)
* fix(sandbox): reconcile E2B sandboxes safely

* fix(sandbox): clear failed E2B adoption intent
2026-07-27 14:10:24 +08:00
阿泽
1baa8ad696
feat(clarification): structured form fields for human-input cards (#4400 Phase 1) (#4406)
* feat(clarification): structured form fields for human-input cards

Add a request-side v2 `form` mode to the ask_clarification protocol so
business flows (e.g. expense reimbursement) can collect several values
in one card instead of sequential free-text questions:

- `ask_clarification` gains a restricted `fields` parameter (text /
  textarea / number / select / multi_select / checkbox / date)
- ClarificationMiddleware validates and normalizes fields explicitly
  (whitelisted types, unknown -> text, select-likes without options ->
  text, duplicate/invalid entries dropped, all-invalid falls back to
  the legacy modes) since the middleware short-circuits before tool
  execution; the plain-text fallback lists fields for IM channels
- Form payloads carry `version: 2` so older frontends degrade to the
  text fallback; replies stay on the v1 response protocol — the card
  submits a readable summary as `response_kind: "text"`, so journal
  persistence and answered-card recovery are unchanged
- Frontend renders typed field controls with required-field validation
  and compact multi-select chips

Part of #4400 (scope narrowed per maintainer feedback: request-side
only, no new response kinds, no top-level multi_choice).

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

* fix(clarification): harden form protocol per review feedback

Address the five review points on #4406:

- Reject field names colliding with JS Object.prototype members on both
  sides; frontend reads form values via own-property access only, so
  `constructor`/`toString`-style names can no longer leak inherited
  members into required validation or the submitted summary
- Close open requests answered through the legacy text fallback: a
  visible plain human reply (no response metadata) now marks every
  previously-opened request as answered, so upgrading to a v2-aware
  frontend cannot leave the composer locked on an already-answered card
- Give checkbox fields deterministic boolean semantics: values are
  seeded to an explicit false ("no" in the summary) and `required` means
  must-agree/consent; documented in the tool schema
- Make middleware field validation atomic: structurally broken entries
  (bad/duplicate/reserved names, over-cap field/option counts or text
  lengths) degrade the whole form instead of silently dropping fields;
  options are trimmed/deduped with blanks removed so the backend never
  emits payloads the frontend parser rejects
- Associate form labels/controls (htmlFor/id), aria-required,
  aria-invalid, and error descriptions for accessibility

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

* refactor(clarification): type the fields item schema via TypedDict

Replace `fields: list[dict[str, Any]]` with `list[ClarificationFormField]`
(a TypedDict with `name` required and the type whitelist as a Literal) so
the provider-facing tool schema documents the item shape instead of an
opaque object relying on the docstring. Runtime validation is unchanged
and stays in ClarificationMiddleware, which intercepts the call before
tool execution. Addresses the non-blocking review suggestion on #4406.

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

* fix(frontend): drop unsupported aria-invalid from multi-select group

jsx-a11y: role=group does not support aria-invalid; the error linkage
stays via aria-describedby.

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

* fix(clarification): coerce numeric required flags and normalize fields once

- `_normalize_bool` now coerces 1/0 (some providers serialize booleans
  as integers), so `required: 1` no longer silently flips to optional
- `_handle_clarification` normalizes `fields` once and passes the result
  to both the text fallback and the payload builder

Addresses the non-blocking review nits on #4406.

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

* fix(clarification): harden form protocol per contract review round 2

Backend:
- Guard unhashable JSON in the intercept path: `type: []`/`{}` degrades
  the field to text and `clarification_type: []` coerces to str instead
  of raising TypeError (which, with return_direct, ended the turn with
  an error and no card or fallback)
- Add a total budget over the serialized normalized fields (16KB UTF-8
  bytes): per-item caps alone admitted forms whose IM text fallback
  exceeded channel delivery limits (Slack 40k chars, Feishu ~30KB card),
  silently truncating trailing fields; a boundary test proves any
  accepted form's fallback stays deliverable

Frontend:
- Submission value now appends a JSON block keyed by stable field names
  (readable summary alone is delimiter-ambiguous), with a collision
  regression test
- Parser boundary tightened to match backend constraints: empty option
  values (Radix SelectItem crash), duplicate option ids/values,
  duplicate field names, and the form<->version-2 binding are rejected
- Keep the error node mounted while any field is still invalid so
  aria-describedby never points at a removed element (happy-dom
  interaction test)
- Required semantics are now accessible: native checkbox control (no
  HTML required attribute — it would intercept the custom submit path),
  visually-hidden localized "required" markers next to the aria-hidden
  asterisks
- Legacy-fallback closure narrowed to the latest unanswered request:
  nothing guarantees a single outstanding clarification across runs, and
  closing all would silently swallow older decisions; an older request
  left open becomes the active card again

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

* fix(frontend): keep clarification selects controlled

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:05:31 +08:00
Vanzeren
e01173d8b2
bench(checkpoint): production-shaped full/delta benchmark with configurable snapshot frequency (#4467)
* feat(checkpoint): production-shaped full/delta benchmark with configurable snapshot frequency

- Group benchmark scripts into per-family folders (checkpoint/, sandbox/)
- Extract shared benchmark infrastructure into checkpoint_bench_common.py
- Add checkpoint_delta_snapshot_frequency config (default 1000, process-frozen);
  freeze it in make_lead_agent and DeerFlowClient; key the state-schema
  adaptation cache by resolved frequency
- New bench_production.py: per-case child processes run N ainvoke turns through
  the real lead-agent graph (scripted deterministic model, real AsyncSqliteSaver),
  then measure GET /state + POST /history through the real Gateway route stack
  in one event loop (httpx ASGITransport), cold/warm accessor-cache split,
  cross-mode digest gates
- New summarize_production.py: delta/full ratios plus decision metrics
  (snapshot_write_spike, cache_effect_ms, checkpoint_write_share,
  auto-discovered history per-limit ratios)

* fix(checkpoint): address production benchmark review
2026-07-27 11:47:49 +08:00
March-77
2e5c8da257
fix(sandbox): bypass proxies for local AIO traffic (#4444)
* fix(sandbox): bypass proxies for local AIO traffic

* fix(sandbox): classify public IPv6 proxy targets
2026-07-27 07:47:39 +08:00
Huixin615
090e80c1dd
fix(runtime): fail-stop runs when lease ownership cannot be confirmed (#4431)
* fix(runtime): fail-stop runs after lease expiry

* test(runtime): cover late successful lease renewal

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 07:25:34 +08:00
Huixin615
1cd5dea336
fix(streaming): signal replay history gaps (#4426)
* fix(streaming): signal replay history gaps

* fix(streaming): guard initial Redis replay window

* fix(frontend): align inactive gap recovery

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 07:13:06 +08:00
Aari
244ce7739f
fix(runtime): linearize delta-mode checkpoint resume (#4460)
* fix(runtime): linearize delta-mode checkpoint resume

Resuming a run from an older checkpoint forks the lineage, and in delta
mode that fork's state cannot be materialized correctly: the delta
history walk collects every pending_writes entry stored on each on-path
ancestor, but a shared parent also carries the writes of the sibling
child that was abandoned. Those writes replay into the fork, so the run
starts from a message list that still contains the answer it was meant to
replace — regenerating in a branched thread surfaced this as the
superseded assistant message reappearing beside the new one after a
reload. All three saver implementations are affected, so write-to-child
ownership is a gap in the upstream delta contract rather than one
saver's slip.

Rather than reimplement that walk, express the fork as what it means:
materialize the requested checkpoint's state, write it as an Overwrite on
the current head (which has no siblings), and run linearly. The abandoned
turn stays in history as the rewritten head's ancestry.

This runs after the rollback point is captured, so cancel-with-rollback
still restores the real pre-run head, and fails closed — an unreadable
resume checkpoint raises instead of falling back to the corrupt fork.
Full mode keeps forking: its checkpoints carry complete channel_values
and need no replay.

* fix(runtime): restore complete delta resume state

* fix(runtime): linearize delta rollback restoration

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(runtime): serialize delta resume preparation

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-26 21:59:19 +08:00
DanielWalnut
bb9f67aaf1
fix(runtime): close cancelled replacement admission (#4472) 2026-07-26 21:57:39 +08:00
Vanzeren
1c7531242c
feat(runtime): record terminal artifact delivery receipts (slice 1 of #4272) (#4365)
* feat(runtime): record terminal artifact delivery receipts (#4272)

* fix(runtime): persist delivery receipts across recovery

* test(runtime): cover delivery receipt invariants

* fix(runtime): preserve terminal status on receipt outages
2026-07-26 21:45:47 +08:00
lllyfff
8145d66a33
feat(memory): memory message processing (#4447)
* feat(memory): signals-based update pipeline + always-on watermark/trivial filter

Refactor the DeerMem memory update pipeline (message_processing -> queue ->
updater) around a signals frozenset seam, replacing the
(filtered, correction_detected, reinforcement_detected) 3-tuple with
(filtered, signals: frozenset[str]) end to end.

message_processing:
- Externalize signal-detection patterns to YAML (message_patterns/*.yaml).
- Extend signals from correction/reinforcement to a 6-class set
  (correction/reinforcement/preference/identity/goal/decision); detect_signals
  returns a frozenset aligned with the fact category enum.
- Pure-acknowledgment turns ("ok"/"好的"/...) are always filtered out before
  enqueue (whole-message fullmatch), saving an extraction LLM call.

queue (core/queue.py):
- In-memory list + debounce timer, with flush_sync (graceful-shutdown drain
  that joins an in-flight worker under a hard timeout) and queue_max_depth
  backpressure (signal-bearing updates always admitted; QueueFull otherwise).
- Same-key updates coalesce with a signal union; per-batch success/fail summary.

updater (core/updater.py):
- head500+tail500 message truncation (replaces the 1000-char head chop).
- Always-on per-thread watermark: feed only messages added since the last
  extraction. The watermark is in-memory and is not advanced on failure, so a
  failed/lost update is re-fed on the next conversation turn.
- [MANUAL] prompt marker for user-authored facts (source.type="manual").
- Post-invoke extraction_callback (host-injected) emitting facts_extracted /
  facts_accepted / rejected_low_confidence; the host default logs metrics and
  flags >60% rejection.

Confidence filtering remains in _apply_updates (the existing
fact_confidence_threshold check); there is no separate write gate.
Consolidation stays opt-in (lossy). The ABC add/add_nowait signature is
unchanged, so the summarization flush hook and host are unaffected.

Tests: add test_message_processing_signals, test_updater_truncation,
test_updater_watermark; update queue/updater/consolidation/staleness/pluggable
tests for the signals seam.

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

* fix(memory): harden update pipeline per PR review

- Catch QueueFull in DeerMem.add/add_nowait so backpressure degrades to
  'update skipped' instead of propagating into after_agent /
  summarization_hook and breaking the agent run (peer middlewares
  self-guard; MemoryMiddleware was the lone exception). Emergency
  (add_nowait) always admits under backpressure -- its data cannot be
  re-fed next turn.
- Rewrite the watermark from index-based to content/identity-based
  (_message_identity + _feed_after_watermark) so it stays correct when
  summarization removes the conversation front -- an index watermark
  pointed at the wrong message and silently skipped un-extracted tail
  turns. The emergency flush bypasses the watermark (bypass_watermark on
  ConversationContext, threaded through update_memory) and coexists with
  (does not replace) a pending normal update, so a flush cannot drop a
  pending update's un-extracted tail.
- Populate facts_accepted / rejected_low_confidence inside _apply_updates
  at the real confidence-filter site (passed_threshold) instead of
  re-deriving the threshold in _finalize_update -- eliminates metric drift.
- Emit extraction metrics in a finally with an 'attempted' flag so
  exception failures (parse error, apply_changes raise after retry) are
  observable, not only the happy path.
- Re-detect signals on the post-watermark feed for the extraction hint so
  it no longer references turns the LLM cannot see; admission-time signals
  still drive backpressure.
- Move the post-batch reschedule inside the queue lock to close a
  non-atomic self._timer race with a concurrent add.

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

* fix(memory): address follow-up review nits (LRU, metric name, docstring)

- Bound the in-memory watermark cache with a configurable LRU
  (watermark_max_keys, default 4096, 0=unbounded). A dropped key re-extracts
  one batch on that thread's next turn (the documented restart behavior), so
  eviction is safe and preserves the content-identity watermark's
  front-removal guarantee. Adds _watermark_get/_watermark_set helpers and a
  bounded-LRU regression test.
- Rename the extraction metric facts_accepted -> facts_passed_confidence so
  the name matches what the >60% rejection-rate warning assumes (a
  confidence-gate signal, not a persisted-fact count); drop the stale
  "historical semantics" justification. Brand-new callback, one consumer.
- Fix the stale test_message_processing_signals module docstring: the signals
  seam is already swapped to frozenset, and a stale stage-numbering prefix is
  removed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 21:16:36 +08:00
Ryker_Feng
68c0ffdac8
feat(frontend): pin recent chats (#4442)
* feat(frontend): pin recent chats

* fix(threads): address pin-chat review feedback

- Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new
  update_metadata(touch=False) path so unpinning no longer jumps a chat
  to the top of the updated_at-sorted recent list.
- Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching
  the Gateway's actual response (no values/context).
- Namespace the pinned metadata key as deerflow_pinned for consistency
  with deerflow_sidecar / deerflow_branch.
- Cover touch/touch=False behavior in repo + router tests; document the
  e2e mock's updated_at preservation now mirrors production.

* style(frontend): format thread utils test

* fix(threads): make pinned ordering server-side

* test(frontend): keep infinite-scroll fixture order stable

* test(frontend): stabilize lark reconnect e2e

* docs: clarify thread pin metadata contract
2026-07-26 20:47:58 +08:00
Zhengcy05
2f60bee388
fix: surface length-capped model responses (#4309)
* fix: surface length-capped model responses

* fix: avoid the influence of the mid-turn

* fix: correcting semantic annotations

* fix: add ModelLengthTerminationDetector to compatible providers

* fix:delete redundancy code

* fix:supplementing log information improves observability

* fix: align the document and complete the assertions.

* fix: unit test

* fix: revert AGENTS.md

* fix: unit test

* fix: add annotation and skip AIMessage has empty content
2026-07-26 14:43:08 +08:00
Aari
d1aeea2c3e
fix(checkpoint): unwrap Overwrite first writes into empty channels (#4383)
* fix(gateway): stop persisting Overwrite wrappers into empty reducer channels on branch

Thread branching (and POST /state on a never-written channel) wraps copied
reducer values in Overwrite. Upstream BinaryOperatorAggregate.update seeds
an empty (MISSING) channel with values[0] verbatim without unwrapping, so
Union-typed channels (sandbox/goal/todos/promoted) stored the wrapper
literally and the next consumer crashed with TypeError: 'Overwrite' object
is not subscriptable (#4380). Patch the channel to unwrap the first write
(mirroring DeltaChannel semantics), and stop copying thread-scoped channels
(sandbox, thread_data) into branches: the parent's sandbox_id would bind
the branch to the parent's workspace and release lifecycle.

* refactor(checkpoint): drop private _get_overwrite import for a local Overwrite check

Importing langgraph's underscored _get_overwrite at module top level meant an
upstream refactor that drops it - plausibly the same release that fixes the
bug - would fail this module's import and crash startup before the probe can
stand the patch down. Replace it with a local helper on the public Overwrite
type, and fix two test docstring nits.

* refactor(checkpoint): write patch flags via their constants to avoid drift

Both saver patches read their "already patched" idempotence flag through a
module constant (_PATCH_FLAG / _BINOP_PATCH_FLAG) but wrote it as a hard-coded
attribute literal, so renaming the constant would silently break the guard and
double-apply the patch. Write via the same constant (setattr), dropping the
now-unneeded attr-defined ignores.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-26 10:44:50 +08:00
Daoyuan Li
5d073991c2
fix(sandbox): widen boxlite/aio_sandbox tenant hash and verify identity on reclaim (#4171)
* fix(sandbox): prevent truncated tenant ID reuse

* fix(sandbox): handle late same-tenant box registration
2026-07-26 09:47:57 +08:00
Yufeng He
6e6c078595
fix(sandbox): unwrap Overwrite-wrapped sandbox state in after_agent (#4381)
* fix(sandbox): unwrap Overwrite-wrapped sandbox state in after_agent

Fork-restored checkpoints can deliver the sandbox channel still wrapped
in langgraph.types.Overwrite: the rollback restore applies replace-style
writes through a state-mutation graph in delta checkpoint mode, and
after_agent/aafter_agent then crash subscripting the wrapper
("TypeError: 'Overwrite' object is not subscriptable") on the next
sandbox tool run in the forked conversation. Unwrap before reading the
sandbox id, and pin both hooks against an Overwrite-wrapped state.

Refs #4380 (bug 1 of 2; the history-loss half is a separate display path)

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(sandbox): don't release fork-restored parent sandboxes

The Overwrite-wrapped value replays the parent thread's sandbox state, so releasing it from the forked run would evict the parent's warm sandbox. _unwrap_sandbox now reports the wrapped form, and both after_agent hooks skip the release for it while keeping the normal path unchanged.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-26 09:02:40 +08:00