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
13 changes: 12 additions & 1 deletion raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,18 @@ def _register_default_tools(self) -> None:
extra_deny_patterns=self.exec_config.extra_deny_patterns,
)
)
self.tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy))
# web_search needs a Serper key it does not have by default, and offering
# it anyway is worse than withholding it: the model reaches for it, the
# call fails, and the error text -- naming a config file and an env var --
# gets relayed to whoever is on the other end of the channel. Ask the tool
# rather than the config, because it resolves the key at call time from
# either source; gating on `brave_api_key` alone would withdraw the tool
# from a deploy that only exports SERPER_API_KEY.
web_search = WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)
if web_search.api_key:
self.tools.register(web_search)
# web_fetch is unconditional by contrast: it works without a key, and the
# Jina one only upgrades the extraction.
self.tools.register(WebFetchTool(api_key=self.jina_api_key, proxy=self.web_proxy))
# Media tools (image/speech/video) are opt-in: a tool is registered only
# when the user configured it (a model or apiKey under tools.media.<tool>),
Expand Down
7 changes: 6 additions & 1 deletion raven/agent/subagent/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,12 @@ async def _run_subagent_inner(
extra_deny_patterns=self.exec_config.extra_deny_patterns,
)
)
tools.register(WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy))
# Withheld without a key, same as the main loop: a sub-agent that
# reaches for a search it cannot run reports the failure to its
# caller, and that text ends up in the parent turn.
web_search = WebSearchTool(api_key=self.brave_api_key, proxy=self.web_proxy)
if web_search.api_key:
tools.register(web_search)
tools.register(WebFetchTool(api_key=self.jina_api_key, proxy=self.web_proxy))

system_prompt = self._build_subagent_prompt()
Expand Down
281 changes: 281 additions & 0 deletions raven/agent/tools/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
"""What each credential-bearing tool needs, in one place.

Providers have had a declarative credential model for a while --
``providers.auth`` names what each connection method requires and
``credential_status`` is the single authority on whether a provider is usable.
An AST invariant enforces that authority, because six surfaces once answered
the same question six ways and each looked reasonable alone.

Tools never got the equivalent. Three rules decide whether a tool is offered to
the model, one per family, each a different shape:

web_search a resolved key, asked of the built tool
web_fetch nothing -- always registered, a key only improves extraction
media x3 an api_key *or* a model, either one counting as configured

For the media family, being offered to the model and being usable are two
different questions: a section naming only a model is registered, because a
model alone counts as asking for the tool, and then every call fails on a
missing key. :func:`is_configured` answers the first and the tools' ``has_key``
answers the second -- collapsing them is how a report ends up ticking a
capability that cannot run.

Each rule is defensible where it sits. What is missing is anywhere to *read*
them. A deployer cannot ask what this install lacks, and since an unconfigured
tool stopped being registered there is no surface at all saying the capability
exists: the model is not offered it, no document lists it, and ``raven doctor``
reports on providers and memory but has never mentioned tools.

This module is that surface. It describes the rules rather than replacing them
-- ``is_configured`` mirrors the loop's judgement instead of inventing a second
one -- and ``tests/test_tool_capabilities.py`` pins the description against what
the loop actually registers, so the two cannot drift apart quietly. Drifting
descriptions of the same fact is the failure this exists to avoid repeating.

``deep_research`` is deliberately absent: it is moving to the sub-agent surface
and its tool is going away.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from raven.config.schema import Config

#: The credential the media family falls back to, named once so what a doctor
#: row tells the deployer to set cannot drift from what is read here.
_OPENROUTER_KEY = "providers.openrouter.apiKey"


class Need(Enum):
"""What stands between a capability and working."""

#: Usable as shipped. A credential, if any, only improves it.
NOTHING = "nothing"
#: A credential this deployment already holds elsewhere; the capability
#: needs switching on, not a new account.
OWN_CREDENTIAL = "own_credential"
#: An account and key the deployer has to go and obtain.
NEW_ACCOUNT = "new_account"


@dataclass(frozen=True)
class Capability:
"""One credential-bearing tool, described for whoever decides what to set up."""

#: The name the model sees, so a doctor row can be matched to a transcript.
tool: str
#: One line in the deployer's terms rather than the code's.
summary: str
need: Need
#: Dotted config path the deployer would edit to switch this on. For the
#: media family this is the *model* field, not a credential -- see
#: :attr:`key_path`.
config_path: str = ""
#: Environment variable accepted instead of this capability's credential.
env_var: str = ""
#: Where to go when ``need`` is NEW_ACCOUNT.
obtain_from: str = ""
#: Attribute on ``tools.media`` backing this tool, for the media family.
media_attr: str = ""
#: Stated before the deployer switches it on rather than after: these cost
#: money per call, and one cannot run at all without prepaid credit.
cost_note: str = ""

@property
def key_path(self) -> str:
"""Dotted path to this capability's own credential field.

