Totoro 35cdcab1c5
feat(scheduled-tasks): signals, honest notices and fair queueing for server-started runs (RFC #6340, 2/2) (#6393)
* 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(gateway): no-transform on SSE responses

All four text/event-stream responses (thread run create stream, join,
existing-run GET/POST stream, stateless create stream) now take their
headers from app/gateway/sse_headers.py and send
Cache-Control: no-cache, no-transform, so compressing proxies such as
Next's rewrite proxy stop buffering streams. Content-Location and
X-Accel-Buffering are unchanged; scheduled and MCP-notification runs keep
stream_mode=None.

Tests pin the headers on every SSE route and fail on a literal no-cache
header left in routers. Docs: API.md SSE headers note, CHANGELOGs.

* feat(scheduler): per-owner run cap and fair queue selection

New scheduler.max_concurrent_runs_per_user (default 2, 0 = off, effective
min(cap, max_concurrent_runs)) caps launching/running scheduled runs per task
owner. claim_queued_run checks it right after the global count under the same
budget lock, so it is atomic across workers and Pods. list_queued_runs ranks
same-thread FIFO heads per owner with ROW_NUMBER() OVER (PARTITION BY owner)
and orders the batch by that rank, so the drain is owner-fair even with the
cap off; owners at their cap drop out before the batch limit. Orphan rows
(task deleted while queued) are never held back by the cap.

Gateway lifespan passes the startup value; the service constructor default
stays 0. Docs: config.example.yaml (config_version 54), CONFIGURATION.md,
the six READMEs, backend AGENTS.md, CHANGELOGs (behavior change flagged).
The live-task quota is untouched.

* feat(scheduler): durable lifecycle events for the originating chat

When a chat-created schedule is paused by the agent, auto-paused or finishes,
finalization now reports task_stopped / task_paused / task_finished, only on a
live -> paused/finished transition. The new finish_task_at_end_condition
replaces the three inline "mark completed" blocks (admission rejected as ended,
claim-time skip, complete_if_ended), so every site emits exactly once.

The scheduler's finalization observer is always installed. It writes
scheduled_task_events rows for the originating chat in the same transaction as
the state change, deduplicated on (task_id, anchor, event), with a title
snapshot, stop condition, run number, last run outcome and the chat's newest
run as placement anchor. Lookups fall back on bad stored data; only database
errors propagate. Manual trials drop only run_* events. The IM step keeps
PR1's selection until B4.

New GET /api/threads/{thread_id}/scheduled-task-events (owner check, caller's
rows, oldest first); rows survive task deletion and go with the chat.
Migration 0032_activity_and_task_events adds the events table plus the
run-origin column/index and read-state tables used later, each object guarded
on its own. Contract scheduled_goal_notes v3 pins the lifecycle vocabulary.
Docs: API.md, migrations chain, backend AGENTS.md, six READMEs, CHANGELOGs.

* feat(scheduler): honest, localized and self-contained IM notices

Every scheduled-task IM notice is now enqueued by the finalization observer
in the transaction that records the outcome, one message per occurrence
(task_stopped > task_paused > task_finished > run_failed > run_unmet >
run_completed; a once task sends only its run event). The merged auto-pause
replaces the separate unmet + pause messages, agent stops and finishes now
notify, and recovery-finalized runs notify exactly once. Idle finishes use a
deterministic idle: key derived from the lifecycle anchor. The old
non-transactional enqueue in handle_run_completion is removed.

New app/channels/capabilities.py holds CHANNEL_CAPABILITIES with a
proactive_notifications flag (adds the missing qq entry); only push-capable
providers (WeCom) get rows, /api/channels/providers returns the flag, and the
worker ends legacy rows for other providers as terminal failed.

New app/scheduler/notification_text.py renders en-US/zh-CN text: title, what
happened (plus the last run's outcome), the tasks page's one-line run summary
(redacted) when the agent replied, and a closing line. No IDs, no links, no
public_base_url. Locale is the owner's new `locale` preference, else the new
channel_connections.notification_locale (default en-US).

Docs: config.example.yaml (config_version 55), CONFIGURATION.md, API.md,
channels AGENTS.md, six READMEs, CHANGELOGs (behavior change flagged).

* feat(gateway): thread origin, activity feed and read markers for server-created threads

Runs started by the server for a user (scheduler, IM channels, GitHub
agents, extension handles, MCP notifications) now carry a server-owned
metadata.deerflow_origin {kind, provider?, namespace?} (new harness module
deerflow.runtime.run_origin, pinned by contracts/thread_origin_contract.json).
It is stamped only from request.state.run_origin or by the internal channel
caller; run admission drops client copies from both metadata and
config.metadata, and thread create/patch strip it. RunRepository derives
runs.origin_kind on put and on create_thread_operation_atomic (agent runs
only), and gains latest_change().

New ThreadReadRepository keeps a per-user, never-decreasing read position
(thread_read_markers) and a per-user read clock (thread_read_versions).
POST /api/threads/{id}/read marks a thread read, thread search items carry
unread, and thread delete removes the markers. GET /api/thread-activity pages
the caller's run changes over the run-change clock, returns only threads
changed by server-originated runs plus read_version, and 503s without SQL;
/api/features reports thread_activity.available.

Docs: API.md, gateway AGENTS.md (condensed to stay under its budget),
CHANGELOGs.

* fix(scheduler): adapt PR2 to PR1 follow-ups

PR1 moved the scheduled-task lifecycle rules out of backend/AGENTS.md into
the persistence guide and grew the sandbox AGENTS.md chain to within 267
bytes of its hard limit. The per-owner queue-fairness detail now lives in
the persistence guide next to the lifecycle rules (backend/AGENTS.md keeps
the cap and a link), so check_agent_guidance passes again.

PR1's goal-outcome unit test pinned scheduled_goal_notes_contract version 2;
PR2 bumps it to 3 (lifecycle vocabulary only, the v2 keys are unchanged).

* 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.

* feat(frontend): thread origin, activity polling, read markers and task events core

Frontend core for server-created threads and schedule lifecycle events
(PR2 F1); the sidebar markers (F2) and the chat event line (F3) build on it.

- core/threads/origin.ts: threadOriginOf reads metadata.deerflow_origin,
  then channel_source (github -> GitHub origin), then a legacy
  scheduled_task_id; key and kinds pinned to
  contracts/thread_origin_contract.json. labelOfThreadOrigin and the now
  exported labelOfChannelProvider(provider, t) use the new localized
  threads.origin.providers names (飞书, 企业微信, ...), with the English map
  (gaining GitHub) only as the fallback.
- core/threads/activity.ts: useThreadActivity polls /api/thread-activity
  every 15 s (focus refetch, never in the background, off unless
  features.thread_activity.available). The first response only seeds the
  cursor; server-origin threads, a truncated page or a higher read_version
  invalidate the thread lists, a scheduled thread also ["scheduled-tasks"];
  an invalid cursor re-seeds. useMarkThreadRead debounces POST /read by 1 s,
  skips a thread whose cached unread is false, patches the search and
  infinite caches optimistically and stores the returned read_version so a
  tab's own read does not refetch its lists. AgentThread gains unread.
- core/scheduled-tasks/events.ts: ScheduledTaskEvent, the events query
  (["scheduled-tasks","thread-events",id], one fetch per saved chat, not
  gated on the task list so a deleted task's lines stay, refetched when a
  task of the chat changes status or finishes a run), describeTaskEvent
  (en/zh copy, title and condition as segments, last-run suffix, See that
  run / Open task) and placeTaskEvents (end of the after_run_id turn, else
  the tail, ordered by created_at); lifecycle names pinned to contract v3.
- features: thread_activity (default unavailable); channels:
  proactive_notifications on providers.
- Locale sync: the I18nProvider reports the UI language and a signed-in
  preference session saves it as the new `locale` preference when the
  account's differs (and on every switch), so IM notices follow the UI
  language; nothing is written with auth disabled.
- i18n: threads.* (unread, origin labels, provider names),
  scheduledTasks.events.*, history.waitingForSlot (now shown for queued run
  rows), channels.scheduledUpdates.*.
- e2e mocks: thread activity feed and controller, POST /read, the events
  route, providers with proactive_notifications, search unread, features
  thread_activity (off by default). Preference e2e mocks return the UI
  locale so they keep asserting only their own edits.
- Docs: frontend AGENTS.md, CHANGELOGs.

* 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".

* feat(frontend): origin markers and unread state in the thread lists

Server-created threads (PR2 F2) now say who created them and whether they
are new, in the sidebar and on the Chats page, and reach an open sidebar
without a reload.

- ThreadOriginIcon (thread-channel-source.tsx): clock for a scheduled run,
  the provider mark for IM threads, the GitHub mark (new in
  ChannelProviderIcon) for GitHub threads, a puzzle piece for extensions;
  the localized "Scheduled run" / "From Feishu" / "From GitHub" / "From an
  extension" label is its accessible name and tooltip. ThreadChannelIcon
  stays exported as a wrapper, and the provider chip tooltip is localized
  (no more "Feishu channel").
- Unread dot after the title (aria-hidden dot plus sr-only "Unread"); the
  row aria-label becomes "{title}, unread" / "{title},未读". Never shown for
  the open thread.
- WorkspaceSidebar mounts useThreadActivity once.
- useMarkOpenThreadRead (components/workspace/chats): the default and
  custom-agent chat pages mark the thread read once its metadata has
  loaded, when its cached unread flips to true while open, when the tab
  becomes visible, and from onFinish (a send or a joined run ended). Each
  trigger goes through useMarkThreadRead, so it is debounced and a no-op
  while the cached unread is false.
- Tests: thread-origin-icon and recent-chat-list DOM tests (en/zh, active
  row, no raw identifiers), use-mark-open-thread-read DOM tests,
  e2e thread-activity.spec.ts (new scheduled run appears unread within one
  fake-clock poll without a reload, opening it posts /read and clears the
  dot, a read on another device clears it, IM and GitHub markers, zh
  labels, the Chats page, no polling when unavailable);
  thread-history.spec.ts now finds the localized origin icon.
- Docs: frontend AGENTS.md, CHANGELOGs.

* feat(frontend): schedule event line in the originating chat and channel notice support

The chat that created a schedule now says when the schedule paused or
ended, and the channel cards say which apps receive scheduled task updates
(PR2 F3).

- ScheduledTaskEventLine (mockup m2, marker 4): a dashed, muted row with
  the describeTaskEvent text (title in <strong>, stop condition clamped to
  one line with the full text in its tooltip, last-run suffix), the time
  via formatTaskTime and a right-aligned "See that run" (run chat) or
  "Open task" link. PauseCircle for paused, CircleCheck for finished and
  once-done, CircleX for once-failed. role="note", aria-label "Scheduled
  task update" / "定时任务通知", data-task-id and data-event-id only.
- MessageList takes scheduledTaskEvents and renders placeTaskEvents output
  through a new VirtualMessageList.renderAfterGroup, so each line sits at
  the end of its after_run_id turn inside the group's measured row (also
  when the list virtualizes); lines with nowhere to go render at the tail.
- Both chat pages load useThreadScheduledTaskEvents (not gated on the task
  list, so the lines stay after a task is deleted). The custom-agent page
  passes its agent so "See that run" opens the run on that agent's route.
- ChannelScheduledUpdates: "Scheduled task updates: sent here" / "... not
  available for this app yet" (定时任务通知:会发送到这里 / 此应用暂不支持)
  on the Settings provider cards and under each sidebar channel row; nothing
  when an older backend does not report proactive_notifications.
- e2e mock controller: setScheduledTasks.
- Tests: scheduled-task-event-line and channel-scheduled-updates DOM tests
  (every kind in en and zh, hrefs, clamp, no raw identifiers, Chinese
  title), MessageList placement DOM tests; e2e
  scheduled-task-chat-events.spec.ts (the line appears with the card's
  "Paused by agent" without a reload, See that run opens the run chat,
  anchored vs tail placement, two events in time order, the line survives
  task deletion and Open task shows the task is gone; en-3 and zh-1) and
  channels.spec.ts (WeCom sent here, Feishu not yet; en and zh).
- Docs: frontend AGENTS.md, CHANGELOGs.

* 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(scheduler): address PR2 review findings

- Event lines: hold an event whose anchor run is on an unloaded history
  page instead of placing it at the bottom (hasMoreHistory); tail fallback
  only for fully loaded (branched/pruned) history or no anchor.
- Idle-finish anchor follows its reason: end:<iso> only for an end_at
  finish, seq:<n> otherwise, so a later end-time finish is not deduplicated
  away by an earlier max_runs finish.
- Observer takes FOR SHARE on the originating threads_meta row, so a racing
  thread delete cannot leave an orphan event line.
- One taskPagePath (core), re-exported by the card.
- Notification locales derived from NotificationLocale; /preferences uses it.
- IM notices drop generic reasons (goal_not_met_yet, unknown) instead of
  repeating the sentence.
- Localized provider names on Settings cards, sidebar rows and setup dialog.
- zh event lines: no space after a Chinese title (Latin-aware gap).
- Unread row label keeps the origin marker; chats page keeps the time as a
  description.
- Sidebar shows the scheduled-updates line only for connected apps;
  Settings says "available after you connect" for unconnected push apps.
- Event line keeps a real space before the time.

* 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".

* test(scheduler): consolidate PR2 backend test setup

- Fold the repeated task, origin-chat and observer setup of the
  lifecycle-event repository tests into one lifecycle_task builder.
- Build the per-owner-cap services through one _capped_service builder
  that keeps every cap explicit at the call site.
- Table the five SSE route header checks into one parametrized test with
  the same routes, bodies and Content-Location expectations.
- Reuse bind_wecom and hoist repeated local imports in the event tests.

Every case and assertion is kept; collected cases are unchanged.

* test(frontend): consolidate PR2 thread activity and origin test setup

Extract shared builders and helpers in the frontend tests PR2 adds, with
every case, input and assertion kept:

- activity.test.ts: seededClient/apply helpers and one LIST_KEYS constant
  replace the per-test seed, spy and apply boilerplate.
- mark-read.dom.test.tsx: newClient() and advance() replace the repeated
  QueryClient wrapper and fake-timer act blocks.
- origin.test.ts: the English and Chinese label checks become tables; the
  no-English-name check now runs on every Chinese row.
- thread-activity.spec.ts: a scheduledRun() builder replaces four inline
  copies of the scheduled run thread.
- channels.spec.ts: the PR2 block uses setLocaleCookie and one card helper.

Collected cases are unchanged (unit 211, e2e 60); no test was removed.

* 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.

* fix(scheduler): two gaps found in live acceptance

- A fresh scheduled run thread is named when it launches. The worker
  copied the title to the thread list only when the run ended, so the
  sidebar, which now shows the new run thread at once, listed it as
  untitled while it ran.
- A task capped at one run finishes with "its one run is done" /
  "唯一一次运行已完成" in the chat event line and the IM notice, instead
  of "all 1 runs are done".

* 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.

* test(scheduler): expect the one-run finish copy in service notices

* fix(gateway): create a scheduled run thread with its title

Pre-setting the name after start_run raced the thread row, which the run
worker creates asynchronously: live, one of two run threads still reached
the sidebar untitled. Run admission now creates a new thread with the title
its run input carries, which the worker would copy only at run end.

* docs(changelog): reference #6393 for the scheduled-task signal entries

* fix(scheduled-tasks): route event-line run links by the agent that ran them

The originating chat's agent was used for "See that run", but a task's
agent can change after the chat created it, so the link could open the run
chat on another agent's route. Lifecycle events now record run_agent_name,
the task's agent when the run finished (edits are refused while a run is
active), and the line routes by it.

