feat(code): make /offload server-owned - #5261
Merged
Mason Daugherty (mdrxy) merged 81 commits intoAug 24, 2026
Merged
Conversation
`/offload` injects a synthetic assistant message carrying a forced `compact_conversation` call directly into graph state, so `awrap_model_call` never runs and no Auto decision plan is checkpointed. `aafter_model` then treated that as an invalid-plan case and raised a HITL interrupt for an action the user had just requested, forcing the `/offload` driver to catch and self-approve its own interrupt. Recognize the seed from trust signals a model cannot forge — the client-set `offload_tool_call_id` run context, the deterministic seed message ID the compaction tool's execution guard already requires, and exact `force=True` args — and pass it through. Any other gated call in the batch keeps the existing manual-review fallback. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
- Log WARNING on the two impossible seed-signal states (no trusted
compaction tool, seed message missing the forced call) so a broken
trust signal no longer degrades silently behind the driver's
blind-approve fallback.
- Reject the bypass when a non-compaction call reuses the authorized
tool-call ID; previously the subset check let a same-ID `execute`
call through.
- Loosen the seed args check to `args.get("force") is True`,
matching `_offload_rejection` and `_decisions_for_interrupt`, and
read the context ID via the existing `_offload_tool_call_id` helper.
- Include the seeded ID in the bypass DEBUG line and drop the
Auto-only wording; the bypass is mode-independent.
- Update `app.py` docstrings: the bypass is the normal path, and
interrupt-self-approve is the fail-closed fallback for graphs
without the Auto HITL middleware.
- Tests: cover message-ID vs tool-call-ID mismatch separately, pin
that a seed-ID'd message carrying another gated tool is reviewed,
and add a positive-control chunk assertion to the integration
recorder. Fix the integration comment (the app runs Manual mode).
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
/offload as a server operation
Mason Daugherty (mdrxy)
marked this pull request as ready for review
August 3, 2026 19:21
…face failures as errors Replace the bare private-attribute writes on the composite backend with an OffloadServerResources NamedTuple attached through attach_offload_resources / offload_resources_from, keeping the attribute name in one place. The operation graph's node now raises RuntimeError with the COMPACTION_FAILURE_PREFIX on compaction failure and on PreCompact hook veto, so a server-backed /offload reports the real reason instead of a generic internal error or a misleading "already compact" no-op. The client unwraps RemoteException via format_agent_exception, and fires the SessionStart(compact) hook itself since the operation graph produces no tool result to key on. RemoteAgent.for_graph now caches sibling clients per graph name instead of leaking two httpx connection pools per call, and the server graph factories share one cached resources tuple. The driver replays the thread's own state as run input (minus _summarization_event, which the server cannot deserialize) because an empty input leaves a real server run with nothing to compact. Also documents the /offload graph's no-HITL authorization model and the noop-auth writable channels in the threat model.
…eded-compaction-no-hitl # Conflicts: # libs/code/deepagents_code/offload_middleware.py
…eded-compaction-no-hitl # Conflicts: # libs/code/deepagents_code/agent.py
…in failures The `offload` operation graph accepted every channel `_OffloadState` declares as writable run input. `PrivateStateAttr` / `OmitFromInput` are honored by `create_agent`, not by a raw `StateGraph`, so the markers inherited from `CostState` did not restrict anything. Because the driver replayed the whole thread state as input and `_session_cost_usd` reduces with `operator.add`, every `/offload` echoed the checkpointed total back and doubled the thread's persisted spend, compounding across runs; `_session_cost_transfers` (`operator.or_`) resurrected settled subagent transfers, and a local caller on the `noop`-auth port could set the compaction cutoff directly. Declare an explicit `input_schema` carrying only `messages`, so the restriction is enforced by the graph rather than by the client's discipline in what it sends, and replay only `messages`. Inherit `_summarization_event` from `SummarizationState` instead of re-declaring it without the SDK's annotation. The driver also gave up silently in two reachable cases: an interrupt it cannot answer (a `PreToolUse` hook returning an `ask` permission makes the hook middleware raise a plain `HITLRequest`, which the operation graph has no HITL middleware to route) and a hook that outlasts the resume bound. Both left the run paused while the caller read the unchanged event as "nothing to offload". Both now return an error string. The resume bound is shared with the seeded driver rather than duplicated as a second literal. Further fixes: - Mint a fresh forced-tool-call id per `PreCompact` dispatch. The hook `invocation_id` derives from it plus the prompt id, which only rotates on user-prompt submit, so a constant id made two `/offload`s in one turn collide and replay the first decision, including a denial. - Convert hook-dispatch failures to `RuntimeError` so the server's serde allowlist does not replace them with "An internal error occurred". - Stop letting a cost-drain failure discard a committed compaction, which would report "your conversation is unchanged" while leaving an orphaned archive section no `_summarization_event` references. - Report a failed post-run graph rebind instead of only logging it; a later `/goal` or `/rubric` would otherwise fail with no explanation. - Do not let a stopping `SessionStart` hook suppress an already-committed offload and leave the status bar at pre-offload counts. - Derive the `offload` langgraph.json ref from the agent ref's module. The two graphs share one server runtime only because both resolve into the same factory closure, so a hardcoded ref would silently build a second sandbox and MCP session if `graph_ref` were overridden. - Return the graph/backend pair as a named `ServerRuntime`, make the hook middleware non-optional, and narrow the resource accessors to `CompositeBackend`. - Correct THREAT_MODEL DC5 (three producers; the archive guard covers only the forced paths), TB2 (`PreToolUse` is dispatched too), and TB10 (the input surface and the mechanism that restricts it), and drop docstrings describing an Auto-mode approval bypass that no longer exists.
…eded-compaction-no-hitl # Conflicts: # libs/code/deepagents_code/offload_middleware.py # libs/code/tests/unit_tests/test_compact_tool.py
The `/offload` operation graph minted its forced tool-call id with `uuid4()`
inside the node. Answering a hook interrupt re-executes a node from the top,
so the id changed between the request and the resume; `ServerHooksMiddleware`
folds it into the hook `invocation_id`, and `parse_hook_resume_value` rejects a
mismatched id as fatal. Every `/offload` therefore failed outright for anyone
with a `PreCompact`/`PreToolUse` hook configured, and the client's whole
fulfill/resume loop was unreachable. The id is now derived from the
task-scoped `checkpoint_ns`, which LangGraph reuses when it replays a task and
mints fresh for each run -- stable across resumes, distinct across runs.
The post-compaction cost drain awaited `aafter_agent`, which
`CostTrackingMiddleware` does not implement, so it resolved to
`AgentMiddleware`'s empty base method and charged nothing. It now calls
`after_agent` through a thread. Its test patched the async name onto a mock,
materializing a method that does not exist in production.
`stream_completed` was set after both `drain_error` breaks, so a failed drain
plus a failed rebind mounted "Offload finished, but..." alongside "Offload
could not complete". The rebind outcome is now recorded and reported by
`_handle_offload`, which also covers the inverse case the old flag missed: a
stream error that still committed the compaction is reconciled into a success
and now carries the warning.
Also:
- Read the hook outcome under the middleware's own `_PRE_TOOL_STATE_KEY`; a
re-spelling yielded `{}` and compacted through a denial.
- Re-raise `GraphBubbleUp` out of the compaction handler, matching the hook
handler, so control-flow exceptions are not reported as failures.
- Log a failed archive write at the call site; `_aoffload_to_backend` swallows
`_ArchiveReadGuard`'s fail-closed error into a bare `None`.
- Report an empty state re-read as unconfirmed rather than "already compact".
- Log discarded hook fulfillments and tell the user those hooks will re-run.
- Check for `RemoteGraph._validate_client` explicitly so an SDK rename is not
downgraded to a generic rebind warning.
- Make `_OffloadInput` total, name the graph factories, and type the forced
compaction update as `SummarizationEvent` -- which surfaced that the
summary message's `HumanMessage` shape was never verified.
- Correct docstrings claiming the graph cannot create a synthetic tool call
(it does, in memory only), the THREAT_MODEL "no HITL interrupt" claim, and
the conflicting accounts of what an empty run input does.
Pydantic rejects `typing.TypedDict` on Python < 3.12 when langgraph routes the `/offload` operation graph's `input_schema` through its schema inspection, failing CI on 3.11 with PydanticUserError.
…load `model_params` The server-owned offload HTTP boundary type-checked `context.model_params` but passed its values verbatim to `create_model`, where they merge into the model constructor kwargs. Keys like `base_url`, `openai_proxy`, `http_client`, or `default_headers` would redirect the summarizer's credentialed provider calls to a client-chosen destination. The dev server accepts connections from any local process (accepted risk T6), so the request's model selection must not extend to its network plumbing. The boundary now drops endpoint, proxy, and transport keys via a denylist in `offload_api._strip_transport_model_params` — a denylist rather than an allowlist because `create_model` serves arbitrary providers and an allowlist would silently drop legitimate provider-specific params. The in-process `CLIContextSchema` path stays unfiltered since those params come from the user's own flags and config. Threat model updated to record the new boundary control.
A run that raises anything other than an interrupt or rollback leaves the thread row on `error` until the next run completes, and `aensure_thread` uses `if_exists="do_nothing"` so it never clears it. Requiring `idle` therefore refused `/offload` for the entire window after a failed turn -- exactly when a user reaches for it to recover from a context overflow -- and the 409 blamed an "active or interrupted run" that did not exist. Gate on a named set of quiescent statuses instead. In-flight work is still caught by the separate `next`/`tasks`/`interrupts` check against the checkpoint, which also covers an errored run that left a pending node.
Everything between `aoffload` returning and the SessionStart hook is local reporting -- token math, message mounting, the status-bar update -- over a conversation the server has already compacted and persisted. All of it sat inside the try whose handler mounts "Offload failed", so any failure there told the user the offload failed and sent them to run it again on an already compacted conversation. The SessionStart hook below it was already carved out for exactly this reason. Track the commit point and give the generic handler the same treatment, naming the reporting failure instead of the operation.
`OffloadStateUpdate` names three permitted checkpoint channels, but the runtime guard tested for `messages` alone. The type stated "only these three"; the check enforced "not this one", so a future merge adding any other channel -- `todos`, `files` -- would pass both the checker and the guard and reach the checkpoint write unattributed to any run. Derive the allowlist from the type's annotations so the two cannot drift, and name the offending channels in the error.
`_OFFLOAD_API_VERSION`, `_OFFLOAD_PROTOCOL_VERSION`, and the `GET /dcode/offload` capability route were a remnant of an abandoned backwards-compatibility effort. No production code sent, read, or compared any of them: the constants' only reader was a test pinning them to each other, and the route's only caller was an integration assertion. Their docstrings claimed version pinning that does not exist, which reads as an enforced contract. Route authentication is still covered -- those assertions use the POST route, not the capability probe.
A custom `graph_ref` server never registers dcode's HTTP app, so `/offload` against one POSTs into a missing route and the SDK's error carries only "404 Not Found" -- naming neither the cause nor a fix. An unregistered thread cannot be confused with this: the server catches that case and answers 409 with its own message. Translate the 404 into a message that says the server does not provide the operation and what to do about it.
`PreparedOperationCost` documents a use-exactly-once contract whose violation
it calls unrecoverable ("nothing can detect the loss afterwards"), but only the
rollback half set `_settled`. A commit left the instance indistinguishable from
a leak, so the destructive case -- a prepare that is neither committed nor
rolled back, silently deleting its spend from the thread total -- was the one
outcome with no trace.
Add `commit()` to settle a persisted delta, call it on both paths that
deliberately keep records claimed, and warn on collection when an instance is
still unsettled.
`_write_landed` returned `True` both when the thread demonstrably advanced and when it could not be read at all, so the caller logged "failed after the thread advanced" for a readback that never happened. Anyone auditing a missing charge would read that line and conclude the spend was accounted for. Return the three outcomes separately. Both non-`unchanged` cases still keep the records claimed -- the bias against double-charging is unchanged -- but the unreadable case now says the write may never have landed and names the amount at risk, making an otherwise undetectable loss auditable.
The operation runs the agent's `PreCompact` and `PreToolUse` hooks, and the server defaults a missing `approval_mode` to `manual`. `/offload` never sent one, so a configured hook saw Manual even in Auto-Accept or YOLO -- a different mode than the same hook sees on every interactive turn, where the adapter injects both fields. The boundary already type-checks `approval_mode` and `auto_approve`; nothing was sending them.
Stripping endpoint/proxy/transport keys from a request's `model_params` was entirely silent: nothing logged, nothing returned, nothing surfaced. A user running against a self-hosted gateway got their `base_url` ignored for `/offload` with no way to discover why. Log the dropped key names -- names only, since the values are endpoints and headers. Also record in the denylist's docstring that it is now a backstop rather than the primary control, since `_checkpoint_model_context` discards the request's model selection outright; a reader could not previously tell which of the two overlapping mitigations was load-bearing.
- The context-fields comment cited "approval mode" as an example of a field the operation never touches, while `approval_mode` sat in the tuple it introduced and is read by `_hook_context`. Reframe it as what the boundary validates, and note that `classifier_model` is validated but unread. - `_handle_offload` no longer writes graph state, so the shell-flush comment's "parity with the offload path's `aupdate_state`" and the token-display docstring's attribution both pointed at deleted behavior. - The 409 enumeration omitted a registered thread carrying no checkpoint. - `_validated_offload_result` said the renderer reads fields "positionally"; it subscripts them by key, which is the failure mode the rest of the paragraph describes. - `DCA_TEST_OFFLOAD_GATE_ENV` sat under a comment introducing prompt markers and is neither, and `_wait_at_summary_gate` writes its marker on every request, not once. - Lowercase `dcode` in the module docstring, as everywhere else.
The DC5 rewrite established that the archive leaf is a framework-minted `session_<uuid4 hex>`, not the thread id, but four sites still asserted the old reading: the DC5 summary-table row, the Gaps line, the input-coverage row, and D4's entire rationale. D4's conclusion survives -- the filename still has no user-controlled component -- but its stated reasoning did not. Also: - `offload.offload_messages_to_backend` does not exist anywhere in the repo. Point at the SDK's `_aoffload_to_backend`, reached through `CLICompactionMiddleware`. - The C18 components row described the route as authenticated without the caveat TB10 and the coverage row both carry: the shipped server runs `noop` auth and relies on the loopback bind. - Split the three multi-claim bullets (DC5 Producers, TB2, TB10) into one claim per sentence. - Pad the two C18 diagram rows to the box width.
`_thread_lock` and its `async with` had no coverage at all: deleting the acquisition, or keying the `WeakValueDictionary` on `operation_id` instead of `thread_id`, broke nothing in the suite. The lock is what makes the read-check-execute-recheck-write sequence meaningful for two same-process requests, so it is worth pinning. Assert that an offload blocks while the thread's lock is held externally, that a different thread is unaffected, and that the registry returns one lock per thread. Verified by mutation: re-keying the lock on `operation_id` fails the first of these.
Every other route test fabricates a request with hand-written `path_params`, so
nothing checked that the app registers the paths and methods the client
actually calls. Renaming the converter to `{tid:str}` would leave the unit
suite green while the real handler raised `KeyError` -- neither `TypeError` nor
`ValueError`, so it escapes the 422 block and surfaces as a bare 500.
Drive both routes with `TestClient`, and pin that GET on the offload path is
405 now that the capability probe is gone. Verified by mutation: the converter
rename fails the first test.
`_event_cutoff` accepted any `int`, and `bool` is an `int` subclass, so a malformed `cutoff_index: true` read as cutoff 1 -- silently shifting `messages_offloaded`/`messages_kept` by one message and the already-compacted short circuit with them. The HTTP boundary already excludes bools from `model_context_limit` for this reason. The helper also had no direct test at all; it was exercised only incidentally through well-formed events. Cover every malformed shape.
Two guards had no test that would notice their removal: - The `break` at the round limit exists because the loop runs one extra iteration to POST the last fulfillment and read the reply. Deleting it fulfills one hook too many while still reporting the lower number, and the existing test -- which asserts only the message and the logged ids -- stayed green. Assert the fulfillment count. - `OffloadOperation.execute` must let a hook request reach the client rather than fold it into a `failed` result. Two mechanisms protect this (the `BaseException` base and the explicit re-raise) and either alone suffices, so the test asserts the outcome; mutating both together fails it.
`HttpClient.post` requires the keyword-only `json` argument, so the Esc-cancellation path raised `TypeError` before reaching the server and the operation could commit after the UI had cancelled it.
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Mason Daugherty (mdrxy)
deleted the
mdrxy/code/offload-seeded-compaction-no-hitl
branch
August 24, 2026 17:19
Mason Daugherty (mdrxy)
pushed a commit
that referenced
this pull request
Aug 24, 2026
> [!CAUTION] > Merging this PR will automatically publish to **PyPI** and create a **GitHub release**. For the full release process, see [`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md). --- _Release notes preview: keep this section in sync with the package `CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`, not this PR description — keep them aligned anyway so the PR stays an accurate historical record for reviewers and anyone returning later._ --- ## [0.1.61](deepagents-code==0.1.60...deepagents-code==0.1.61) (2026-08-24) ### Features - Added `google_anthropic_vertex` provider support for Claude on Vertex AI ([#5760](#5760)). - Enforced configured model allowlists ([#5649](#5649)). - Injected goal and rubric context directly, replacing `get_goal` and `get_rubric` ([#5041](#5041)). - Made `/offload` server-owned ([#5261](#5261)). - Added prompt clipboard support ([#5733](#5733)). - Show Auto approval review progress ([#5729](#5729)). ### Bug Fixes - Kept long thread resumes responsive ([#5772](#5772)). - Render first streamed text immediately ([#5761](#5761)). - Show the incognito shell command widget ([#5768](#5768)). - Only highlight actionable tool rows ([#5769](#5769)). - Warn and ignore `--auto-approve` and `--yolo` in headless mode ([#5750](#5750)). - Sweep expired history archives at startup ([#5751](#5751)). - Clarified auth environment setup ([#5767](#5767)). _End release notes preview._ --- > [!NOTE] > A **community contributors** list and a **Special thanks** section (crediting the users who filed the issues this release's PRs closed) are appended to the GitHub release notes automatically at publish time (see [Release Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline), step 3). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
/offloadnow runs only as a server-owned operation on built-in dcode servers. Local and ACP agents no longer support it, and custom or older servers without the route are unsupported.The graph-operation prototype coupled the TUI to LangGraph run routing and lifecycle behavior. This revision puts the ownership boundary in dcode's server backend: the built-in LangGraph deployment registers a custom HTTP app that resolves the same cached runtime, compaction policy, hooks, model configuration, and
CompositeBackendas the interactive agent.flowchart LR user["User runs /offload"] --> tui["TUI"] subgraph client["Client"] tui --> remote["RemoteAgent"] hookexec["Configured hook executor"] remote <--> hookexec end subgraph server["Built-in dcode LangGraph server"] api["Offload HTTP boundary"] runtime["Shared server runtime"] operation["OffloadOperation"] hooks["PreCompact + PreToolUse"] compact["Agent compaction service"] state[("Thread checkpoint")] backend["Agent CompositeBackend"] cost["Cost recorder"] api -->|"resolve"| runtime runtime --> operation operation --> hooks --> compact api -->|"read + validate"| state compact -->|"plan archive"| backend cost -->|"priced delta"| api api -->|"summary event + cost"| state api -->|"then append archive"| backend end remote -->|"thread ID + runtime context"| api api -->|"typed result or hook request"| remote compact -->|"summarize"| model["Configured model provider"] style api fill:#dcfce7,stroke:#16a34a style operation fill:#dcfce7,stroke:#16a34a style state fill:#dcfce7,stroke:#16a34aThe server operation:
erroruntil the next run completes, which is exactly when a user reaches for/offloadto recover from an overflow;OffloadStateUpdatedeclares. The runtime guard is an allowlist derived from that type, so a future merge adding any other channel is refused, not justmessages. No synthetic assistant or tool message is persisted, and the operation cannot replace conversation history;PreCompactandPreToolUsehooks, transporting interrupt/resume payloads opaquely through the client and returning denials as typed results. The session's approval mode is carried across, so a configured hook sees the same mode it sees on an interactive turn;The client calls the server route directly. There is no capability probe and no seeded tool-call fallback. A local in-process or ACP agent gets a short unsupported message. A server that does not register the route gets a message naming that cause instead of a bare transport error.
Careful review is warranted around the custom-route/thread-state boundary, the archive reserve-then-append ordering, and hook replay identity. The real integration test launches the production server configuration, checks message preservation around
/offload, verifies route authentication, and reads the archive back through the running agent.User-visible output
Success is unchanged in shape:
Three failure paths now say something actionable:
error)Offload failed: Cannot offload while the thread has an active or interrupted run.— and no way out until a turn succeedsOffload failed: 404 Not FoundOffload failed: This server does not provide dcode's /offload operation. Use the built-in dcode server, or upgrade the server to a version that registers it.Offload failed: <exception>— prompting a second offload of an already compacted conversationThe conversation was offloaded, but the result could not be displayed. Check logs for details.A dropped endpoint override is now logged with the key names, so a user whose gateway configuration is being ignored has something to find.
Test plan
make test: 14,199 passed, 2 skipped.make formatandmake lint: passed, including Ruff,ty, and command-catalog validation.operation_id, renaming the route's path converter, and deleting the hook round-limitbreakeach fail the new test and passed before it.Made by Open SWE