Distinct from :attr:`config_path`, which for the media family is the
model field: naming a model path as where a key came from sends the
deployer to edit a line that holds no credential.
"""
return f"tools.media.{self.media_attr}.apiKey" if self.media_attr else self.config_path


#: Ordered by how much the deployer has to do, least first, because that is the
#: order the question "what am I missing" wants answering in.
CAPABILITIES: tuple[Capability, ...] = (
Capability(
tool="web_fetch",
summary="Read a web page the agent already has the URL for",
need=Need.NOTHING,
config_path="tools.web.jinaApiKey",
),
Capability(
tool="image_generate",
summary="Generate an image",
need=Need.OWN_CREDENTIAL,
config_path="tools.media.image.model",
media_attr="image",
env_var="OPENROUTER_API_KEY",
cost_note="Billed per image.",
),
Capability(
tool="text_to_speech",
summary="Generate speech from text",
need=Need.OWN_CREDENTIAL,
config_path="tools.media.speech.model",
media_attr="speech",
env_var="OPENROUTER_API_KEY",
cost_note="Billed per call.",
),
Capability(
tool="video_generate",
summary="Generate a video",
need=Need.OWN_CREDENTIAL,
config_path="tools.media.video.model",
media_attr="video",
env_var="OPENROUTER_API_KEY",
cost_note="Billed per call; needs prepaid OpenRouter credit to run at all.",
),
Capability(
tool="web_search",
summary="Search the web",
need=Need.NEW_ACCOUNT,
config_path="tools.web.search.apiKey",
env_var="SERPER_API_KEY",
obtain_from="https://serper.dev",
),
)


def _resolved_media(cap: Capability, config: "Config") -> Any:
"""This tool's media section with the OpenRouter borrow already applied."""
return getattr(config.effective_media_config(), cap.media_attr)


def is_configured(cap: Capability, config: "Config") -> bool:
"""Whether this capability's credential gate is satisfied.

Delegates to the tool rather than deciding here. The rule for each family
lives with the tool that owns the credential, so this module cannot become
a second opinion about configured-ness -- which is the divergence
``providers.auth`` exists to prevent on the provider side, and the reason
an AST invariant guards it there.

Not the same question as "is it offered", which :func:`is_offered` answers:
a deployment can switch a fully credentialed tool off. See
:func:`is_disabled`.
"""
if cap.need is Need.NOTHING:
return True
if cap.media_attr:
from raven.agent.tools.media_gen import _OpenRouterMediaTool

return _OpenRouterMediaTool.is_configured(_resolved_media(cap, config))

from raven.agent.tools.web import WebSearchTool