* fix(scheduled-tasks): load up to the route's maximum of chat event lines

The chat loads its lifecycle events once, and the route's default of 50
would silently drop the oldest lines of a chat with many schedules. Ask
for the maximum, 200.

* fix(threads): keep activity reads and the first cursor in step with the lists

- Accepting the first activity cursor refreshes the thread lists once. The
  lists load on their own, possibly before a run the seed already counts,
  and later polls start after it, so such a thread never appeared.
- A local read whose returned read clock jumps past that one read also
  covers reads on other devices; the lists are refreshed instead of the
  clock silently skipping those threads' stale unread dots.
- An invalid cursor re-seeds through the same path, so it refreshes once.

* docs(agents): bring the middlewares guidance chain back under the hard limit

#6378 and #6363 each fit the budget, but together the middlewares AGENTS.md
chain on main is 98,425 bytes, 121 over the 98,304 hard limit, so Lint
Check fails on main. Move the scheduled-task recovery, launch-update and
queue-timeout rules from backend/AGENTS.md into the persistence guide's
lifecycle section, which backend/AGENTS.md already points to. The chain is
now 98,067 bytes.

* fix(helm): bump the chart's config_version to 55

config.example.yaml moved to 55 in this PR, so the Helm chart's
validate-chart drift check failed. The chart's embedded config needs no
field changes: the new scheduler and channel keys have defaults.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 10:22:15 +08:00

2661 lines
122 KiB
Python

