* docs: turn README into an index and move reference detail to docs/guide
The README had grown to 393 KB because every change appended its notes there. The text moves verbatim into 18 topic guides under docs/guide/; only headings are re-leveled and relative links re-pointed. The README keeps the introduction, a quick start and a guide index. Contributor test notes move to CONTRIBUTING.md.
* docs: send user docs to topic guides and budget the root README
The rule 'update README.md for user-facing changes' is what grew the README. Guidance now points to docs/guide/, and check_agent_guidance.py fails when the root README exceeds 16 KiB (AG003).
* chore: remove unreferenced screenshots and finished plan notes
pr-build/ holds two PR screenshots (3 MB) from #1986. plans/ holds the plan for the merged #4049. docs/SKILL_NAME_CONFLICT_FIX.md is a February change note. Nothing references them.
* docs: route always-loaded AGENTS.md files to scoped guides
* docs(agents): cut scoped guides to scope, verify, traps, decisions
Each AGENTS.md keeps four kinds of content: the scope of the directory, the
verification commands, traps a local test run does not show, and decisions the
code cannot state. Values, field lists, function inventories, test names, issue
numbers and history are deleted, because the code, the tests and git state them.
Search and fetch providers share one community guide. Four guides with no
remaining content are removed. The size check now allows 6 KiB per file (hard
limit 8 KiB) and 24 KiB per chain (hard limit 32 KiB).
* style: format frontend guide table and guidance test imports
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): copy share links on the current origin
The sidebar Share action rewrote the origin to the hosted demo
(https://deer-flow-v2.vercel.app) whenever the page ran on localhost or
127.0.0.1, which are the default make dev and Docker entry points. The
copied link never opened the thread, because the thread only exists on
the local deployment, and opening it sent the thread id (and a custom
agent name) to a host the operator does not control.
Always build the link from window.location.origin and pathOfThread().
* docs(changelog): reference #8126 for the share link origin fix
The frontend, gateway, provisioner, Postgres and Redis pods include
deer-flow.imagePullSecrets, but the nginx Deployment did not. With
nginx.image mirrored to a private registry, the nginx pod stayed in
ImagePullBackOff while every other pod started.
Add a helm-template test that renders every workload and checks that
each one uses the configured pull secrets and none sets them by default.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): keep assembly evidence from reviving a run a peer reclaimed
In heartbeat mode every run-row write is conditional on this worker's
live lease except assembly evidence, which went through RunStore.put().
That upsert rewrote status, owner, lease, error and stop_reason from the
local record. When a peer had already reclaimed the run, it put the row
back to running and erased the takeover error. If the owner's local lease
was still live, its renewal then succeeded, the peer never reclaimed it
again, and the run finished as success after the peer had published its
terminal recovery event. If the write landed late, the row came back with
an expired lease and was reclaimed a second time.
Add RunStore.merge_owned_metadata(), which merges metadata keys only for
the owner holding a live lease on a pending/running row and writes no
other field. SQL follows the existing clock -> row lock -> conditional
UPDATE order; MemoryRunStore mirrors it. With heartbeat enabled,
record_assembly_evidence() uses it, refuses a locally fenced run before
any write, fences the run when the store rejects the merge, and fails
closed on stores without the method instead of falling back to put().
Single-worker mode keeps the full snapshot.
* docs(changelog): reference #8046 for the assembly evidence takeover fix
* test(runtime): scope the simulated lease expiry to the run's thread
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(hitl): never pause on an approval call without a usable id
A provider can emit a tool call with no id (langchain_core keeps `id=None`),
an empty id, or the same id twice in one response. `HitlApprovalMiddleware`
copied that id into the interrupt payload, but the Gateway resume validator
requires unique non-empty strings: every resume answered 422 and every new run
stayed 409 `approval_pending`, so the thread was wedged permanently — the
all-reject escape is validated after that check. Denial paths also built
`ToolMessage(tool_call_id=None)`, which raises inside `after_model`, so a
non-interactive run failed instead of denying the call.
Treat such calls as unaddressable: strip them from the model response with a
visible rejection note instead of pausing, and deny by removal whenever a
`ToolMessage` could not pair. The tool never executes and the thread stays
usable.
Fixes#8069
* docs(hitl): state the empty-id rule accurately and track legacy recovery
Review follow-up on the unaddressable-approval guard:
- An empty tool-call id was technically resumable end to end (langchain-core
accepts `ToolMessage("")`, and the Gateway validator accepts `""`), so only a
null or duplicated id can wedge a pause. The rule still denies empty ids
because strict providers reject them when the conversation is replayed; the
docstring, CHANGELOG and interaction policy now say exactly that, and
`_process_responses` uses the same `_addressable` predicate as the pause
guard so the two checks cannot drift.
- Add an operator note for pauses stored before this fix: they are not cleared
here, so continue in a new thread; in-place all-reject recovery is tracked in
#8071.
* fix(hitl): drop a colliding call id from every serialized surface
Review follow-up: `_strip_calls` removed the unaddressable approval calls from
`tool_calls`, but `clone_ai_message_with_tool_calls` filters the raw
`additional_kwargs["tool_calls"]` payload and content tool-call blocks by id.
A non-target call sharing a removed id kept that id in `kept_ids`, so the
removed calls stayed in the raw provider payload and the next request would
advertise calls that have no result.
Drop the whole colliding id group: an id shared with a denied approval call
cannot be answered unambiguously either. The regression covers a mixed response
(duplicate approval ids plus an ordinary call with the same id) and asserts the
raw payload, content blocks, execution and visible note.
* fix(hitl): reject an id shared with an ordinary call before pausing
Review follow-up: `_addressable(targets)` only asked whether a `ToolMessage`
can answer the targeted calls. A single approval-required call whose id is
shared by an ordinary call in the same response still paused, even though
approving or rejecting it would also answer the ordinary call by id.
Add `_unambiguous_ids`: every targeted id must also be unique across the whole
response. A collision denies the colliding group (strip + visible note) before
the pause, and the resume path applies the same check to a legacy pause. New
regression: one approval-required call and one ordinary call sharing an id.
* fix(hitl): strip a deny target whose id an ordinary call shares
`_deny_all` routed only on `_addressable`, which checks uniqueness among the
targeted calls. A target whose id an ordinary non-target call also carried was
therefore answered with a deny `ToolMessage` while the ordinary call stayed
serialized, so the next request advertised the id twice with two results.
`_deny_all` now requires `_unambiguous_ids` and strips the whole colliding id
group, matching the pause path. The tool still never executes.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): reject unusable skill_context entries at the input boundary
merge_skill_context indexes entry["path"] and calls entry.get(), so a
caller-supplied non-object entry or one without a usable path raised during
the state merge and failed the run with a 500. Validate the list shape at the
run-input and thread-state boundaries, mirroring the todos and delegations
channels.
* Normalize entry by ensuring 'name' is a string
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(runtime): persist a rejected HITL call in the resume run's history
An approval pause and its resolution are two Runs. The paused Run dispatches
the gated call; the resume Run produces its result. An approved call executes
and reaches on_tool_end, but a rejection is written by HITL after_model with no
tool callback. Journal reconciliation only persists such middleware-written
results for calls dispatched by the current Run, so the rejection stayed in
checkpoint state and never reached the run-event history: /messages and the
resume run's messages had no llm.tool.result for it.
The worker already reads the durable interrupt payload to recover the gated
tool names. It now also returns each paused tool_call_id with its name, and the
resume journal adopts those calls, so the existing reconciliation persists the
rejection under the resume Run. Approved results keep their tool-callback event
and are deduplicated by message id; results from other earlier Runs are still
not re-attributed.
* fix(runtime): keep HITL replay names independent of call adoption
Derive the replay-gate tool names and the adopted call ids separately
from the matched pause's actions, so an action without a usable
tool_call_id still gates replay. Pin that the all-reject escape paths
(approval disabled, gated tools changed or cleared while paused) reach
the history feed under the resume run, check resume-run attribution in
the mixed-decision test, and add a CHANGELOG entry.
* refactor(runtime): drop unused _pending_hitl_resume_calls wrapper
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(benchmark): add offline memory vs project-context comparison suite (part of #7884)
* fix(benchmark): route prompts through DynamicContextMiddleware, flatten rows, and share test run
* refactor(benchmark): run through local Gateway and simplify first-pass suite (#7884)
* docs(agents): keep context memory benchmark guidance within chain budget
* fix(benchmark): verify DeerMem scope rejection
---------
Co-authored-by: Totoro <msy626836554@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Re-uploading a file whose name collides with a compound archive suffix
(e.g. .tar.gz, .tar.bz2) previously inserted the dedupe counter inside
the suffix: a second report.tar.gz became report.tar_1.gz, which broke
file-type detection. Treat the compound suffix as a single extension so
the counter lands before it (report_1.tar.gz), matching single-suffix
behavior (report_1.tar, report_1.txt).
- Add _COMPOUND_SUFFIXES and split stem/suffix as one unit, sorted
longest-first with a closed, non-overlapping allowlist (.tar.br added).
- Expand tests to all 13 suffixes plus counter-escalation and plain .tar.
- Add a CHANGELOG [Unreleased] Fixed entry.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): recall CJK sub-phrases when jieba is not installed
Without the memory-zh extra, _tokenize fell back to whitespace splitting, so
the FTS5 index kept a whole CJK run as a single unicode61 token and every CJK
sub-phrase query returned no hits without any warning. The relevance ranker
already emitted CJK character bigrams for the same text, so the two retrieval
strategies disagreed on the same data.
Reuse relevance.cjk_token for CJK runs when jieba is unavailable and route
_preprocess_content through _tokenize, so the index and the query side agree.
Installs with jieba keep the existing jieba path.
Existing index rows are rebuilt on the first search or warm-up after the
upgrade; no schema or version change is involved.
* style(tests): format the CJK fallback regression test
`ruff format --check .` in the backend lint job reformats the new assertion in tests/test_memory_retrieval_adapter.py: the implicitly concatenated message is joined into one line. Apply the formatter output, no behavior change.
* fix(deermem): recall single CJK characters through an FTS5 prefix phrase
The fallback index stores CJK runs as character bigrams, so a one-character
query had no equal token and came back empty; `"重"*` is an FTS5 prefix phrase
that matches the bigram `重试` and still matches a standalone `重` token, while
multi-character tokens keep the plain quoted phrase. Also force the no-jieba
branch with a monkeypatch so the regression test runs on installs that have
the optional `memory-zh` extra, and cover the single-character and absent-
character cases.
* docs(deermem): scope the single-CJK prefix note to leading matches
The prefix phrase only reaches indexed tokens that start with the queried
character, so a character occurring only later inside a bigram (务 in 任务)
is not matched. Say that in the docstring and mark the residual gap as
accepted instead of widening the prefix to the run's trailing character.
* fix(nginx): use checkout-local temp paths for local development
* docs(nginx): clarify local request buffering comment
* docs: keep nginx guidance within the inherited byte budget
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agents): drop empty text items from sanitized list content
InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but only replaces the items it collected. Empty
bare strings and empty text blocks are skipped by the extractor (matching
message_content_to_text) and then re-emitted as-is, so a request whose
content was ["", {"type": "text", ...}] still carried a bare "" content
part. The Chat Completions schema only allows typed content-part objects
in a content list, so endpoints that validate against it rejected the
whole turn — and kept rejecting every later call that resent the message.
Empty text items are now recognised during extraction and dropped when the
content list is rebuilt: leading, interleaved and trailing positions. A
list made only of empty items collapses to one empty typed block, and an
image-only message still passes through untouched. Nothing else changes:
non-text blocks keep their positions, and the transform stays
request-scoped.
* fix(agents): drop empty text items when rebuilding sanitized content
InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but it only replaced the items it had collected.
Empty bare strings and empty text blocks were skipped during extraction
(matching message_content_to_text) and then re-emitted unchanged, so a
message whose content was ["", {"type": "text", ...}] still carried a bare
"" content part. The Chat Completions schema only allows typed content-part
objects inside a content list, so endpoints that validate against it
rejected the whole turn, and kept rejecting every later model call that
resent the message.
Empty text items are now collected during extraction (they are text-shaped,
so the rebuild is what has to drop them) and therefore disappear in all
positions: leading, interleaved and trailing. A list made only of empty
items collapses to one empty typed block instead of leaking the bare
string, and an image-only message still passes through untouched.
Non-text blocks keep their existing placement rule, and the transform stays
request-scoped.
* docs(middleware): state that empty typed text blocks are dropped
The #8038 scope note in middlewares/AGENTS.md still said empty typed text
blocks remain unchanged and that providers can still reject them. This
change drops those blocks too, so the note described the opposite of what
the code does. Rewrite the summary and that bullet to match, and keep the
remaining limits explicit: user content only, and an empty user turn can
still be rejected.
* test(middleware): keep the legacy cases out of the new class and pin keep-alive
Inserting TestEmptyTextBlockItemsAreDropped before the existing
already-wrapped, graph-bubble-up and fail-open cases silently moved those
three tests into the new class, changing their node IDs and putting them
under a docstring about empty typed text blocks. Place the new class after
test_fail_open_on_processing_error so the legacy tests keep their home.
Also pin the boundary the sweep must not cross: a whitespace-only part,
typed or bare, carries no words but still keeps the turn alive. Assert
both shapes survive next to an image so tightening not block to not
block.strip() fails here rather than turning an empty user turn into a
rejected one.
* docs(middleware): keep the empty-text note inside the guidance size limit
The added paragraph pushed the effective AGENTS.md chain 55 bytes past its hard limit. Condense the same facts instead of adding a new line to the budget.
---------
Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
* fix(feedback): return 409 instead of 500 when feedback is POSTed twice
POST /api/threads/{id}/runs/{rid}/feedback did a plain INSERT. A second
POST by the same user for the same run (a retry, or feedback on another
message of that run) hit uq_feedback_thread_run_user and the uncaught
IntegrityError surfaced as HTTP 500.
FeedbackRepository.create now rolls back and raises
FeedbackAlreadyExistsError on that collision, and the route maps it to
409 pointing callers at PUT, which already upserts.
* fix(feedback): only map the run/user unique key to 409; cover concurrent POSTs
Address review on #8064:
- create() now re-reads the (thread, run, user) row after an
IntegrityError and raises FeedbackAlreadyExistsError only when it
exists; any other violation (e.g. a feedback_id collision) propagates.
- Add a commit-barrier test where two first POSTs race: one 200, one
409, and the winner's row is kept.
- Add CHANGELOG.md / CHANGELOG_zh.md Fixed entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): return 404 on cross-owner thread_id instead of 500
When a caller-supplied thread_id already belongs to another user, the SQL-backed store rejects the duplicate primary key with IntegrityError. The route's recovery path resolved the surviving row through the requesting owner's scope, so a foreign row could not be read back and the route surfaced a 500 instead of the documented 404 ownership-conflict response ("Thread not found").
Read the row back bypassing the owner scope before collapsing to a 500 so the cross-owner case mirrors ThreadOwnershipConflictError.
Fix#8052
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
* test(gateway): pin thread-ownership 404 and real-failure 500
Address review feedback on the cross-owner 404 recovery:
- Update test_insert_race_concurrent_trusted_claims_have_one_winner: the
losing request now resolves the surviving unscoped row as a 404 ownership
conflict instead of a 500, while the single-winner assertions and the
non-disclosing detail are preserved.
- Add test_create_thread_keeps_real_integrity_failure_as_500 to pin the
fall-through branch where a duplicate-key IntegrityError coincides with no
surviving row, which must still surface as a 500 rather than collapse into
the ownership-conflict 404.
- Document the user-facing error-surface change in the Unreleased Fixed
section.
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
---------
Signed-off-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(browserless): resolve the token through the shared credential rule
Browserless read BROWSERLESS_TOKEN and the configured token raw, so a value
from a file kept its trailing newline and became the auth query parameter;
every request then failed authentication. A whitespace-only value was sent as
a token instead of counting as unset. Use provider_credentials.resolve_api_key
like the other search and fetch providers, with the blank-config fallback to
the environment.
* fix(browserless): keep an explicit blank token opt-out
An explicitly configured token ("" or null) is authoritative: a self-hosted
capture endpoint uses it to opt out of authentication, and falling back to
BROWSERLESS_TOKEN sent the cloud credential to that endpoint. Only an omitted
token key falls back to the environment. Regression tests cover the client
token and the outgoing capture query.
* docs(changelog): record the browserless token normalization
Match the sibling #7981 entry in both locales; the fix closes out the
credential-rule family for the bundled search and fetch providers.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(typesafe): build the per-evaluation HTTPX client off the event loop
TypeSafeClient.aask constructs httpx.AsyncClient on the running loop, and
that constructor loads the certificate store synchronously
(create_ssl_context -> ssl.create_default_context(cafile=certifi.where())).
aask is the path GuardrailMiddleware.awrap_tool_call awaits, plus both
memory paths, so every guarded tool call stalls the Gateway loop for the
duration of the trust-store load.
Move the construction into asyncio.to_thread, the shape backend/AGENTS.md
records for the other HTTPX clients and the one the merged Crawl4AI anchor
tests. Requests and cleanup stay on-loop, so the deadline scope, retry
loop, bounded body read and the error taxonomy are untouched.
* fix(typesafe): close clients built during cancellation
Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Niranjan Bajgai <2053809+bajgai@users.noreply.github.com>
CONTRIBUTING documents the public skill tests as a self-contained suite:
python -m pip install -r tests/skills/requirements.txt
python -m pytest tests/skills -q
That install is short one dependency. skills/public/skill-creator/scripts
imports PyYAML at module level (quick_validate.py, utils.py), and its SKILL.md
already states PyYAML is required, but tests/skills/requirements.txt never
listed it. A fresh environment built from that file therefore fails 34 tests
with ModuleNotFoundError: No module named 'yaml' instead of running the suite -
test_skill_packager_cli.py, test_skill_creator_frontmatter.py and
test_skill_creator_eval_output.py all drive the skill's CLI in a subprocess,
so every one of their cases reports a traceback.
CI does not see this because skill-review-ci.yml runs the suite through
`uv run --locked` inside the backend environment, which already resolves
PyYAML transitively. Only the documented local path is affected.
Pin the version the backend lockfile already resolves (uv.lock: >=6.0.3).
* fix(skills): read undecodable skill state as missing
`_read_manifest` and `_read_skill_states` document that an unreadable file
means "no manifest" / "no overrides", and both catch OSError and
JSONDecodeError. A file that is not valid UTF-8 raises UnicodeDecodeError,
which is a ValueError and matches neither, so the projection and the skill
state read raise instead of falling back.
Catch it alongside the existing exceptions.
* fix(skills): keep the reason when profile state cannot be decoded
Review feedback on #7963: the state reader dropped the exception, so a decode
failure that silently discards the user's disabled set left nothing to diagnose,
and the next toggle overwrites those bytes. Log the exception, and log a debug
line when an unreadable projection manifest forces a rebuild.
* docs(skills): state that undecodable skill state fails open
_read_skill_states already degraded a malformed file to {} before this change;
making undecodable bytes take the same path made that explicit, so say so next to
the sandbox check that documents the opposite verdict for a single named skill.
---------
Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
* fix(artifacts): bind ranged reads to validated generation
* fix(artifacts): drain pinned reads without Windows hard links
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): select FTS5 matches by composite score
Score every SQL-filtered match before selecting the final composite top-k.
Keep candidate retention bounded with a streaming heap and preserve the
existing scoring formula and adapter-level filtering behavior.
Add regressions for confidence and time-decay reversals, category filters,
result prefixes, ties, and the public DeerMem storage path.
Fixes#8003
* fix(memory): report matched and retained FTS5 rows
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): make chat stream server errors actionable
* fix(frontend): log chat stream server errors
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
remain from its review:
- `stored_next_run_at` caught the ValueErrors a dropped zone or a newer cron
parser raises, but a stored `schedule_spec` that is JSON null, a list or a
string fails on `.get` with AttributeError. The launch-failure handler
recomputes the schedule, so that escaped `run_once`'s claimed-task loop and
skipped the rest of the batch. Catch it too; the task is paused with the
reason like the other unusable schedules.
- `run_once` dispatched its claimed batch with a bare loop, so any per-record
raise in `dispatch_task` re-aborted the batch. Isolate each task's dispatch
and keep claim-level store failures loud above the loop.
Tests: `test_scheduler_poll_batch_isolation.py` (non-object specs, end-to-end
pause, per-task isolation). Red on `91f20a7`, green at head.
Refs #7998
* fix(threads): evict in-memory runs when a thread is deleted
RunManager.get() and list_by_thread() answer from the process-local _runs
registry before consulting the RunStore. DELETE /api/threads/{id} only
removed the durable rows, so a worker that had admitted the thread's runs
kept serving them: after the delete returned 200 and the thread itself
answered 404, GET /api/threads/{id}/runs still listed the run and
GET /api/threads/{id}/runs/{run_id} still returned 200 with the original
prompt intact in kwargs. The residue disappeared only on restart, which
is what made it look like a cache rather than a leak.
Add RunManager.discard_thread_runs() to drop this worker's copies for the
thread and call it from the delete path, next to the durable run
deletion. Internal thread-operation rows are left alone: the reservation
protecting the deletion is one of them and its own release path owns it.
Historical runs, the thread row, run events and checkpoints are unchanged.
* fix(threads): keep a live finalize tail attached when evicting runs
discard_thread_runs() popped every record it found, but worker.py clears
finalizing before the tail that publishes the terminal frame, closes the
journal and releases the sandbox lease. Popping mid-tail turns the tail's
manager-mediated writes into warnings on a record that no longer exists.
Skip records whose task is still running, mirroring what cleanup() already
does through shield(), and pin the guard with a unit test that seeds both
a finished run and a delete reservation. Document that a DELETE served by
one Gateway bounds, rather than removes, a peer's copies: they survive
until that peer's deferred cleanup(run_id) fires.
* fix(threads): hide a deleted run from reads during its finalize tail
A DELETE that arrives after worker.py clears `finalizing` but before the
worker task finishes cannot drop the record: the tail still publishes the
terminal frame, closes the journal and releases the sandbox lease through it,
so popping turns those writes into `set_status called for unknown run`.
Leaving the record readable instead keeps the run and its prompt reachable
through `GET /threads/{id}/runs` until `cleanup()`'s 300s delay fires, which
is exactly the window #8034 reports.
Records that a deletion reaches mid-tail are now flagged instead of skipped.
`get()`, `list_by_thread()`, `get_many_by_thread()`, the regenerate-source and
edit-replay reads skip a flagged record (and never rebuild it from a store row,
since the durable rows were deleted first), and the tail's own completion
evicts it — no second delete request and no timer.
* fix(threads): keep the tail eviction task referenced until it settles
The done-callback eviction is a separate task because it cannot await the
registry lock inline. The loop holds only weak references to tasks, so an
unreferenced eviction could be collected before it drops the record and the
deleted run would come back within `cleanup()`'s 300s window. Track it in a
manager-owned set and discard on completion.
* fix(threads): keep hidden runs hidden on every read path
The deletion flag set by discard_thread_runs is authoritative for "this run
is deleted", but two reads still treated it as a hint.
get() narrowed its store-less guard to a missing record, so a flagged
record on a store-less RunManager fell through to self._store.get() on
None; the AttributeError was swallowed by the broad except below while
every read in the flag window logged a hydration warning with a full
traceback. The record now settles the read before any store call, which
also keeps the read side-effect free as the comment intends. aget()
shares the path.
get_many_by_thread() rebuilt flagged runs from store rows. list_by_thread
already refuses that, because the durable row is deleted before the flag
is set and anything left is a stale or partial read; the batch read now
applies the same guard so the invariant is uniform.
---------
Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
* fix(subagents): keep tests_passed evidence per call when a provider reuses its id
Tool-call ids are unique per response, not per conversation. Some providers
number calls per response, so two turns can both issue call_0.
The bash evidence for tests_passed acceptance assumed one call per id in two
places:
- _harvest_bash_executions built one id -> command map over the whole
conversation, so every result under a reused id was labelled with the
last call's command. A passing subset run (pytest tests/unit) became
evidence for the full suite when a later call_0 ran pytest tests and had
no result yet.
- update_bash_executions merged values chunks by tool_call_id. A dict keeps
a key's first position, so a later run under a reused id overwrote the
earlier entry in place. With call_0 failing, call_1 passing and call_0
failing again, the merged list ended on the stale pass and the leaf held.
Pair results through pair_tool_call_results, which answers a call only from
its own response, and merge chunks by result message id, keeping the call id
for entries without one. Each run stays a separate entry in execution order,
so the latest run decides the leaf again.
* fix(subagents): key the summary cache per run and document id-less calls
- Cache full-output test summaries by result message id (call id only
without one), so reused call ids keep one cache slot per run.
- Update the bash_executions field doc and the subagents guide to the
result-id merge, and state that calls without an id record no
evidence (the leaf stays UNVERIFIED).
- Tests pin both: one classification per run under a reused id, and no
evidence for a call with a missing or empty id.
* chore(subagents): drop the stray empty deerflow/executor.py
The previous commit added a 0-byte module at the package root by mistake.
Nothing imports deerflow.executor; the real module is deerflow/subagents/executor.py.
* fix(ci): label every harness tree as backend
`area:backend` enumerated the harness subdirectories that map to it instead
of the trees that map elsewhere, so thirteen of the twenty-eight directories
under `deerflow/` matched no area rule at all: community/, authz/, scheduler/,
storage/, tui/ and eight more. A PR that only touched `deerflow/community/`
therefore came out unlabelled, or with `area:docs` alone when it also edited a
markdown file — #7482 (crawl4ai) and #7826 (jina) are merged examples.
Match the harness as a whole and exclude the six trees that carry a more
specific label (agents, subagents, reflection, sandbox, skills, mcp). Checked
against all 28 directories: the 13 unlabelled ones now match `area:backend`,
and the sandbox / skills / mcp / agents / frontend results are unchanged.
* fix(ci): cover backend package paths in PR triage
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(skills): give a bracketed literal in the userinfo slot no host on every Python
`_http_host` answered from `urlsplit(url).hostname`, which decides the
ambiguous authority `http://[::1]@Example.COM/api` differently across
interpreters: 3.12 reads the bracketed part as userinfo and returns the host
`example.com`, 3.13 rejects the authority and `urlsplit` raises, so the helper
returns None. The same URL got two different answers depending on the runtime.
The answer is not cosmetic: `_is_outbound_url` compares it against
`_LOCAL_HTTP_HOSTS`, so whether a URL counts as local or outbound moved with the
interpreter. The test table added with the surrounding refactor (#7950) already
states the intended answer, None, which is why `tests/test_skillscan_native.py`
fails on 3.12 and passes on 3.13.
The bracketed-literal case is now rejected before `hostname` is read, so the
answer no longer depends on that URL-parsing change, and the shape keeps the
treatment the other malformed authorities get: no host, therefore outbound.
The test table also gains a bracketed literal with an IPv6 host and a port
(`http://[2001:DB8::1]@example.com:8443/api`), so a fix that only special-cased
the exact URL from the failure cannot pass.
On 3.12: `tests/test_skillscan_native.py` goes from 1 failed / 387 passed to 388
passed, and the skillscan suite (834 tests) passes. Reverting only the
orchestrator change puts both bracketed cases back to failing.
* fix(skills): reject raw brackets in URL userinfo
---------
Co-authored-by: lxcxjxhx <lxcxjxhx@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(threads): keep the owner row when deleting checkpoints fails
DELETE /api/threads/{id} treated the checkpoint delete as best-effort: a
failure was logged at DEBUG, the threads_meta row was removed anyway and
the request returned success. Read routes allow a thread without a meta
row as an untracked legacy thread, so the surviving checkpoints (title,
messages, goal) became readable by every signed-in user through
GET /threads/{id}, /state, /history and /goal, and the owner could not
retry because DELETE requires the row.
A failed checkpoint delete now logs the exception and returns a generic
500 before the meta row is touched, so the thread stays owned and the
owner can retry. The remaining cleanup steps stay best-effort; their
reads are owner-scoped.
* docs(changelog): reference #7992 for the thread delete checkpoint fix
* fix(threads): require checkpoint deletion before dropping ownership
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(agents): drop empty strings from rebuilt user content
* fix(agents): normalize empty content before sanitizer early returns
* docs(agents): clarify input sanitization provider limits