return WebSearchTool.is_configured(config.tools.web.search.api_key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This answers only the credential gate, but production applies tools.disabledTools afterward in AgentLoop._apply_disabled_tools. With a Serper key plus disabledTools: ["web_search"], I reproduced is_configured=True and a green doctor row naming tools.web.search.apiKey, while loop.tools.has("web_search") is false. That makes the new inventory claim a capability is offered when Raven has explicitly removed it. Please represent the disabled state separately (simply treating it as unconfigured would wrongly tell the deployer to set a key), and pin the doctor/table contract against the final registry with a credentialed disabled tool.



def is_disabled(cap: Capability, config: "Config") -> bool:
"""Whether the deployment has switched this tool off by name.

A separate state from unconfigured, and reported as one: a switched-off
tool usually has its credential set, and calling it unconfigured would send
the deployer to set a key that is already there.

``tools.disabledTools`` is applied after registration
(``AgentLoop._apply_disabled_tools``), so this is the only thing standing
between a satisfied credential gate and a tool the agent actually holds.
"""
return cap.tool in (config.tools.disabled_tools or [])


def is_offered(cap: Capability, config: "Config") -> bool:
"""Whether the agent ends up holding this tool: credentialed and not off.

The predicate that matches the final registry, which is what a report about
available capabilities has to agree with.
"""
return is_configured(cap, config) and not is_disabled(cap, config)


def has_credential(cap: Capability, config: "Config") -> bool:
"""Whether a credential actually resolves for this capability.

A different question from :func:`is_configured`, which answers whether the
deployment asked for the tool. They come apart for the media family: a
section naming only a model is registered and offered to the model, and
every call then fails on a missing key. A report that collapses the two
ticks a capability that cannot run, which is the one thing it must not do.

The same answer twice for the other families -- web_search is configured by
a resolved key and nothing else, and web_fetch needs none -- so this only
ever diverges where the rule itself does.
"""
if cap.need is Need.NOTHING:
return True
if cap.media_attr:
from raven.agent.tools.media_gen import _OpenRouterMediaTool

return _OpenRouterMediaTool.has_key(_resolved_media(cap, config))
return is_configured(cap, config)


def configured_from(cap: Capability, config: "Config") -> str:
"""Where a satisfied capability got its credential, for a doctor row.

Reads keys to *report* on them, never to rule on whether anything is set
up -- :func:`is_configured` answers that, and it asks the tools. The
distinction matters to a deployer: "reusing the OpenRouter key you already
have" and "needs a key" are different instructions, and a row that cannot
tell them apart sends someone to create an account they already have.

Empty when the capability is unconfigured, when it needs nothing, and --
for the media family only -- when it is registered with no credential at
all: a section naming just a model is offered to the model and fails on
every call, so there is no source to name.

Names a source; it does not rule on whether one exists. Callers wanting that
answer ask :func:`has_credential`, which asks the tools -- inferring it from
an empty string here would make the two facts one, and they are not.
"""
if cap.need is Need.NOTHING or not is_configured(cap, config):
return ""
if cap.media_attr:
if getattr(config.tools.media, cap.media_attr).api_key:
return cap.key_path
# effective_media_config resolves the borrow, so a key present after it
# but absent in the raw section came from the provider entry.
if _resolved_media(cap, config).api_key:
return f"borrowed: {_OPENROUTER_KEY}"
elif config.tools.web.search.api_key:
return cap.key_path
return cap.env_var if os.environ.get(cap.env_var) else ""


def borrowable_credential(cap: Capability, config: "Config") -> str:
"""Where an unconfigured media capability would get its key once switched on.

"Reuse the OpenRouter key you already have" and "get a key as well" are
different instructions, and only this tells them apart. Stating the first
unconditionally is worse than saying nothing: the deployer sets a model,
the tool is registered because a model alone counts, and every call then
fails on a credential they were told they already had.

Empty for the other families, whose own rows already name what to set.
"""
if not cap.media_attr:
return ""
openrouter = config.providers.get("openrouter")
if openrouter and openrouter.api_key:
return _OPENROUTER_KEY
return cap.env_var if os.environ.get(cap.env_var) else ""


__all__ = [
"CAPABILITIES",
"Capability",
"Need",
"borrowable_credential",
"configured_from",
"has_credential",
"is_configured",
]
45 changes: 43 additions & 2 deletions raven/agent/tools/media_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,51 @@ def __init__(

# ── config resolution (at call time, so env/config edits are picked up) ──

@staticmethod
def _resolve_key(config: "MediaToolConfig | None") -> str:
"""The credential chain in one place: this tool's section, then the
shared environment variable.

A static method so :meth:`has_key` can ask it without an instance --
this base is abstract, and the callers deciding whether a capability
works hold a config and no tool.
"""
cfg_key = getattr(config, "api_key", "") if config else ""
return cfg_key or os.environ.get("OPENROUTER_API_KEY", "")

@property
def api_key(self) -> str:
cfg_key = getattr(self._config, "api_key", "") if self._config else ""
return cfg_key or os.environ.get("OPENROUTER_API_KEY", "")
return self._resolve_key(self._config)

@classmethod
def is_configured(cls, config: "MediaToolConfig | None") -> bool:
"""Whether this deployment asked for the tool at all.

A model *or* a key, matching what ``AgentLoop`` registers on: the key
alone would let an OpenRouter credential set for chat quietly switch on
three tools that bill per call, and the model alone would miss the
deployment that names no model and relies on ``default_model``.

A classmethod because the caller deciding whether to offer the tool has
a config and no instance, and because the answer has to be askable
without building one.
"""
if config is None:
return False
return bool(config.api_key or config.model)

@classmethod
def has_key(cls, config: "MediaToolConfig | None") -> bool:
"""Whether a credential resolves for this tool.

A different question from :meth:`is_configured`, which answers whether
the deployment asked for the tool at all: a section naming only a model
is registered and offered to the model, and then every call returns
:meth:`_no_key_error`. Asked of the tool because only the tool consults
both the section and ``OPENROUTER_API_KEY``, so a caller reading the
config alone answers wrong for every deployment that exports it.
"""
return bool(cls._resolve_key(config))

@property
def api_base(self) -> str:
Expand Down
Loading
Loading