Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ All notable changes to Raven are documented here.

### Fixed

- When an agent cannot inherit the host's model, its launcher now names
what is missing -- for example the OpenAI Codex sign-in, with the
command that adds it -- instead of reporting that the host has no
provider key, which was untrue when the host's key sits on a provider
other than the one its selected model uses.

- `raven doctor` warns when `permissions.mode` is `full` and when
`tools.sandbox.backend` is `none`. Ask-tier calls then run without asking,
and commands run on the host with no isolation. The exit code stays 0.
Expand Down
22 changes: 16 additions & 6 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@ Five shipped agents today: `raven-code`, `raven-design`, `raven-oncall`,
-- the scaffold command that instantiates this whole shape into a fresh
folder; `BUILDING.md` in this directory is the from-zero guide.

Agents that have no model key of their own inherit the host's model binding,
provider configuration, routing and reasoning effort. Inheritance uses the
same credential check as the host runtime, including stored OAuth sign-ins
such as OpenAI Codex. The child reads OAuth credentials from the host's
`RAVEN_HOME` (or the provider's configured token-directory override); tokens
stay in that credential store rather than the rendered agent config.
Agents that have no model key of their own inherit the host's model
binding, provider configuration, routing and reasoning effort.
Inheritance uses the same credential check as the host runtime,
including stored OAuth sign-ins such as OpenAI Codex. The child reads
OAuth credentials from the host's home, which it shares because the
host passes its `RAVEN_HOME` when one is set and both otherwise
normally resolve `~/.raven`; tokens stay in that credential store
rather than the rendered agent config. An agent is spawned with the
login shell's environment -- raven's own environment only when that
shell cannot be captured -- so a token-location override
(`CHATGPT_TOKEN_DIR`, `CHATGPT_AUTH_FILE`) reaches it when the login
profile exports it, when the agent's stored row carries it in `env` (a
stored row replaces the discovered one whole), or in that fallback. A
`RAVEN_HOME` reaches the agent the same ways: from the login profile
while the host has none set, and from the agent's stored row even when
it has. The agent then reads that home instead of the host's.

What one agent directory carries:

Expand Down
10 changes: 7 additions & 3 deletions agents/raven-code/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,14 @@ def render_config(source: Path, partition: Path, mode: str | None = None, *, una
else:
taken = render.inherit_llm(config, host)
if not taken:
# A home tree copied out by a newer wheel also serves an older checkout, so this
# can run on a raven that predates inherit_refusal; that one refuses without the reason.
explain = getattr(render, "inherit_refusal", None)
reason = f" ({explain(config, host)})" if explain else ""
raise SystemExit(
f"error: {llm_key} is not set and the host config has no provider key to "
f"inherit from; put the key in {HERE / '.env'} (see .env.example), export "
f"it, or configure a provider in the host raven"
f"error: {llm_key} is not set and the host's model cannot be inherited{reason}; "
f"put the key in {HERE / '.env'} (see .env.example), export it, or configure a "
f"provider in the host raven"
)
log(f"[run] llm: inherited from the host ({taken})")

Expand Down
14 changes: 9 additions & 5 deletions agents/raven-design/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,19 @@ def render_config(source: Path) -> Path:
config.pop("routing", None)
taken = render.inherit_llm(config, deepcopy(host))
if not taken:
raise SystemExit("error: configure a model provider in the host Raven settings before starting Design")
# A home tree copied out by a newer wheel also serves an older checkout, so this
# can run on a raven that predates inherit_refusal; that one refuses without the reason.
explain = getattr(render, "inherit_refusal", None)
reason = f" ({explain(config, host)})" if explain else ""
raise SystemExit(f"error: configure a model provider in the host Raven settings before starting Design{reason}")
log(f"[run] llm: inherited from the host ({taken})")

# The pooled loop reads identity, sessions, transcripts and the skill pool
# from ONE agent home; unpinned it would be the host's own (the launcher
# inherits RAVEN_HOME), which this agent must not share -- and it must sit
# OUTSIDE the host Agent home, which the host hands over as the session
# cwd (the runtime refuses a cwd that contains the engine's home). The
# shared placement helper seats it in the raven data directory;
# normally shares the host's home), which this agent must not share -- and
# it must sit OUTSIDE the host Agent home, which the host hands over as the
# session cwd (the runtime refuses a cwd that contains the engine's home).
# The shared placement helper seats it in the raven data directory;
# DESIGN_ACP_HOME overrides. setdefault, so an operator's explicit
# workspace wins.
defaults = config.setdefault("agents", {}).setdefault("defaults", {})
Expand Down
10 changes: 7 additions & 3 deletions agents/raven-oncall/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,14 @@ def render_config(source: Path) -> Path:
else:
taken = render.inherit_llm(config, host)
if not taken:
# A home tree copied out by a newer wheel also serves an older checkout, so this
# can run on a raven that predates inherit_refusal; that one refuses without the reason.
explain = getattr(render, "inherit_refusal", None)
reason = f" ({explain(config, host)})" if explain else ""
raise SystemExit(
f"error: {llm_key} is not set and the host config has no provider key to "
f"inherit from; put the key in {HERE / '.env'} (see .env.example), export "
f"it, or configure a provider in the host raven"
f"error: {llm_key} is not set and the host's model cannot be inherited{reason}; "
f"put the key in {HERE / '.env'} (see .env.example), export it, or configure a "
f"provider in the host raven"
)
log(f"[run] llm: inherited from the host ({taken})")

Expand Down
18 changes: 11 additions & 7 deletions agents/raven-ppt/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,14 @@ def render_config(source: Path) -> Path:
else:
taken = render.inherit_llm(config, host)
if not taken:
# A home tree copied out by a newer wheel also serves an older checkout, so this
# can run on a raven that predates inherit_refusal; that one refuses without the reason.
explain = getattr(render, "inherit_refusal", None)
reason = f" ({explain(config, host)})" if explain else ""
raise SystemExit(
f"error: {llm_key} is not set and the host config has no provider key to "
f"inherit from; put the key in {HERE / '.env'} (see .env.example), export "
f"it, or configure a provider in the host raven"
f"error: {llm_key} is not set and the host's model cannot be inherited{reason}; "
f"put the key in {HERE / '.env'} (see .env.example), export it, or configure a "
f"provider in the host raven"
)
ignored = [name for name in ("PPT_MODEL", "PPT_API_BASE") if env_value(name)]
log(
Expand Down Expand Up @@ -589,10 +593,10 @@ def render_config(source: Path) -> Path:

# The pooled loop reads identity, sessions, transcripts and the skill pool
# from ONE agent home; unpinned it would be the host's own (the launcher
# inherits RAVEN_HOME), which this agent must not share -- and it must sit
# OUTSIDE the host Agent home, which the host hands over as the session
# cwd (the runtime refuses a cwd that contains the engine's home). The
# shared placement helper seats it in the raven data directory;
# normally shares the host's home), which this agent must not share -- and
# it must sit OUTSIDE the host Agent home, which the host hands over as the
# session cwd (the runtime refuses a cwd that contains the engine's home).
# The shared placement helper seats it in the raven data directory;
# PPT_ACP_HOME overrides. The state root keeps the work (rendered
# configs, sweep) exactly as before. setdefault, so an operator's
# explicit workspace wins.
Expand Down
10 changes: 7 additions & 3 deletions agents/raven-research/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,14 @@ def render_config(source: Path) -> Path:
else:
taken = render.inherit_llm(config, host)
if not taken:
# A home tree copied out by a newer wheel also serves an older checkout, so this
# can run on a raven that predates inherit_refusal; that one refuses without the reason.
explain = getattr(render, "inherit_refusal", None)
reason = f" ({explain(config, host)})" if explain else ""
raise SystemExit(
f"error: {llm_key} is not set and the host config has no provider key to "
f"inherit from; put the key in {HERE / '.env'} (see .env.example), export "
f"it, or configure a provider in the host raven"
f"error: {llm_key} is not set and the host's model cannot be inherited{reason}; "
f"put the key in {HERE / '.env'} (see .env.example), export it, or configure a "
f"provider in the host raven"
)
log(f"[run] llm: inherited from the host ({taken})")

Expand Down
26 changes: 22 additions & 4 deletions raven/agent/subagent/vendored_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,28 @@ def _folder_addresses_openrouter(folder: Path) -> bool:
def host_can_lend_a_key() -> bool:
"""Whether ``inherit_llm`` in the launchers would find anything to inherit.

Read the same host file and use the launcher's own inheritance decision,
including OAuth credentials, so setup cannot offer a model the launcher
refuses or withhold one it accepts. Invalid host settings leave nothing
to inherit rather than preventing the setup wizard from opening.
Asks ``inherit_llm`` itself, on the host file the launchers read, so an
OAuth sign-in counts exactly when a launcher would accept it. An agent
reaches a sign-in stored under the host's OAuth directory because it
normally resolves the same home: it is spawned with the host's
``RAVEN_HOME`` when the host has one set, and with none set both fall back
to ``~/.raven``.

It can still answer True for a launch that is then refused, because the
agent's environment and config are not this process's. It is spawned with
the login shell's environment, and with raven's own environment only when
that shell cannot be captured, so a token-location override
(``CHATGPT_TOKEN_DIR``, ``CHATGPT_AUTH_FILE``) set only in this process's
environment reaches it only in that fallback. A ``RAVEN_HOME`` that the
login profile exports while this process has none, or that the agent's
stored row carries in ``env`` (merged last, over the host's), sends it to
that home's config and sign-ins instead of the host's. And a host config
that names no ``agents.defaults.provider`` leaves an agent's own
configured provider in force, which this check, reading only the host
file, does not see; ``raven onboard`` writes the provider before it asks.

Invalid host settings leave nothing to inherit rather than preventing the
setup wizard from opening.
"""
from raven.config.product_render import host_config, inherit_llm

Expand Down
110 changes: 74 additions & 36 deletions raven/config/product_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ def env_value(name: str, *, env_file: Path | None = None) -> str | None:
def host_config() -> dict:
"""The host raven's config, or an empty dict when there is none to read.

Read as JSON through the path paper's answer -- the host propagates its
``RAVEN_HOME`` into a launcher process (builtin_agents does), so
``raven_home()`` here is the host's home.
Read as JSON through the path paper's answer. ``raven_home()`` here is the
host's home because the host passes its ``RAVEN_HOME`` to a launcher when
it has one set, and with none set both normally resolve ``~/.raven``; a
``RAVEN_HOME`` the login profile exports while the host has none sends the
launcher elsewhere.
"""
try:
return json.loads((raven_home() / CONFIG_FILENAME).read_text(encoding="utf-8"))
Expand Down Expand Up @@ -118,32 +120,13 @@ def apply_secret_slots(
put(config, path, value)


def inherit_llm(config: dict, host: dict) -> str:
"""Take the host raven's whole LLM configuration; return what was taken.

Only reached when the product has no key of its own. The provider block
is copied wholesale rather than matched by name -- two providers spelled
the same can be two different endpoints. What is inherited is which
brains are reachable, which one is chosen, how a model name routes, and
the host's reasoning effort; deliberately not the rest of
``agents.defaults``, which are the product's own operating limits. ``""``
when the inherited model binding has no usable credentials, which the
caller treats as a refusal to launch. Only the LLM settings are parsed for
that check; the original sections are copied so newer fields survive.
def _inherited_defaults(config: dict, host: dict) -> dict:
"""The ``agents.defaults`` an agent would run on after taking the host's LLM.

``RAVEN_PARENT_MODEL`` / ``RAVEN_PARENT_REASONING_EFFORT`` are honoured
on this branch, the fork launchers' own riders: the trunk cli dispatcher
injects them per spawn so a cli-hosted child follows the parent session's
model. For a pooled acp product this is a LAUNCH-TIME capture -- the env
is read once, when the server starts, so a parent ``/model`` switch made
mid-session does not follow into an already-running child (the fork's
per-turn form was a cli-lane property; ledgered, D3). On the own-key
branch the riders are deliberately ignored, as they always were.
The agent's own defaults with the host's provider, model and reasoning
effort over them, then the dispatcher's riders, then a provider derived
from the model when none is named. Reads both arguments, changes neither.
"""
from raven.config.schema import Config
from raven.providers.auth import MissingCredentialsError
from raven.providers.factory import check_provider_credentials

providers = host.get("providers") or {}
defaults = dict((config.get("agents") or {}).get("defaults") or {})
host_defaults = (host.get("agents") or {}).get("defaults") or {}
Expand All @@ -170,26 +153,68 @@ def inherit_llm(config: dict, host: dict) -> str:
# Failing that, the host's own choice beats launching with none.
from raven.providers.registry import split_model_id

head = split_model_id(model)[0]
# A non-string model names no provider. It is left to ``Config``
# validation, whose ValueError host_can_lend_a_key catches; splitting
# it would raise an AttributeError that escapes that except clause.
head = split_model_id(model)[0] if isinstance(model, str) else ""
defaults["provider"] = head if head in providers else host_defaults.get("provider", "")
return defaults


def _credential_refusal(host: dict, defaults: dict) -> str | None:
"""What the inherited binding is missing, or ``None`` when it can authenticate."""
from raven.config.schema import Config
from raven.providers.auth import MissingCredentialsError
from raven.providers.factory import check_provider_credentials

inherited = Config.model_validate(
{
"providers": providers,
"providers": host.get("providers") or {},
"agents": {
"defaults": {key: defaults[key] for key in ("provider", "model", "reasoningEffort") if key in defaults}
},
}
)
try:
check_provider_credentials(inherited)
except MissingCredentialsError:
except MissingCredentialsError as exc:
return exc.summary or "the inherited model has no usable credentials"
return None


def inherit_llm(config: dict, host: dict) -> str:
"""Take the host raven's whole LLM configuration; return what was taken.

Only reached when the product has no key of its own. The provider block
is copied wholesale rather than matched by name -- two providers spelled
the same can be two different endpoints. What is inherited is which
brains are reachable, which one is chosen, how a model name routes, and
the host's reasoning effort; deliberately not the rest of
``agents.defaults``, which are the product's own operating limits. ``""``
when the inherited model binding has no usable credentials, which the
caller treats as a refusal to launch (:func:`inherit_refusal` says why).
Only the LLM settings are parsed for that check; the original sections
are copied so newer fields survive.

``RAVEN_PARENT_MODEL`` / ``RAVEN_PARENT_REASONING_EFFORT`` are honoured
on this branch, the fork launchers' own riders: the trunk cli dispatcher
injects them per spawn so a cli-hosted child follows the parent session's
model. For a pooled acp product this is a LAUNCH-TIME capture -- the env
is read once, when the server starts, so a parent ``/model`` switch made
mid-session does not follow into an already-running child (the fork's
per-turn form was a cli-lane property; ledgered, D3). On the own-key
branch the riders are deliberately ignored, as they always were.
"""
defaults = _inherited_defaults(config, host)
if _credential_refusal(host, defaults) is not None:
return ""
for key in ("providers", "routing"):
if key in host:
config[key] = host[key]
config.setdefault("agents", {}).setdefault("defaults", {}).update(defaults)
parent_model = os.environ.get("RAVEN_PARENT_MODEL", "").strip()
if parent_protocol := os.environ.get("RAVEN_PARENT_PROTOCOL", "").strip():
provider = providers.get(defaults.get("provider") or "")
provider = (host.get("providers") or {}).get(defaults.get("provider") or "")
if isinstance(provider, dict) and parent_model:
overrides = provider.setdefault("modelProtocols", {})
if isinstance(overrides, dict):
Expand All @@ -200,6 +225,18 @@ def inherit_llm(config: dict, host: dict) -> str:
)


def inherit_refusal(config: dict, host: dict) -> str:
"""Why :func:`inherit_llm` would refuse ``config`` and ``host``, or ``""`` when it would not.

The launchers' refusal message names the missing credential through
this call rather than through ``inherit_llm``'s return value: that value
being ``""`` on a refusal is a contract every launcher branches on,
scaffolded copies outside this repository included. Changes neither
argument.
"""
return _credential_refusal(host, _inherited_defaults(config, host)) or ""


_DENY = "deny"
_EXTRA_DENY_KEYS = ("extraDenyPatterns", "extra_deny_patterns")

Expand Down Expand Up @@ -278,11 +315,12 @@ def inherit_host_denials(config: dict, host: dict) -> list[str]:
def inherit_plugin_opt_outs(config: dict, host: dict, *, own: Iterable[str] = ()) -> list[str]:
"""Carry the host's ``plugins.disabled`` into the product config.

A product engine scans the host's plugin roots -- the launcher inherits
``RAVEN_HOME``, so ``<home>/plugins`` is the host's -- and the entry points
of the interpreter they share, but it reads its opt-outs only from the
rendered file. So a plugin the host operator switched off, most often one
that fails to load, came back in every product the host dispatched.
An agent's engine scans the host's plugin roots -- ``<home>/plugins`` is
the host's, since the launcher normally resolves the host's home -- and
the entry points of the interpreter they share, but it reads its opt-outs
only from the rendered file. So a plugin the host operator switched off,
most often one that fails to load, came back in every agent the host
dispatched.

``own`` names the product's own engine plugins, which never travel: the
product is that plugin, and a host turning it off for its own agent is not
Expand Down
Loading
Loading