"""Run lifecycle service layer.
Centralizes the business logic for creating runs, formatting SSE
frames, and consuming stream bridge events. Router modules
(``thread_runs``, ``runs``) are thin HTTP handlers that delegate here.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import threading
import uuid
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import replace
from types import SimpleNamespace
from typing import Any
from deerflow_extension_api import PROVENANCE_KEYS
from fastapi import HTTPException, Request
from langchain_core.messages import BaseMessage, ChatMessage, HumanMessage, SystemMessage
from langchain_core.messages.utils import convert_to_messages
from langgraph.types import Command
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_INTERNAL, get_auth_disabled_user, is_auth_disabled
from app.gateway.authz import require_cancel_permission_if, resolve_route_permissions
from app.gateway.deps import get_checkpointer, get_local_provider, get_run_context, get_run_manager, get_stream_bridge
from app.gateway.internal_auth import (
INTERNAL_OWNER_USER_ID_HEADER_NAME,
INTERNAL_SYSTEM_ROLE,
get_internal_user,
get_trusted_internal_owner_user_id,
)
from app.gateway.knowledge_scope_admission import admit_message_knowledge_scope
from app.gateway.run_models import RunCreateRequest
from app.gateway.run_origin import resolve_request_origin
from app.gateway.utils import sanitize_log_param
from app.mcp_tasks.errors import PermanentNotificationError
from deerflow.agents.human_input import read_human_input_response
from deerflow.agents.interaction_policy import resolve_run_interaction_policy
from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY, _REMINDER_DATE_KEY
from deerflow.agents.middlewares.input_sanitization_middleware import frame_untrusted_text
from deerflow.agents.middlewares.message_utils import _SUMMARY_MESSAGE_NAME, is_genuine_user_message
from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, SKILL_USAGES_KEY
from deerflow.agents.middlewares.tool_output_budget_middleware import TOOL_OUTPUT_BLOB_KEY
from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY, TOOL_RECEIPT_LEDGER_KEY
from deerflow.agents.middlewares.tool_transform_meta import TOOL_TRANSFORMS_KEY
from deerflow.agents.middlewares.view_image_middleware import _IMAGE_CONTEXT_MESSAGE_MARKER_KEY
from deerflow.config.agents_config import load_agent_config
from deerflow.config.app_config import get_app_config
from deerflow.config.database_config import resolve_checkpoint_graph_cache_max
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_KEY, KNOWLEDGE_SCOPE_RUNTIME_KEY
from deerflow.mcp_scope import (
THREAD_INCARNATION_METADATA_GUARD_KEY,
is_valid_thread_incarnation,
)
from deerflow.projects.context import PROJECT_CONTEXT_MESSAGE_MARKER, resolve_project_context
from deerflow.runtime import (
END_SENTINEL,
HEARTBEAT_SENTINEL,
ORPHAN_RECOVERY_STOP_REASON,
CheckpointStateAccessor,
ConflictError,
DisconnectMode,
RunContext,
RunManager,
RunRecord,
RunStatus,
StreamBridge,
StreamGap,
ThreadOperationKind,
UnsupportedStrategyError,
build_state_mutation_graph,
run_agent,
)
from deerflow.runtime.checkpoint_mode import (
INTERNAL_CHECKPOINT_MODE_KEY,
CheckpointModeMismatchError,
checkpoint_tuple_uses_delta,
inject_checkpoint_mode,
)
from deerflow.runtime.checkpoint_state import graph_state_schema
from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY
from deerflow.runtime.events.message_identity import MESSAGE_SEQ_KEY
from deerflow.runtime.goal import goal_thread_lock
from deerflow.runtime.journal import build_checkpoint_history_seed_events
from deerflow.runtime.keyed_lock import KeyedLockTable
from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, make_origin
from deerflow.runtime.runs.naming import resolve_root_run_name
from deerflow.runtime.secret_context import (
LegacyRunMetadataSecretError,
redact_config_secrets,
validate_run_metadata_secrets,
)
from deerflow.runtime.stream_modes import normalize_stream_modes
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.sandbox.lease import SANDBOX_SERVER_OWNED_CONTEXT_KEYS
from deerflow.scheduler.runtime import scheduler_tools_enabled
from deerflow.scheduler.schedules import validate_timezone
from deerflow.subagents.status_contract import SUBAGENT_ACCEPTANCE_VERDICT_KEY, SUBAGENT_RECEIPT_VERDICT_KEY, SUBAGENT_TOOL_RECEIPTS_KEY
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, ensure_trace_context, ensure_trace_id
from deerflow.utils.assembly_io import run_assembly
from deerflow.utils.file_io import await_drained
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, UNTRUSTED_INPUT_KEY
from deerflow.utils.thread_id import validate_thread_id
logger = logging.getLogger(__name__)
class BusyThreadConflict(HTTPException):
"""A retryable run-manager admission conflict exposed as HTTP 409."""
def __init__(self, detail: str) -> None:
super().__init__(status_code=409, detail=detail)
@asynccontextmanager
async def reserve_checkpoint_write(
request: Request,
thread_id: str,
*,
user_id: str | None = None,
) -> AsyncIterator[None]:
"""Serialize an out-of-run checkpoint writer against all thread operations."""
run_manager = get_run_manager(request)
async with goal_thread_lock(thread_id):
async with run_manager.reserve_thread_operation(
thread_id,
kind=ThreadOperationKind.checkpoint_write,
user_id=user_id,
):
yield
_TERMINAL_RUN_STATUSES = {
RunStatus.success,
RunStatus.error,
RunStatus.timeout,
RunStatus.interrupted,
}
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
# Message metadata a scheduled launch puts on its prompt: the task, run and the
# user-language parts (instructions, stop condition, notes) the run thread shows
# instead of the launched text. A cross-language contract
# (contracts/scheduled_goal_notes_contract.json "scheduled_origin_key").
SCHEDULED_ORIGIN_KEY = "deerflow_scheduled_origin"
_SERVER_OWNED_MESSAGE_METADATA_KEYS = (
frozenset(
{
# Only the scheduler's internal launch may mark a message as a
# scheduled run prompt.
SCHEDULED_ORIGIN_KEY,
_DYNAMIC_CONTEXT_REMINDER_KEY,
_REMINDER_DATE_KEY,
_IMAGE_CONTEXT_MESSAGE_MARKER_KEY,
KNOWLEDGE_SCOPE_RUNTIME_KEY,
TOOL_RECEIPT_KEY,
TOOL_RECEIPT_LEDGER_KEY,
TOOL_OUTPUT_BLOB_KEY,
TOOL_TRANSFORMS_KEY,
SKILL_USAGE_KEY,
SKILL_USAGES_KEY,
# Attached when a values frame is serialized, for display ordering only.
# A replayed message carrying it back would write a thread-scoped seq
# into the checkpoint, which a fork then re-seeds and reassigns (#4380).
MESSAGE_SEQ_KEY,
# The transient project-context request message marker (spec §12):
# a client-supplied copy must never survive into a run, where the
# renderer would treat the message as its own.
PROJECT_CONTEXT_MESSAGE_MARKER,
SUBAGENT_TOOL_RECEIPTS_KEY,
SUBAGENT_RECEIPT_VERDICT_KEY,
SUBAGENT_ACCEPTANCE_VERDICT_KEY,
UNTRUSTED_INPUT_KEY,
}
)
| PROVENANCE_KEYS
)
# ---------------------------------------------------------------------------
# SSE formatting
# ---------------------------------------------------------------------------
def format_sse(event: str, data: Any, *, event_id: str | None = None) -> str:
"""Format a single SSE frame.
Field order: ``event:`` -> ``data:`` -> ``id:`` (optional) -> blank line.
This matches the LangGraph Platform wire format consumed by the
``useStream`` React hook and the Python ``langgraph-sdk`` SSE decoder.
"""
payload = json.dumps(data, default=str, ensure_ascii=False)
parts = [f"event: {event}", f"data: {payload}"]
if event_id:
parts.append(f"id: {event_id}")
parts.append("")
parts.append("")
return "\n".join(parts)
def _run_is_terminal(record: RunRecord) -> bool:
return record.status in _TERMINAL_RUN_STATUSES
def _consume_task_result(task: asyncio.Task) -> None:
"""Retrieve a detached task's exception without propagating cancellation."""
if not task.cancelled():
task.exception()
def _log_thread_metadata_task_result(task: asyncio.Task, *, thread_id: str) -> None:
"""Log detached metadata setup failures while ignoring cancellation."""
if task.cancelled():
return
try:
task.result()
except asyncio.CancelledError:
return
except Exception:
logger.warning(
"Failed to ensure thread_meta for %s after worker detached (non-fatal)",
sanitize_log_param(thread_id),
exc_info=True,
)
async def _ensure_thread_metadata(
run_ctx: RunContext,
record: RunRecord,
*,
owner_user_id: str | None,
require_existing_thread: bool = False,
) -> dict[str, Any]:
"""Ensure an admitted run's thread exists without delaying task attachment."""
thread_store = run_ctx.thread_store
existing = await thread_store.get(record.thread_id)
if existing is None and owner_user_id:
unscoped = await thread_store.get(record.thread_id, user_id=None)
if unscoped is not None:
if unscoped.get("user_id") != owner_user_id:
await thread_store.update_owner(record.thread_id, owner_user_id, user_id=None)
existing = await thread_store.get(record.thread_id)
if existing is None:
if require_existing_thread:
raise LookupError(f"Thread {record.thread_id} was deleted during run admission")
from deerflow.persistence.thread_meta import THREAD_PROJECT_METADATA_KEY
run_metadata = record.metadata or {}
metadata = {
key: value
for key, value in run_metadata.items()
# Strip the run-scoped trace id (existing) and the reserved
# membership key: run admission never modifies project membership —
# the column is written only by POST /api/threads and
# /threads/{id}/move — so the key must not persist either.
if key not in (DEERFLOW_TRACE_METADATA_KEY, THREAD_PROJECT_METADATA_KEY)
}
# A run that names its thread in the input (a scheduled run) creates it
# named: the worker copies the title to the thread list only when the
# run ends, and a server-created thread is listed as soon as it exists.
run_input = (record.kwargs or {}).get("input")
title = run_input.get("title") if isinstance(run_input, dict) else None
existing = await thread_store.create(
record.thread_id,
assistant_id=record.assistant_id,
display_name=title if isinstance(title, str) and title.strip() else None,
metadata=metadata,
)
return existing
async def _terminal_record_stream_missing(bridge: StreamBridge, record: RunRecord) -> bool:
"""True when a terminal run has no retained stream on bridges that can tell."""
if not _run_is_terminal(record):
return False
stream_exists = getattr(bridge, "stream_exists", None)
if stream_exists is None:
return False
try:
return not bool(await stream_exists(record.run_id))
except Exception:
logger.debug(
"Failed to probe stream existence for terminal run %s",
sanitize_log_param(record.run_id),
exc_info=True,
)
return False
async def _orphan_recovery_observed_after_heartbeat(
record: RunRecord,
run_mgr: RunManager,
) -> bool:
"""Return whether durable orphan recovery is the consumer's liveness edge.
A normal terminal status is not sufficient: the producer persists status
before publishing its final error/data frames and END. Orphan recovery is
different because the producer is known to be gone and the durable
``stop_reason`` is written atomically with the terminal status. Only that
explicit signal may synthesize END after a heartbeat.
"""
if not record.store_only:
return False
refreshed = await run_mgr.get(record.run_id, user_id=record.user_id)
return refreshed is not None and _run_is_terminal(refreshed) and refreshed.stop_reason == ORPHAN_RECOVERY_STOP_REASON
# ---------------------------------------------------------------------------
# Input / config helpers
# ---------------------------------------------------------------------------
def _skips_input_guardrail(additional_kwargs: dict[str, Any], name: Any) -> bool:
"""Whether these markers would make ``InputSanitizationMiddleware`` skip a message.
Mirrors ``is_genuine_user_message`` exactly, truthiness included: a
``summary`` name, or a truthy ``hide_from_ui`` without a valid human-input
reply. Keying off key presence instead would mark ``hide_from_ui: False``,
which never skipped the guardrail and so needs no mark.
"""
if name == _SUMMARY_MESSAGE_NAME:
return True
return bool(additional_kwargs.get("hide_from_ui")) and read_human_input_response(additional_kwargs) is None
def _mark_untrusted_framework_markers(additional_kwargs: dict[str, Any], name: Any) -> dict[str, Any]:
"""Mark a caller's message whose markers would skip the input guardrail.
``is_genuine_user_message`` reads ``hide_from_ui`` and a human
``name="summary"`` as proof the framework wrote the message, and the guardrail
skips those — so a caller able to set either one placed raw
``<system-reminder>`` text outside the user-input boundary markers, which the
lead-agent prompt declares trusted internal framework data.
The markers are deliberately *kept*. ``hide_from_ui`` has a second,
legitimate role: three frontend senders (quoted conversation context, sidecar
context, the agent save command) set it purely to keep a context message out
of the transcript, carry no ``human_input_response``, and are hidden by
nothing else — removing it would render all three as chat bubbles. Only the
guardrail-skipping role is a vulnerability, so the two are separated here
instead: the message stays hidden, and ``requires_input_sanitization`` reads
this mark and sanitizes it anyway. Marking rather than removing also keeps
this boundary from silently changing behaviour that reads the marker for
presentation, persistence, or memory filtering.
HumanInputCard replies need no mark: a valid ``human_input_response`` already
makes them genuine, so they are sanitized on that path.
"""
if not _skips_input_guardrail(additional_kwargs, name):
return additional_kwargs
return {**additional_kwargs, UNTRUSTED_INPUT_KEY: True}
def _is_human_message_like(message: Any) -> bool:
"""Whether *message* is the human role ``is_genuine_user_message`` acts on.
Only that role reads ``name`` as a framework marker, and ``name`` on a
ToolMessage is the tool's own name — reserving it there would rename tools.
Matched by ``isinstance``, exactly as the predicate this defends does: a
``HumanMessageChunk`` is a ``HumanMessage`` whose ``type`` is not ``"human"``,
so a ``type``-based check would leave that subclass's marker settable.
"""
if isinstance(message, BaseMessage):
return isinstance(message, HumanMessage)
if isinstance(message, dict):
return (message.get("type") or message.get("role")) in {"human", "user"}
return False
def _strip_external_message_metadata(message: Any) -> Any:
"""Remove server-owned metadata from an untrusted input message.
Also stamps ``untrusted_input`` on a human message whose caller-owned markers
would skip the input guardrail — see ``_mark_untrusted_framework_markers``.
The stamp is applied after the strip loop, so a caller cannot preset it.
"""
if not isinstance(message, BaseMessage):
return message
additional_kwargs = dict(message.additional_kwargs)
additional_kwargs.pop(ORIGINAL_USER_CONTENT_KEY, None)
for key in _SERVER_OWNED_MESSAGE_METADATA_KEYS:
additional_kwargs.pop(key, None)
if _is_human_message_like(message):
additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, message.name)
if additional_kwargs == message.additional_kwargs:
return message
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def _strip_external_metadata_from_message_like(item: Any) -> Any:
"""Strip server-owned keys from message-like values outside ``messages``.
The top-level ``messages`` channel is canonicalized and role-checked by
``_normalize_input_messages``. Other middleware-contributed channels may
still carry either ``BaseMessage`` objects or raw dictionaries, so this
helper preserves those shapes while stripping metadata and stamping
``untrusted_input`` where caller-owned markers would skip the guardrail.
"""
if isinstance(item, BaseMessage):
return _strip_external_message_metadata(item)
if not isinstance(item, dict):
return item
# A missing (or non-dict) ``additional_kwargs`` is the most natural request
# shape, and it still needs the mark: the messages reducer coerces the dict
# with ``convert_to_messages``, which supplies ``additional_kwargs={}``, so an
# unmarked ``name="summary"`` would reach the model on the guardrail's
# genuine-user fallback. Treat it as empty for both steps rather than
# returning early.
source_kwargs = item.get("additional_kwargs")
source_kwargs = source_kwargs if isinstance(source_kwargs, dict) else {}
additional_kwargs = {key: value for key, value in source_kwargs.items() if key not in _SERVER_OWNED_MESSAGE_METADATA_KEYS and key != ORIGINAL_USER_CONTENT_KEY}
if _is_human_message_like(item):
additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, item.get("name"))
if additional_kwargs == source_kwargs:
# Nothing to change — including the ordinary key-omitted message, which
# must not gain an empty dict just by passing through here.
return item
return {**item, "additional_kwargs": additional_kwargs}
#: Server-owned verdict keys on a delegation-ledger entry: runtime-stamped
#: execution evidence (citation verdict PR2, acceptance checklist PR4) that a
#: caller must never supply.
_SERVER_OWNED_DELEGATION_VERDICT_KEYS = frozenset({"receipt_verdict", "acceptance_verdict"})
def _strip_external_delegation_verdict(entry: Any) -> Any:
"""Remove runtime-stamped verdicts from a caller-supplied ledger entry.
``receipt_verdict``/``acceptance_verdict`` are server-owned execution
evidence stamped at task write-back. Ledger entries are plain dicts, not
messages, so the message-metadata stripper never sees them; without this
a caller can persist a forged verdict that ``render_delegation_ledger``
would present as fact.
"""
if isinstance(entry, dict) and _SERVER_OWNED_DELEGATION_VERDICT_KEYS & entry.keys():
return {key: value for key, value in entry.items() if key not in _SERVER_OWNED_DELEGATION_VERDICT_KEYS}
return entry
def _normalize_input_messages(
value: Any,
*,
location: str,
trusted_internal: bool = False,
) -> list[BaseMessage]:
"""Coerce once, then check the actual role before any checkpoint write.
Match add_messages' list-or-single convention. Checking raw ``role`` keys
misses type aliases, (role, content) pairs, constructor envelopes and chunks.
The normalized objects are also the ones forwarded to the graph: there is
no second, unchecked interpretation of an accepted wire representation.
"""
messages = value if isinstance(value, list) else [value]
converted: list[BaseMessage] = []
for index, item in enumerate(messages):
try:
message = convert_to_messages([item])[0]
except (ValueError, TypeError, NotImplementedError, KeyError) as exc:
# LangChain's error may contain the complete caller message.
raise HTTPException(
status_code=400,
detail=f"Invalid message at {location}[{index}]",
) from exc
if not trusted_internal:
if isinstance(message, SystemMessage) or (isinstance(message, ChatMessage) and message.role.strip().lower() in {"system", "developer"}):
raise HTTPException(
status_code=400,
detail=(f"External system/developer messages are not allowed at {location}[{index}]"),
)
message = _strip_external_message_metadata(message)
converted.append(message)
return converted
def strip_server_owned_state_metadata(values: Mapping[str, Any]) -> dict[str, Any]:
"""Validate and sanitize caller-supplied state values before checkpointing.
The server-owned ``sandbox``, ``thread_data``, and ``viewed_images`` channels
are rejected. The ``messages`` channel
is canonicalized to a list of ``BaseMessage``
objects, rejects external system/developer roles with HTTP 400, and strips
server-owned metadata. Other channels keep their existing shapes while
forged metadata and delegation verdicts are removed. ``normalize_input``
applies the same message boundary to run input.
The thread-state mutation route writes values straight into a checkpoint,
so an authenticated client must not be able to persist forged provenance,
transform trails, or privileged message roles. Every channel is walked
because middleware-contributed channels can also carry message-like values.
"""
server_owned_channels = {"sandbox", "thread_data", "viewed_images"}
rejected = server_owned_channels.intersection(values)
if rejected:
raise HTTPException(
status_code=400,
detail=f"External {sorted(rejected)[0]} state is not allowed",
)
stripped: dict[str, Any] = {}
for channel, value in values.items():
if channel == "messages" and value is not None:
stripped[channel] = _normalize_input_messages(value, location="values.messages")
elif channel == "delegations" and isinstance(value, list):
stripped[channel] = [_strip_external_delegation_verdict(item) for item in value]
elif isinstance(value, list):
stripped[channel] = [_strip_external_metadata_from_message_like(item) for item in value]
else:
stripped[channel] = _strip_external_metadata_from_message_like(value)
return stripped
def normalize_input(raw_input: dict[str, Any] | None, *, trusted_internal: bool = False) -> dict[str, Any]:
"""Convert LangGraph Platform input format to LangChain state dict.
Delegates dict→message coercion to ``langchain_core.messages.utils.convert_to_messages``
so that ``additional_kwargs`` (e.g. uploaded-file metadata — gh #3132), ``id``,
``name``, and history roles (ai/tool) survive unchanged. System/developer
messages require authenticated internal admission; ordinary API credentials
(including admin and PAT callers) do not grant system-prompt authority. An earlier
hand-rolled version only forwarded ``content`` and collapsed every role to
``HumanMessage``, which silently stripped frontend-supplied attachments.
Malformed message dicts (missing ``role``/``type``/``content``, unsupported
role, etc.) raise ``HTTPException(400)`` with the offending index, instead
of bubbling up as a 500. The gateway is a system boundary, so per-entry
validation errors are the right shape for clients to retry against.
The ``sandbox``, ``thread_data``, and ``viewed_images`` channels are also
server-owned. External callers cannot select a provider resource by id or
supply host image paths; trusted internal run admission may carry restored
values.
``original_user_content``, dynamic-context reminder markers, the transient
view-image context marker, the execution-only knowledge-scope marker, tool
receipts, delegated receipt metadata/verdicts, and ``untrusted_input`` are
server-owned. External callers cannot supply them; trusted internal channel
calls may preserve metadata they added before invoking this boundary. The
same applies to the ``delegations`` channel: a caller-supplied ledger entry's
``receipt_verdict`` is a forgery and is stripped before the graph runs.
``hide_from_ui`` and a human ``summary`` name are the exception: they stay
caller-owned and are deliberately preserved, because ``hide_from_ui`` is also
how three frontend senders (quoted conversation context, sidecar context, the
agent save command) keep a context message out of the transcript, and nothing
else hides those. What they must not do is tell
``is_genuine_user_message`` the framework authored the message, which would
skip input sanitization — so a caller's message carrying either marker is
stamped with ``untrusted_input`` instead, and
``requires_input_sanitization`` sanitizes it anyway. That key is stripped
first, so a caller can neither forge nor clear it. HumanInputCard replies need
no stamp: a valid ``human_input_response`` already makes them genuine.
"""
if raw_input is None:
return {}
if not trusted_internal:
server_owned_channels = {"sandbox", "thread_data", "viewed_images"}
rejected = server_owned_channels.intersection(raw_input)
if rejected:
raise HTTPException(
status_code=400,
detail=f"External {sorted(rejected)[0]} state is not allowed",
)
result = raw_input
messages = raw_input.get("messages")
if messages is not None:
converted = _normalize_input_messages(messages, location="input.messages", trusted_internal=trusted_internal)
result = {**raw_input, "messages": converted}
if not trusted_internal:
delegations = result.get("delegations")
if isinstance(delegations, list):
cleaned = [_strip_external_delegation_verdict(entry) for entry in delegations]
if cleaned != delegations:
result = {**result, "delegations": cleaned}
return result
def _canonical_run_record_input(
raw_input: dict[str, Any] | None,
graph_input: object,
) -> dict[str, Any] | None:
"""Persist the same normalized messages that cross run admission.
The run record is a client-visible audit surface. Keeping the original raw
message there would preserve a non-canonical scope even though the graph
receives the validated form.
"""
if not isinstance(graph_input, dict):
return raw_input
canonical = dict(raw_input or {})
messages = graph_input.get("messages")
if isinstance(messages, list):
canonical["messages"] = [message.model_dump(mode="json") if isinstance(message, BaseMessage) else message for message in messages]
return canonical
_DEFAULT_ASSISTANT_ID = "lead_agent"
# Whitelist of run-context keys that the langgraph-compat layer forwards from
# ``body.context`` into the run config. ``config["context"]`` exists in
# LangGraph >=0.6, but these values must be written to both ``configurable``
# (for legacy ``_get_runtime_config`` consumers) and ``context`` because
# LangGraph >=1.1.9 no longer makes ``ToolRuntime.context`` fall back to
# ``configurable`` for consumers like ``setup_agent``.
_CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
{
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"max_total_subagents",
"agent_name",
"is_bootstrap",
}
)
# Keys honored only for internally-authenticated callers (the scheduler path).
# ``interaction_mode`` and ``non_interactive`` control clarification availability;
# arbitrary HTTP/IM clients must not be able to force autonomous execution.
_CONTEXT_INTERNAL_CALLER_KEYS: frozenset[str] = frozenset({"interaction_mode", "non_interactive"})
# Server-owned authorization and sandbox lifecycle identity fields. These must
# never be accepted from client-supplied ``body.config.context`` or
# ``body.config.configurable``. They
# are either produced by Gateway auth state, admitted from a separately
# authenticated internal request channel, or reserved for LangGraph Server.
# ``is_internal`` — derived from ``request.state.auth_source``
# ``authz_attributes`` — Phase 1A has no Gateway-side producer; cleared.
# ``channel_user_id`` — accepted only from trusted internal context.
# ``langgraph_auth_user*`` — populated only by LangGraph Server auth.
# ``sandbox_*_id`` — created only inside the run/subagent lifecycle.
_SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = (
frozenset(
{
"is_internal",
"authz_attributes",
"channel_user_id",
"is_subagent",
"agent_id",
"__run_loop_detection_recorder",
"__run_tool_promotion_recorder",
"__run_tool_progress_recorder",
"langgraph_auth_user",
"langgraph_auth_user_id",
THREAD_INCARNATION_METADATA_GUARD_KEY,
# Server-owned pinned project snapshot (spec §7.1): resolved once
# at admission from threads_meta; a client-supplied value must
# never survive in either run-config section.
PROJECT_CONTEXT_KEY,
KNOWLEDGE_SCOPE_KEY,
KNOWLEDGE_SCOPE_RUNTIME_KEY,
}
)
| SANDBOX_SERVER_OWNED_CONTEXT_KEYS
)
# Keys forwarded from ``body.context`` into ``config['context']`` ONLY (the
# runtime context that becomes ``ToolRuntime.context`` / ``runtime.context``),
# never into ``config['configurable']``. These are read by tools and
# middlewares from ``runtime.context`` and have no reason to live in
# ``configurable`` — and ``configurable`` is persisted in checkpoints, so
# keeping secrets like ``github_token`` out of it avoids writing a
# short-lived installation token into the checkpoint store.
#
# ``github_token`` — App installation token minted by the GitHub
# channel; the bash tool exposes it as
# ``GH_TOKEN``/``GITHUB_TOKEN`` so ``gh`` and
# ``git`` push as the bot, not the host user.
# ``disable_clarification`` — set for non-interactive channels (GitHub
# webhooks) so ClarificationMiddleware proceeds
# instead of dead-ending the run.
#
# ``channel_name`` — trusted channel identity used by interaction policy.
#
# These are produced server-side by the channel run policies
# (``ChannelManager._apply_channel_policy`` and ``app.gateway.github.run_policy``),
# which reach the Gateway over the internally-authenticated request channel, so
# they are internal-only as well — see :data:`_INTERNAL_ONLY_CONTEXT_KEYS`.
_CONTEXT_RUNTIME_ONLY_KEYS: frozenset[str] = frozenset({"github_token", "disable_clarification", "channel_name"})
# Every run-context key an external client may never supply, in either section.
# The two sets differ only in *where* a legitimate internal caller's value lands
# (both sections vs. ``context`` alone); their trust requirement is identical.
#
# ``disable_clarification`` is not a milder cousin of ``non_interactive``:
# ``ClarificationMiddleware`` answers every clarification — ``risk_confirmation``
# included — with "proceed without asking" instead of interrupting, and
# ``SandboxMiddleware`` reads the two keys as the same non-interactive signal.
# Accepting it from a client therefore reproduces the effect the
# ``non_interactive`` gate exists to prevent. ``github_token`` is a live
# credential that ``bash`` exports as ``GH_TOKEN``/``GITHUB_TOKEN``, and a copy
# smuggled through ``body.config['configurable']`` would be written to the
# checkpoint store.
_INTERNAL_ONLY_CONTEXT_KEYS: frozenset[str] = _CONTEXT_INTERNAL_CALLER_KEYS | _CONTEXT_RUNTIME_ONLY_KEYS | _SERVER_OWNED_RUNTIME_CONTEXT_KEYS
def strip_internal_context_keys(config: dict[str, Any]) -> None:
"""Drop internal-only keys a non-internal caller smuggled into the run config.
Gating :func:`merge_run_context_overrides` is not enough on its own:
``build_run_config`` copies a client-supplied ``body.config['context']`` /
``body.config['configurable']`` verbatim, so the same keys must be scrubbed
from both sections after the config is assembled.
"""
for section in ("context", "configurable"):
value = config.get(section)
if isinstance(value, dict):
for key in _INTERNAL_ONLY_CONTEXT_KEYS | _SERVER_OWNED_RUNTIME_CONTEXT_KEYS:
value.pop(key, None)
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None, *, internal: bool = False) -> None:
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
and ``config['context']`` so they are visible to legacy configurable readers and
to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool —
see issue #2677).
``user_id`` is intentionally propagated into ``config['context']`` in addition to
the whitelisted keys, so non-web callers (e.g. IM channels) that supply identity in
``body.context`` keep it on ``ToolRuntime.context``. It is merged with
``setdefault`` so a server-authenticated id stamped by
:func:`inject_authenticated_user_context` always wins over the client-supplied one.
:data:`_CONTEXT_INTERNAL_CALLER_KEYS` are also forwarded when ``internal``
is True; for non-internal callers those keys are dropped from client requests
by :func:`strip_internal_context_keys`.
A second set of keys (``_CONTEXT_RUNTIME_ONLY_KEYS`` — e.g. ``github_token``,
``disable_clarification``) is likewise forwarded only when ``internal`` is True,
and then into ``config['context']`` only, never ``configurable``. These are
secrets / runtime flags read by tools and middlewares from ``runtime.context``;
keeping them out of ``configurable`` avoids persisting a short-lived token in the
checkpoint store.
"""
if not context:
return
configurable = config.setdefault("configurable", {})
runtime_context = config.setdefault("context", {})
keys = _CONTEXT_CONFIGURABLE_KEYS | _CONTEXT_INTERNAL_CALLER_KEYS if internal else _CONTEXT_CONFIGURABLE_KEYS
for key in keys:
if key in context:
if isinstance(configurable, dict):
configurable.setdefault(key, context[key])
if isinstance(runtime_context, dict):
runtime_context.setdefault(key, context[key])
# Context-only keys (secrets / runtime flags) land in ``config['context']``
# only — never ``configurable`` (which is persisted in checkpoints) — and only
# for internal callers, the sole legitimate producers.
if internal:
for key in _CONTEXT_RUNTIME_ONLY_KEYS:
if key in context and isinstance(runtime_context, dict):
runtime_context.setdefault(key, context[key])
if "user_id" in context and isinstance(runtime_context, dict):
runtime_context.setdefault("user_id", context["user_id"])
async def resolve_trusted_internal_owner_for_attribution(request: Request, owner_user_id: str | None) -> Any | None:
"""Resolve the DeerFlow user used only for trusted internal attribution."""
if not owner_user_id:
return None
user = getattr(request.state, "user", None)
if getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
return None
try:
return await get_local_provider().get_user(owner_user_id)
except Exception:
logger.exception("Failed to resolve trusted internal owner %s", sanitize_log_param(owner_user_id))
return None
def inject_authenticated_user_context(
config: dict[str, Any],
request: Request,
*,
internal_owner_user: Any | None = None,
request_context: Mapping[str, Any] | None = None,
) -> None:
"""Stamp the authenticated user into the run context for background tools.
Tool execution may happen after the request handler has returned, so tools
that persist user-scoped files should not rely only on ambient ContextVars.
The value comes from server-side auth state, never from client context.
``request_context.channel_user_id`` is the sole exception: it is honored
only after ``request.state.auth_source`` proves the caller is internal.
Values copied through the free-form RunnableConfig are always cleared.
"""
# --- Server-owned authorization and sandbox lifecycle identity fields ---
# Clear any client-forged values from both config sections, then write the
# authoritative is_internal. This runs before ALL early returns so that
# even user_id-is-None paths get a defined is_internal value.
runtime_context = config.setdefault("context", {})
if not isinstance(runtime_context, dict):
raise TypeError("run context must be a mapping")
for key in _SERVER_OWNED_RUNTIME_CONTEXT_KEYS:
runtime_context.pop(key, None)
configurable = config.get("configurable")
if isinstance(configurable, dict):
for key in _SERVER_OWNED_RUNTIME_CONTEXT_KEYS:
configurable.pop(key, None)
auth_source = getattr(getattr(request, "state", None), "auth_source", None)
# ``user_id`` is server-owned for EXTERNAL callers: it now selects which
# user's credential user-scoped MCP auth injects, so a client-forged value
# must never survive any early return below — scrub it here and restamp it
# only from ``request.state.user``. Internal callers (IM channels, the
# scheduler) are the deliberate exception: they authenticate their own end
# users and supply that identity in run context (PR #3294), which the
# internal-role branch below preserves.
user = getattr(getattr(request, "state", None), "user", None)
if auth_source != AUTH_SOURCE_INTERNAL and getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
runtime_context.pop("user_id", None)
if isinstance(configurable, dict):
configurable.pop("user_id", None)
runtime_context["is_internal"] = auth_source == AUTH_SOURCE_INTERNAL
if auth_source == AUTH_SOURCE_INTERNAL and request_context is not None:
channel_user_id = request_context.get("channel_user_id")
if channel_user_id is not None:
runtime_context["channel_user_id"] = channel_user_id
user = getattr(request.state, "user", None)
user_id = getattr(user, "id", None)
if user_id is None:
return
if getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
runtime_context = config.setdefault("context", {})
if not isinstance(runtime_context, dict):
return
if internal_owner_user is None:
runtime_context.pop("user_role", None)
runtime_context.pop("oauth_provider", None)
runtime_context.pop("oauth_id", None)
return
owner_user_id = getattr(internal_owner_user, "id", None)
if owner_user_id is not None:
runtime_context["user_id"] = str(owner_user_id)
runtime_context["user_role"] = getattr(internal_owner_user, "system_role", None)
runtime_context["oauth_provider"] = getattr(internal_owner_user, "oauth_provider", None)
runtime_context["oauth_id"] = getattr(internal_owner_user, "oauth_id", None)
return
runtime_context = config.setdefault("context", {})
if isinstance(runtime_context, dict):
runtime_context["user_id"] = str(user_id)
runtime_context["user_role"] = getattr(user, "system_role", None)
runtime_context["oauth_provider"] = getattr(user, "oauth_provider", None)
runtime_context["oauth_id"] = getattr(user, "oauth_id", None)
def resolve_agent_factory(assistant_id: str | None):
"""Resolve the agent factory callable from config.
Custom agents are implemented as ``lead_agent`` + an ``agent_name``
injected into ``configurable`` or ``context`` — see
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
same factory; the routing happens inside the assembly when it reads
``cfg["agent_name"]``.
The result is ``assemble_lead_agent``, which returns a
``LeadAgentAssembly(graph, descriptor)`` rather than a bare graph, so every
consumer must unwrap ``.graph``. A third-party factory that still returns a
bare graph keeps working: the unwrap sites are type-checked, not assumed.
"""
from deerflow.agents.lead_agent.agent import assemble_lead_agent
return assemble_lead_agent
# Lead-agent recursion budget bounds. The Gateway must NOT trust a
# client-supplied ``recursion_limit`` verbatim: an arbitrarily large value lets
# a single run execute unbounded LangGraph super-steps (each at least one LLM
# call), enabling runaway API cost / DoS. ``_DEFAULT_RECURSION_LIMIT`` is the
# fallback when app config cannot be loaded; the normal server default and hard
# ceiling are configurable via ``AppConfig.recursion_limit`` and
# ``AppConfig.max_recursion_limit``.
_DEFAULT_RECURSION_LIMIT = 100
_DEFAULT_MAX_RECURSION_LIMIT = 1000
def _resolve_gateway_recursion_limits() -> tuple[int, int]:
"""Resolve the run default and ceiling from one hot-reloaded snapshot."""
try:
app_config = get_app_config()
raw = app_config.recursion_limit
max_limit = app_config.max_recursion_limit
if raw > max_limit:
logger.warning(
"recursion_limit %d exceeds max_recursion_limit %d; clamped to %d for Gateway runs",
raw,
max_limit,
max_limit,
)
return min(raw, max_limit), max_limit
except Exception:
logger.warning(
"failed to load app config; falling back to recursion_limit=%d and max_recursion_limit=%d for Gateway runs",
_DEFAULT_RECURSION_LIMIT,
_DEFAULT_MAX_RECURSION_LIMIT,
)
return _DEFAULT_RECURSION_LIMIT, _DEFAULT_MAX_RECURSION_LIMIT
def _resolve_max_recursion_limit() -> int:
"""Resolve the clamp ceiling from ``AppConfig.max_recursion_limit``.
Falls back to ``_DEFAULT_MAX_RECURSION_LIMIT`` when the app config cannot be
loaded (e.g. no ``config.yaml`` in a bare unit-test environment) so that the
clamp still applies rather than crashing the run-config assembly.
"""
try:
return get_app_config().max_recursion_limit
except Exception:
return _DEFAULT_MAX_RECURSION_LIMIT
def _resolve_scheduler_recursion_limit() -> int:
"""Resolve the scheduled-run recursion_limit from ``AppConfig.scheduler``.
Falls back to ``_DEFAULT_RECURSION_LIMIT`` when the app config cannot be
loaded so a missing ``config.yaml`` in tests still launches with the server
default rather than crashing dispatch. The value is clamped to
``max_recursion_limit`` here, at dispatch, so an operator value above the
ceiling never reaches ``build_run_config`` as an unclamped value — which
would otherwise be misattributed as a "client" overage and emit a
``clamped client recursion_limit`` warning on every scheduled run.
``build_run_config`` keeps its own clamp for genuine client-supplied
bodies (defense in depth; its warning then only fires for real clients).
Both silent paths now emit an operator-visible warning: the pre-clamp
(operator value above ``max_recursion_limit``) and the config-load failure
fallback (``_DEFAULT_RECURSION_LIMIT`` returned instead of the operator
value).
"""
try:
raw = get_app_config().scheduler.recursion_limit
max_limit = _resolve_max_recursion_limit()
if raw > max_limit:
logger.warning(
"scheduler.recursion_limit %d exceeds max_recursion_limit %d; clamped to %d for scheduled runs",
raw,
max_limit,
max_limit,
)
return min(raw, max_limit)
except Exception:
logger.warning(
"failed to load app config; falling back to recursion_limit=%d for scheduled runs",
_DEFAULT_RECURSION_LIMIT,
)
return _DEFAULT_RECURSION_LIMIT
def _clamp_recursion_limit(value: Any, max_limit: int, default_limit: int) -> int:
"""Clamp a client-supplied ``recursion_limit`` into a safe server range.
Non-integer values (including ``bool``, an ``int`` subclass) and non-positive
values fall back to the configured default; valid positive integers are
capped at ``max_limit`` (from ``AppConfig.max_recursion_limit``).
"""
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return default_limit
return min(value, max_limit)
def build_run_config(
thread_id: str,
request_config: dict[str, Any] | None,
metadata: dict[str, Any] | None,
*,
assistant_id: str | None = None,
) -> dict[str, Any]:
"""Build a RunnableConfig dict for the agent.
When *assistant_id* refers to a custom agent (anything other than
``"lead_agent"`` / ``None``), the name is forwarded as ``agent_name`` in
both ``configurable`` and ``context`` so it is visible to legacy
configurable readers and to LangGraph ``ToolRuntime.context`` consumers
(e.g. the ``setup_agent`` tool, which since LangGraph >=1.1.9 no longer
falls back from ``context`` to ``configurable``). An explicit
``agent_name`` in either container takes precedence over the value
derived from ``assistant_id``. ``make_lead_agent`` reads this key to
load the matching ``agents/<name>/SOUL.md`` and per-agent config —
without it the agent silently runs as the default lead agent.
This mirrors the channel manager's ``_resolve_run_params`` logic except for
the recursion default: Gateway API runs use the configured top-level
``recursion_limit``, while IM channel runs retain their own default.
"""
# Lead-agent recursion budget (LangGraph super-steps for the lead graph
# only). Independent of subagent depth: a `task()` dispatch runs the whole
# subagent inside ONE lead tools-node step, and subagents enforce their own
# limit via `subagents.max_turns`. Do not conflate this budget with the
# general-purpose subagent's max_turns.
default_recursion_limit, max_recursion_limit = _resolve_gateway_recursion_limits()
config: dict[str, Any] = {"recursion_limit": default_recursion_limit}
if request_config:
# LangGraph >= 0.6.0 introduced ``context`` as the preferred way to
# pass thread-level data and rejects requests that include both
# ``configurable`` and ``context``. If the caller already sends
# ``context``, honour it and skip our own ``configurable`` dict.
if "context" in request_config:
if "configurable" in request_config:
logger.warning(
"build_run_config: client sent both 'context' and 'configurable'; preferring 'context' (LangGraph >= 0.6.0). thread_id=%s, caller_configurable keys=%s",
thread_id,
list((request_config.get("configurable") or {}).keys()),
)
context_value = request_config["context"]
if context_value is None:
context = {}
elif isinstance(context_value, Mapping):
# Strip caller-supplied ``__``-prefixed keys: those are the
# harness's private run-context channels (skill secret-binding
# sources, the active-secret set, the run journal). A caller must
# not be able to seed them and forge internal state — e.g. a
# forged ``__slash_skill_secret_source`` would otherwise bypass the
# skill enabled/allowlist/declaration gates (#3938). Legitimate
# caller keys (``secrets``, ``user_id``, model overrides) never use
# the ``__`` prefix.
context = {key: value for key, value in context_value.items() if not (isinstance(key, str) and key.startswith("__"))}
else:
raise ValueError("request config 'context' must be a mapping or null.")
context["thread_id"] = thread_id
config["context"] = context
# The checkpointer always scopes state by configurable["thread_id"],
# regardless of whether the caller drives the run via context (e.g.
# request-scoped secrets, #3861). thread_id comes from the URL path,
# not caller config, so mirror it here while keeping secret-bearing
# context keys out of configurable.
config["configurable"] = {"thread_id": thread_id}
else:
configurable = {"thread_id": thread_id}
configurable.update(request_config.get("configurable") or {})
configurable["thread_id"] = thread_id
config["configurable"] = configurable
for k, v in request_config.items():
if k not in ("configurable", "context"):
config[k] = v
# Never trust a client-supplied recursion_limit verbatim: clamp it to a
# safe server range so a single run cannot execute unbounded LangGraph
# super-steps (runaway LLM cost / DoS). Applied after the passthrough so
# it overrides whatever the client sent.
if "recursion_limit" in request_config:
clamped = _clamp_recursion_limit(request_config["recursion_limit"], max_recursion_limit, default_recursion_limit)
if clamped != request_config["recursion_limit"]:
logger.warning(
"build_run_config: clamped client recursion_limit %r -> %d (max %d). thread_id=%s",
request_config["recursion_limit"],
clamped,
max_recursion_limit,
thread_id,
)
config["recursion_limit"] = clamped
else:
config["configurable"] = {"thread_id": thread_id}
# Inject custom agent name when the caller specified a non-default assistant.
# Honour an explicit agent_name in either runtime options container.
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
normalized = assistant_id.strip().lower().replace("_", "-")
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
configurable = config.setdefault("configurable", {})
runtime_context = config.setdefault("context", {})
explicit_agent_name: str | None = None
if isinstance(configurable, dict) and isinstance(configurable.get("agent_name"), str):
explicit_agent_name = configurable["agent_name"]
elif isinstance(runtime_context, dict) and isinstance(runtime_context.get("agent_name"), str):
explicit_agent_name = runtime_context["agent_name"]
effective_agent_name = explicit_agent_name or normalized
if isinstance(configurable, dict):
configurable["agent_name"] = effective_agent_name
if isinstance(runtime_context, dict):
runtime_context["agent_name"] = effective_agent_name
config.setdefault("run_name", resolve_root_run_name(config, normalized))
for section in ("configurable", "context"):
external_values = config.get(section)
if isinstance(external_values, dict):
external_values.pop(INTERNAL_CHECKPOINT_MODE_KEY, None)
if metadata:
# Merged onto a copy: config["metadata"] is the same dict object as the
# caller's body.config["metadata"] (the passthrough above copies
# references), and an in-place update would write server-stamped keys
# -- the trace id -- through into the request body that is persisted
# and echoed as the run's kwargs.
existing_metadata = config.get("metadata")
merged_metadata = dict(existing_metadata) if isinstance(existing_metadata, dict) else {}
merged_metadata.update(metadata)
config["metadata"] = merged_metadata
return config
def build_checkpoint_state_mutation_accessor(
request: Request,
*,
thread_id: str,
as_node: str,
checkpoint_id: str | None = None,
state_schema: Any | None = None,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Build a state-only graph whose writer node finishes immediately.
``state_schema`` should be the thread's effective schema (from
:func:`graph_state_schema` on the assistant graph) whenever the write
carries materialized state; with the base-schema fallback, channels
contributed by custom middleware are silently discarded.
"""
mode = getattr(request.app.state, "checkpoint_channel_mode", "full")
config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
if checkpoint_id is not None:
config["configurable"]["checkpoint_id"] = checkpoint_id
inject_checkpoint_mode(config, mode)
graph = build_state_mutation_graph(as_node, mode, state_schema)
accessor = CheckpointStateAccessor.bind(
graph,
get_checkpointer(request),
store=getattr(request.app.state, "store", None),
mode=mode,
)
return accessor, config
# Cache of factory-built accessor graphs. Accessor operations (aget_state /
# aupdate_state) never execute graph nodes or middleware, so per-request
# variations (user, model, skills) cannot affect materialization semantics;
# the compiled graph is stable per (assistant_id, mode, snapshot_frequency,
# app_config). The
# factory and app_config identities are re-validated on every call so patched
# factories take effect immediately and a config.yaml hot-reload (which
# rebuilds the AppConfig object) never serves a stale compiled graph — the
# cached reference keeps the old config alive, so id-reuse cannot produce a
# false hit. Bounded: cleared when too many distinct assistants appear. The
# cap is configurable (database.checkpoint_graph_cache.accessor_graph_max)
# and re-read on every eviction check, so a hot-reload takes effect without
# a restart.
_STATE_ACCESSOR_GRAPH_CACHE_MAX = 64
_state_accessor_graph_cache: dict[tuple[str | None, str, int | None], tuple[Any, Any, Any]] = {}
_state_accessor_graph_cache_lock = threading.Lock()
_state_accessor_graph_build_locks = KeyedLockTable[tuple[str | None, str, int | None]]()
def _accessor_graph_cache_max(app_config: Any) -> int:
return resolve_checkpoint_graph_cache_max(
getattr(app_config, "database", None),
"accessor_graph_max",
_STATE_ACCESSOR_GRAPH_CACHE_MAX,
)
def _cached_state_accessor_graph(key: tuple[str | None, str, int | None], agent_factory: Any, app_config: Any) -> Any | None:
with _state_accessor_graph_cache_lock:
cached = _state_accessor_graph_cache.get(key)
if cached is not None and cached[0] is agent_factory and cached[1] is app_config:
return cached[2]
return None
def _cache_state_accessor_graph(key: tuple[str | None, str, int | None], agent_factory: Any, app_config: Any, graph: Any) -> None:
with _state_accessor_graph_cache_lock:
if len(_state_accessor_graph_cache) >= _accessor_graph_cache_max(app_config):
_state_accessor_graph_cache.clear()
_state_accessor_graph_cache[key] = (agent_factory, app_config, graph)
def _build_state_accessor_graph(agent_factory: Any, config: dict[str, Any]) -> Any:
agent_result = agent_factory(config=config)
try:
from deerflow.agents.lead_agent.agent import unwrap_agent_graph
return unwrap_agent_graph(agent_result)
except Exception:
# A custom factory must keep working even if importing the lead
# assembly type fails.
return agent_result
def _state_accessor_graph(agent_factory: Any, assistant_id: str | None, mode: str, snapshot_frequency: int | None, config: dict[str, Any]) -> Any:
app_config = (config.get("context") or {}).get("app_config")
key = (assistant_id, mode, snapshot_frequency)
cached = _cached_state_accessor_graph(key, agent_factory, app_config)
if cached is not None:
return cached
# Construction runs on assembly-pool threads, so same-key cold misses are
# serialized with a thread lock. The re-check under the lock makes
# overlapping first readers run the factory exactly once; a waiter whose
# factory or app-config identity changed while it waited still rebuilds,
# preserving identity-based cache invalidation.
with _state_accessor_graph_build_locks.hold(key):
cached = _cached_state_accessor_graph(key, agent_factory, app_config)
if cached is not None:
return cached
graph = _build_state_accessor_graph(agent_factory, config)
_cache_state_accessor_graph(key, agent_factory, app_config, graph)
return graph
class _RawCheckpointSnapshot:
"""StateSnapshot-shaped view over a raw checkpoint tuple (full mode only).
``next``/``tasks`` are not derivable without the compiled graph and
degrade to empty; everything the read endpoints serialize (values,
metadata, config ancestry, created_at) comes straight from the tuple.
"""
__slots__ = ("checkpoint_exists", "config", "values", "metadata", "parent_config", "created_at", "tasks", "tasks_known", "next")
def __init__(self, config: dict[str, Any], tup: Any | None) -> None:
self.checkpoint_exists = tup is not None
self.config = getattr(tup, "config", None) or config
checkpoint = getattr(tup, "checkpoint", None) or {}
self.values = dict(checkpoint.get("channel_values") or {})
self.metadata = dict(getattr(tup, "metadata", None) or {})
self.parent_config = getattr(tup, "parent_config", None)
self.created_at = checkpoint.get("ts") or self.metadata.get("created_at", "")
self.tasks: tuple = ()
self.tasks_known = False
self.next: tuple = ()
class _RawCheckpointReadAccessor:
"""Degraded full-mode read accessor for when the agent factory is down.
Full-mode checkpoints persist complete ``channel_values``, so reads do not
need the compiled graph. The fail-closed delta gate still applies: delta
checkpoints are rejected with :class:`CheckpointModeMismatchError` instead
of being served as partial state. Writes are unsupported — mutation paths
keep using the graph-backed accessor.
"""
def __init__(self, checkpointer: Any, mode: str) -> None:
self.checkpointer = checkpointer
self.mode = mode
@staticmethod
def _gate(tup: Any) -> None:
if checkpoint_tuple_uses_delta(tup):
raise CheckpointModeMismatchError("Thread requires delta mode; materialize and convert its checkpoints before using full mode.")
async def aget(self, config: dict[str, Any]) -> _RawCheckpointSnapshot:
tup = await self.checkpointer.aget_tuple(config)
self._gate(tup)
return _RawCheckpointSnapshot(config, tup)
async def ahistory(self, config: dict[str, Any], *, limit: int | None = None) -> list[_RawCheckpointSnapshot]:
if limit is not None and limit <= 0:
return []
result: list[_RawCheckpointSnapshot] = []
before = None
walk_config = config
if config.get("configurable", {}).get("checkpoint_id"):
# Pregel's get_state_history treats config.checkpoint_id as the
# inclusive start of the walk, while alist(before=...) is
# exclusive — fetch the anchor explicitly so the degraded path
# matches the graph path.
before = config
walk_config = {
**config,
"configurable": {k: v for k, v in config.get("configurable", {}).items() if k != "checkpoint_id"},
}
anchor = await self.checkpointer.aget_tuple(before)
self._gate(anchor)
if anchor is not None:
result.append(_RawCheckpointSnapshot(config, anchor))
if limit is None or len(result) < limit:
remaining = None if limit is None else limit - len(result)
async for tup in self.checkpointer.alist(walk_config, before=before, limit=remaining):
self._gate(tup)
result.append(_RawCheckpointSnapshot(config, tup))
if limit is not None and len(result) >= limit:
break
return result
def build_checkpoint_state_accessor(
request: Request,
*,
thread_id: str,
assistant_id: str | None = None,
checkpoint_id: str | None = None,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Build the mode-selected lead graph used for materialized checkpoint state."""
ctx = get_run_context(request)
config = build_run_config(thread_id, None, None, assistant_id=assistant_id)
configurable = config.setdefault("configurable", {})
configurable["checkpoint_ns"] = ""
if checkpoint_id is not None:
configurable["checkpoint_id"] = checkpoint_id
if ctx.app_config is not None:
config.setdefault("context", {})["app_config"] = ctx.app_config
inject_checkpoint_mode(config, ctx.checkpoint_channel_mode)
agent_factory = resolve_agent_factory(assistant_id)
try:
graph = _state_accessor_graph(
agent_factory,
assistant_id,
ctx.checkpoint_channel_mode,
getattr(ctx, "checkpoint_snapshot_frequency", None),
config,
)
except Exception:
if ctx.checkpoint_channel_mode != "full":
# Delta materialization needs the graph's channel table; there is
# no degraded path. Surface the factory failure as-is.
raise
# Full-mode checkpoints carry complete channel_values: degrade to raw
# checkpointer reads so state endpoints survive a broken agent factory
# (bad model config, MCP server down, misconfigured skill).
logger.warning(
"Agent factory unavailable for thread %s; falling back to raw checkpointer reads",
thread_id,
exc_info=True,
)
return _RawCheckpointReadAccessor(ctx.checkpointer, ctx.checkpoint_channel_mode), config
accessor = CheckpointStateAccessor.bind(
graph,
ctx.checkpointer,
store=ctx.store,
mode=ctx.checkpoint_channel_mode,
)
return accessor, config
async def abuild_checkpoint_state_accessor(
request: Request,
*,
thread_id: str,
assistant_id: str | None = None,
checkpoint_id: str | None = None,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Async variant of :func:`build_checkpoint_state_accessor`.
Identical accessor construction, but the agent-factory assembly — which
re-enters ``get_available_tools()`` and may block on MCP cache
initialization — runs off-loop on the dedicated assembly pool so the
Gateway event loop keeps making progress (issue #5172). Repeat calls hit
``_state_accessor_graph_cache`` and only pay the thread hop; overlapping
cold readers with the same cache key are serialized per key so the
factory runs exactly once, and a reader whose factory or app-config
identity changed while it waited rebuilds instead of reusing the
winner's graph.
"""
return await run_assembly(
build_checkpoint_state_accessor,
request,
thread_id=thread_id,
assistant_id=assistant_id,
checkpoint_id=checkpoint_id,
)
async def resolve_thread_assistant_id(
request: Request,
thread_id: str,
*,
fail_closed: bool = False,
) -> str | None:
"""Return the assistant_id recorded in thread metadata, or ``None``.
Missing records degrade to ``None`` (the default lead agent). Store
failures do the same for read callers, while mutation callers set
``fail_closed`` so they cannot compile a write graph with the wrong schema.
"""
from app.gateway.deps import get_thread_store
try:
thread_store = get_thread_store(request)
record = await thread_store.get(thread_id)
except Exception:
logger.warning("Failed to resolve assistant_id for thread %s", thread_id, exc_info=True)
if fail_closed:
raise
return None
return record.get("assistant_id") if isinstance(record, dict) else None
async def build_thread_checkpoint_state_accessor(
request: Request,
*,
thread_id: str,
checkpoint_id: str | None = None,
fail_closed: bool = False,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Single resolution boundary for state endpoints.
Thread metadata -> assistant_id -> effective assistant graph. Materializing
with the default lead schema would drop channels contributed by a custom
``AgentMiddleware.state_schema`` from the response.
"""
assistant_id = await resolve_thread_assistant_id(request, thread_id, fail_closed=fail_closed)
return await abuild_checkpoint_state_accessor(
request,
thread_id=thread_id,
assistant_id=assistant_id,
checkpoint_id=checkpoint_id,
)
async def build_thread_checkpoint_state_mutation_accessor(
request: Request,
*,
thread_id: str,
as_node: str,
checkpoint_id: str | None = None,
) -> tuple[CheckpointStateAccessor, dict[str, Any]]:
"""Mutation accessor compiled with the thread's effective state schema.
Derives the schema through :func:`build_thread_checkpoint_state_accessor`
so writes carrying materialized state do not silently discard
extension-owned channels.
"""
read_accessor, _read_config = await build_thread_checkpoint_state_accessor(
request,
thread_id=thread_id,
checkpoint_id=checkpoint_id,
fail_closed=True,
)
state_schema = graph_state_schema(getattr(read_accessor, "graph", None))
return build_checkpoint_state_mutation_accessor(
request,
thread_id=thread_id,
as_node=as_node,
checkpoint_id=checkpoint_id,
state_schema=state_schema,
)
async def apply_checkpoint_to_run_config(
config: dict[str, Any],
*,
body: Any,
thread_id: str,
request: Request,
) -> None:
"""Validate an optional run checkpoint and attach it to RunnableConfig."""
checkpoint = getattr(body, "checkpoint", None)
checkpoint_id = getattr(body, "checkpoint_id", None)
checkpoint_ns = ""
checkpoint_map = None
if checkpoint:
if not isinstance(checkpoint, Mapping):
raise HTTPException(status_code=400, detail="checkpoint must be an object")
checkpoint_thread_id = checkpoint.get("thread_id")
if checkpoint_thread_id is not None and str(checkpoint_thread_id) != thread_id:
raise HTTPException(status_code=400, detail="checkpoint thread_id does not match request thread_id")
raw_checkpoint_id = checkpoint.get("checkpoint_id")
if raw_checkpoint_id:
checkpoint_id = str(raw_checkpoint_id)
raw_checkpoint_ns = checkpoint.get("checkpoint_ns")
if raw_checkpoint_ns is not None:
checkpoint_ns = str(raw_checkpoint_ns)
checkpoint_map = checkpoint.get("checkpoint_map")
if not checkpoint_id:
return
read_config: dict[str, Any] = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": str(checkpoint_id),
}
}
if checkpoint_map is not None:
read_config["configurable"]["checkpoint_map"] = checkpoint_map
checkpointer = get_checkpointer(request)
try:
checkpoint_tuple = await checkpointer.aget_tuple(read_config)
except Exception as exc:
logger.exception("Failed to validate checkpoint %s for thread %s", checkpoint_id, sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to validate checkpoint") from exc
if checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Checkpoint {checkpoint_id} not found")
configurable = config.setdefault("configurable", {})
if not isinstance(configurable, dict):
raise HTTPException(status_code=400, detail="request config configurable must be an object")
configurable["thread_id"] = thread_id
configurable["checkpoint_ns"] = checkpoint_ns
configurable["checkpoint_id"] = str(checkpoint_id)
if checkpoint_map is not None:
configurable["checkpoint_map"] = checkpoint_map
async def ensure_checkpoint_history_seeded(
request: Request,
*,
thread_id: str,
assistant_id: str | None,
) -> None:
"""Backfill an empty run-event feed from an existing checkpoint head.
No-op unless the feed is empty AND a checkpoint head with messages
exists — i.e. a legacy checkpoint-only thread facing its first journaled
run. This is a migration shim: remove it once pre-journal threads are no
longer a supported upgrade source. The info log on a successful seed is
the observability hook for that decision — when it stops appearing, the
shim is dead.
"""
event_store = request.app.state.run_event_store
# The emptiness check is deliberately thread-scoped, never user-scoped:
# seed rows may be stamped with a different principal (NULL for ownerless
# seeds, or another user on a shared NULL-owner thread), so a user-scoped
# query would miss them and re-seed a duplicate history per principal.
# Passing user_id=None also opts out of AUTO resolution explicitly, which
# would raise when no user contextvar is set (e.g. the scheduler launch
# path for ownerless internal tasks).
if await event_store.list_messages(thread_id, limit=1, user_id=None):
return
checkpoint_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
if await get_checkpointer(request).aget_tuple(checkpoint_config) is None:
return
accessor, config = await abuild_checkpoint_state_accessor(
request,
thread_id=thread_id,
assistant_id=assistant_id,
)
snapshot = await accessor.aget(config)
values = getattr(snapshot, "values", None)
messages = values.get("messages") if isinstance(values, dict) else None
if not isinstance(messages, list) or not messages:
return
events = build_checkpoint_history_seed_events(
messages,
thread_id=thread_id,
run_id_prefix=f"checkpoint-seed-{thread_id}",
)
if not events:
return
await event_store.put_batch(events)
logger.info("Seeded %d checkpoint-history events for thread %s", len(events), thread_id)
def _message_identifier(message: Any) -> str | None:
if isinstance(message, BaseMessage):
return str(message.id) if message.id else None
if isinstance(message, Mapping):
value = message.get("id")
return str(value) if value else None
return None
def _message_additional_kwargs(message: Any) -> Mapping[str, Any]:
if isinstance(message, BaseMessage):
return message.additional_kwargs
if isinstance(message, Mapping):
value = message.get("additional_kwargs")
return value if isinstance(value, Mapping) else {}
return {}
def _is_scope_source_human_message(message: Any) -> bool:
"""Return whether a checkpoint message can originate a recovered scope."""
if isinstance(message, HumanMessage):
return is_genuine_user_message(message)
if not isinstance(message, Mapping):
return False
if message.get("type") != "human" and message.get("role") not in {"human", "user"}:
return False
return not _skips_input_guardrail(dict(_message_additional_kwargs(message)), message.get("name"))
async def _recover_run_knowledge_scope(
request: Request,
*,
thread_id: str,
target_message_id: str | None,
) -> object | None:
"""Resolve one replay/resume scope from the authoritative latest checkpoint."""
accessor, config = await build_thread_checkpoint_state_accessor(
request,
thread_id=thread_id,
)
try:
snapshot = await accessor.aget(config)
except Exception as exc:
logger.exception("Failed to recover knowledge scope for thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to recover knowledge scope") from exc
values = getattr(snapshot, "values", None)
messages = values.get("messages") if isinstance(values, Mapping) else None
if not isinstance(messages, list):
messages = []
source: Any | None = None
if target_message_id:
target_index = next(
(index for index, message in enumerate(messages) if _message_identifier(message) == target_message_id),
None,
)
if target_index is not None:
source = next(
(message for message in reversed(messages[:target_index]) if _is_scope_source_human_message(message)),
None,
)
else:
# Interrupted assistant output may never reach a checkpoint. Its
# source is still the terminal HumanMessage of the latest state.
source = next(
(message for message in reversed(messages) if _is_scope_source_human_message(message)),
None,
)
if source is None:
raise HTTPException(
status_code=409,
detail="Could not recover the source HumanMessage knowledge_scope",
)
else:
source = next(
(message for message in reversed(messages) if _is_scope_source_human_message(message)),
None,
)
if source is None:
return None
additional_kwargs = _message_additional_kwargs(source)
return additional_kwargs.get(KNOWLEDGE_SCOPE_KEY)
def _current_human_message(graph_input: object) -> HumanMessage | None:
if not isinstance(graph_input, Mapping):
return None
messages = graph_input.get("messages")
if not isinstance(messages, list):
return None
return next(
(message for message in reversed(messages) if isinstance(message, HumanMessage)),
None,
)
async def _load_scope_agent_config(
*,
assistant_id: str | None,
user_id: str | None,
) -> Any | None:
if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID:
return None
normalized = assistant_id.strip().lower().replace("_", "-")
try:
return await asyncio.to_thread(
load_agent_config,
normalized,
user_id=user_id,
)
except (FileNotFoundError, ValueError) as exc:
raise HTTPException(
status_code=422,
detail="knowledge_scope assistant configuration could not be resolved",
) from exc
async def _validate_scope_thread_binding(
run_ctx: RunContext,
*,
thread_id: str,
assistant_id: str | None,
) -> None:
existing = await run_ctx.thread_store.get(thread_id)
if not isinstance(existing, Mapping):
return
bound = existing.get("assistant_id")
if isinstance(bound, str) and bound and assistant_id and bound != assistant_id:
raise HTTPException(
status_code=409,
detail="Thread assistant does not match knowledge_scope assistant",
)
# ---------------------------------------------------------------------------
# Run lifecycle
# ---------------------------------------------------------------------------
_SCHEDULER_METADATA_KEYS = frozenset(
{"scheduled_task_id", "scheduled_task_run_id", "scheduled_trigger", "scheduled_goal_objective", "scheduled_previous_thread_id", "scheduled_context_mode", "scheduled_tool_created", "__scheduler_capability"}
)
_MAX_CLIENT_TIMEZONE_CHARS = 64
async def _client_timezone_from_context(context: Mapping[str, Any] | None) -> str | None:
"""The browser timezone the web client sent in ``body.context``, if valid.
Read only for the schedule capability (default zone for new tasks). It is
not a ``_CONTEXT_CONFIGURABLE_KEYS`` entry, so it never reaches the run
config, the checkpoint or the prompt.
"""
raw = context.get("client_timezone") if isinstance(context, Mapping) else None
if raw is None:
return None
if not isinstance(raw, str) or not raw or len(raw) > _MAX_CLIENT_TIMEZONE_CHARS:
logger.debug("Ignoring a malformed client_timezone in the run context")
return None
try:
return await asyncio.to_thread(validate_timezone, raw)
except (ValueError, OSError):
logger.debug("Ignoring an unknown client_timezone %s", sanitize_log_param(raw))
return None
def _admit_scheduler_metadata(value: object, *, trusted: bool) -> dict[str, Any]:
"""Keep scheduler snapshots only on the private scheduler launch path."""
result = dict(value) if isinstance(value, Mapping) else {}
if not trusted:
for key in _SCHEDULER_METADATA_KEYS:
result.pop(key, None)
# A serialized value can never recover an execution capability.
result.pop("__scheduler_capability", None)
return result
async def start_run(
body: RunCreateRequest,
thread_id: str,
request: Request,
*,
idempotency_key: str | None = None,
require_existing_thread: bool = False,
scheduled_task_runtime: Mapping[str, Any] | None = None,
) -> RunRecord:
"""Create a RunRecord and launch the background agent task.
Parameters
----------
body : RunCreateRequest
The validated request body shared by HTTP and internal launch paths.
thread_id : str
Target thread.
request : Request
FastAPI request — used to retrieve singletons from ``app.state``.
require_existing_thread : bool
Reject a missing thread instead of auto-creating metadata. Internal
notification runs use this so a deleted chat cannot be resurrected.
"""
# Cancel-capability gate. interrupt/rollback strategies terminate an already
# active run — runs:cancel capability, not runs:create — so a create-only
# PAT must not reach them. Enforced here, the single choke point every
# run-creation path flows through (HTTP routes and internal launchers
# alike), so no entry point can bypass it; regenerate launches pass
# multitask_strategy="reject" and are unaffected. Requests without a
# stamped auth context (internal/test compositions) skip the gate.
require_cancel_permission_if(request, body.multitask_strategy != "reject")
if scheduled_task_runtime is not None and getattr(request.state, "auth_source", None) != AUTH_SOURCE_INTERNAL:
raise HTTPException(status_code=403, detail="Scheduled runtime authority requires an internal launcher")
try:
validate_thread_id(thread_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
body_config = getattr(body, "config", None)
config_metadata = body_config.get("metadata") if isinstance(body_config, dict) else None
try:
validate_run_metadata_secrets(getattr(body, "metadata", None))
validate_run_metadata_secrets(config_metadata)
except LegacyRunMetadataSecretError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
stream_modes = normalize_stream_modes(body.stream_mode)
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
run_ctx = get_run_context(request)
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
body_context = getattr(body, "context", None) or {}
model_name = body_context.get("model_name")
# Coerce non-string model_name values to str before truncation.
if model_name is not None and not isinstance(model_name, str):
model_name = str(model_name)
# Validate model against the allowlist when a model_name is provided.
if model_name:
app_config = get_app_config()
resolved = app_config.get_model_config(model_name)
if resolved is None:
raise HTTPException(
status_code=400,
detail=f"Model {model_name!r} is not in the configured model allowlist",
)
owner_user_id = get_trusted_internal_owner_user_id(request)
# Stateless run endpoints carry thread_id in the request *body*, so the
# @require_permission(owner_check=True) decorator -- which resolves ownership
# from the path param -- cannot protect them. Enforce thread ownership here,
# before any run is created, so one user cannot start runs on (or read /wait
# checkpoint state from) another user's thread. Missing rows (auto-created
# temp threads) and NULL-owner rows (shared / pre-auth data) stay accessible
# via check_access; only a thread already owned by another user is rejected
# with 404, matching thread_runs.py's anti-enumeration behaviour. Internal
# channel runs act on behalf of the connection owner carried in
# X-DeerFlow-Owner-User-Id, so they are scoped to that owner instead of
# bypassing the check -- a leaked internal token must not grant cross-user
# thread access.
user = getattr(request.state, "user", None)
async def thread_access_allowed() -> bool:
if user is None:
if not require_existing_thread:
return True
return await run_ctx.thread_store.get(thread_id) is not None
allowed = await run_ctx.thread_store.check_access(
thread_id,
str(user.id),
require_existing=require_existing_thread,
)
if not allowed and owner_user_id and getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
# Channel workers may also act for the connection owner named in
# the trusted header (e.g. claiming a legacy default-owned channel
# thread for its real owner).
allowed = await run_ctx.thread_store.check_access(
thread_id,
owner_user_id,
require_existing=require_existing_thread,
)
return allowed
if not await thread_access_allowed():
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
owner_context_token = set_current_user(SimpleNamespace(id=owner_user_id)) if owner_user_id else None
try:
is_internal_caller = getattr(getattr(request, "state", None), "auth_source", None) == AUTH_SOURCE_INTERNAL
# Validate even when resume takes precedence, so ignored input cannot
# appear to have been admitted or persist as unchecked run audit data.
normalized_input = normalize_input(body.input, trusted_internal=is_internal_caller)
# Cold resolution imports the lead-agent stack. Use the assembly pool
# so import-lock waiters cannot starve unrelated default-executor work.
# Keep this before admission so import failures cannot create a run.
agent_factory = await run_assembly(resolve_agent_factory, body.assistant_id)
command = getattr(body, "command", None)
if command and command.get("resume") is not None:
graph_input = Command(resume=command["resume"])
else:
graph_input = normalized_input
# deerflow_trace_id is server-issued, so the caller's value is replaced
# here at the trust boundary. body.metadata forks two ways -- through
# build_run_config into config["metadata"], which the run worker
# restamps, and through create_or_reject into the run record, which the
# runs API echoes verbatim. Only the first is covered downstream, so
# without this the run record is the one surface that persists a forged
# id, disagreeing with the response header, the logs, and the
# checkpoint. The caller's own metadata keys are preserved.
# deerflow_origin is server-owned as well: only a server-side launcher
# (request.state.run_origin) or the internal channel caller may set it,
# and both metadata forks drop any client copy. build_run_config merges
# body.config["metadata"] into the live config, so the second pop below
# is what keeps a forged value out of the live run config.
origin = resolve_request_origin(request, body.metadata)
run_metadata = _admit_scheduler_metadata(body.metadata, trusted=scheduled_task_runtime is not None)
run_metadata.pop(DEERFLOW_ORIGIN_KEY, None)
if origin is not None:
run_metadata[DEERFLOW_ORIGIN_KEY] = origin
run_metadata[DEERFLOW_TRACE_METADATA_KEY] = ensure_trace_id()
config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id)
if isinstance(config.get("metadata"), dict):
config["metadata"] = _admit_scheduler_metadata(config["metadata"], trusted=scheduled_task_runtime is not None)
config["metadata"].pop(DEERFLOW_ORIGIN_KEY, None)
if origin is not None:
config["metadata"][DEERFLOW_ORIGIN_KEY] = origin
await apply_checkpoint_to_run_config(config, body=body, thread_id=thread_id, request=request)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there.
strip_internal_context_keys(config)
replay_kind = run_metadata.get("replay_kind")
target_message_id = run_metadata.get("regenerate_from_message_id")
scope_graph_input = graph_input if isinstance(graph_input, dict) else {"messages": []}
current_human_message = _current_human_message(graph_input)
current_message_has_scope = current_human_message is not None and KNOWLEDGE_SCOPE_KEY in current_human_message.additional_kwargs
replay_requires_scope_recovery = isinstance(graph_input, Command) or (isinstance(target_message_id, str) and bool(target_message_id) and (replay_kind != "edit" or not current_message_has_scope))
is_human_input_response = current_human_message is not None and "human_input_response" in current_human_message.additional_kwargs
# Clarification and edit-replay messages may intentionally replace the
# source scope. If either client omits its current selector snapshot,
# inherit the source turn's authoritative scope instead of widening the
# run to every operator-approved dataset. Other replay paths always use
# server recovery regardless of client input.
is_scope_recovery = replay_requires_scope_recovery or (is_human_input_response and not current_message_has_scope)
recovery_scope = (
await _recover_run_knowledge_scope(
request,
thread_id=thread_id,
target_message_id=(target_message_id if isinstance(target_message_id, str) else None),
)
if is_scope_recovery
else None
)
# Match lead-agent assembly: runtime context overrides configurable.
# Older API/channel callers may name an agent through context while
# retaining lead_agent as their routing assistant ID.
scope_runtime_config = dict(config.get("configurable") or {})
if isinstance(config.get("context"), dict):
scope_runtime_config.update(config["context"])
scope_assistant_id = scope_runtime_config.get("agent_name") or _DEFAULT_ASSISTANT_ID
# Bootstrap assembly intentionally does not load an agent config: the
# new agent may not exist yet and setup_agent creates its definition.
agent_config = (
await _load_scope_agent_config(
assistant_id=scope_assistant_id,
user_id=owner_user_id or (str(user.id) if user is not None else None),
)
if not scope_runtime_config.get("is_bootstrap")
else None
)
# Keep the pre-default identity even when the agent is initially
# unbound: adding a default must not reject an already-accepted retry.
# The durable input still exposes the original accepted scope.
request_input = _canonical_run_record_input(body.input, graph_input) if idempotency_key else None
knowledge_default_request_hash = hashlib.sha256(json.dumps(request_input, sort_keys=True, ensure_ascii=False).encode()).hexdigest() if idempotency_key else None
accepts_knowledge_default = not is_scope_recovery and not current_message_has_scope and knowledge_default_request_hash is not None
admitted_knowledge_scope = admit_message_knowledge_scope(
scope_graph_input,
assistant_id=scope_assistant_id,
app_config=run_ctx.app_config or get_app_config(),
agent_config=agent_config,
recovery_scope=recovery_scope,
recovery=is_scope_recovery,
)
if admitted_knowledge_scope is not None:
await _validate_scope_thread_binding(
run_ctx,
thread_id=thread_id,
assistant_id=body.assistant_id,
)
run_record_input = _canonical_run_record_input(body.input, graph_input)
internal_owner_user = await resolve_trusted_internal_owner_for_attribution(request, owner_user_id)
if internal_owner_user is None and owner_user_id == AUTH_DISABLED_USER_ID and is_auth_disabled():
internal_owner_user = get_auth_disabled_user()
inject_authenticated_user_context(
config,
request,
internal_owner_user=internal_owner_user,
request_context=getattr(body, "context", None),
)
if scheduled_task_runtime is not None:
run_ctx = replace(run_ctx, scheduled_task_runtime=dict(scheduled_task_runtime))
conversation_references = list(getattr(body, "conversation_references", None) or [])
if conversation_references:
from app.gateway.conversation_access import prepare_conversation_reader
prepared = prepare_conversation_reader(
conversation_references,
request=request,
user_id=owner_user_id or (str(user.id) if user is not None else None),
run_context=run_ctx,
run_manager=run_mgr,
app_config=get_app_config(),
)
reader, source_ids = prepared
run_ctx = replace(run_ctx, conversation_reader=reader)
if isinstance(graph_input, dict):
# Keep this endpoint's list-only wire contract even though
# message admission canonicalizes single-message shorthand.
raw_messages = (body.input or {}).get("messages")
if raw_messages is not None and not isinstance(raw_messages, list):
raise HTTPException(status_code=422, detail="input.messages must be a list")
reference_messages = graph_input.get("messages")
if reference_messages is None:
reference_messages = []
# ``normalize_input`` guarantees a list here. The raw-input
# check above is the authoritative list-only wire validation.
# Reference IDs are user-selected data. Keep them out of the
# system prompt and grant no authority from this persisted hint.
graph_input = {
**graph_input,
"messages": [
*reference_messages,
HumanMessage(
content="Read-only conversation references for this run: " + json.dumps(source_ids),
additional_kwargs={"hide_from_ui": True},
),
],
}
elif scheduled_task_runtime is not None and isinstance(run_metadata.get("scheduled_previous_thread_id"), str):
from app.gateway.conversation_access import prepare_scheduled_conversation_reader
owner = internal_owner_user
if owner is not None and "runs:read" in await resolve_route_permissions(owner, is_internal=False):
prepared = prepare_scheduled_conversation_reader([run_metadata["scheduled_previous_thread_id"]], owner_user_id=str(owner.id), run_context=run_ctx, run_manager=run_mgr, app_config=run_ctx.app_config or get_app_config())
if prepared is not None:
reader, source_ids = prepared
async def scheduled_reader(*args, **kwargs):
current_owner = get_auth_disabled_user() if str(owner.id) == AUTH_DISABLED_USER_ID and is_auth_disabled() else await get_local_provider().get_user(str(owner.id))
if current_owner is None or "runs:read" not in await resolve_route_permissions(current_owner, is_internal=False):
return json.dumps({"status": "unavailable", "messages": [], "next_cursor": None, "has_more": False})
return await reader(*args, **kwargs)
run_ctx = replace(run_ctx, conversation_reader=scheduled_reader)
if isinstance(graph_input, dict):
graph_input = {
**graph_input,
"messages": [*(graph_input.get("messages") or []), HumanMessage(content="Read-only conversation references for this run: " + json.dumps(source_ids), additional_kwargs={"hide_from_ui": True})],
}
# Resolve and pin the thread's project context once per run (spec
# §7.1): middlewares and tools read only this server-owned snapshot —
# nothing re-resolves membership mid-run, and admission never writes
# membership (§10.7). Resolution failure degrades to unassigned with a
# warning inside the resolver; it never fails the run.
project_context = await resolve_project_context(
run_ctx.thread_store,
getattr(request.app.state, "project_repo", None),
thread_id,
getattr(request.app.state, "project_document_repo", None),
)
if project_context is not None:
config["context"][PROJECT_CONTEXT_KEY] = project_context
async def run_after_metadata(record: RunRecord) -> None:
metadata_task = asyncio.create_task(
_ensure_thread_metadata(
run_ctx,
record,
owner_user_id=owner_user_id,
require_existing_thread=require_existing_thread,
)
)
abort_task = asyncio.create_task(record.abort_event.wait())
metadata_failure_logged = False
metadata_failure: Exception | None = None
metadata_record: dict[str, Any] | None = None
try:
done, _ = await asyncio.wait(
(metadata_task, abort_task),
timeout=_THREAD_METADATA_SETUP_TIMEOUT_SECONDS,
return_when=asyncio.FIRST_COMPLETED,
)
if metadata_task in done:
try:
metadata_record = metadata_task.result()
except asyncio.CancelledError:
pass
except Exception as exc:
metadata_failure_logged = True
metadata_failure = exc
logger.warning(
"Failed to ensure thread_meta for %s%s",
sanitize_log_param(thread_id),
"" if require_existing_thread else " (non-fatal)",
exc_info=True,
)
elif abort_task not in done:
logger.warning(
"Timed out ensuring thread_meta for %s after %.1fs",
sanitize_log_param(thread_id),
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS,
)
if require_existing_thread:
metadata_failure = TimeoutError("Timed out verifying existing thread metadata")
finally:
if metadata_task.done():
if metadata_record is None and not metadata_failure_logged:
try:
metadata_record = metadata_task.result()
except asyncio.CancelledError:
pass
except Exception as exc:
metadata_failure_logged = True
metadata_failure = exc
logger.warning(
"Failed to ensure thread_meta for %s%s",
sanitize_log_param(thread_id),
"" if require_existing_thread else " (non-fatal)",
exc_info=True,
)
else:
metadata_task.cancel()
metadata_task.add_done_callback(
lambda task: _log_thread_metadata_task_result(
task,
thread_id=thread_id,
)
)
if not abort_task.done():
abort_task.cancel()
abort_task.add_done_callback(_consume_task_result)
if metadata_failure is not None and require_existing_thread:
await run_mgr.fail_start_if_pending(
record.run_id,
error=str(metadata_failure),
)
# Continue through run_agent even after metadata abort, timeout,
# or strict verification failure:
# its startup barrier is the single path that turns pending
# cancellation into no-agent-construction plus publish_end.
incarnation_kwargs: dict[str, str | None] = {}
if metadata_record is None:
if not record.abort_event.is_set():
logger.warning(
"Thread metadata for %s is unavailable; MCP access will fail closed",
sanitize_log_param(thread_id),
)
else:
if "incarnation" not in metadata_record:
logger.warning(
"Thread metadata for %s has no incarnation; MCP access will fail closed",
sanitize_log_param(thread_id),
)
else:
incarnation = metadata_record["incarnation"]
if is_valid_thread_incarnation(incarnation):
incarnation_kwargs["thread_incarnation"] = incarnation
else:
logger.warning(
"Thread metadata for %s has an invalid incarnation; MCP access will fail closed",
sanitize_log_param(thread_id),
)
from app.gateway.scheduled_task_access import prepare_scheduler_capability
human_text = ""
if current_human_message is not None:
original = current_human_message.additional_kwargs.get("original_user_content", current_human_message.content)
if isinstance(original, str):
human_text = original
elif isinstance(original, list):
human_text = "\n".join(block["text"] for block in original if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str))
scheduler_capability = None
if scheduler_tools_enabled(getattr(run_ctx, "app_config", None)):
client_timezone = await _client_timezone_from_context(body.context)
try:
scheduler_capability = await prepare_scheduler_capability(
request,
user=internal_owner_user or user,
thread_id=thread_id,
run_id=record.run_id,
assistant_id=scope_assistant_id,
original_user_text=human_text,
interaction_policy=resolve_run_interaction_policy(config),
scheduled_task_runtime=scheduled_task_runtime,
client_timezone=client_timezone,
)
except Exception:
logger.warning("Scheduler tool capability is unavailable for this run", exc_info=True)
execution_context = replace(run_ctx, scheduler_capability=scheduler_capability) if scheduler_capability is not None else run_ctx
await run_agent(
bridge,
run_mgr,
record,
ctx=execution_context,
agent_factory=agent_factory,
graph_input=graph_input,
config=config,
stream_modes=stream_modes,
stream_subgraphs=body.stream_subgraphs,
interrupt_before=body.interrupt_before,
interrupt_after=body.interrupt_after,
knowledge_scope=admitted_knowledge_scope,
**incarnation_kwargs,
)
try:
async with goal_thread_lock(thread_id):
await ensure_checkpoint_history_seeded(
request,
thread_id=thread_id,
assistant_id=body.assistant_id,
)
# A strict caller may have observed the thread before a
# concurrent delete removed it while checkpoint preparation
# yielded. Recheck immediately before durable admission. The
# delete route holds a durable thread-operation reservation,
# so after this point either the run or the delete wins; they
# cannot both succeed across Gateway workers.
if require_existing_thread and not await thread_access_allowed():
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
record = await run_mgr.create_or_reject(
thread_id,
body.assistant_id,
on_disconnect=disconnect,
metadata=run_metadata,
# Persist a secret-redacted copy of the config: the run record is
# written to runs.kwargs_json and echoed by the run API, so a
# request-scoped secret (#3861) must not ride along. The live
# config built above keeps the secrets for the actual run.
kwargs={
"input": run_record_input,
**({"knowledge_default_request_hash": knowledge_default_request_hash} if accepts_knowledge_default else {}),
"config": redact_config_secrets(body.config),
**({"conversation_references": conversation_references} if conversation_references else {}),
},
multitask_strategy=body.multitask_strategy,
model_name=model_name,
user_id=owner_user_id,
idempotency_key=idempotency_key,
)
if record.idempotency_reused:
stored = record.kwargs or {}
stored_input = stored.get("input")
# New runs persist the admitted, canonical message snapshot
# so a scope display cannot be rewritten through the run
# record. Accept the raw request as well for records written
# by older Gateway versions, while comparing canonical
# retries to the same representation as the stored record.
matches_default_request = knowledge_default_request_hash is not None and stored.get("knowledge_default_request_hash") == knowledge_default_request_hash
# Pre-feature unscoped records may already contain normalized
# messages, but have no digest. Compare them before injecting
# today's default; explicit scopes and recovery do not use
# this compatibility path.
matches_legacy_default_request = accepts_knowledge_default and "knowledge_default_request_hash" not in stored and stored_input == request_input
matches_input = matches_default_request or matches_legacy_default_request or stored_input == body.input or stored_input == run_record_input
if not matches_input or record.assistant_id != body.assistant_id or stored.get("conversation_references", []) != conversation_references:
raise HTTPException(
status_code=409,
detail="Idempotency-Key already used with a different request",
)
return record
worker = run_after_metadata(record)
try:
# No await is allowed between durable admission and task
# attachment. Metadata setup runs inside the attached
# worker so a pending cancellation can bypass stalled
# thread-store IO and still reach run_agent's startup
# barrier / stream finalization.
record.task = asyncio.create_task(worker)
except Exception as exc:
worker.close()
await run_mgr.fail_start_if_pending(
record.run_id,
error=f"Failed to attach run worker: {exc}",
)
raise
except ConflictError as exc:
raise BusyThreadConflict(str(exc)) from exc
except UnsupportedStrategyError as exc:
raise HTTPException(status_code=501, detail=str(exc)) from exc
# Title sync is handled by worker.py's finally block which reads the
# title from the checkpoint and calls thread_store.update_display_name
# after the run completes.
return record
finally:
if owner_context_token is not None:
reset_current_user(owner_context_token)
async def launch_scheduled_thread_run(
*,
thread_id: str,
assistant_id: str | None,
prompt: str,
request: Request | None = None,
app: Any | None = None,
owner_user_id: str | None = None,
metadata: dict[str, Any] | None = None,
origin: dict[str, Any] | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Start one scheduled occurrence as an internal run.
The prompt message gets a stable id per occurrence (retried launches and
reconnect hydration see one message) and, with ``origin``, the
``SCHEDULED_ORIGIN_KEY`` metadata the run thread renders. ``title``
pre-sets a fresh run thread's title, so no title is generated.
"""
if request is None:
if app is None:
raise ValueError("launch_scheduled_thread_run requires request or app")
request = SimpleNamespace(
app=app,
headers=({INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_user_id} if owner_user_id else {}),
state=SimpleNamespace(
user=get_internal_user(),
auth_source=AUTH_SOURCE_INTERNAL,
# Every trigger (schedule or "Run now") is server-started.
run_origin=make_origin("schedule"),
),
cookies={},
)
scheduled_task_run_id = (metadata or {}).get("scheduled_task_run_id")
message: dict[str, Any] = {
"role": "user",
"content": prompt,
"id": f"scheduled-{scheduled_task_run_id}" if isinstance(scheduled_task_run_id, str) else f"scheduled-{uuid.uuid4().hex}",
}
if origin:
message["additional_kwargs"] = {SCHEDULED_ORIGIN_KEY: origin}
graph_input: dict[str, Any] = {"messages": [message]}
if title:
graph_input["title"] = title
body = RunCreateRequest(
assistant_id=assistant_id,
input=graph_input,
command=None,
metadata=metadata or {},
config={"recursion_limit": _resolve_scheduler_recursion_limit()},
# ``user_id`` mirrors what IM channels put in ``body.context`` so
# runtime-context consumers without a ContextVar fallback (e.g.
# user-scoped GuardrailMiddleware providers) see the owning user;
# ``inject_authenticated_user_context`` skips the internal user.
context=({"non_interactive": True, "user_id": owner_user_id} if owner_user_id else {"non_interactive": True}),
webhook=None,
checkpoint_id=None,
checkpoint=None,
interrupt_before=None,
interrupt_after=None,
stream_mode=None,
stream_subgraphs=False,
stream_resumable=None,
on_disconnect="continue",
on_completion=None,
multitask_strategy="reject",
after_seconds=None,
if_not_exists="create",
feedback_keys=None,
)
scheduled_task_runtime = None
task_id = (metadata or {}).get("scheduled_task_id")
if owner_user_id and isinstance(task_id, str) and isinstance(scheduled_task_run_id, str):
scheduled_task_runtime = {"task_id": task_id, "occurrence_id": scheduled_task_run_id, "user_id": owner_user_id}
if (metadata or {}).get("scheduled_context_mode") == "fresh_thread_per_run" and isinstance((metadata or {}).get("scheduled_goal_objective"), str):
scheduled_task_runtime["goal_objective"] = metadata["scheduled_goal_objective"]
idempotency_key = f"scheduled-task:{scheduled_task_run_id}" if isinstance(scheduled_task_run_id, str) else None
# Non-HTTP entry point: the lifespan scheduler calls this with a synthetic
# request, so TraceMiddleware never runs. The scope is opened per launch,
# never around the poller loop, or every scheduled run would collapse onto
# one id. Reached from inside an HTTP request -- a manual trigger, or the
# scheduler service's own per-occurrence scope -- ensure_trace_context
# keeps that trace instead of minting a competing one.
with ensure_trace_context():
record = await start_run(
body,
thread_id,
request,
idempotency_key=idempotency_key,
scheduled_task_runtime=scheduled_task_runtime,
)
return {"run_id": record.run_id, "thread_id": record.thread_id}
def _mcp_task_notification_prompt(event: dict[str, Any]) -> str:
"""Build the internal user turn for one immutable MCP task event snapshot."""
payload = frame_untrusted_text(json.dumps(event, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str))
instruction = (
"A durable background MCP task has an update that requires the user's attention. "
"Explain the update clearly and concisely. Do not expose or ask for a remote task ID. "
"When status is input_required, show the question but explain that this MCP integration "
"cannot resume the remote task with user input yet. When tracking_degraded is true, explain "
"that DeerFlow will continue retrying at a lower frequency."
)
return f"{instruction}\n\n{payload}"
async def launch_mcp_task_notification_run(
*,
app: Any,
thread_id: str,
assistant_id: str | None,
owner_user_id: str,
task_id: str,
dispatch_version: int,
dispatch_attempt: int,
event: dict[str, Any],
) -> dict[str, Any]:
"""Idempotently launch the Agent run that delivers one task event."""
request = SimpleNamespace(
app=app,
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_user_id},
state=SimpleNamespace(user=get_internal_user(), auth_source=AUTH_SOURCE_INTERNAL, run_origin=make_origin("mcp_notification")),
cookies={},
)
body = RunCreateRequest(
assistant_id=assistant_id,
input={
"messages": [
{
"role": "user",
"content": _mcp_task_notification_prompt(event),
"additional_kwargs": {"hide_from_ui": True},
}
]
},
command=None,
metadata={
"mcp_task_notification": {
"task_id": task_id,
"dispatch_version": dispatch_version,
"dispatch_attempt": dispatch_attempt,
}
},
config=None,
context={"non_interactive": True, "user_id": owner_user_id},
webhook=None,
checkpoint_id=None,
checkpoint=None,
interrupt_before=None,
interrupt_after=None,
stream_mode=None,
stream_subgraphs=False,
stream_resumable=None,
on_disconnect="continue",
on_completion=None,
multitask_strategy="reject",
after_seconds=None,
if_not_exists="create",
feedback_keys=None,
)
idempotency_key = f"mcp-task:{task_id}:{dispatch_version}:{dispatch_attempt}"
# Non-HTTP entry point, same as launch_scheduled_thread_run above: the MCP
# task service drives this from its own background loop, so one scope per
# notification keeps every delivery attempt separately correlatable.
try:
with ensure_trace_context():
record = await start_run(
body,
thread_id,
request,
idempotency_key=idempotency_key,
require_existing_thread=True,
)
except HTTPException as exc:
if isinstance(exc, BusyThreadConflict):
raise ConflictError(str(exc.detail)) from exc
if exc.status_code in {400, 401, 403, 404, 409, 422, 501}:
raise PermanentNotificationError(str(exc.detail)) from exc
raise
return {"run_id": record.run_id, "thread_id": record.thread_id}
async def sse_consumer(
bridge: StreamBridge,
record: RunRecord,
request: Request,
run_mgr: RunManager,
*,
apply_on_disconnect: bool = True,
emit_gap_on_missing_stream: bool = False,
):
"""Async generator that yields SSE frames from the bridge.
The ``finally`` block implements ``on_disconnect`` semantics, but only for
the stream returned by the *creating* endpoint (``apply_on_disconnect=True``):
- ``cancel``: abort the background task on client disconnect.
- ``continue``: let the task run; events are discarded.
Join/observer streams pass ``apply_on_disconnect=False``: the creator's
cancel-on-disconnect policy expresses the creator's intent for their own
connection, and a read-only observer closing a join must not cancel the
run (a runs:read-only credential would otherwise cancel without
runs:cancel just by disconnecting).
``emit_gap_on_missing_stream`` is a separate creating-retry signal, default
``False``. ``create_or_reject`` sets ``record.idempotency_reused`` on the
shared cached record and never clears it, so this function must not read
that flag. Thread-scoped ``/runs/stream`` passes True only for this
request's reuse; default callers (joins, stateless ``/api/runs/stream``,
tests) keep ``end`` when a terminal record's stream is gone.
"""
last_event_id = request.headers.get("Last-Event-ID")
if await _terminal_record_stream_missing(bridge, record):
if emit_gap_on_missing_stream:
# Creating-endpoint retry: a bare `end` looks like the run
# produced nothing. Point the client at durable state instead.
yield format_sse(
"gap",
{
"code": "stream_replay_gap",
"run_id": record.run_id,
"requested_event_id": last_event_id,
"earliest_available_event_id": None,
"latest_available_event_id": None,
"recovery": "reload_durable_state",
},
)
return
yield format_sse("end", None)
return
gap_emitted = False
terminal_emitted = False
disconnect_observed = False
try:
async for entry in bridge.subscribe(record.run_id, last_event_id=last_event_id):
if await request.is_disconnected():
disconnect_observed = True
break
if isinstance(entry, StreamGap):
gap_emitted = True
yield format_sse(
"gap",
{
"code": "stream_replay_gap",
"run_id": record.run_id,
"requested_event_id": entry.requested_event_id,
"earliest_available_event_id": entry.earliest_available_event_id,
"latest_available_event_id": entry.latest_available_event_id,
"recovery": "reload_durable_state",
},
)
return
if entry is HEARTBEAT_SENTINEL:
if await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
terminal_emitted = True
yield format_sse("end", None)
return
yield ": heartbeat\n\n"
continue
if entry is END_SENTINEL:
terminal_emitted = True
yield format_sse("end", None, event_id=entry.id or None)
return
yield format_sse(entry.event, entry.data, event_id=entry.id or None)
if not disconnect_observed:
raise RuntimeError("stream bridge subscription ended before a terminal event")
except (GeneratorExit, asyncio.CancelledError):
# Starlette closes the response generator or cancels its request task
# when the creator connection disappears. Ordinary bridge exceptions
# must not be mistaken for that client-owned lifecycle signal.
disconnect_observed = True
raise
finally:
# store_only records are cross-worker observation handles. An explicit
# cancel-then-stream action has already persisted its request before
# subscribing; a plain join disconnect must not invent a new
# cancellation request. Only apply on_disconnect to locally-owned runs,
# and only on the creator's own stream — never on an observer join.
if disconnect_observed and apply_on_disconnect and not gap_emitted and not terminal_emitted and not record.store_only and record.status in (RunStatus.pending, RunStatus.running):
if record.on_disconnect == DisconnectMode.cancel:
await await_drained(run_mgr.cancel(record.run_id))
def serialize_wait_run_status(record: RunRecord) -> dict[str, Any]:
"""Return durable status and the error envelope recognized by the SDK."""
result: dict[str, Any] = {"status": record.status.value, "error": record.error}
if record.status == RunStatus.error:
result["__error__"] = {"error": "RunError", "message": record.error or "Run failed."}
return result
async def wait_for_run_completion(
bridge: StreamBridge,
record: RunRecord,
request: Request,
run_mgr: RunManager,
) -> bool:
"""Block until the run publishes ``END_SENTINEL``, honouring on_disconnect.
Creator-side only, unlike ``sse_consumer``'s observer joins: every caller
must be the endpoint that created the run or a path reached only after an
explicit, permission-gated cancel. This helper intentionally keeps
applying the record's ``on_disconnect`` policy on disconnect — do not
wire it to observer surfaces.
The non-streaming ``/wait`` endpoints used to ``await record.task``
directly with no disconnect handling. When the client (or an
intermediate HTTP proxy) timed out during a long tool call such as
``pip install``, the handler would swallow ``CancelledError`` and
serialize whatever checkpoint happened to exist — masking a half-finished
run as a normal completion (issue #3265).
This helper consumes the same bridge that ``sse_consumer`` does so the
wait path shares its disconnect semantics: each wake-up polls
``request.is_disconnected()``; on a real disconnect it cancels the
background run when ``record.on_disconnect`` is ``cancel``. The bridge's
heartbeat sentinels guarantee at least one wake-up per
``heartbeat_interval`` even when the agent emits no events for a while.
Returns:
``True`` when ``END_SENTINEL`` was observed (run reached a terminal
state), ``False`` when the loop exited because the client
disconnected. Callers must skip checkpoint serialization on
``False`` so a partial checkpoint is not returned as a normal
response.
Raises:
RuntimeError: The bridge subscription ended without a terminal event.
Other bridge failures propagate unchanged.
"""
completed = False
disconnect_observed = False
if await _terminal_record_stream_missing(bridge, record):
return True
resume_from_event_id: str | None = None
try:
while True:
gap_seen = False
async for entry in bridge.subscribe(record.run_id, last_event_id=resume_from_event_id):
# END_SENTINEL means the run reached a terminal state; honour it
# even if the client just disconnected so the caller still serializes
# the real final checkpoint.
if entry is END_SENTINEL:
completed = True
return True
if isinstance(entry, StreamGap):
# The wait API only needs terminal completion, not a complete
# event replay. Resume at the retained tail rather than
# treating a bridge gap as a client disconnect.
resume_from_event_id = entry.latest_available_event_id
gap_seen = True
break
if entry is HEARTBEAT_SENTINEL and await _orphan_recovery_observed_after_heartbeat(record, run_mgr):
completed = True
return True
if await request.is_disconnected():
disconnect_observed = True
return False
# Heartbeats and regular events: keep waiting for END_SENTINEL.
if not gap_seen:
raise RuntimeError("stream bridge subscription ended before a terminal event")
except asyncio.CancelledError:
# Request-task cancellation is the non-streaming equivalent of
# Starlette closing an SSE response generator.
disconnect_observed = True
raise
finally:
if disconnect_observed and not completed and record.status in (RunStatus.pending, RunStatus.running):
if record.on_disconnect == DisconnectMode.cancel:
await await_drained(run_mgr.cancel(record.run_id))