* fix(persistence): normalize non-finite floats before every JSON column write
The shared ORM engine serialized JSON columns with the lenient json.dumps
default (allow_nan=True), and the sync agent-storage engine set no
json_serializer at all. A non-finite float reaching a JSON column — run
metadata merges caller-supplied values, goal_verdict and
token_usage_by_model carry model/provider numbers, and json.loads('1e400')
yields inf — was emitted as the bare NaN/Infinity token, which PostgreSQL
jsonb rejects as invalid JSON, failing the whole write with DataError.
SQLite silently accepts and round-trips the invalid token, so the failure
was postgres-only and environment-dependent.
Adds dumps_json_safe/json_safe_value to persistence/json_compat.py (same
None-normalization the canonical display serializer applies since #7670,
which explicitly does not cover persisted state) and wires it into both
engines' json_serializer. Regressions pin: nested non-finite floats
normalize everywhere, finite values/bools pass through unchanged, the
async engine kwargs and the sync agent engine (postgres only; SQLite takes
no json_serializer) both route through it.
* fix(persistence): normalize JSON across synchronous engines
* docs: keep frontend guidance within the CI size budget
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(runtime): add Queue and Steer for active Web runs
* style(frontend): format Queue and Steer controls
* fix(runtime): retain user Steer controls and order active inputs
* docs: add Chinese changelog entry for Queue and Steer
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): stop stalled subtask pagination without losing steps
* docs(frontend): note the non-advancing page stop in the subtask backfill
The AGENTS.md backfill sentence only said the loop pages until a short page. It also stops on a full page that fails to advance the after_seq cursor, so record that stop condition as well.
The file sits at 49,149 of its 49,152-byte hard limit, so this is the minimal wording change that fits.
---------
Co-authored-by: TYY-2005 <334029581+TYY-2005@users.noreply.github.com>
* fix(artifacts): preserve account-scoped drafts across navigation
* docs(artifacts): keep draft lifecycle guidance within its budget
* fix(artifacts): retain drafts through unknown auth and isolate typing updates
* fix(artifacts): preserve drafts when visiting public showcases
* fix(artifacts): preserve edits after pending save
* feat(goal): record the met goal and keep goal state on the history head
- Add a goal_outcome LastValue channel (GoalOutcomeState). Only the
guarded clear of a satisfied ordinary goal writes it, in the same
checkpoint as the clear; the reply anchor is the latest visible AI
message with text, the reply the visible signature keys on. A goal
stored without created_at still clears and gets a record.
- write_thread_goal removes the record on every other goal write and
lists it in channel_versions, new_versions and metadata writes when
it changes. Threads that never had a record keep their checkpoint
shape. The sync-only SqliteSaver and CachedHistorySaver, which take
the #6448 sync fallback, store and drop it the same way.
- goal_outcome is server-owned: POST /state and non-internal run input
that carry it get 400. A POST /state goal write, or a goal in
external run input, clears it.
- A branch head written onto its replay base resets the channels the
head lacks, so a goal or record that write_thread_goal removed does
not come back. Branching from the met turn keeps the record without
the met goal.
- The POST /history head now carries an active goal and an achieved
goal_outcome, also before any message exists, so the post-run
history refetch keeps them. Older entries stay title/thread_data.
- contracts/thread_goal_contract.json pins the chat stand-down and
check-failure codes, the record shape and status, the head keys and
the host-written reasons.
* feat(frontend): show why a goal stopped and when it was met
- The goal bar derives its state from the active goal, the goal_outcome
record and the messages: set, in progress, continuing, stopped (limit,
auto-continue off, no progress, token budget, missing evidence, failed
run, unknown code), waiting for you, waiting, couldn't check the goal,
paused and goal met. An 8/8 stand-down now reads "Stopped" with the
next step instead of "Continuing 8/8", and a count-0 stand-down no
longer looks like a fresh goal.
- "Goal met" shows while its anchor reply is the latest visible message
(a visible human message, or a visible AI message with text, moves the
chat on), so a reload agrees with the live view. An active goal wins
over the record, and this tab's /goal set or clear hides it.
- useActiveGoal drops the optimistic goal when the record of that same
goal arrives, which fixes the bar that stayed after a goal met on the
first run (the clear frame has no goal key), also for a goal stored
without created_at. Server goal values pass through isGoalState; a
/goal status read uses the backend's own active check, so the bar, the
toast and the edit lock agree with the server.
- Row 2 gives the reason and what to do next; Details holds the full
goal, the checker's own note (never host-written text, a check failure
or a paused goal's older check), the assumption note and an unknown
code, and starts collapsed in each new state. The bar is a labelled
section with a live region that announces state changes only (another
goal's same state counts) and re-reads its text after a language switch.
- The edit pencil on the latest turn is shown disabled with "Editing is
off while a goal is set" instead of hidden; the message toolbar shows
on focus-within, so keyboard users can reach it.
- A /goal set or clear rejected with 409 keeps the draft and says a run
is still going; other failures keep the server's message and the draft.
A /goal request replaced by a newer one leaves the composer alone, so
a double submit no longer empties it before the second one fails.
- Copy lives in inputBox.goalBar (en-US, zh-CN); a contract test reads
contracts/thread_goal_contract.json, and the raw-identifier guard now
catches stand-down codes and goal field names.
* test(frontend): cover the goal bar on both chat pages
- tests/e2e/goal-status.spec.ts runs every case on the chat page and the
agent chat page, since each wires the goal bar and the edit lock itself:
a goal stopped at 8/8, one waiting for details and one waiting on an
open question card show their reason and next step, also after a
reload; the locked edit pencil gets keyboard focus, shows its toolbar
and tooltip, does not open the editor, and hides while a file uploads;
Goal met after one automatic follow-up (In progress, Continuing 1/8,
then "Auto-continued once") survives a reload and hides after the next
visible message; a goal met on the first run of a new chat replaces the
set bar and frees the pencil, and a new /goal replaces Goal met while
its own run goes on; a /goal set or clear refused with 409 keeps the
draft for the retry and shows its own busy toast.
- The mock POST /history head carries only the contract's
history_head_keys, with goal only while active and goal_outcome only
when achieved; thread state and run values omit an unset goal instead
of sending goal: null. PUT and DELETE /goal drop the met record,
setGoalWritesBusy returns the Gateway's 409, and a repeat create of a
requested thread id returns the thread as is, like the Gateway.
- The raw-identifier guard also catches checker blockers and the met
record's field names.
- frontend/src/AGENTS.md: the GoalStatus sentence now names goal,
goal_outcome and useActiveGoal, reworded in place.
* test(frontend): keep the draft through a failed upload and a busy /goal
#6412 fires onSent only once a send is dispatched, so a failed upload
keeps the text, files and stored draft. A /goal the Gateway refuses with
409 never reaches sendMessage, and the composer rejects it so PromptInput
keeps the text. Drive both through the real sendMessage in one composer:
a failed upload keeps "summarize it" stored, a busy /goal keeps its text
and stored draft with no run, and the retry saves the goal and starts
one run with the objective, not the "/goal" text. After that run the
composer is empty and no stored draft comes back.
* fix(frontend): drop the stop condition's closing period before the pause clause
A stored stop condition often ends with a period, so the schedule card
read "…remain)., pauses itself" (and "…。,满足后自动暂停" in Chinese).
Trim the condition's closing sentence punctuation before the clause that
follows it. The event line, where the condition ends the sentence, is
unchanged.
* fix(frontend): point "evidence missing" at the agent, not a user reply
"Evidence missing" asked the user to reply with what's left, which is
the push a goal is meant to save. It now asks the agent to show the
result or explain what's missing, and offers /goal clear only for a goal that
no longer applies: clearing drops the met record, so work that is right
would never show "Goal met".
zh-CN follows. Copy only: no key, state or mapping changes, and the
scheduled page's reason labels are untouched.
tests/unit/components/workspace/goal-status-next-steps.test.ts checks
the row in en-US and zh-CN, and that no stop row offers /goal clear
without that condition. goal-status.dom.test.tsx and
goal-stream.dom.test.tsx now expect the new copy.
* fix(frontend): say "Couldn't check the goal" once in the goal bar
After a goal check that could not run, the bar's second line borrowed
the scheduled page's whole reason, so it repeated the chip: "Couldn't
check the goal · Couldn't check the goal; the run itself may be fine.
It's checked again after your next message." Chinese had the same
repeat.
The line now gives only why the check failed ("The run itself may be
fine", "No final reply was saved" or "The chat changed during the
check"), then when it is checked again. The screen-reader announcement
follows. The chip and the scheduled page's reason labels are unchanged.
tests/unit/components/workspace/goal-status-unchecked.test.ts checks
every check-failure code in en-US and zh-CN: the label appears once,
and the line still says why the check failed.
* feat(goal): redact the evaluator input when pii_redaction is enabled
The goal evaluator calls its model directly, outside
PiiRedactionMiddleware, so it sent raw user lines, tool arguments, tool
results and Human Input Card answers even with pii_redaction enabled.
With pii_redaction enabled:
- the messages in the evidence window are redacted whole before the
evidence caps can cut an identifier in two, as TitleMiddleware does.
This reuses redact_queued_messages, which already covers content,
call arguments and card answers. Earlier messages are left alone: the
evaluator never sees them, and the pass is synchronous work on the
event loop (0.84 s for an 800-message, 4 MB thread, 0.06 s for its
window);
- the assembled input, the goal objective included, is redacted again.
A redaction error fails the check with evaluator_failed; raw text is
never sent instead. When redaction is off, the input is byte-identical.
Thread state keeps the raw text. README.md, config.example.yaml and the
middleware docstring list the evaluator with the other redacted inputs.
tests/test_goal_pii.py puts an address where the evidence caps cut a
tool argument, a tool result and a card answer, and checks the
objective, the unchanged input when redaction is off, that only the
evidence window reaches the redactor and gives the same evidence as
redacting the whole thread, that a redaction error stands down with
evaluator_failed before the model is called, and the worker path.
* fix(goal): redact the hidden continuation when pii_redaction is enabled
The hidden /goal continuation repeats the goal objective and the
evaluator's reason and evidence summary. PiiRedactionMiddleware skips
it, since it is a framework message and not a user message, so with
pii_redaction enabled each continuation turn sent them raw to the
agent's model, and the message stayed in the thread for later model
calls. The durable-context block and the evaluator input already redact
the objective.
With pii_redaction enabled, make_goal_continuation_message now redacts
the objective, the reason and the evidence summary with redact_text
before the message is built. The thread keeps the redacted message,
which the UI hides; the goal state keeps the raw objective.
The worker builds the message before it counts the continuation. A
redaction error fails the check the way an evaluator redaction error
does: run_failed with stand_down_reason evaluator_failed, the goal stays
active, nothing is sent and the continuation budget is not spent. Only
the exception type is stored. With redaction off, the message is
byte-identical.
README.md, config.example.yaml and the middleware docstring list the
continuation with the other redacted inputs.
tests/test_goal_continuation_pii.py checks the redacted objective,
reason and evidence summary against the tokens redact_text gives, the
unchanged message with redaction off or no config, the worker path
under the run config, and a redaction error.
* docs(changelog): link the goal outcome entries to #6556
Fill the four ([#PR]) placeholders in CHANGELOG.md and CHANGELOG_zh.md with this PR's number and add the matching link references.
* fix(frontend): keep an active goal stored without timestamps visible
POST /threads/{id}/state and run input can store an active goal without
created_at or updated_at. The backend still evaluates it and rejects an
edit with 409 (is_active_goal checks only status "active"), but
isGoalState required both timestamps, so history and live values
dropped it: both chat pages hid the goal bar and unlocked the last-turn
pencil, and only a /goal status read, which used a looser predicate,
brought them back.
isGoalState now accepts any record with a string objective and status
"active", and normalizeGoalStatusRead uses it, so thread values, live
updates and status reads share one rule. Malformed or inactive values
are still dropped.
Tests cover a sparse active goal from the history head and from a live
update before the post-run refetch (bar shown, hasGoal true), and a
malformed goal that stays hidden.
* test(goal): check the outcome write metadata the same way on every saver
PostgresSaver stores checkpoint metadata through
get_serializable_checkpoint_metadata, which drops "writes", so the
[postgres] case of test_goal_outcome_round_trips_through_the_sync_fallback
failed with KeyError: 'writes' when CI ran it against a live database.
The test now records the metadata write_thread_goal passes to put and
asserts the clear's and the next goal's "writes" there, for every saver.
The stored channel values are checked as before, and on sqlite and
cached-sqlite the stored "writes" must still equal what was passed.
* docs(agents): keep goal_outcome out of the agents guidance chain
The middlewares AGENTS.md chain is already over the 98,304-byte AG002
hard limit on main, and the PR-mode check fails any chain a PR touches,
so adding goal_outcome to the ThreadState field list in
deerflow/agents/AGENTS.md failed the agent-guidance job. goal_outcome
stays documented in runtime/AGENTS.md and backend/app/gateway/AGENTS.md,
which are outside that chain.
* refactor(goal): read the evaluator's evidence and redaction from one window
format_visible_conversation recomputed the window start that the redaction
pass takes from _evidence_window. Both now use _evidence_window, so a later
change to the cap or to what counts as visible moves both.
The window test now fills the window with tool-only replies and tool results.
Before, it caught a cap change in one copy but not a change to what counts
as visible.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(voice-input): separate transcript segments for providers that trim final results
readSpeechRecognitionTranscript concatenated final and interim
transcripts with no separator, relying on providers padding final
results with trailing whitespace. WebKit's SpeechRecognition
implementation trims them, so the last finalized word glued onto the
interim hypothesis ("hello world" + "today is" -> "hello worldtoday
is"), both in the live composer and in the submitted message.
Join every adjacent segment pair with an explicit space unless either
side of the boundary is a no-space script or its punctuation (Han, kana,
CJK symbols, full-width forms) — those keep the plain concatenation
Chinese and Japanese dictation always had. Korean is treated as a
space-delimited script, matching its eojeol spacing.
* fix(voice-input): consolidate transcript boundary regressions
Port the remaining regression cases from #6449, retaining equivalent English and Hangul fixtures already present in #6432. Attach leading punctuation and percent signs without losing spacing after sentence punctuation, quoted phrases, or combining accents. Pin whitespace-only segments and document the join contract.
Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* fix(frontend): keep composer context when an attachment upload fails
sendMessage called onSent right after the in-flight guard, before any
attachment was prepared or uploaded. The composer's onSent drops its
one-time state there: quotes, conversation references, staged project
files and the stored draft. PromptInput keeps the text and files when the
upload rejects, so the user saw the same message and retried it, but the
retry went out without that context.
onSent now fires once the send is dispatched: after the attachments
upload, right before the run is submitted. A failed preparation or upload
rejects first, so every caller (the chat composer and the side chat)
keeps its state for a retry.
Because onSent can now arrive after the composer has moved to another
conversation, InputBox checks which conversation it shows. If it changed,
onSent leaves that conversation's live state alone and retires only the
sending conversation's stored draft and staged files, through a new
clearProjectAttachments helper that does not touch mounted state.
The chat E2E that held an attachment upload asserted the draft was gone
mid-upload, which encoded the old timing. It now asserts the draft stays
stored while the upload is pending and is gone, without being rewritten,
once the send is dispatched.
* docs(changelog): reference #6412
* docs(frontend): note onSent's guarantee ends at dispatch
* fix(frontend): retire only what a detached send carried
A send whose upload finishes after its composer unmounted still took the
live path, because only a conversation switch was detected, and could
delete a draft a new composer saved under the same reusable key. A late
send also cleared the thread's whole staged list, dropping a document
attached during the upload.
Track the composer's lifetime instead of its key, and in the detached
path clear the stored draft only while it still holds the sent text and
retire only the staged entries the send captured.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): keep the side-chat draft when a send fails
PromptInput clears the composer only when onSubmit resolves and keeps it
on rejection. The side chat's handleSubmit caught every failure, showed
the toast and resolved, so a failed side-chat creation or attachment
upload lost the typed text and attachments. The first message to a new
side chat was also cleared as soon as it was queued, before the queued
effect sent it, so a failure there could not be retried either.
handleSubmit now rethrows after its toast, and the queued first send
carries the submit's resolve/reject so the composer settles with that
send's outcome. The queued effect no longer toasts on its own, so a
failed first send shows one error instead of two.
* docs(changelog): reference #6407
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): retry a failed same-tab reconnect after refresh
After a refresh the SDK reconnects once from the tab's lg:stream pointer
and keeps that pointer when the join fails. Active-run recovery defers to
the SDK whenever the pointer matches the active run, and its error handler
only scheduled retries for joins it started itself, so a failed reconnect
(at connect time or mid-stream) left the tab without a live stream until
another refresh.
When an SDK reconnect fails, release the pointer and wake the recovery
effect, which rejoins the run if the runs read still reports it active,
with its existing bounded retries. Runs created by this hook's submit
(onCreated) keep their current error handling.
The new native-reconnect-recovery test drives the real SDK useStream, so
it also pins the SDK behavior this relies on: one reconnect per mount and
a pointer that survives a failed join.
* docs(changelog): reference #6400
* test(frontend): pin that a released reconnect skips finished runs
A run handed to recovery after a failed SDK reconnect was never streamed
by this hook, so completedRunIdsRef cannot guard it; only the
server-reported status in activeRunId does. Resolve the runs read with
each terminal status after the failed reconnect and assert recovery
never joins and the pointer stays cleared.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(frontend): support extension providers in composer mentions
* fix(mentions): retain settled plugin suggestions during search
* fix(extensions): validate identifiers and skip empty mention searches
* test: stabilize history pagination after latest page refresh
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): ignore literal base tags in HTML artifact previews
* fix(frontend): address HTML preview base review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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
* fix(frontend): keep additional_kwargs on the optimistic bubble through an upload
When a send carried files, the upload-complete update replaced the
optimistic human message's additional_kwargs with only the uploaded
files. Quote and conversation reference chips vanished until the server
echoed the message, and project attachments staged with the send were
missing from the bubble both while uploading and after. The submitted
message was always intact.
The optimistic copy (before and after the upload) and the submit now
build additional_kwargs through one helper,
buildHumanMessageAdditionalKwargs, and share a single uploaded-files
value, so the bubble always matches what was sent.
* docs(changelog): reference #5982
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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>
* feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063)
Mirrors #5294's stop gating on the send side: both chat routes pass
canCreateRuns (from PERMISSIONS.RUNS_CREATE, lockstep with the backend
enum) into the shared composer. The gate sits at the top of
submitThreadMessage — the single choke point every composer entry
(submit button, Enter, goal-set-triggered run) funnels through — and
denies with a toast plus a rejected promise so PromptInput keeps the
text. The idle submit button is disabled and explains the boundary via
conditionally-spread aria-label/title (startTurnUnavailable, en/zh);
while streaming the button stays the runs:cancel stop affordance.
Also removes the unreachable kind === "stop" branch in handleSubmit
(flagged during #5294's review): the Enter path early-returns with the
streaming toast before the classifier runs.
* fix(frontend): reject denied goal starts before saving state
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): rejoin active runs after reopening chats
* test(frontend): mock thread runs query in stream options test
* fix(frontend): avoid rejoining completed runs from stale cache
* fix(frontend): tighten active run recovery cleanup
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(knowledge): add verifiable RAGFlow source citations
* docs(knowledge): scope RAGFlow guidance to its own directory
* fix(knowledge): preserve citations through rendering and budgets
* fix(frontend): gate tool-step links through the href scheme allowlist
The chain-of-thought renderer turned web_fetch args and web_search /
image_search result URLs straight into <a href>. Markdown links already
pass isSafeHref, but these tool-step links bypassed it, so a
prompt-injected tool call could put file:, ms-msdt:, vscode: or other
OS protocol-handler links into the chat. React 19 only rewrites
javascript: hrefs.
All three sites now reuse the markdown allowlist and render an unsafe
URL as plain text (the image thumbnail stays, unlinked). Tests render
MessageGroup for each tool with unsafe schemes plus a web-URL control.
* docs(changelog): note tool-step link scheme gating (#5526)
* fix(frontend): mark omitted tool-step links and guard web_fetch url type
Review follow-up. Tool steps dropped an unsafe URL to bare text, while
markdown and artifact links show a dotted "Unsafe link omitted" span, so
the two surfaces applying the same rule degraded differently. That span
was already duplicated between markdown-link.tsx and artifact-link.tsx;
it is now one UnsafeLink component used by all three renderers. It
passes extra props through so the image tile still works as a Radix
tooltip trigger.
web_fetch also read args.url with a cast only. A non-string url (models
occasionally emit one mid-stream) reached JSX as an object and threw,
taking down the message list. It is now typeof-guarded.
* fix(frontend): default missing tool-call args before rendering steps
Review follow-up. The web_fetch typeof guard dropped the optional
chaining of the cast it replaced, so a tool call without an args object
threw again. Other branches were already exposed the same way: seven
tool kinds (web_fetch, web_search, image_search, read_file, write_file,
str_replace, browser_*) threw on a missing or null args while building
their labels. convertToSteps now defaults args to {} once, so every
ToolCall branch receives an object.