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
7 changes: 7 additions & 0 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ 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.

What one agent directory carries:

- `run.py` -- a stdlib-only launcher: render the agent's config (secret
Expand Down
37 changes: 13 additions & 24 deletions raven/agent/subagent/vendored_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,34 +384,23 @@ 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.

A folder with no key of its own is not stranded: each launcher reads the
host raven's ``config.json`` (through ``raven.config.product_render``) and
copies its whole provider block, so the common case needs no credential
anywhere near the tree.

Mirrors ``inherit_llm``'s own test rather than asking ``providers.auth``,
and the difference is the whole point. ``inherit_llm`` accepts exactly one
shape -- a literal ``apiKey`` on some provider section. A host signed in
through OAuth is configured by auth's rule and has nothing to lend by the
launcher's, because those credentials live under ``~/.raven/oauth/``.
Asking auth here would advertise an agent that dies at the first dispatch.

The same file the launcher reads (``$RAVEN_HOME`` or ``~/.raven``), for
the same reason: two readers of one credential that disagree would have
the roster offer what the launcher then refuses.
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.
"""
from raven.contracts.path_policy import CONFIG_FILENAME
from raven.home import raven_home
from raven.config.product_render import host_config, inherit_llm

config = raven_home() / CONFIG_FILENAME
try:
raw = json.loads(config.read_text(encoding="utf-8"))
except (OSError, ValueError):
raw = host_config()
if not isinstance(raw, dict) or not isinstance(raw.get("providers", {}), dict):
return False
providers = raw.get("providers") if isinstance(raw, dict) else None
if not isinstance(providers, dict):
agents = raw.get("agents") or {}
if not isinstance(agents, dict) or not isinstance(agents.get("defaults") or {}, dict):
return False
try:
return bool(inherit_llm({}, raw))
except (OSError, TypeError, ValueError):
return False
return any(isinstance(p, dict) and str(p.get("apiKey") or "").strip() for p in providers.values())


def _resolved_python() -> str:
Expand Down
6 changes: 3 additions & 3 deletions raven/cli/subagent_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ def configure_subagents(*, non_interactive: bool = False, warnings: Optional[lis
if not can_inherit:
console.print(
t(
" [dim]This raven has no provider key to lend (an OAuth sign-in is not one):"
" [dim]This raven has no usable model provider to inherit:"
" an agent tuned for its own model needs a key of its own, and one that runs on"
" this raven's LLM is not ready until a provider is configured.[/dim]"
)
Expand All @@ -367,8 +367,8 @@ def configure_subagents(*, non_interactive: bool = False, warnings: Optional[lis
# A folder that recommends no model of its own runs on this raven's
# LLM and nothing else: there is no key to take (the launcher would
# not read it) and no model to recommend. It is ready exactly when
# this raven has a key to lend; otherwise the launcher refuses to
# start, and a tick here would certify a product that cannot.
# this raven has usable model credentials; otherwise the launcher
# refuses to start, and a tick here would certify a product that cannot.
if can_inherit:
set_up += 1
console.print(f" [green]\u2713[/green] {t('ready')} {t("(runs on this raven's LLM)")}")
Expand Down
32 changes: 24 additions & 8 deletions raven/config/product_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ def inherit_llm(config: dict, host: dict) -> str:
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 host has no provider key to lend, which the caller treats as a
refusal to launch.
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.

``RAVEN_PARENT_MODEL`` / ``RAVEN_PARENT_REASONING_EFFORT`` are honoured
on this branch, the fork launchers' own riders: the trunk cli dispatcher
Expand All @@ -139,13 +140,12 @@ def inherit_llm(config: dict, host: dict) -> str:
per-turn form was a cli-lane property; ledgered, D3). On the own-key
branch the riders are deliberately ignored, as they always were.
"""
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 {}
if not any(isinstance(p, dict) and p.get("apiKey") for p in providers.values()):
return ""
for key in ("providers", "routing"):
if key in host:
config[key] = host[key]
defaults = config.setdefault("agents", {}).setdefault("defaults", {})
defaults = dict((config.get("agents") or {}).get("defaults") or {})
host_defaults = (host.get("agents") or {}).get("defaults") or {}
for key in ("provider", "model", "reasoningEffort"):
if key in host_defaults:
Expand All @@ -172,6 +172,22 @@ def inherit_llm(config: dict, host: dict) -> str:

