184 Commits

Author SHA1 Message Date
llb555
19768d73cc
fix(setup-agent): keep a failed discard from replacing the publish outcome (#7564)
* fix(setup-agent): keep a failed discard from replacing the publish outcome

* test(setup-agent): pin the post-publish discard and assert the real temp name

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 23:33:49 +08:00
Felix Shi
d53ec767af
fix(persistence): keep LF bytes in skill and agent text writes (#7501)
* fix(persistence): keep LF bytes in skill and agent text writes

Text mode translates a newline into os.linesep on write, so on Windows every
caller-supplied LF reached disk as CRLF while readers use universal newlines or
decode bytes explicitly. The difference stayed invisible: the API returned LF,
the file held CRLF, and a skill history record's prev_content described a
revision no API view could return (#7174).

Pass newline="" at the ten writers that persist or judge caller text: the local
and user-scoped skill writers, the skill history JSONL, the user skill-states
JSON, the SOUL.md/config.yaml staging in FileAgentStore, the managed-subagent
store, setup_agent's SOUL.md publish, the USER.md route, and the two temp copies
the frontmatter validator and the static scanner judge.

The tests emulate the Windows translation on io.open, so the regression runs on
the Linux CI shards instead of only on a Windows host: 10 failed before this
commit, 10 pass after.

* fix(skills): preserve candidate newlines during static scan

---------

Co-authored-by: Felix Shi <204130665+shi-felix@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 18:45:49 +08:00
Aling33
3fdd9bdfff
fix(artifacts): keep sandbox paths inside CJK corner brackets whole (#7377)
The quote-delimiter tables only knew ASCII quotes, backticks and curly
quotes, so `已生成「/mnt/user-data/outputs/季度 报告.pdf」。` captured the
handle `/mnt/user-data/outputs/季度` (the path pattern stops at the space)
and `已生成『…』。` kept the closer inside `real_ref`; either handle is dead
on a later read.

Register `「」` and `『』` as wrappers: a paired closer stays inside a
filename, an unpaired one is trimmed from a bare token, and corner-trailed
URLs and refs are no longer cut.

The persistence regression (capture -> registry round-trip -> resolution ->
real file read with the tool result gone) is ported from #7376 by
@yangjj-iso, per review.
2026-10-24 20:45:02 +08:00
Evan
195bf358ca
fix(acp): fail fast when an ACP agent exits and drain its stderr (#7354)
* fix(acp): fail fast when an ACP agent exits and drain its stderr

An ACP agent that exited before answering (missing credentials, a wrong
argument) left invoke_acp_agent waiting for the whole timeout_seconds
budget (30 minutes by default) and then suggested raising the timeout.
The pinned SDK never fails pending requests when the agent's stdout
reaches EOF, so nothing noticed the process was gone.

The SDK also opens the agent's stderr as a pipe and never reads it. An
agent that logged more than asyncio's pipe buffering absorbs blocked on
its next stderr write and stalled the invocation the same way.

Race the ACP exchange against the agent process: when it exits (or a
newer SDK reports the connection closed), allow a short grace for
responses still buffered in the pipe, then report the exit code. Drain
stderr continuously into a bounded tail that is logged on failure and
kept out of model-visible text.

* fix(acp): log the full stderr tail on timeout and pin tail retention

Log the timeout tail after the SDK context has terminated and reaped the
agent, so lines still in flight when it stalled are included. Make the
exit test write more stderr than the retained tail, so keeping the start
of the stream instead of its end fails the assertion on the fatal line.
2026-10-23 22:30:26 +08:00
ShiYuan Xiong
3802db3392
feat(agent): add read-only run status introspection (phase 1) (#7297)
* feat(agent): expose read-only run status tool

* refactor(tools): name the subagent dispatch tool set

Hoist the hardcoded subagent dispatch tool-name literal into _SUBAGENT_DISPATCH_TOOL_NAMES so a future dispatch-tool rename has one place to update. No behavior change: the folded-subagent context_fill guard compares the same two names.

* test(tools): account for run status in collision assembly

* docs: keep script guidance within size budget
2026-10-23 22:06:00 +08:00
李刚 / Bryan Li
1c9e962b32
fix(artifacts): keep a delimited sandbox path whole (#7308)
* fix(artifacts): keep a delimited sandbox path whole

The free-text artifact scan matched `/mnt/user-data/\S+`, so a tool that
quoted a filename with spaces registered a handle for the prefix: `Created
"/mnt/user-data/outputs/quarterly report.pdf" successfully.` became
`/mnt/user-data/outputs/quarterly`, which resolves to nothing after compact or
checkpoint reload. Quotes and inline backticks had the same failure.

The scan now classifies each candidate by how it is delimited. A span opened by
a matching quote or backtick on the same line is one value and keeps its spaces
and punctuation; a span whose delimiter never closes on that line reports
nothing, because a truncated prefix names a file that does not exist; a bare
path keeps the conservative whitespace boundary it always had. Ordering is the
text order, and the shared `seen` set still deduplicates against structured
references.

Pins the three delimiters, both unchanged behaviours (bare path boundary,
punctuation outside the delimiter), the unterminated and next-line cases, two
delimited paths in one result, a mixed delimited/bare result, and the extracted
handle end to end.

* fix(artifacts): reject line breaks and match escaped quotes

* fix(artifacts): validate quoted path boundaries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 17:59:44 +08:00
Lachlan Allen
7a32a5e177
feat(exa): support a configurable Exa-compatible base URL (#7306)
Exa web_search and web_fetch accept a per-tool base_url, falling back to
EXA_BASE_URL and then the SDK default. Overrides are validated like the
Serper base URL. web_fetch screens a configured backend with the shared
delegated-backend guard.

Co-authored-by: lachy3433 <lachlanallen.co@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:51:36 +08:00
哈基米
a1eb397141
fix(skills): treat only top-level scripts/ paths as executable in skill_manage (#7088)
* fix(skills): treat only top-level scripts/ paths as executable in skill_manage

write_file classified a support file as executable content with a substring
test ("scripts/" in path), so any support path merely containing "scripts/"
(e.g. references/scripts/overview.md or templates/my-scripts/setup.md) was
moderated as executable: that scan requires an exact "allow" decision and
hard-blocks when the moderation model is unavailable. Executable content is
now exactly a file under the skill's top-level scripts/ directory, matching
the scripts/ member rule in skills/package_files.py and the same Path
semantics the storage layer already used to validate the support path.

* refactor(skills): share the scripts/ member rule with skill_manage

The write_file executable gate re-derived the top-level scripts/ member rule
locally. Use package_files.is_scripts_member instead so the gate, installer,
and SkillScan stay in lockstep.

Extend the regression test to pin scripts/sub/run.sh and ./scripts/run.sh as
executable content, plus the shared helper's normalization.

* fix(skills): moderate nested code files as executable content in skill_manage

The write_file executable gate classified only top-level <scripts/> members as
code, so runnable support files elsewhere in the package
(references/scripts/helper.py, templates/my-scripts/run.sh,
assets/my-scripts/tool.js) and extensionless shebang files fell back to static
moderation: a warn decision, or an unavailable moderation model with
security_fail_closed=false, wrote them to disk. The deterministic package scan
does not enforce the allow-only model verdict, and this write path publishes
through write_custom_skill without running the mutations CandidateScanner, so no
later check restores executable moderation.

Classify with the complete shared code-file rule from
deerflow/skills/package_files.py (scripts members, code suffixes, and
extensionless shebang files) instead of the scripts-member rule alone, and keep
Markdown/data support files on the static tier. Extend the regression suite with
the code-suffix/ shebang cases plus the warn and unavailable-provider outcomes.
2026-10-23 00:28:20 +08:00
Totoro
0baa99ca90
feat(models): add scoped model controls to Web chats (#7249)
* test: stabilize reporter evidence and guard missing chat nodes

* feat(models): scope Web model choices to tasks, turns and conversations

* test(e2e): mock model-control preview for ordinary chat submissions

* fix(models): harden scoped choices and stale Agent submissions

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-22 08:08:23 +08:00
trent87
0d73798beb
feature(skills): keep allowed-tools as compatibility metadata (#6935)
* refactor(skills): keep allowed-tools as compatibility metadata

* docs(skills): align current guides with agent tool authority

* test(skills): simplify authority regressions and sync Korean docs
2026-10-20 19:34:28 +08:00
RongJie G
cd28a1cfe9
fix(subagents): bound hard timeout terminalization (#7053)
* fix(subagents): bound hard timeout terminalization

Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>

* fix(subagents): address timeout lifecycle review

Bound durable terminal cleanup waits to 60 seconds while unfinished execution resources stay supervised. Preserve terminal results when cancellation races a child failure, and register deferred registry removal when its fence wait expires.

Add five lifecycle regression cases and preload the sandbox prewarm dependency in the executor test fixture. Validate 470 focused tests, 261 strict blocking-I/O tests, and 50 repeated regression executions under asyncio debug.

---------

Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 23:13:12 +08:00
Nan Gao
0550816c50
fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create (#7099)
* fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create

Two leftovers from the multi-instance work (#7097).

The agent's skill_manage tool refreshed only its own process's skills
prompt cache after a create, edit, patch, delete, write_file or
remove_file, so a Gateway replica sharing the home volume kept rendering
that user's previous skill list. Every mutation now runs one drained tail,
as the skill routes do: the write and its history record, the local
refresh, then publish_skills_cache_reset(user_id=...) off the event loop.
A run cancelled while the write is in flight still refreshes and
publishes before the cancellation propagates. A marker that cannot be
written is logged inside the tail and raised as SkillCacheResetPublishError
with a message saying the change was applied, so the model does not retry
a write that succeeded.

Two provisioner replicas starting together against a missing
K8S_NAMESPACE both read 404 and both create; the loser's 409 escaped the
lifespan and crashed it. A 409 on create now counts as the namespace
existing, as the Pod and Service creates already do.

Also drops a stale duplicate of the multi-instance "declaration also
drives" note in CONFIGURATION.md.

* docs(changelog): reference #7099 for the multi-instance leftovers

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:28:19 +08:00
Ryker_Feng
d1d0958bea
feat(skills): add guarded evolution host APIs (#5645)
* feat(skills): add guarded evolution host APIs

* docs(agents): avoid growing saturated guidance chains

* fix(skills): address mutation host review findings

* fix(skills): order name fence before projection lock

* fix(skills): preserve composite toggle lock order

* fix(skills): address catalog and pagination review feedback

* docs: keep subagent guidance within merged instruction budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-18 21:17:02 +08:00
BUG设计工程师
218fff3c76
fix(artifacts): preserve balanced ASCII delimiters in references (#6967) 2026-10-18 19:23:19 +08:00
haotianqi666
07f0d74f70
fix(agents): clear incompatible knowledge defaults on self-update (#6995)
* fix(agents): clear incompatible knowledge defaults on self-update

* fix(agents): share knowledge default cleanup with HTTP updates

---------

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
2026-10-18 11:12:57 +08:00
Totoro
002bcc521a
fix(skills): let skill_manage lock waiters wake across event loops (#7020)
* fix(skills): let skill_manage lock waiters wake across event loops

The sync tool wrapper runs every call under its own asyncio.run, and
ToolNode runs same-turn calls on separate threads. The per-(user, skill)
asyncio.Lock bound itself to the first contending loop, so a release from
another thread never woke that waiter, and a third caller failed with
"bound to a different event loop".

Reuse the read-before-write gate lock: each waiter parks on its own
loop without holding an executor thread, and release wakes every waiter
to re-contend, so a cancelled waiter cannot strand the skill. Keep the
gate's waiters in arrival order, so same-turn calls on one skill still
take the lock in the order they reached it, as with asyncio.Lock. Guard
the registry lookup, which the sync path runs on several threads.

Fixes #5846

* fix(skills): hand the skill lock to the oldest waiter

Reusing the read-before-write gate let a later same-skill call overtake
one already waiting. Release unlocked the gate and woke every waiter, so
a call whose storage lookup finished in that same loop step took the
free lock first. asyncio.Lock on main never lets a new caller pass a
queued one.

Give skill_manage its own small lock. Release hands the lock straight to
the oldest waiter and keeps it locked, so a later caller queues behind
it. Each waiter still parks on its own future and wakes on its own loop,
so waiting holds no executor thread. Claiming the future settles a race
with its cancellation, and a waiter cancelled after the handoff passes
the lock on. Releasing an unlocked skill lock raises, as with
asyncio.Lock. read_before_write_middleware.py is back to main.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-18 08:03:13 +08:00
CSXizhang
160fbf40c0
fix(agents): capture standard URL content blocks as artifact handles (#6893) 2026-10-14 22:39:19 +08:00
Xuehao Xu
201b9840cd
feat(knowledge): unify read-only retrieval and source contracts (phase 1) (#6716)
* feat(knowledge): unify read-only retrieval and source contracts

* fix(knowledge): address citation and contract review feedback

* fix(knowledge): resolve citations in delegated task reports

* docs(frontend): keep agent guidance within CI size budget

* docs(backend): preserve merged guidance chain headroom
2026-10-14 14:16:08 +08:00
ZJPex
e4c893f2fb
feat(acceptance): verify required CSV header columns (#6714)
* feat(acceptance): verify required CSV header columns

* fix(acceptance): address CSV header review feedback

* fix(acceptance): guard CSV criteria and malformed headers

* test(acceptance): isolate CSV deadline clock simulations
2026-10-13 19:52:22 +08:00
ZJPex
9a0ea6abf7
fix(sandbox): enforce image byte limits during downloads (#6712)
* fix(sandbox): enforce image byte limits during downloads

* docs: keep image download guidance within inherited budget

* docs(sandbox): specify download overflow error contract

* refactor(sandbox): consolidate download budgets and explain AIO locking

Signed-off-by: ZJPex <3258236335@qq.com>

---------

Signed-off-by: ZJPex <3258236335@qq.com>
2026-10-12 18:49:41 +08:00
AochenShen99
5b2a1352cd
fix(agents): allow SQL updates with retained legacy files (#6747) 2026-10-12 17:49:55 +08:00
Jason Cat
ca81ace2dc
fix(acp): resolve an absolute Windows agent command before spawning (#6656)
* fix(acp): resolve an absolute Windows agent command before spawning

A command configured as an absolute path without an extension -- npm's global
shim layout, e.g. C:\npm\mcode beside mcode.cmd -- was handed to the ACP SDK
unchanged and always failed: create_subprocess_exec applies no PATHEXT, so it
ran npm's extensionless shell script and died with WinError 193 (or raised
FileNotFoundError when only the .cmd exists). The separator guard from #6598
was too broad: only relative paths need to stay with the spawn, because their
meaning is owned by the ACP-workspace cwd, while an absolute path does not
depend on the cwd.

Absolute paths now go through the same shutil.which lookup as bare names: the
lookup searches the command's own directory and probes PATHEXT, and the
configured value is kept when nothing matches so the not-found remediation
still fires. Relative paths (bin/agent, ./bin/agent) still go to the spawn
unchanged.

Docs: README.md and backend/packages/harness/deerflow/tools/AGENTS.md now
describe the bare/absolute vs relative split.

AI disclosure: this change was drafted with AI assistance at my request, under
my direction and review.

* fix(acp): keep already-absolute lookup results unchanged

Review feedback on #6656 (willem-bd): os.path.abspath collapsed a `..` that
follows a symlink, so an absolute POSIX command like
`/srv/current/../shared/agent` (with `current -> releases/v1`) was rewritten to
`/srv/shared/agent` -- a path that either does not exist or names a different
launcher. The anchoring to the Gateway cwd exists only for relative PATH
lookup results, where shutil.which cannot know the cwd the spawn will use; an
already-absolute result needs no rewrite.

Regressions: a platform-independent stub-lookup test pinning the literal
parent-segment path, and a POSIX-only test that builds the symlink layout and
asserts the configured command is returned unchanged.
2026-10-11 18:40:31 +08:00
Hyeonsang Cho
29a958100e
fix(sandbox): stop resolving sandbox and ACP env values a second time (#6673)
* fix(sandbox): stop resolving sandbox and ACP env values a second time

AppConfig.resolve_env_variables already replaces every $VAR when
config.yaml loads. The AIO, E2B and OpenSandbox providers and the ACP
tool then resolved the result again, so a secret that itself starts with
$ (a bcrypt hash, a generated password) was looked up as a variable name
and reached the sandbox or agent as "" -- or as an unrelated host
variable's value when that name existed.

Pass the loaded values through unchanged, as BoxLite and the local
provider already do. The removed helpers' str() coercion was dead: both
fields are typed dict[str, str] and reject non-strings at load.

* docs(changelog): reference #6673

* docs(config): pin when sandbox and ACP env values are resolved

ACPAgentConfig.env and the SandboxConfig docstring said $ values are
resolved from the host environment without saying when, which reads as
"at spawn time" and invites a consumer-side second pass again. Both now
say it happens when config is loaded, matching SandboxConfig.environment.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-11 17:50:04 +08:00
ZJPex
65e8b6591b
fix(tools): bound deferred catalog regex execution (#6676)
* fix(tools): bound deferred catalog regex execution

* fix(tools): clarify deferred regex limit diagnostics
2026-10-11 16:10:26 +08:00
yetuge
8a3350ac8a
fix(tools): publish the default agent's SOUL.md atomically (#6668)
* fix(tools): publish the default agent's SOUL.md atomically

setup_agent persisted the default agent's global SOUL.md with a
truncating write_text. The open truncates the previous file before the
new content lands, so an interrupted setup — process kill, disk full,
an exception while flushing — leaves a truncated persona file, and the
prompt builder reads SOUL.md on every run, so the agent then silently
runs with part of its instructions missing.

The same file is already published atomically everywhere else: the
custom-agent store stages it to a temporary and commits with
os.replace, and #6618 replaced the identical truncating pattern for
the Lark CLI runtime files the same day. The default agent's copy was
the one remaining write of this file that could tear.

Publish through a staged temporary plus os.replace, mirroring the
store's _stage_temp commit, and clean the temporary up when the
publish fails so no partial artifact survives.

Tests: a failed publish (os.replace raising) keeps the previous
SOUL.md byte-for-byte and leaves no temporary behind, a control pins
that the old truncating write loses the content in the same failure,
and the happy path writes the full file.

Fixes #6667

Signed-off-by: yetuge <2219677952@qq.com>

* fix(tools): keep SOUL.md's mode and clean the temporary on staging failure

Review follow-up on #6668, both non-blocking suggestions that guard the
publish itself:

- NamedTemporaryFile creates the temporary with mode 0600, so the first
  publish made the global SOUL.md 0600 (the old write_text produced 0644
  & ~umask) and every re-publish clobbered a mode an operator had set.
  Stat the target and chmod the temporary to that mode (0644 for a first
  publish) before the replace — the order #6618 codified for the Lark
  runtime files and atomic_write_extensions_config already follows.
- A failure inside the staging block (handle.write or the implicit
  flush/close, e.g. ENOSPC) raised before the try started, orphaning the
  delete=False temporary in base_dir. One try/finally now spans staging
  and publishing, so neither phase can leave a temporary behind.

The exact POSIX mode semantics were verified outside the Windows host
(WSL Ubuntu: mkstemp is 0600, first publish is 0644, a re-publish keeps
an operator-set 0640, and both failure phases leave no temporary); the
exact-mode tests are therefore POSIX-gated, with a cross-platform test
pinning that the chmod lands on the temporary before the replace and a
staging-failure test that is red on the previous head.

The shared-helper suggestion is left as a follow-up: folding six
independent publishers into one helper is a cross-module refactor, not
part of this bug fix.

Fixes #6667

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
2026-10-11 14:49:41 +08:00
Value Add
4beb6c4ea4
feat(community): add You.com web search provider (#6515)
* feat(community): add You.com web search provider

Adds You.com as an opt-in community `web_search` engine, mirroring the
existing community engines (tavily, brave, exa, firecrawl, groundroute).

You.com runs its own web index and returns extracted page snippets per
hit rather than a single meta description, so a research loop gets usable
context without a follow-up fetch.

The provider picks its endpoint by whether a key is configured, so it
works out of the box and scales up when the operator adds one:

- no key  -> /v1/agents/search, keyless free tier
- YDC_API_KEY -> /v1/search with X-API-Key, full Search API

Endpoint and headers are always chosen together; the keyless endpoint
rejects an auth header, so a stale key can never be sent there. Both
return the same body shape, so one normalizer covers both.

httpx only, no new dependency and no pyproject extra, so uv.lock is
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(community/youcom): review follow-ups — drop stray .pr-body.md, guard OverflowError, add 429 hint

* fix(doctor): make the You.com free-provider detail true with or without YDC_API_KEY

The doctor detail returned before any key check, so operators who had
set YDC_API_KEY still saw 'set YDC_API_KEY for the full Search API'.
Reworded to match the sibling free-provider entries and stay accurate in
both states.

* fix(search): sweep youcom max_results onto the shared owner

The single-owner gate that landed with #6563 flags this branch's private
_coerce_max_results as a newly hand-rolled copy, so backend-unit-tests
now fails after merging main. Follow the coordination note from review:
delegate to deerflow.community.search_max_results.coerce_max_results,
which owns the rejection profile (booleans, non-integral floats,
OverflowError), and keep only the 1-100 mirror of You.com's server-side
count clamp on top — clamping policy stays per-provider.

The .inf regression test now goes through the public tool path and also
pins the bool and non-integral-float shapes of the shared bar.

* docs(tools): use the bare you.com platform link in config and docs

Match every other provider entry: no other provider in config.example.yaml
or the en/zh tools.mdx uses campaign-tagged URLs, so drop the utm params
from the three you.com/platform links. Integration attribution stays in
the identifying User-Agent sent to You.com hosts.

* test(youcom): cover shared max_results ownership and bounds

Register You.com among the shared-owner providers and assert default handling and the 100-result cap through outbound request parameters.

---------

Co-authored-by: Brian Sparker <brainsparker@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: valueadd-lgtm <339269770+valueadd-lgtm@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-11 11:56:32 +08:00
ZJPex
128dafd0ce
feat(tools): add opt-in literal keyword discovery (#6659) 2026-10-11 11:48:57 +08:00
Storm
588ad010c0
fix(mcp): stop persisting URL-sourced ResourceLink blocks and heal bricked threads (#6606)
* fix(agents): sanitize persisted URL content blocks before model calls

Threads whose checkpoints carry URL-sourced file blocks (written before
the MCP conversion-layer fix) fail every model call at serialization: the
Chat Completions translator rejects any file block carrying a url, and the
error handler answers each later turn with a non-retriable fallback. Add a
read-time sanitizer that rewrites those blocks in the model-bound request
view only, so poisoned threads heal on their next turn with no migration
and no user action.

- ModelContentCompatibilityMiddleware wraps the model call (sync and
  async) and rewrites ToolMessage list content: URL-sourced file blocks
  become "[Resource ({mime or unknown type}) available at {url}]" text
  blocks, and image blocks whose scheme is not http/https/data get the
  same placeholder. Fetchable image URLs, base64 payloads, text, and
  non-ToolMessage messages pass through. The rewrite is order-preserving,
  idempotent, and never writes state or mutates checkpoints, so artifact
  capture and other state readers keep seeing the original blocks.
- Register on all three chains that replay checkpointed ToolMessages:
  build_middlewares (lead; outer of the MODEL_PHYSICAL extension anchor so
  its final-request guarantee holds), the SDK factory
  _assemble_from_features (new step 13a; docstring updated and its stale
  omission of TokenBudgetMiddleware fixed), and
  build_subagent_runtime_middlewares (after DurableContext/summarization,
  before the date/coalescer tail).
- Subagent coverage verified by tracing every subagent agent-creation
  site: executor.py _create_agent is the only caller of
  build_subagent_runtime_middlewares and serves both the task tool and the
  durable batch worker (SubagentRuntime itself only carries dependencies),
  and a wiring test now asserts the middleware is present in that
  builder's output; the lead and factory chains gained the same wiring
  assertions.
- Update the chain-order contract tests for the new slot: full-chain
  order, loop-detection adjacency, custom loop-detection tail positions,
  and the subagent chain count (22 -> 23).

* fix(mcp): stop emitting URL-sourced file blocks from ResourceLink results

MCP ResourceLink conversion produced a URL-sourced file content block for
every non-image link, which LangChain's Chat Completions translator rejects
at serialization time. Once such a block was checkpointed in the
ToolMessage, every subsequent turn of the thread failed at model-call time,
permanently bricking the conversation.

- Downgrade every ResourceLink that is not an http(s) image to a short text
  placeholder carrying the resource name, MIME type, and the
  already-virtualized URL, so host paths never leak and the model still
  learns a resource was produced and where it lives.
- Preserve each downgraded link as structured data in the tool-message
  artifact under resource_links (merged with structured_content when
  present) for artifact capture and future MCP Apps rendering.
- Keep http(s) image links as image blocks; embedded base64 payloads, text
  path rewriting, and the error-to-ToolException path are unchanged.
- Re-contract the file-migration tests to the new behavior and document
  the conversion contract in the MCP AGENTS.md.

* docs(middlewares): add ModelContentCompatibilityMiddleware to the chain inventory

Insert the read-time sanitizer at its actual lead-chain position (after the
token-budget guard, before the custom-middleware injection point) and renumber
the tail entries 34-39 to 35-40.

* fix(agents): rewrite file blocks on url key presence, not truthiness

langchain-core's OpenAI translator raises for ANY file block carrying a
url key — an empty string or None included — so the read-time sanitizer
must not let such blocks through just because the value is falsy. A
persisted block with url="" or url=None would still brick every later
model call in the thread.

- Gate the file-block rewrite on "url" in block instead of a truthiness
  check; a non-empty string URL keeps the
  "[Resource (mime) available at url]" placeholder, while an empty or
  None URL gets a location-less "[Resource (mime)]" placeholder, so no
  unusable location is advertised to the model.
- Pin both shapes with new middleware tests (url="" and url=None file
  blocks are both rewritten to text). The image branch is unchanged: an
  empty-image case fails provider-side with a 400, not a bricked thread.

* fix(mcp): keep data URIs out of model text and resource_links

A data: scheme ResourceLink fell into the generic placeholder branch,
inlining megabytes of base64 into the model-visible text and into
resource_links, which is checkpointed state — against the repo policy
that data:/blob: URIs never enter state.

- Special-case data: links in _convert_call_tool_result: the
  model-visible placeholder becomes "[Resource: name (mime) embedded
  inline]" and the raw URI is not added to resource_links at all, so the
  payload can neither bloat the context window nor persist in the
  checkpoint.
- Pin the behavior with a conversion test asserting both the placeholder
  wording and the absence of the payload/URI from every emitted block and
  the artifact.
- Add the missing passthrough pin for an HTTPS:// uppercase-scheme image
  link: scheme matching is case-insensitive, so the link stays an image
  block (the block's URL validation normalizes the scheme to lowercase).

* fix(tools): consume resource_links in extract_artifacts_from_result

The MCP conversion layer preserves downgraded ResourceLinks as
{"name", "uri", "mime_type"} entries under the tool message
artifact's resource_links key, but the artifact extractor only read
structured_content — a result whose only artifact signal was
resource_links produced no entries and the file silently vanished from
the artifacts panel.

- Add a fourth extraction path: each resource_links entry becomes a file
  artifact entry, reusing _is_referenceable_url like the
  structured-content and content-block paths, so data:/blob: and other
  unresolvable URIs stay rejected per existing policy. The entry is named
  after the link when a name is present, falling back to the URI's final
  segment; a string mime_type is carried through.
- Integration-test a result whose only signal is resource_links, plus the
  referenceability gate (data:, ui://, and absolute-path URIs in one
  artifact).

* docs(mcp): update the ResourceLink conversion contract bullet

Mention the new artifact-registry consumer and the data: special case so
the bullet stays accurate: uppercase http(s) schemes match
case-insensitively, data: URIs get the embedded-inline placeholder and
are dropped from resource_links, and extract_artifacts_from_result turns
resource_links into registry entries through the shared
referenceability gate.

* refactor(tools): add the canonical resource placeholder formatter

Both the MCP conversion layer and the read-time
ModelContentCompatibilityMiddleware downgrade model-incompatible resource
references to a text placeholder, but each built its own literal. Extract
the single formatter they will share so a thread healed at read time shows
the model the same placeholder shape a fresh conversion produces; the name
segment is optional because persisted content blocks carry no resource name.

* fix(mcp): keep host paths out of ResourceLink placeholder text

Review follow-up on the ResourceLink conversion contract. The placeholder
embedded the resolved URL unconditionally, so a link outside the user-data
tree (or converted without thread context) leaked a raw host path — a
file:// URI or bare filesystem path — into model-visible text, violating
US-16. Compute the resolved URL's scheme and withhold the location segment
when it spells out a host filesystem location (empty scheme, file, or a
single-letter Windows drive scheme); virtual paths and remote schemes keep
their location. The unresolved URI still lands in the artifact channel,
which the model never sees.

- add _model_visible_link_location gate and route the generic placeholder
  through it, re-contracting the outside-user-data and no-context tests to
  the location-less shape and pinning the Windows-drive case
- treat blob: like data: as non-referenceable inline input: drop its raw
  URI from checkpointed resource_links and emit the location-less
  placeholder instead of the mismatched 'embedded inline' wording, and fix
  the comment that claimed blob: was already excluded
- replace every inline placeholder f-string in the ResourceLink branch
  with the shared resource_placeholder_text formatter so the conversion
  layer and the read-time middleware emit byte-identical shapes; data:
  links now render in the canonical location-less form
- sync the ResourceLink conversion contract bullet in mcp/AGENTS.md and
  the _convert_call_tool_result docstring with the host-path suppression
  rule and blob: handling

* fix(agents): sanitize URL content blocks on any message role

Address the second-pass review of the request sanitizer with two
changes that share the same code path.

- Adopt the canonical resource_placeholder_text formatter from
  deerflow.tools.resource_placeholder instead of the module-local
  placeholder helpers, so the read-time rewrite and the MCP conversion
  layer emit byte-identical placeholder text from one definition.
- Widen the rewrite from ToolMessage-only to every request message with
  list-form content. User uploads are verified to produce base64 blocks,
  but imported or cross-client history can carry a URL-sourced file
  block on a HumanMessage or AIMessage, and the Chat Completions
  serializer rejects such a block regardless of the role carrying it.
  The rewrite stays request-view-only: state and checkpoints are never
  mutated, and base64/file-id blocks and string content pass through
  untouched on any role.

* docs(agents): state the ModelContentCompatibility placement contract

The comment above the registration claimed the sanitizer sits just
before Clarification. In the lead chain it is actually followed by the
custom/extension injection point and the whole terminal tail
(terminal-response, finish-reason, and the always-last
ClarificationMiddleware). Describe the real contract: after every
built-in content transform, before extension injection and the
terminal tail.

* docs(middlewares): clarify provenance stamps for request-view-only rewrites

The message-provenance rule read as if every rewrite needs a
provenance_kwargs stamp. Add one line noting the exception: rewrites
that only modify the model-bound request view and never enter state
need no stamps, citing InputSanitizationMiddleware,
DanglingToolCallMiddleware, and ModelContentCompatibilityMiddleware as
precedents.

* fix(agents): keep host paths and data URIs out of healed placeholders

The read-time ModelContentCompatibilityMiddleware rewrote URL-sourced
file blocks and non-fetchable image blocks into placeholders that
inlined the block's URL verbatim. Pre-fix checkpoints — exactly the
threads this middleware exists to heal — can carry a file:// URI, a bare
or Windows host path (links that never resolved to virtual paths), or a
data: URI embedding megabytes of base64, so healing leaked deployment
filesystem layout into prompts (US-16) and could inject the whole inline
payload into model-visible text.

The conversion layer already suppresses those forms via
_model_visible_link_location. Move that gate into the shared
deerflow.tools.resource_placeholder module as model_visible_location
(now also collapsing data:/blob: URIs) and route both middleware
branches through it, so both layers apply identical suppression rules
with one definition. Conversion behavior is unchanged: its data:/blob:
branch short-circuits before the gate, and the gate covers those schemes
for defense in depth.

- resource_placeholder.py gains model_visible_location alongside the
  shared formatter; mcp/tools.py drops its private copy and imports it.
- The middleware's file and image branches pass the URL through the gate
  before building the placeholder; module docstring documents the rules.
- Tests: gate unit tests (virtual/remote visible; file://, bare/Windows
  host paths, data:/blob: withheld) and middleware regression tests
  pinning that a pre-fix data: payload, file:// URI, and Windows drive
  path heal to location-less placeholders.
- AGENTS.md bullets in mcp/ and middlewares/ state the shared gate.

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

* fix(agents): downgrade malformed image URLs instead of raising in the sanitizer

The read-time ModelContentCompatibilityMiddleware gated persisted image
blocks on urlparse(url).scheme, but urlparse raises ValueError on
structurally invalid URLs — a bad IPv6 literal, invalid netloc
characters — instead of returning an unusable scheme. Pre-fix
checkpoints and imported history can carry exactly those URLs, and the
block stays in the checkpoint, so every later model call in the thread
raised again: the sanitizer re-bricked the very threads it exists to
heal, only with a framework-side exception instead of the langchain
serialization error.

Catch ValueError and treat the URL as non-fetchable, so the block
degrades into the same text placeholder as any other unresolvable
scheme (the shared model_visible_location gate already swallows
ValueError, so the location segment is withheld as well).

- wrap the image branch's urlparse call in try/except ValueError and
  fall back to an empty scheme, keeping http/https/data pass-through
  behavior unchanged for well-formed URLs
- add a regression test pinning that an image block with a bad IPv6
  literal heals to the location-less placeholder without raising

Co-Authored-By: Codex <noreply@openai.com>

* fix(tools): dedupe artifact refs across extraction sources

One downgraded ResourceLink produced two registry entries: the typed
resource_links channel added an entry for the URI, and the free-text
scan over the conversion layer's placeholder text — which embeds the
same virtual path or remote URL — matched it again. The two handles
pointed at one resource, spent double of the render budget, and showed
the model two handles for a single artifact.

extract_artifacts_from_result now tracks seen real_refs across all
sources and keeps only the first entry per ref. Extraction order makes
the typed sources (structured content, resource_links, content blocks)
win over the free-text scan, so the surviving entry keeps the link's
display name and mime type.

- route all six entry-producing sites through a local _add_unique
  helper keyed on real_ref; per-source behavior is unchanged
- document the cross-source dedup in the function docstring
- add a regression test covering a virtual-path and a remote-URL
  placeholder together with their resource_links entries

Co-Authored-By: Codex <noreply@openai.com>

* docs(mcp): state the intentional data: image downgrade asymmetry

The conversion layer downgrades image/* data: ResourceLinks to
location-less text placeholders, while the read-time middleware lets
persisted data: image blocks pass through to the model. The two
contracts looked contradictory but are deliberately layered: the
conversion layer keeps new inline payloads out of checkpointed state,
and the middleware only preserves blocks already occupying one.

- mcp/AGENTS.md's ResourceLink conversion contract and the conversion
  comment in mcp/tools.py now state the image/* data: downgrade and
  why it differs from the read-time pass-through
- middlewares/AGENTS.md's ModelContentCompatibilityMiddleware entry
  states the symmetric read-time rule
- pin the behavior with an image-mime data: ResourceLink conversion
  test (location-less placeholder, nothing in the artifact channel)

Co-Authored-By: Codex <noreply@openai.com>

* docs(backend): compact AGENTS.md guidance to fit the chain byte budget

The branch's new contract bullets pushed the middlewares and mcp
effective AGENTS.md chains past the 98304-byte AG002 hard limit (the
sandbox/subagents chains on main were already over it). Compact the
guidance without dropping contracts:

- middlewares item 34 points at the MCP ResourceLink conversion
  contract and the module docstring instead of restating both
- the mcp ResourceLink conversion contract bullet is tightened
- backend/AGENTS.md merges the File Upload and Context Summarization
  bullet sprawl, folds the stray ATX test note into the upload outline
  rule, drops the Nginx routing block the root guide already covers,
  merges the duplicate MCP-task pointer bullet, compacts Vision
  Support, and scopes the Project Structure tree to the backend
  subtree (the root guide maps the full repo)

Co-Authored-By: Codex <noreply@openai.com>

* fix(agents): gate the file-URL downgrade on provider support and neutralize placeholder metadata

Two review findings on the read-time sanitizer:

- The file-block downgrade ran for every provider, but only Chat
  Completions rejects URL-sourced file blocks: the OpenAI Responses API
  (input_file file_url) and Anthropic (document url source) accept
  http(s) document URLs, so a persisted block created for those
  providers lost its document from the model request. Keep the block
  when the selected model serializes http(s) file URLs natively; every
  other URL shape (non-http schemes, empty/None, malformed) still
  degrades to the placeholder.
- The placeholder interpolated persisted MIME/location metadata
  verbatim, but the middleware runs after the input/tool-result
  sanitization passes and historical URL blocks never went through them
  (non-text content is left untouched), so values carrying
  <system-reminder>-style tags or user-input boundary markers reached
  the model raw. Route the interpolated metadata through the shared
  neutralize_untrusted_tags guard in both the file and image branches.

Tests: provider gating (Responses/Anthropic preserve, Chat Completions
still downgrades, non-http and malformed URLs downgrade even for
capable providers) and metadata neutralization (injection tags
HTML-escaped, boundary tokens neutralized, file and image branches).

Co-Authored-By: Codex <noreply@openai.com>

* fix(tools): require a directory boundary before exposing virtual locations

model_visible_location admitted any URL starting with the virtual
prefix, so a sibling like /mnt/user-data-backups/ops/private/report.pdf
was treated as a virtual path and its host location reached
model-visible placeholder text — defeating the US-16 host-path
suppression the gate exists for. Require the exact virtual root or a
slash-delimited descendant; siblings now collapse to the location-less
placeholder like any other host path. Both the file and image
downgrade branches share the gate, and the conversion layer applies
the same rules to fresh results.

Regressions: gate unit tests (exact root and descendants stay visible;
/mnt/user-data-backups and /mnt/user-database siblings withheld) and
middleware tests pinning the location-less placeholder for both
branches.

Co-Authored-By: Codex <noreply@openai.com>

* docs(agents): scope the placeholder artifact-visibility claim to the conversion layer

The module docstring claimed the healed placeholder keeps the location
visible to the artifact free-text scan and that the link survives in
the structured artifact channel — true for the conversion layer, whose
placeholders are checkpointed, but not on the healing path: the request
view never reaches artifact capture, and no resource_links entry is
created for a downgraded block. Spell out what actually happens here:
a referenceable original block (http(s) URL or absolute host path) is
still captured, from the capture-time scan of the state block rather
than the placeholder, while a non-referenceable one (file://, data:,
blob:) yields no entry and survives only in the untouched checkpoint.

Co-Authored-By: Codex <noreply@openai.com>

* docs(mcp): clarify healing and artifact preservation contracts

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 21:23:27 +08:00
Jason Cat
f623e64e07
fix(acp): resolve a bare agent command through PATH before spawning (#6598)
* fix(acp): resolve a bare agent command through PATH before spawning

`invoke_acp_agent` handed `acp_agents.<name>.command` straight to the ACP
SDK, which spawns it with `asyncio.create_subprocess_exec`. That API applies
no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`)
raised `FileNotFoundError` although its `.cmd` shim was on `PATH`. The
documented `command: npx` and `command: mcode` shapes could not start, and
the tool reported "was not found on PATH" for an installed launcher.

Resolve the configured command with `shutil.which` before spawning — the way
the MCP Python SDK normalizes stdio server commands — and keep the configured
value when nothing matches, so the existing install guidance still fires.

Fixes #6597

* fix(acp): resolve the ACP agent command off the event loop

`_resolve_agent_command` calls `shutil.which`, which stats the filesystem.
Calling it inline in the async `_invoke` tripped the strict BlockBuster gate
(`BlockingError: Blocking call to os.access`) for every command that resolves,
and would block the Gateway event loop in production.

Offload the lookup with `asyncio.to_thread`, matching the neighbouring
`_get_work_dir`, `_build_acp_mcp_servers`, and `_format_invocation_error`
calls.

The blocking-io anchor configured `command="test-agent"`, a name that resolves
nowhere, so `shutil.which` short-circuited before `os.access` and the gate
never covered the new call. Point it at `sys.executable` so the lookup is
really exercised, and cover the unresolvable-guidance branch end-to-end with a
new test.

Refs #6597

* fix(acp): keep the configured command in ACP remediation text

Review follow-ups on the PATH resolution:

- Only bare names are resolved. A configured path is interpreted by the spawn
  against the per-thread ACP workspace, so resolving it against the Gateway's
  working directory could pick a different file or hand the spawn a path that
  then fails.
- `_format_invocation_error` receives `agent_config.command` again. A command
  can resolve on PATH and still fail to launch (a broken shebang, for example),
  and reporting the resolved path made "was not found on PATH" false and
  dropped the `codex-acp` hint.
- The blocking-io anchor uses a bare name backed by a shim on a controlled PATH
  instead of `sys.executable`, which the bare-name scoping no longer resolves.

Tests: the pass-through assertion in
`test_invoke_acp_agent_uses_fixed_acp_workspace` pins PATH, and new coverage
covers the bare-name scoping and the spawn-failure message.

Refs #6597

* docs(acp): correct the path-resolution rationale

A configured path is resolved by the spawn in its own process, so the
pass-through reason must not claim it is resolved against the ACP workspace:
that only holds on POSIX, while Windows resolves a relative executable against
the calling process's directory. State the platform-neutral reason instead.

Refs #6597

* fix(acp): resolve the launcher against the agent's own PATH

The ACP SDK merges acp_agents.<name>.env over its inherited environment at spawn time, so a configured PATH, not the Gateway's, decides which launcher the agent would find. Resolve a bare command against that effective PATH, and use the Gateway's only when the agent does not override it.

Return a successful lookup as an absolute path: a relative PATH entry makes shutil.which yield a result relative to the Gateway's cwd, while the spawn runs with cwd set to the ACP workspace, where that path resolves elsewhere or not at all.

Regressions: a real-subprocess test with same-named launchers in the Gateway and agent PATHs (asserting which one ran), and a real-subprocess test whose PATH holds a relative directory while the ACP workspace is elsewhere. Both are red on the pre-fix code.
2026-10-10 19:23:39 +08:00
yangfucheng123
3b8141aa43
fix(community): Fail closed on delegated fetch backends to close SSRF gap (#6531)
* Fail closed on delegated fetch backends to close SSRF gap

* fix(community): resolve delegated-fetch credentials from one config snapshot

Address review feedback on the fail-closed SSRF guard:

- firecrawl/fastcrw client construction no longer re-reads get_app_config(),
  so the API key and endpoint come from the same revision the endpoint screen
  validated (prevents cross-revision credential misrouting).
- fix the browserless test double to accept the new two-argument
  _get_browserless_client signature.
- document the check-only (DNS rebinding) caveat on
  validate_delegated_backend_url.
- add a CHANGELOG upgrade note for the default self-hosted backend break.

* fix(doctor): consolidate delegated fetch backend checks and deployment guidance

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 18:45:58 +08:00
kuku
2d55c75870
fix(agents): preserve complete remote artifact URLs (#6574)
* fix(agents): preserve complete remote artifact URLs

* fix(agents): handle CJK punctuation and mixed-case artifact suffixes

* fix(agents): preserve balanced CJK artifact references

---------

Co-authored-by: kuku <186267668+xbzz1018@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 10:07:50 +08:00
Hyeonsang Cho
16877b92fc
fix(skills): confine review_skill_package local targets to the caller (#6580)
* fix(skills): confine review_skill_package local targets to the caller

Local path targets were allowed anywhere under the Gateway cwd or /tmp.
Every documented deployment keeps DEER_FLOW_HOME under the cwd, so a user
who knew another user's id could pass
.deer-flow/users/<id>/skills/custom/<skill> and get that skill's SKILL.md
and references/ back in the model response. The tool is always available,
so skill tool policy could not remove it.

Local targets are now confined to the configured skills root and the
caller's own user directory (resolved from the runtime user id through
make_safe_user_id, like the user-scoped skill storage). skill:// and
inline:// targets are unchanged.

* test(skills): pin dot-dot and sandbox-planted symlink review targets

* docs(changelog): reference #6580

* fix(skills): ignore a review skills root that contains per-user directories

A skills root configured at or above DEER_FLOW_HOME would re-admit every
user's directory as a local review target. Drop such a root and log a
warning instead of trusting it silently.

* test(skills): use the platform temp dir for the shared-temp review case

A hardcoded /tmp does not exist on Windows dev hosts.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 08:58:26 +08:00
ZJPex
4cb8e3096b
feat(mcp): filter background tasks by status (#6527)
* feat(mcp): filter background tasks by status

* feat(mcp): expose task list filters in Gateway

* docs(mcp): describe background task query filters

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 08:53:51 +08:00
yang rui
286bb5fe1c
fix(tools): ignore unusable output budget limits (#6516)
* fix(tools): ignore non-finite output budget limits

* fix(tools): skip overflowing budget estimates
2026-10-09 07:10:54 +08:00
Hyeonsang Cho
fb0ed9c960
fix(browserless): send the web_fetch token as a query parameter (#6484)
* fix(browserless): send the web_fetch token as a query parameter

fetch_html_with_status put the configured token in the /content JSON body.
Browserless reads the token only from the token query parameter or the
Authorization header, and checks it before reading the body, so with
BROWSERLESS_TOKEN set every web_fetch failed with HTTP 401; against an
instance started without TOKEN the unknown body key failed schema
validation with HTTP 400. capture_screenshot already sent ?token=, so
web_capture worked with the same token.

Both endpoints now take the token from BrowserlessClient._auth_params().
The test that pinned the body token is rewritten, and a stand-in for the
Browserless request gate (query/header auth, no extra body keys) drives
both endpoints through the real httpx request path.

* docs(changelog): reference #6484

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-08 21:14:26 +08:00
Guo-Yixin
8011677d70
feat(subagents): add bounded same-thread batch result inspection (#6416)
* feat(subagents): add bounded same-thread batch result reader

* test(subagents): validate batch result reader on PostgreSQL

* docs(subagents): explain batch reader budget and position contracts

* fix(tests): adapt batch reader PostgreSQL CI connection options

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 18:43:31 +08:00
Guo-Yixin
3182ecb1e4
feat(subagents): preserve RAG evidence in durable batch exports (#6413)
* feat(subagents): preserve RAG evidence in durable batch exports

* docs(subagents): clarify evidence omissions and migration head

* docs(persistence): update the migration chain head

* test(persistence): track the new batch evidence migration head

* test(persistence): verify batch evidence migration on PostgreSQL

* docs(subagents): retain evidence migration compatibility guidance

* fix(subagents): propagate cited evidence forwarding omissions

* fix(tests): adapt batch evidence PostgreSQL CI connection options

* fix(tests): preserve TLS policy in batch evidence migration fixture
2026-10-07 18:37:31 +08:00
Totoro
2dd2e5415c
feat(scheduled-tasks): finish the user-facing side of conversation schedules (RFC #6340, 1/2) (#6378)
* feat(scheduler): lifecycle rules, goal/limit parity and coded errors for scheduled tasks

- Migration 0031: nullable scheduled_tasks.unmet_streak_after_seq (internal
  streak boundary) and stop_condition (the user's stop rule, own column).
- Stop rule is normalized (stop_rule.py) and appended only at launch; the
  stored prompt never contains it. Wording adapts when scheduler.tool_enabled
  is off.
- Willem 1: page-created tasks get goal evaluation, <standing_notes>, the
  previous-run reference and own-schedule stop; request_stop and the
  scheduled capability no longer require an origin conversation.
- Repository: single reactivation choke point raising
  ScheduledTaskLimitsExhausted; streak boundary moves on goal/prompt/stop
  condition/note changes (not on resume); goal-check failures neither count
  nor reset the streak; pause of a finished task returns "finished"; trials
  on a paused task keep the host pause marker.
- New repository reads: automatic_runs_used_for, active_run_status_for,
  list_manageable_from_thread, thread_relation/thread_run on thread tasks,
  task_ids_for_thread, run_number, and run_number/total_tokens/summary on
  run history rows.
- REST: goal/max_runs/end_at/stop_condition on create and PATCH (null
  clears, naive end_at = task timezone), resume recomputes from now with an
  optional renewal body, once_time_passed, task_finished, limits_exhausted,
  scheduler_not_running on create, trigger returns outcome/existing/thread_id,
  task responses add automatic_runs_used and active_run_status.
- Coded errors {code, message, params} for routes, shared validation and the
  chat capability (scheduled_task_errors.py), pinned by
  contracts/scheduled_task_errors_contract.json; goal notes contract v2 with
  check_failure_codes, scheduled_origin_key and host_run_errors (host_notes.py).
- /api/features adds scheduled_tasks {available, running, tool_enabled,
  min_interval_seconds}; ScheduledTaskService.is_running.
- Docs: six README scheduler sections, API.md scheduled tasks section
  (breaking error shape), CONFIGURATION.md, AGENTS notes, CHANGELOGs.
- Tests: migration 0031, stop rule, error contract, run listing, lifecycle
  over HTTP, repository, service and capability cases; updated tests for the
  new error shape, finished-task pause and head revision.

* feat(scheduler): conversation update/resume, run-conversation authority and readable launches

- schedule_task: update and resume actions, stop_condition and clear_fields
  parameters, plain parameter types (value rules come back as coded results),
  rewritten docstrings for schedule_task and stop_scheduled_task.
- Capability: update/resume share the REST validation and error codes;
  interactive turns in a task's run conversation may manage that task;
  scheduled runs keep only own-stop; grants require a running scheduler.
- Results: compact JSON-safe task views with local times, display card/text,
  last run in list, timezone_source/browser_timezone, now_local; no ISO, user,
  lease or origin fields; stop_instructions removed.
- Timezone: explicit > browser (context.client_timezone, validated in
  start_run and never forwarded to the run config) > not needed for intervals
  and offset run_at > timezone_required.
- Trial gate accepts natural direct requests after acknowledgements and
  particles; trial results report thread_id and existing.
- Scheduled launches: stable message id scheduled-<task_run_id>, server-owned
  deerflow_scheduled_origin metadata with the user-language parts, run thread
  title for fresh-thread tasks.
- Worker records goal continuations in the verdict; lead prompt language rule
  covers stored task text and scheduled runs.
- Docs: six READMEs, CONFIGURATION, API, runtime/tools AGENTS, CHANGELOGs.

* feat(frontend): scheduled-task core types, formatting, errors and glossary

- throwGatewayApiError now throws GatewayApiError (status, code, params,
  rawMessage); message stays the string detail or detail.message
- scheduled-task types gain stop_condition, automatic_runs_used,
  active_run_status, origin/notes, run_number/total_tokens/summary, thread
  relation, trigger result, renewal and scheduled origin; isTaskBusy and
  hasActiveRun helpers
- api: fetchScheduledTask, resume with optional renewal body, typed trigger
  result and update payload with null clears
- hooks: polling at 3 s while a run is active (list, thread list, run
  history), useScheduledTask with live gating and final 404, useInView,
  localized error toasts; run history refreshes tasks when a run finishes
- new pure modules: errors.ts (contract code to localized copy), format.ts
  (relative task times, viewer time, display zone), status.ts, actions.ts,
  run-error.ts; describeTaskSchedule in cron.ts; goal-outcome adds unchecked
  goal checks, describeTaskOutcome and parseStopRunId
- features: scheduled_tasks block with defaults when missing
- buildRunContext sends client_timezone
- titleOfThread takes a localized untitled label; breadcrumb names scheduled
  tasks and agents; reference chips use the localized untitled label
- schedule input: shared browserTimeZone, labelled selects, aria-pressed
  type toggles, interval minimum from the hint template
- i18n: all PR1 scheduled-task keys in en-US and zh-CN, glossary fixes,
  recipe instructions moved into i18n
- e2e mocks follow the new task, trigger, resume, thread-task and features
  shapes; expectNoRawIdentifiers helpers for e2e and DOM tests
- tests for errors, format, cron, status, actions, goal outcome, run errors,
  hooks, run context, thread titles and features; scheduled e2e copy updated

* feat(frontend): list-first scheduled tasks page with outcomes and editable goals

- page.tsx is now a thin composition of components/workspace/scheduled-tasks:
  status tabs (All/Active/Paused/Finished) and search over the task list,
  ?task_id= deep link with router.replace on selection, ?thread_id= chip
  with Show all, empty state, scheduler-off notice and unavailable state
- task list rows show the schedule in words and one status line (next run,
  running now, paused by agent, auto-paused, finished by its cap)
- task detail: status badge, actions from availableActions with reasons,
  More actions menu (Duplicate, Copy task ID, Delete), outcome notice,
  Runs / Stops when / Each run's goal / Does / Notes from chat / History,
  origin chat link; success toasts for every action
- run rows: run number or Trial run, summary, goal and assumption badges,
  continuations, tokens, Open chat link; raw errors and the evaluator
  reason only behind Details
- create/edit/duplicate dialog with goal, stop condition, safety caps and
  advanced agent/context options; PATCH sends only changed fields, null
  clears a cap
- renew dialog raises or removes caps and resumes in one request; a
  limits_exhausted Resume opens it
- removed i18n keys the old page used; added feedback.resumedNoTime
- recorded en-3 and zh-1 page fixtures; scheduled e2e specs rewritten for
  the new page; DOM tests for the detail and the form dialog
- CHANGELOG entries and the frontend AGENTS.md scheduled-task note

* feat(frontend): live schedule card, run conversation provenance and header entry

- schedule_task results (create, update, pause, resume, trial, delete) become
  an assistant:scheduled-task message group rendered as a live card: schedule,
  next run, stop condition with the safety cap, goal, where results go, Run
  once now, Pause/Resume and Open task; the card polls the task only while it
  is on screen and shows deleted, scheduler-off and limits-exhausted states
- one card per task per assistant turn; the card group is not a turn boundary
  for usage, branching, editing, outline or skill attribution
- a human message with deerflow_scheduled_origin stays a human group with
  scheduledOrigin and renders as a "Scheduled run / Trial run" block with the
  task instructions, stop condition and notes collapsed; the launched text is
  never shown and the message is not editable
- readable step labels for schedule_task actions and stop_scheduled_task
- ThreadScheduledTasksButton replaces the header link: hidden without tasks,
  shows the count, links to the task (or the chat filter), and a run's chat
  links to its task
- recorded en-3 / zh-1 chat and run-thread fixtures; chat-card and run-thread
  e2e specs; unit and DOM tests for parsing, grouping, turn boundaries, card,
  run block, header button, step labels and reconnect hydration
- i18n: header.countLabelOne added, unused card.next removed; CHANGELOG and
  frontend AGENTS.md notes

* fix(scheduler): address verification findings

Move the scheduled-task lifecycle guidance from backend/AGENTS.md into the persistence guide and leave a one-line pointer, so the effective AGENTS.md chains for agents/middlewares, sandbox and subagents stay under the 98304-byte hard limit enforced by scripts/check_agent_guidance.py in lint-check.

* fix(scheduler): address review findings

* fix(scheduled-tasks): polish from live acceptance

- zh schedule wording: an interval of one unit reads 每分钟 / 每小时 / 每秒,
  and a saved interval of whole days reads 每天 / 每 2 天 (Every day / Every 2
  days) instead of 每 1 分钟 or 每 24 小时; several units keep the number
- zh spacing: 已于{time} 满足 and 在{time} 的运行中 now leave a space between
  the interpolated time and the Chinese after it; a copy test guards every
  scheduled-task zh string against a placeholder glued to Chinese
- run summary: when the first line of the final reply only leads in to a list
  (ends with ":" or ":"), the list items follow it as plain text (bullets,
  numbering, checkboxes, bold, code and links stripped), joined with ";" for
  CJK text and "; " otherwise, within the 160-character cap
- a schedule_task result (create, update, pause, resume, trial, delete) that
  arrives in a chat invalidates that chat's task list, the tasks list and the
  named task, so the header button and cards update without waiting for the
  idle poll; results already in the loaded history do not refetch
- e2e and unit fixtures are now the live acceptance recordings (DeepSeek
  Flash, demo data, fictional names): live-weekday.json (create, trial, edit,
  pause, resume in one zh chat) and live-minute.json (per-minute chat, run 1
  and run 2, paused by the agent), fetched with GET from the demo Gateway and
  trimmed; the hand-shaped en-3 / zh-1 fixtures are removed and the chat-card,
  run-thread and tasks-page specs run in both locales over the recorded data

* docs(changelog): reference #6378 for the scheduled-tasks entries

* docs(agents): shorten the scheduled-task pointer to stay under the guidance limit

* test(scheduled-tasks): pin the live takes to their recorded time zone

The live fixtures were recorded in Asia/Shanghai, and an interval task
reads in the viewer's zone, so the 16:48 assertions failed on UTC CI
runners. Pin the browser zone in the two live-take e2e specs and the
zh live-take unit test.

* fix(scheduled-tasks): keep untouched end times and drop a chat filter the new task is not in

- The edit, duplicate and renew dialogs keep a stored end time's instant
  while its field still shows it. A minute-precision wall value cannot
  keep seconds or name the later 01:30 of a DST fall-back, so a
  title-only edit moved the cap an hour earlier, and raising the run
  limit re-sent the end time without its seconds.
- Creating or duplicating a task from a chat's task view drops the chat
  filter when the new task does not belong to that chat, instead of
  showing "This task isn't one of this chat's tasks".

* fix(scheduled-tasks): tie pause notices to the pause they describe

- Paused by agent links and times only the run named in last_error. When
  that run is not loaded, the notice shows no link and the task's own time
  instead of an earlier run that also asked to stop.
- The outcome notice reads the latest history page whichever page is
  shown, so browsing older runs no longer swaps the auto-pause reason and
  "Open latest run" to an earlier miss. It shares that page's request.

* fix(scheduled-tasks): three lifecycle gaps found while re-checking review classes

- The edit form compares a cron in the form its schedule input emits, so an
  untouched "0 09 * * 0,6" is not re-sent as "0 9 * * 6,0" and a title-only
  edit no longer re-arms or refuses a finished task.
- Chat resume asks for a timezone, as update does, when a naive end_at would
  land in the UTC placeholder of a zone-free task.
- Re-arming a task clears a host pause marker, as resume does, so a later
  manual pause no longer reads as "Paused by agent".

* fix(scheduled-tasks): keep the notice's latest-runs query from refreshing older pages

useLatestScheduledTaskRuns kept React Query's default refetch on mount,
focus and reconnect, so going offline and back while an older history page
was shown fetched the latest page again. Refreshing stays with the history,
which refreshes the latest page only while it is shown.

* test(scheduled-tasks): let the viewer's time carry its own day label

Between 16:00 and 24:00 UTC, UTC and Asia/Shanghai are on different days,
so the run prompt shows "Tomorrow 01:00 · Today 09:00 your time" and the
fixed "01:00 · 09:00 your time" assertion failed depending on the hour.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 07:47:32 +08:00
Hyeonsang Cho
b9ee8af328
fix(skills): keep custom-skill edit and storage lookups off the event loop (#6332)
* fix(skills): keep custom-skill edit checks off the event loop

update_custom_skill built the user-scoped storage, ran the editability
probes, validated the frontmatter through a temporary directory and read
the content being replaced on the Gateway loop; only the write and history
append had been offloaded. Run the pre-scan checks in one worker and the
replaced-content read in another, matching rollback_custom_skill, and build
delete_custom_skill's storage off the loop as well.

* fix(skills): build skill storage off the loop in install and skill_manage

The same sweep that moved the custom-skill edit checks off the loop finds
two more storage lookups on it: the archive-install helper behind both
install routes, and the agent's skill_manage tool, whose lookup resolves
and stats config.yaml on every call. Offload both, and pin that the
caller's user context still reaches the storage lookup now that the edit
route resolves it inside a worker.

* docs(changelog): reference #6332 for the skill storage off-loop fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-06 07:20:04 +08:00
NanPan
78150e9b30
fix(subagents): keep isolated-loop shutdown ownership retryable (#6316)
* fix(subagents): retain isolated-loop shutdown ownership

* fix(subagents): reap stopped isolated loop before replacement

* test(subagents): cover pending-loop recovery after worker exit

* docs(subagents): document pending-loop recovery

* fix(subagents): revalidate pending loop recovery
2026-10-05 21:55:01 +08:00
PeaceMaker-best
fcee85b822
feat(storage): persist viewed images as shared blobs (#6160)
Store validated image bytes in the configured content-addressed blob backend and resolve checkpointed refs before local compatibility paths.

Refs: #4189

Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
2026-10-05 19:06:16 +08:00
quifox
420b39ed90
fix(acp): apply invocation timeout to initialization and session creation (#6292)
* fix(acp): apply invocation timeout to initialization and session creation

* fix(acp): preserve SDK timeout errors before the invocation deadline
2026-10-05 14:39:59 +08:00
Totoro
35023bb908
feat(scheduler): create conversation schedules and support agent self-stop (#6229)
* feat(scheduler): create conversation schedules and support agent self-stop

* fix(scheduler): validate goals and trial requests before dispatch

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-04 06:46:13 +08:00
Hyeonsang Cho
bb4f7ac5e2
fix(browser): pin browser connections to the SSRF-vetted addresses (#6201)
* fix(browser): pin browser connections to the SSRF-vetted addresses

The navigate screen and the per-request guard resolve a hostname to vet
it, but Chromium resolved the name again to open the connection. A
rebinding DNS server could answer both checks with a public address and
the connection with a private or cloud-metadata one, and the browser
followed it.

Launch each private browser through a per-session loopback SOCKS5 proxy.
Over SOCKS5 Chromium sends the hostname itself, so the proxy resolves it
exactly once through the session's egress resolver and connects only to
the addresses that resolver vetted. resolve_browser_egress applies the
same allow_private_addresses policy as validate_browser_url, on top of a
new url_safety.resolve_public_addresses helper that
validate_public_http_url now shares. Loopback is proxied explicitly with
bypass "<-loopback>". The tools, navigate_and_capture and the Live
WebSocket all pass the resolver, because a thread's first caller fixes
its launch options.

CDP-attached Chrome is unchanged. Delegated fetch services (crawl4ai,
Browserless, fastcrw) still resolve on their own side, which the Gateway
cannot pin.

* docs(changelog): reference #6201 in the browser egress pinning entry

* fix(browser): bound egress proxy faults and close, and record the WebRTC gap

Review follow-ups for the pinned browser egress proxy:

- A resolver exception other than ValueError escaped the connection
  handler as an unretrieved task error. Log it with its traceback and
  reply SOCKS5 general failure (0x01), so a resolver fault stays
  distinguishable from a policy refusal (0x02).
- Bound the resolve with its own timeout. The handshake timeout did not
  cover it, so a wedged getaddrinfo held the half-open tunnel until
  Chromium gave up. A timeout replies host unreachable.
- Bound close(): Server.wait_closed() also waits for a connection whose
  handler registered after the writer sweep, which could stall session
  close and eviction for the 15 s handshake. A handler that starts after
  close now drops its connection without opening a tunnel.
- Docs said a launched browser connects "solely" through the proxy.
  WebRTC UDP does not traverse a SOCKS proxy, so AGENTS.md, README (en/zh)
  and both changelogs now scope the guarantee to TCP and name the gap.
2026-10-03 10:05:47 +08:00
Hyeonsang Cho
457aa3a492
fix(community): resolve SSRF-guarded hostnames off the event loop (#6140)
* test(blocking-io): catch socket.getaddrinfo on the event loop

Blockbuster wraps socket.socket methods but not the module-level
resolver, so a synchronous hostname lookup on the loop passes the strict
gate. The SSRF guard in deerflow.community.url_safety resolves with
socket.getaddrinfo from async web tools, and the anchors added next stay
green against that unfixed code without this rule.

Add socket.getaddrinfo to _PROJECT_BLOCKING_RULES, scoped to app.* and
deerflow.* callers. loop.getaddrinfo and asyncio.to_thread resolve in a
worker thread and are not flagged. The full make test-blocking-io suite
stays green with the rule active (187 passed).

* fix(community): resolve SSRF-guarded hostnames off the event loop

validate_public_http_url resolves hostnames with blocking
socket.getaddrinfo. The crawl4ai and Browserless web_fetch, web_capture,
browser_navigate, the Gateway browser navigate route, and the Live
stream's navigate input and seed all called it directly from async code,
so a slow DNS answer for a model- or user-chosen URL stalled the Gateway
event loop for the whole lookup.

Run the guard through asyncio.to_thread at each of those call sites, the
same way personal_network already offloads it. The screening result is
unchanged. fastcrw's web_fetch is a sync tool and personal MCP validation
already runs in a worker, so neither needed a change.

* docs(changelog): reference #6140 in the SSRF guard event-loop entry

* fix(browser): run the Playwright request guard off the shared browser loop

BrowserSession._install_request_guard screens every redirect hop, iframe
and subresource by calling the SSRF guard inside the async route
callback. The guard resolves hostnames with blocking socket.getaddrinfo,
and that callback runs on the process-wide Playwright loop, which also
pumps Live frames and input for every browser session, so one slow
lookup stalled all of them.

Run the guard through asyncio.to_thread there as well, and pin it with a
strict-gate anchor that drives the real route handler. Also note in the
url_safety docstring that the screen is check-time only: a caller that
re-resolves at connect time is not pinned to the vetted IPs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-02 22:33:48 +08:00
lilei
f840e843d3
fix: track converted upload ownership for outlines and listings (#6101)
* fix: track ownership of converted upload markdown

* fix: invalidate overwritten companions and restore branch ownership

* docs: explain legacy upload companion behavior on upgrade

* docs: shorten upload companion guidance to fit instruction budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-01 20:36:35 +08:00
Wenchao An
52a3e2564f
fix(mcp): isolate personal connections from deployment configuration (#5966)
* fix(mcp): isolate personal connections from deployment configuration

* fix(mcp): preserve personal task ownership and repair plugin E2E fixtures

* fix(mcp): handle corrupt personal config and cache owned calls

* fix(mcp): pin personal endpoint addresses and handle invalid listings

* fix(mcp): recheck current authority for privileged personal connections
2026-09-28 16:02:34 +08:00
ZJPex
1129ee4e8a
feat: add bounded JSON syntax acceptance criteria (#5947)
* feat: add bounded JSON syntax acceptance criteria

* fix(subagents): authorize bounded local JSON acceptance reads

* docs: keep JSON acceptance guidance within chain limit

* docs: use English for JSON acceptance comments and guidance

* fix(subagents): keep unauthorized JSON probes unverified
2026-09-28 15:13:34 +08:00
lekt8
f889ce7517
feat(community): add Unbrowse web fetch provider (#5981)
* feat(community): add Unbrowse web fetch provider

Add a community web_fetch provider backed by Unbrowse
(https://unbrowse.ai), a hosted service that returns a page as markdown,
over plain HTTP when that is enough and through a cloud browser when the
page needs JavaScript. The tool sends one JSON-RPC tools/call POST
(unbrowse.scrape) to Unbrowse's MCP endpoint, so it needs no extra Python
package and no MCP client (uses httpx, already a dependency).

Unbrowse is not a general web search engine, so only web_fetch is added.

Changes:
- backend/packages/harness/deerflow/community/unbrowse/{__init__,tools}.py
  web_fetch_tool with the same output shape as the Sofya, Exa and
  Firecrawl fetch tools (# <title> plus markdown, 4KB cap). API key is read
  from the config.yaml api_key field or the UNBROWSE_API_KEY env var.
  Optional render: auto|never|always; unknown values log a warning and fall
  back to auto. JSON-RPC errors, isError tool results, HTTP errors and
  malformed payloads come back as "Error: ..." strings.
- backend/tests/test_unbrowse_tools.py: key resolution, render handling,
  request shape, structuredContent vs text payloads, error paths.
- scripts/doctor.py, scripts/wizard/providers.py: register the key and the
  web_fetch provider; backend/tests/test_doctor.py covers both doctor paths.
- config.example.yaml, .env.example, backend/docs/CONFIGURATION.md,
  tools/AGENTS.md, EN/ZH harness/tools.mdx: document the provider.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(changelog): add Unbrowse web_fetch provider entry

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-28 15:08:49 +08:00
李靖
10bd065615
feat(agents): persist tool artifacts as resolvable handles (#4676) (#4929)
* feat(agents): persist tool artifacts as resolvable handles (#4676)

MCP and sandbox tools return file paths, URLs, and task ids that are
lost when context compaction summarizes the conversation. This adds a
per-thread artifact handle registry so the model can reference
artifacts by short deterministic handles (art_xxxxxxxx) that survive
compaction and resolve to real references at the tool-call boundary.

- ThreadState.tool_artifacts: reducer channel capped at 100 entries,
  same-handle latest-wins, survives summarization compaction
- artifact_registry: deterministic handle derivation from
  (thread_id, tool_call_id, call_index); extraction precedence
  structured_content -> file/image blocks -> conservative text scan;
  HTML-escaped model-context rendering with char budget
- ArtifactCaptureMiddleware: before_model hook captures lightweight
  metadata (never content) and tracks handle consumption
- ArtifactResolutionMiddleware: rewrites handles in tool-call args to
  real references before execution; unknown handles untouched
- DurableContextMiddleware: projects the registry into the hidden
  durable-context data block ([available]/[consumed] status)
- config: new tool_artifacts section (all switches default on)
- frontend: artifact badges on tool-call steps keyed by tool_call_id

Fixes #4676

* fix(agents): harden artifact registry after review (#4676)

Review findings on #4929:

- capture/consumption updates emitted in the same before_model call are
  now concatenated instead of dict-merged; dict-merging clobbered the
  shared tool_artifacts key and permanently dropped fresh captures
- handles embed a per-result ordinal so multiple references inside one
  tool result never collide (two paths in one text block previously
  collapsed into one handle)
- structured_content extraction pulls concrete refs from known keys
  (file/path/url/task_id/..., string or list-of-string values) instead
  of storing a truncated JSON preview, falls back to an untruncated
  whole-object data entry, and no longer skips file/image content blocks
- plain-string tool results (e.g. bash/write_file output) are scanned
  for sandbox paths / remote URLs too, gated by detect_refs_in_text
- wire max_entries through configure_tool_artifact_max_entries at
  assembly (clamped 10..1000) and gate detect_refs_in_text scans
- resolution requires both enabled and resolve_handles_in_args,
  matching its docs
- add real ToolCallRequest.override coverage and the spec 14.4
  integration cycle (capture -> inject -> resolve -> consumption)

* fix(agents): address second-round artifact registry review (#4676)

Follow-up findings on #4929:

- drop generic `output`/`outputs` from structured ref keys: prose under
  those keys no longer becomes a bogus file entry (falls back to the
  whole-object data entry; path-shaped refs are still caught by the
  free-text scan)
- strip closing quotes, brackets, and backticks from detected refs so
  markdown- and JSON-wrapped paths resolve to clean real references
- remove the process-wide cap setter: the reducer enforces only an
  absolute ceiling (1000) and ArtifactCaptureMiddleware applies the
  configured max_entries per agent when emitting updates, so agents
  with different configs in one process never share or flap a cap
- skip extraction for tool messages whose tool_call_id already has
  registry entries and memoize known-empty results (bounded FIFO), so
  steady-state capture cost drops to the new message tail instead of a
  full-history rescan per model call
- correct the inverted ordering claim: capture is position-independent
  in the wrap chain because it is a before_model hook reading state
  messages; fixed the assembly comment, AGENTS.md item 15, and the
  ordering test docstring

CI parity verified locally: ruff check + format --check, full offline
suite, blocking-io suite, collect-only, uv lock --check,
check_agent_guidance, frontend format/lint/typecheck/build/tests.

* fix(agents): sliding-window cap and URI gates for artifact registry (#4676)

Third-round review findings on #4929:

- max_entries is now a sliding retention window: fresh captures are
  always registered, and when the projection exceeds the configured
  cap the middleware appends a trailing {"op": "trim_to", "keep": N}
  directive that the reducer applies by evicting the oldest entries.
  The previous remaining-budget emission froze the registry at the cap,
  permanently dropping post-compaction artifacts — the exact failure
  mode #4676 targets — and silently disabled the round-2 rescan
  optimizations at the point threads grew longest
- _track_consumption memoizes settled tool-call args in a bounded FIFO
  alongside the existing empty-result memo, so historical AIMessage
  args are not regex-rescanned every model call
- file/image content-block URLs must be http(s) or path-shaped;
  data:/blob: URIs (MCP embedded resources carrying base64 payloads)
  can no longer enter thread state, tool args, or crowd out the render
  budget; protocol-relative //host forms are rejected as well
- structured-content ref values go through the same URI-shape gate as
  content blocks, closing the embedded-payload hole via known keys such
  as url/urls
- tool_artifacts.enabled now gates the whole middleware including
  consumption tracking via an early return in before_model

Docs updated to describe the sliding window, URI gates, enabled scope,
and both bounded memos.

* fix(agents): final evictions and pending-aware consumption for artifact registry (#4676)

Fourth-round review findings on #4929:

- the sliding window interacted with the seen_call_ids skip to
  resurrect evicted entries every round: once an entry was trimmed, its
  tool_call_id left the seen set while its ToolMessage stayed in
  context, so the next model call re-extracted it, re-registered it as
  newest, and evicted something else — a never-converging rotation.
  The empty-result memo is now a unified _processed_results memo that
  records every extraction regardless of outcome, making evictions
  final; steady state over a saturated thread is quiescent (pinned by
  a multi-round test asserting no updates and zero extractions)
- the consumption scan built its handle map from pre-capture state, so
  a call referencing a handle captured in the same before_model call
  missed, got settled as quiet, and its consumption mark was lost
  forever. _track_consumption now receives the pending capture entries
  and resolves against state + pending; a call settles as quiet only
  after every referenced handle resolved, so transient misses retry
  next round instead of permanently suppressing the mark

AGENTS.md entry 15 updated to describe both memos and the
eviction-is-final semantics.

* fix(artifacts): address occurrence identity, PII and URL review findings

* fix(artifacts): enforce resolved policy checks and bounded registry handling

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-27 11:09:41 +08:00