Files
deepagents/libs/code/deepagents_code/configurable_model.py
Mason Daugherty eae7c28f11 feat(code): classifier-backed Auto approval mode (#4804)
Deep Agents Code now offers an opt-in **Auto** approval mode. Auto
immediately runs a narrow, deterministic set of routine actions. For
other approval-gated actions, the active model checks whether they match
the user's literal request. It runs approved actions and blocks the
rest. After repeated denials or classifier failures, Auto sends the next
batch to the normal approval UI, then continues in Auto mode.

Auto is experimental and currently limited to interactive, local TUI
sessions using `FilesystemBackend`. Manual remains the default.

> [!WARNING]
> Auto is an authorization heuristic for a local coding agent. It is
**not** sandbox containment, an operating-system boundary, or a
guarantee that model-generated actions are safe.

## Enable Auto

1. Set `DEEPAGENTS_CODE_EXPERIMENTAL=1`.
2. Select Auto in one of these ways:
- In-app: use <kbd>Shift</kbd>+<kbd>Tab</kbd> or
<kbd>Ctrl</kbd>+<kbd>T</kbd> to toggle between Manual and Auto.
   - At launch: pass `-y` or `--auto-approve`.
- By default: set `[startup].mode = "auto"` in
`~/.deepagents/config.toml`.

`-y` and `--auto-approve` now select Auto rather than unrestricted
execution. Users who want the previous unrestricted behavior must choose
`--yolo`. The removed `dangerously-auto` startup value is no longer
recognized and falls back to Manual.

---

## Why

The previous approval modes forced a binary choice:

- **Manual** preserved a human checkpoint, but repeatedly interrupted
routine coding work.
- **YOLO** removed that checkpoint entirely, including for actions that
exceeded the user's request (blanket approval).

Auto introduces a third policy: keep the low-friction path for clearly
routine work, but make authorization explicit for potentially
consequential actions.

| Mode | Policy | Entry points | Availability |
|---|---|---|---|
| **Manual** | Uses the normal human approval UI for gated actions |
Default; `[startup].mode = "manual"` | All interactive sessions |
| **Auto** | Uses narrow deterministic rules, then classifier review,
with human fallback after repeated denials or failures | `-y`,
`--auto-approve`, `[startup].mode = "auto"`, or the live keyboard toggle
| Experimental, local, unsandboxed TUI sessions |
| **YOLO** | Runs gated actions without review | `--yolo` or
`[startup].mode = "yolo"` | Interactive sessions after a one-time
acknowledgement |

## How Auto handles a proposed action

```mermaid
flowchart TD
    A[Model proposes tool calls] --> B{Covered by the approval policy?}
    B -->|No| X[Existing tool behavior is unchanged]
    B -->|Yes| S{Is this thread still in Auto, with readable denial/failure history?}
    S -->|No| J[Open the existing approval HITL widget]
    S -->|Yes| C{Narrow deterministic allow?}
    C -->|Yes| R[Execute without a classifier call]
    C -->|No| D[Build one structured decision batch]
    D --> H{Was this batch already reviewed, or do earlier denials/failures require human review?}
    H -->|Yes| J
    H -->|No| E[Active model reviews effects against user request/input]
    E -->|Allow| R
    E -->|Deny| Q{Total-denial threshold reached?}
    Q -->|No| F[Return a sanitized error result]
    Q -->|Yes| J
    E -->|Unavailable or invalid| G[Return a compact unavailable result]
    F --> I[Agent can revise its plan]
    G --> I
    J -->|Approve| R
    J -->|Reject| K[Return a rejection result]
    J -->|Switch to Manual| L[Persist Manual, then review the full gated batch]
    R --> M[Reconcile the result and continue]
    K --> M
    I --> M
    L --> M
```

In practice:

1. **Auto starts with Manual's existing human-in-the-loop (HITL)
rules.** It changes only how HITL-gated actions are reviewed.
- These built-in tools are not HITL-gated: `ls`, `read_file`, `glob`,
`grep`, `ask_user`, `get_goal`, `get_rubric`, and `update_goal`.
- MCP tools with valid read-only annotations, the optional `js_eval`
tool, and caller-supplied tools without HITL configuration continue to
run normally.
2. **Only narrow cases resolve locally.** The deterministic policy can
allow, never deny:
- A routine write such as `src/parser.py` can proceed based on its
resolved path and suffix; a sensitive target such as
`.github/workflows/ci.yml` goes to classifier review.
- A read-only Git command such as `git status` can proceed; a mutating
command such as `git commit -m change` goes to classifier review.
Explicit shell allow-list entries still apply after Auto removes broad
and wildcard rules.
- An MCP tool with a valid read-only annotation can proceed; tools
without read-only annotations, or with contradictory or malformed
annotations, go to classifier review.

     <details>
     <summary>How MCP read-only annotations are evaluated</summary>

MCP servers can attach standard `ToolAnnotations` when advertising a
tool. Deep Agents Code copies those annotations into the wrapped tool's
metadata and lets the tool bypass classifier review only when:

     - `readOnlyHint` is the literal Boolean `true`;
     - `destructiveHint` is absent, `null`, or `false`; and
- every supplied standard hint (`readOnlyHint`, `destructiveHint`,
`idempotentHint`, or `openWorldHint`) is a Boolean or `null`, rather
than a string or another type.

     For example:

- `readOnlyHint: true, destructiveHint: false` proceeds without
classifier review.
- No `readOnlyHint` goes to review because the tool never explicitly
claimed to be read-only.
- `readOnlyHint: true, destructiveHint: true` goes to review because the
hints contradict each other.
- `readOnlyHint: true, destructiveHint: "false"` goes to review because
`"false"` is a string, not a Boolean.

In Auto, tools that do not pass this check enter the classifier batch.
In Manual, they use the normal approval UI; headless runtimes reject
them because they have no approval UI. The annotation is a
server-provided assertion that Deep Agents Code does not independently
verify.

     </details>
3. **The classifier reviews all unresolved calls together.** Only
pre-expansion literal user text can authorize an action. Referenced
paths, summarized arguments, prior tool calls, and MCP metadata provide
effect context but cannot grant permission. Expanded files, assistant
prose, and tool results are excluded.
- **Example:** For the literal request `Research issue #123, update
@src/parser.py, and open a PR`, proposed `fetch_url` and `git push`
calls can be reviewed in one batch and receive separate decisions. The
referenced path and summarized call arguments help identify what each
action would affect, but the expanded file contents are not sent.
Instructions found in that file, assistant prose, or a prior tool result
therefore cannot widen the user's request or authorize another action.
4. **Auto revalidates before side effects.** The decision plan is bound
to the thread, mode, batch, and exact gated calls. Missing or invalid
state, a mode race, or a replay falls back to human review. Denied calls
are skipped and receive sanitized error results so the agent can revise
its plan.
- **Examples:** If the user switches to Manual while classifier review
is in progress, an earlier Auto decision cannot execute silently; the
normal approval UI opens instead. A plan associated with another thread,
a different set of tool-call IDs, missing decisions, or a previously
processed batch also requires human review. If Auto denies a proposed
`delete`, it returns an error result with that call's ID instead of
invoking the tool, allowing the agent to propose a safer next step.

## Limits and user-visible behavior

- The Manual approval menu can enable Auto for the current thread.
Threshold fallback can switch permanently to Manual or perform a one-off
review while leaving Auto enabled.
- Denials, classifier failures, and fallbacks appear as short sanitized
transcript events rather than raw classifier output.
- Auto requested without the experimental opt-in, or in a sandboxed
session, falls back to Manual with a warning. Explicit autonomous-mode
flags are rejected in non-interactive and ACP entry points.
- The active model is not an independent security authority. MCP
read-only annotations are trusted as a deliberate beta tradeoff.
- Parent-level Auto review does not cover actions performed inside
delegated subagents or broader explicitly configured PTC/`js_eval`
fan-out. Model providers and tracing backends may still observe
classifier inputs and outputs even though the TUI hides them.

<details>
<summary><strong>Implementation and reviewer details</strong></summary>

### Approval boundary and decision inputs

The following tools are HITL-gated:

- `execute`, `write_file`, `edit_file`, and `delete`;
- `web_search` and `fetch_url`;
- `task`;
- `start_async_task`, `update_async_task`, and `cancel_async_task`;
- `compact_conversation` when optional compaction approval is enabled;
and
- MCP tools without valid read-only annotations.

For deterministic writes and edits, the fast path uses only the resolved
target path and suffix; it does not inspect proposed content. Dependency
and lock files, sensitive names and locations, shell scripts,
unsupported file types, and paths outside the trusted root do not
qualify. Selected Git inspection commands must also pass shell-control
and outside-worktree path checks. Configured shell entries remain an
explicit user trust exception, but broad commands and wildcard entries
are ignored by Auto.

The classifier receives one typed batch containing:

- up to 20 client-attached, pre-expansion prompt rows, including literal
prompts, referenced path names, and turn IDs;
- trusted project-root and redacted-origin facts; and
- depth- and length-limited effect context from current arguments, prior
AI tool calls after the latest trusted prompt, and selected MCP
annotations.

Only `literal_user_text` is authorizing. Content-bearing write/edit
arguments are reduced to character counts. The classifier must return
exactly one unique decision for every unresolved tool-call ID; missing,
duplicated, unknown, or malformed decisions make the classifier
unavailable rather than partially authorizing the batch.

Before routing calls, Auto re-reads the live mode and validates its
private decision plan. Manual mode, invalid control state, mismatched
calls, or replayed batches require human review. Classifier review uses
a second call to the active session model with a 20-second timeout. Raw
classifier text and tool-call chunks are hidden from the transcript,
while usage accounting and tracing are retained.

Denial reasons are sanitized before persistence or display by removing
control characters, known credential values, credential-like
assignments, and URL credentials or query values. MCP tools without
valid read-only annotations are HITL-gated; headless runtimes reject
them because no approval UI is available.

### Failure and fallback behavior

| Condition | Result |
|---|---|
| Selected mode cannot be persisted | Persist Manual instead; if that
also fails, block graph execution |
| Classifier timeout, provider error, or invalid response | Block the
affected calls and return a compact unavailable result |
| Three consecutive policy denials in one user turn | Send the next
classifier-review-eligible calls to one-off human review |
| Two consecutive classifier-unavailable results | Send the next
classifier-review-eligible calls to one-off human review |
| A denial raises the thread's total denial count to 20 | Require human
review for that call immediately |
| Counter state cannot be read or reset before classification | Require
human review for every gated call in the batch |
| Counter state cannot be written after classification | Require human
review for classifier-path decisions; deterministic fast-path allows
remain eligible |
| Decision plan is missing, malformed, replayed, or bound to another
thread | Require Manual review |
| User chooses **Switch to Manual** during fallback | Persist Manual
before presenting the full gated batch for review |

### Python API and state compatibility

- Existing Boolean compatibility input retains its previous meaning:
`auto_approve=True` maps to YOLO, not Auto.
- Per-thread Store records persist the typed mode instead of a Boolean.
Missing, legacy, or malformed records fail closed.
- Startup and live mode changes take effect only after the Store write
succeeds.
- YOLO requires a versioned local acknowledgement and cannot be entered
through the live keyboard toggle.

### Reviewer guide

Suggested review order:

1. **Mode semantics and persistence** — `ApprovalMode`, Store payloads,
acknowledgement, startup resolution, and live mode writes.
2. **Authorization policy** — `AutoModeHITLMiddleware`, literal prompt
metadata, deterministic routing, structured classification,
sanitization, counters, and checkpoint validation.
3. **Graph boundary** — middleware replacement, the gated-tool
inventory, MCP annotation handling, headless rejection, PTC, and
delegated-subagent scope.
4. **Client behavior** — stream metadata, classifier-output filtering,
fallback interrupts, keyboard toggles, status presentation, and
transcript events.

Highest-risk invariants:

- every consequential tool in the supported boundary is gated or
explicitly outside scope;
- path-based writes and selected Git fast paths stay within their
documented checks;
- only literal user prompts can authorize classifier-reviewed effects;
- malformed output, Store failures, mode races, and checkpoint replay
fail closed; and
- hidden classifier chunks never create transcript content while usage
accounting remains intact.

</details>
2026-07-17 17:09:26 -04:00

599 lines
23 KiB
Python

"""Middleware for runtime model selection via LangGraph runtime context.
Allows switching the model per invocation by passing a `CLIContext` via
`context=` on `agent.astream()` / `agent.invoke()` without recompiling
the graph.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from deepagents._models import ( # noqa: PLC2701
get_model_identifier,
model_matches_spec,
)
from langchain.agents.middleware.types import (
AgentMiddleware,
ExtendedModelResponse,
ModelRequest,
ModelResponse,
)
from langgraph.types import Command
from deepagents_code._cli_context import CLIContextSchema
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from langchain_core.language_models import BaseChatModel
from deepagents_code.config import ModelResult
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _ResolvedModelRequest:
"""Model request plus the checkpoint metadata it should persist."""
request: ModelRequest
"""Request to pass to the downstream model handler."""
model_spec: str | None
"""Resolved `provider:model` spec to persist for resume, when known."""
model_params: dict[str, Any] | None = None
"""Invocation params to persist, or `None` to clear checkpointed params."""
model_params_known: bool = False
"""Whether `model_params` is known and should be written to the checkpoint."""
def _get_ls_provider(model: object) -> str | None:
"""Return the LangSmith provider name reported by a chat model.
Returns:
The `ls_provider` string when the model reports one, otherwise `None`
(including when `_get_ls_params` is missing, raises, or yields a
non-string provider).
"""
try:
ls_params = model._get_ls_params() # ty: ignore[unresolved-attribute]
except (AttributeError, TypeError, RuntimeError, NotImplementedError):
logger.debug("_get_ls_params raised for %s", type(model).__name__)
return None
if isinstance(ls_params, dict):
provider = ls_params.get("ls_provider")
if isinstance(provider, str):
return provider
return None
def _is_anthropic_model(model: object) -> bool:
"""Check whether a resolved model reports `'anthropic'` as its provider.
Uses `_get_ls_params` from `BaseChatModel` to read the provider name.
Args:
model: A model instance to inspect.
Typed as `object` (rather than `BaseChatModel`) so the caller can
pass any model without an import-time dependency on a specific
provider package.
Returns:
`True` if the model's `ls_provider` is `'anthropic'`.
"""
return _get_ls_provider(model) == "anthropic"
def _is_fireworks_model(model: object) -> bool:
"""Check whether a resolved model reports `'fireworks'` as its provider.
Returns:
`True` if the model's `ls_provider` is `'fireworks'`.
"""
return _get_ls_provider(model) == "fireworks"
def _is_openai_model(model: object) -> bool:
"""Check whether a resolved model targets OpenAI's official API.
`ChatOpenAI` reports `'openai'` even when configured with a custom base URL,
so provider metadata alone cannot establish support for OpenAI-specific
request fields. Unknown endpoints are treated conservatively as
incompatible.
Returns:
`True` if the model reports `'openai'` and uses `api.openai.com` or an
official regional subdomain.
"""
if _get_ls_provider(model) != "openai":
return False
# Prefer the SDK client's resolved base URL: a default `ChatOpenAI()` leaves
# `openai_api_base` unset, but its `root_client.base_url` still resolves to
# the official `api.openai.com` default. Fall back to the constructor field
# only when the client is unavailable.
client = getattr(model, "root_client", None)
base_url = getattr(client, "base_url", None)
if base_url is None:
base_url = getattr(model, "openai_api_base", None)
if base_url is None:
# The provider is 'openai' yet no endpoint is discoverable. A genuine
# `ChatOpenAI` always exposes one, so this points to an unexpected model
# shape (e.g. an attribute renamed by an upstream upgrade). Skip the
# optimization instead of guessing, and leave a trace so the silent
# regression is diagnosable.
logger.debug("OpenAI model exposes no base URL; skipping prompt_cache_key")
return False
try:
hostname = urlsplit(str(base_url)).hostname
except ValueError:
logger.debug("OpenAI base URL is unparseable; skipping prompt_cache_key")
return False
return hostname == "api.openai.com" or (
hostname is not None and hostname.endswith(".api.openai.com")
)
_ANTHROPIC_ONLY_SETTINGS: set[str] = {"cache_control"}
"""Keys injected by Anthropic-specific middleware (e.g.
`AnthropicPromptCachingMiddleware`) that are not accepted by other providers and
must be stripped on cross-provider swap."""
_FIREWORKS_SESSION_AFFINITY_HEADER = "x-session-affinity"
"""Fireworks prompt-cache affinity header populated from the active thread ID."""
def _has_header(headers: Mapping[object, object], target: str) -> bool:
"""Return whether a headers mapping already includes `target`.
Comparison is case-insensitive; `target` must be supplied in lowercase.
Returns:
`True` if a string key case-insensitively equal to `target` is present.
"""
return any(isinstance(key, str) and key.lower() == target for key in headers)
def _with_fireworks_session_settings(
model_settings: dict[str, Any], thread_id: str
) -> dict[str, Any] | None:
"""Return model settings with Fireworks session settings added if needed.
Existing settings are preserved and never overwritten. Missing
`x-session-affinity` headers are populated directly so Fireworks can route
the conversation to the prompt-cache session for the active thread.
Returns:
A new `model_settings` dict with the missing session settings added, or
`None` when nothing needed adding or `extra_headers` is present but
not a mapping (leaving the request untouched).
"""
raw_headers = model_settings.get("extra_headers")
if raw_headers is None:
headers: dict[object, object] = {}
elif isinstance(raw_headers, Mapping):
headers = dict(raw_headers)
else:
logger.warning(
"Cannot inject Fireworks session settings because extra_headers is %s",
type(raw_headers).__name__,
)
return None
updated: dict[str, Any] = {}
has_session_affinity = _has_header(headers, _FIREWORKS_SESSION_AFFINITY_HEADER)
if "prompt_cache_key" not in model_settings and not has_session_affinity:
updated["prompt_cache_key"] = thread_id
if not has_session_affinity:
headers[_FIREWORKS_SESSION_AFFINITY_HEADER] = thread_id
updated["extra_headers"] = headers
if not updated:
return None
return {**model_settings, **updated}
def _with_openai_prompt_cache_key(
model: object, model_settings: dict[str, Any], thread_id: str
) -> dict[str, Any] | None:
"""Return model settings with an OpenAI `prompt_cache_key` added if needed.
Setting `prompt_cache_key` lets OpenAI route a conversation to a stable
prompt-cache prefix across turns, giving more reliable cache hits than the
automatic (keyless) matching; it is optional, and requests still cache
without it. It is a supported top-level `ChatOpenAI` invocation setting
forwarded to the OpenAI request payload, so passing it through
`model_settings` reaches the wire unchanged. `prompt_cache_key` is an
optional, additive request field: it sharpens prefix-cache routing on model
families that support it (GPT-5.6 and later) and is otherwise inert, so the
same key is sent to every OpenAI model without a version gate. This mirrors
the Fireworks path (`_with_fireworks_session_settings`), which sets
`prompt_cache_key` the same way and additionally sends an
`x-session-affinity` header.
A user-supplied `prompt_cache_key` is always preserved, whether it was
configured on the model (`model_kwargs`) or supplied for this invocation
(`model_settings`).
Returns:
A new `model_settings` dict with `prompt_cache_key` added, or `None` when
a key is already present on the model or in the settings (nothing to
add).
"""
model_kwargs = getattr(model, "model_kwargs", None)
if model_kwargs is not None and not isinstance(model_kwargs, Mapping):
# A non-mapping `model_kwargs` cannot carry a user-supplied key, so it is
# treated as "no key present" and injection proceeds. Trace the anomaly
# since a real `ChatOpenAI` always exposes a mapping here.
logger.debug(
"Ignoring non-mapping model_kwargs (%s) when checking for a "
"user-supplied prompt_cache_key",
type(model_kwargs).__name__,
)
if "prompt_cache_key" in model_settings or (
isinstance(model_kwargs, Mapping) and "prompt_cache_key" in model_kwargs
):
return None
return {**model_settings, "prompt_cache_key": thread_id}
def _get_context(request: ModelRequest) -> CLIContextSchema | None:
"""Return runtime context when it matches the CLI context shape."""
runtime = request.runtime
if runtime is None:
return None
ctx = runtime.context
if isinstance(ctx, CLIContextSchema):
return ctx
if isinstance(ctx, dict):
raw_key = ctx.get("approval_mode_key")
raw_thread_id = ctx.get("thread_id")
return CLIContextSchema(
model=ctx.get("model"),
model_params=ctx.get("model_params") or {},
profile_overrides=ctx.get("profile_overrides") or {},
model_context_limit=ctx.get("model_context_limit"),
approval_mode=(
ctx.get("approval_mode")
if isinstance(ctx.get("approval_mode"), str)
else "manual"
),
auto_approve=bool(ctx.get("auto_approve", False)),
approval_mode_key=raw_key if isinstance(raw_key, str) else None,
thread_id=raw_thread_id if isinstance(raw_thread_id, str) else None,
)
return None
def _model_spec_from_model(model: BaseChatModel) -> str | None:
"""Return a resumable `provider:model` spec for a model object."""
provider = _get_ls_provider(model)
model_name = get_model_identifier(model)
if provider and model_name:
return f"{provider}:{model_name}"
from deepagents_code.config import settings
settings_provider = settings.model_provider or ""
settings_model = settings.model_name or ""
if settings_provider and settings_model:
return f"{settings_provider}:{settings_model}"
return None
def _model_spec_from_result(
model_result: ModelResult | None, model: BaseChatModel
) -> str | None:
"""Return the resolved spec from `create_model`, falling back to model metadata."""
if model_result is not None and model_result.provider and model_result.model_name:
return f"{model_result.provider}:{model_result.model_name}"
return _model_spec_from_model(model)
def _build_overrides(
request: ModelRequest, ctx: CLIContextSchema, model_result: ModelResult | None
) -> ModelRequest:
"""Build the overridden request from a (possibly resolved) model result.
Holds the post-construction logic shared by the sync and async override
paths: applying the model swap, merging `model_params`, stripping
Anthropic-only settings on a cross-provider swap, and patching the
`### Model Identity` system-prompt section. The only thing that differs
between the two callers is how `model_result` is produced (a direct
`create_model` call vs. an `asyncio.to_thread` offload).
Args:
request: The incoming model request from the middleware chain.
ctx: Runtime CLI context carrying the requested overrides.
model_result: The resolved model result from `create_model`, or `None`
when no model swap was requested.
Returns:
The original request when no overrides apply, otherwise a new request
with overrides applied via `request.override()`.
"""
overrides: dict[str, Any] = {}
new_model = model_result.model if model_result is not None else None
if new_model is not None:
overrides["model"] = new_model
# Param merge
model_params = ctx.model_params
if model_params:
overrides["model_settings"] = {**request.model_settings, **model_params}
# Inject the provider's prompt-cache routing hint from the active thread.
# Only one provider path applies per call; both share the fetch/guard/log
# tail below. `overrides.get` is side-effect-free, so resolving `settings`
# before the provider check is equivalent to doing it inside each branch.
effective_model = new_model if new_model is not None else request.model
if ctx.thread_id:
settings = overrides.get("model_settings", request.model_settings)
if _is_fireworks_model(effective_model):
updated_settings = _with_fireworks_session_settings(settings, ctx.thread_id)
injected = "Fireworks session settings"
elif _is_openai_model(effective_model):
updated_settings = _with_openai_prompt_cache_key(
effective_model, settings, ctx.thread_id
)
injected = "OpenAI prompt_cache_key"
else:
updated_settings = None
injected = ""
if updated_settings is not None:
overrides["model_settings"] = updated_settings
# The thread ID is a sensitive session identifier, so it is kept out
# of the log line; the line firing at all confirms injection ran.
logger.debug("Injected %s", injected)
if not overrides:
return request
# When switching away from Anthropic, strip provider-specific settings
# that would cause errors on other providers (e.g. cache_control passed
# to the OpenAI SDK raises TypeError).
if new_model is not None and not _is_anthropic_model(new_model):
settings = overrides.get("model_settings", request.model_settings)
dropped = settings.keys() & _ANTHROPIC_ONLY_SETTINGS
if dropped:
logger.debug(
"Stripped Anthropic-only settings %s for non-Anthropic model",
dropped,
)
overrides["model_settings"] = {
k: v for k, v in settings.items() if k not in dropped
}
# Patch the Model Identity section in the system prompt so the new model
# sees its own name/provider/context-limit, not the original's.
# We read metadata from model_result (not the app's settings singleton)
# because the middleware runs in the server subprocess where settings
# are never updated by /model.
if model_result is not None and request.system_prompt:
from deepagents_code.agent import (
MODEL_IDENTITY_RE,
build_model_identity_section,
)
prompt = request.system_prompt
new_identity = build_model_identity_section(
model_result.model_name,
provider=model_result.provider,
context_limit=model_result.context_limit,
unsupported_modalities=model_result.unsupported_modalities,
)
patched = MODEL_IDENTITY_RE.sub(new_identity, prompt, count=1)
if patched != prompt:
overrides["system_prompt"] = patched
elif "### Model Identity" in prompt:
logger.warning(
"System prompt contains '### Model Identity' but regex "
"did not match; identity section was NOT updated for "
"model '%s'. The regex may be out of sync with the "
"prompt template.",
model_result.model_name,
)
return request.override(**overrides)
def _apply_overrides(request: ModelRequest) -> _ResolvedModelRequest:
"""Apply model/param overrides and return checkpoint persistence metadata.
Reads `'model'` and `'model_params'` from `runtime.context` and, when
present, swaps the model and/or merges extra settings into the request.
On a cross-provider swap away from Anthropic, Anthropic-only settings
(e.g. `cache_control`) are stripped. The `### Model Identity` section
in the system prompt is also patched to reflect the new model.
Args:
request: The incoming model request from the middleware chain.
Returns:
The request to send downstream plus the actual model spec and user-supplied
model params that should be recorded for resume.
"""
ctx = _get_context(request)
if ctx is None:
return _ResolvedModelRequest(request, _model_spec_from_model(request.model))
model_result = None
model = ctx.model
if model and not model_matches_spec(request.model, model):
from deepagents_code.config import create_model
from deepagents_code.model_config import ModelConfigError
logger.debug("Overriding model to %s", model)
model_kwargs = (
{"profile_overrides": ctx.profile_overrides}
if ctx.profile_overrides
else {}
)
try:
model_result = create_model(model, **model_kwargs)
except ModelConfigError:
logger.exception(
"Failed to resolve runtime model override '%s'; "
"continuing with current model",
model,
)
return _ResolvedModelRequest(
request,
_model_spec_from_model(request.model),
model_params_known=True,
)
updated = _build_overrides(request, ctx, model_result)
params = dict(ctx.model_params) if ctx.model_params else None
return _ResolvedModelRequest(
updated,
_model_spec_from_result(model_result, updated.model),
params,
model_params_known=True,
)
async def _apply_overrides_async(request: ModelRequest) -> _ResolvedModelRequest:
"""Async variant of `_apply_overrides` that offloads model construction.
Returns:
The request to send downstream plus the actual model spec and user-supplied
model params that should be recorded for resume.
"""
ctx = _get_context(request)
if ctx is None:
return _ResolvedModelRequest(request, _model_spec_from_model(request.model))
model_result = None
model = ctx.model
if model and not model_matches_spec(request.model, model):
from deepagents_code.config import create_model
from deepagents_code.model_config import ModelConfigError
logger.debug("Overriding model to %s", model)
model_kwargs = (
{"profile_overrides": ctx.profile_overrides}
if ctx.profile_overrides
else {}
)
try:
model_result = await asyncio.to_thread(
create_model,
model,
**model_kwargs,
)
except ModelConfigError:
logger.exception(
"Failed to resolve runtime model override '%s'; "
"continuing with current model",
model,
)
return _ResolvedModelRequest(
request,
_model_spec_from_model(request.model),
model_params_known=True,
)
updated = _build_overrides(request, ctx, model_result)
params = dict(ctx.model_params) if ctx.model_params else None
return _ResolvedModelRequest(
updated,
_model_spec_from_result(model_result, updated.model),
params,
model_params_known=True,
)
def _checkpoint_command(resolved: _ResolvedModelRequest) -> Command[Any] | None:
"""Build the private resume-state update for a completed model call.
Returns:
Command with private checkpoint updates, or `None` when nothing is known.
"""
update: dict[str, Any] = {}
if resolved.model_spec:
update["_model_spec"] = resolved.model_spec
if resolved.model_params_known:
update["_model_params"] = resolved.model_params
if not update:
return None
return Command(update=update)
class ConfigurableModelMiddleware(AgentMiddleware):
"""Swap the model or per-call settings from `runtime.context`.
Reads two optional keys from the runtime context dict:
- `'model'` — a `provider:model` spec (e.g. `"openai:gpt-5"`).
When present and different from the current model, the request is
re-routed to the new model.
- `'model_params'` — a dict of extra model settings (e.g.
`{"temperature": 0}`) that are shallow-merged into the
request's `model_settings`.
This middleware is typically the outermost layer so it intercepts every
model call before provider-specific middleware (like
`AnthropicPromptCachingMiddleware`) runs.
"""
def __init__(self, *, persist_model_state: bool = True) -> None:
"""Initialize the middleware.
Args:
persist_model_state: Whether completed calls should write private
resume metadata. Subagent instances disable this because they do
not own the parent thread's resume state.
"""
self._persist_model_state = persist_model_state
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | ExtendedModelResponse:
"""Apply runtime overrides and delegate to the next handler.
Returns:
The downstream response plus a private resume-state update when the
completed call has model metadata to checkpoint.
"""
resolved = _apply_overrides(request)
response = handler(resolved.request)
command = _checkpoint_command(resolved) if self._persist_model_state else None
if command is None:
return response
return ExtendedModelResponse(model_response=response, command=command)
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | ExtendedModelResponse:
"""Apply runtime overrides and delegate to the next async handler.
Returns:
The downstream response plus a private resume-state update when the
completed call has model metadata to checkpoint.
"""
resolved = await _apply_overrides_async(request)
response = await handler(resolved.request)
command = _checkpoint_command(resolved) if self._persist_model_state else None
if command is None:
return response
return ExtendedModelResponse(model_response=response, command=command)