mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
eae7c28f11
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>
599 lines
23 KiB
Python
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)
|