head = split_model_id(model)[0]
defaults["provider"] = head if head in providers else host_defaults.get("provider", "")
inherited = Config.model_validate(
{
"providers": providers,
"agents": {
"defaults": {key: defaults[key] for key in ("provider", "model", "reasoningEffort") if key in defaults}
},
}
)
try:
check_provider_credentials(inherited)
except MissingCredentialsError:
return ""
for key in ("providers", "routing"):
if key in host:
config[key] = host[key]
config.setdefault("agents", {}).setdefault("defaults", {}).update(defaults)
if parent_protocol := os.environ.get("RAVEN_PARENT_PROTOCOL", "").strip():
provider = providers.get(defaults.get("provider") or "")
if isinstance(provider, dict) and parent_model:
Expand Down
2 changes: 1 addition & 1 deletion raven/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@
" [green]Built.[/green]": " [green]构建完成。[/green]",
" [dim]No sub-agent tree in this installation. A release wheel carries one; reinstall from a release, or run from a source checkout.[/dim]": " [dim]本次安装没有子代理目录。发布版 wheel 自带子代理;可重装发布版,或改用源码检出运行。[/dim]",
" [dim]No sub-agent folders found.[/dim]": " [dim]没有找到子代理目录。[/dim]",
" [dim]This raven has no provider key to lend (an OAuth sign-in is not one): an agent tuned for its own model needs a key of its own, and one that runs on this raven's LLM is not ready until a provider is configured.[/dim]": " [dim]本机 raven 没有可借出的 provider key(OAuth 登录不算):为自己的模型调优的 agent 需要自己的 key,使用本机 raven LLM 的 agent 在配置 provider 之前不会就绪。[/dim]",
" [dim]This raven has no usable model provider to inherit: an agent tuned for its own model needs a key of its own, and one that runs on this raven's LLM is not ready until a provider is configured.[/dim]": " [dim]本机 raven 没有可继承的可用模型服务:为自己的模型调优的 agent 需要自己的 key,使用本机 raven LLM 的 agent 在配置 provider 之前不会就绪。[/dim]",
"Recommended: {a0} via OpenRouter (reusing this raven's OpenRouter key)": "推荐: {a0},经 OpenRouter(复用本机 raven 的 OpenRouter key)",
"\n {set_up} sub-agent(s) ready.": "\n {set_up} 个子代理已就绪。",
"Recommended: {a0} via OpenRouter (needs an OpenRouter key)": "推荐: {a0},经 OpenRouter(需要一个 OpenRouter key)",
Expand Down
9 changes: 9 additions & 0 deletions tests/test_agents_design_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@ def test_no_host_llm_key_refuses_even_with_own_key(grounded, tmp_path):
grounded.render_config(RUN_PY.parent / "config.json")


def test_unconfigured_host_model_refuses_even_with_another_provider_key(grounded, tmp_path):
_host_config(tmp_path, {"providers": {"openrouter": {"apiKey": "sk-host-or"}}})
with pytest.raises(SystemExit, match="host Raven settings"):
grounded.render_config(RUN_PY.parent / "config.json")


def test_optional_keys_fall_back_per_slot_to_the_host_config(grounded, tmp_path):
_host_config(
tmp_path,
Expand Down Expand Up @@ -411,9 +417,12 @@ def test_image_inherits_custom_host_settings(grounded, tmp_path):
{
"tools": {"media": {"image": {"apiKey": "sk-foreign", "apiBase": "https://images.example/v1"}}},
"providers": {"openrouter": {"apiKey": "sk-host-or"}},
"agents": {"defaults": {"provider": "openrouter", "model": "openrouter/openai/gpt-5.6-sol"}},
},
)
data = _render(grounded)
assert data["agents"]["defaults"]["provider"] == "openrouter"
assert data["agents"]["defaults"]["model"] == "openrouter/openai/gpt-5.6-sol"
image = data["tools"]["media"]["image"]
assert image["apiKey"] == "sk-foreign"
assert image["apiBase"] == "https://images.example/v1"
Expand Down
40 changes: 30 additions & 10 deletions tests/test_cli_subagent_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,13 +753,8 @@ def test_host_openrouter_key_is_empty_when_no_provider_is_set_up(
assert subagent_setup.host_openrouter_key() == ""


def test_an_oauth_host_is_not_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The launchers accept a literal key and nothing else.

An OAuth sign-in leaves `providers` with no `apiKey`, so `inherit_llm`
returns "" and the run exits -- after the wizard has already said
"registered". Offering the option at all is the defect.
"""
def test_an_unconfigured_host_is_not_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Setup withholds inheritance when the launcher has no usable host model."""
_folder(tmp_path, "raven-code")
monkeypatch.setattr(subagent_setup, "agents_root", lambda: tmp_path)
monkeypatch.setattr(subagent_setup, "host_openrouter_key", lambda: "")
Expand All @@ -781,13 +776,38 @@ def test_a_host_with_a_literal_key_keeps_the_option(tmp_path: Path, monkeypatch:
assert scripted.offered[0] == ["own", "inherit", "skip"]


def test_an_oauth_sign_in_is_not_a_key_to_lend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Mirrors `inherit_llm`, not `providers.auth`: auth calls this host
# configured, and the launcher still has nothing to inherit.
def test_an_unsigned_oauth_provider_is_not_lendable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_host_config(tmp_path, monkeypatch, {"openai_codex": {"models": []}})
assert subagent_setup.host_can_lend_a_key() is False


def test_a_signed_in_oauth_host_is_offered_its_own_llm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_host_config(tmp_path, monkeypatch, {"openai_codex": {}})
home = tmp_path / ".raven"
(home / "config.json").write_text(
json.dumps(
{
"providers": {"openai_codex": {}},
"agents": {"defaults": {"provider": "openai_codex", "model": "openai-codex/gpt-5.6-sol"}},
}
),
encoding="utf-8",
)
token_dir = home / "oauth" / "chatgpt"
token_dir.mkdir(parents=True)
(token_dir / "auth.json").write_text(json.dumps({"refresh_token": "synthetic-token"}), encoding="utf-8")
_folder(tmp_path, "raven-code")
monkeypatch.setattr(subagent_setup, "agents_root", lambda: tmp_path)
monkeypatch.setattr(subagent_setup, "host_openrouter_key", lambda: "")
scripted = _ScriptedSelect([("Set up", "skip")])
monkeypatch.setattr(onboard_commands, "_require_questionary", lambda: scripted)

subagent_setup.configure_subagents(warnings=[])

assert subagent_setup.host_can_lend_a_key() is True
assert scripted.offered[0] == ["own", "inherit", "skip"]


def test_a_literal_key_anywhere_is_a_key_to_lend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_host_config(tmp_path, monkeypatch, {"custom": {"apiKey": "sk-x", "apiBase": "http://10.0.0.9:3000/v1"}})
assert subagent_setup.host_can_lend_a_key() is True
Loading
Loading