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
73 changes: 64 additions & 9 deletions raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,11 @@ def __init__(
self.search_api_key = search_api_key
self.jina_api_key = jina_api_key
self.web_proxy = web_proxy
self.web_search_provider = web_search_provider
self.web_fetch_provider = web_fetch_provider
# What this process was built with. The selection itself is a property
# over the file (see ``wiring.web_search_provider``); these answer when
# the file names nothing.
self._boot_web_search_provider = web_search_provider
self._boot_web_fetch_provider = web_fetch_provider
self.web_provider_keys = web_provider_keys
self.image_search = image_search
from raven.config.raven import MemoryConfig, SubagentDagConfig, SubagentQuestionsConfig
Expand Down Expand Up @@ -765,6 +768,21 @@ async def close_executor(self) -> None:
except Exception as exc: # noqa: BLE001 -- teardown must not die on bookkeeping
logger.warning("Error marking MCP servers after executor close: %s", exc)

def _register_deep_research_offer(self) -> None:
"""Register the offer stand-in with the broker this host wired.

The broker arrives after construction (``set_deep_research_broker``),
which applies it to whatever is registered at that moment. A stand-in
registered later -- when a credential is cleared mid-session -- is past
that call, so it has to be handed the stored one here. Without it
``_ask_search_mode`` answers None and the offer degrades to regular
search silently, for the rest of the process.
"""
offer = DeepResearchOfferTool()
if self._deep_research_broker is not None:
offer.set_broker(self._deep_research_broker)
self.tools.register(offer)

def _register_real_deep_research(self, cfg: DeepResearchToolConfig) -> None:
"""Build the working deep_research tool (+ async manager) and register it.
Shared by initial registration and mid-session promotion."""
Expand Down Expand Up @@ -797,17 +815,23 @@ def set_deep_research_broker(self, broker: QuestionResponder) -> None:
tool.set_broker(broker)

def _maybe_promote_deep_research(self) -> None:
"""Swap the offer stand-in for the working tool once a key appears on disk,
so a mid-session ``raven deep-research enable`` is picked up on the next
turn without a restart. Called from ``run_turn`` before the per-turn tool
wiring, so the promoted tool gets this turn's stream callback and routing.
"""Take the deep-research key the file has now, on the next turn.

Swaps the offer stand-in for the working tool once a key appears, so a
mid-session ``raven deep-research enable`` is picked up without a
restart -- and swaps it back when the credential is cleared, so a
removed key stops being spent rather than outliving its removal. Called from ``run_turn`` before the per-turn tool wiring, so
the tool gets this turn's stream callback and routing.

A key *replaced* counts too: the working tool holds the key it was built
with, so rotating one on the settings page used to leave every call on
the old credential until a restart -- the same "saved and nothing
happened" the promotion path exists to prevent, one step later.

Re-reads config (the in-memory copy is fixed at startup); a corrupt config
must not fail the turn, so a read error just skips promotion. The promoted
manager inherits the gateway's async-delivery handle via
``set_deep_research_submit``, so a channel keeps the async path."""
if not isinstance(self.tools.get("deep_research"), DeepResearchOfferTool):
return
from raven.config.loader import ConfigReadError
from raven.config.schema import DeepResearchToolConfig
from raven.config.update_tools import get_deep_research
Expand All @@ -817,11 +841,42 @@ def _maybe_promote_deep_research(self) -> None:
except ConfigReadError as exc:
logger.warning("deep_research: skipping promotion, config unreadable: {}", exc)
return
# Identity, not the name: a plugin that shadows ``deep_research``, or a
# test double that replaced it, owns that entry and none of this is
# about it. Only the two tools this method registers are its to move.
current = self.tools.get("deep_research")
working = isinstance(current, DeepResearchTool)
offered = isinstance(current, DeepResearchOfferTool)
if not working and not offered:
return
if not DeepResearchTool.is_configured(cfg):
Comment thread
gloryfromca marked this conversation as resolved.
if not working:
return
# Cleared on the settings page. Returning here would leave the
# working tool registered on the credential that was just removed,
# and it would keep spending against it until a restart.
self.tools.unregister("deep_research")
self.deep_research_config = cfg
self.deep_research_manager = None
self._register_deep_research_offer()
logger.info("deep_research: credential cleared; the offer stand-in is back")
return
# The whole section, not the credential alone: an endpoint or a model
# moved is the same "saved and nothing happened" one field over, and
# comparing the section keeps configuredness where it belongs (it was
# settled by ``is_configured`` above).
if working and cfg == self.deep_research_config:
return
self.deep_research_config = cfg
if working:
# The registry refuses a duplicate name, so the tool this replaces
# has to go first.
self.tools.unregister("deep_research")
self._register_real_deep_research(cfg)
logger.info("deep_research: promoted offer stand-in to the working tool (key configured mid-session)")
logger.info(
"deep_research: {} (configured mid-session)",
"promoted offer stand-in to the working tool" if offered else "rebuilt the tool on the new section",
)

async def run(self) -> None:
"""Bring the agent runtime up and stay alive.
Expand Down
11 changes: 8 additions & 3 deletions raven/agent/loop/turn_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,11 @@ def _hook_rollback(decision) -> bool:
# top: a mode that asks for more thinking sets the turn's default,
# and a gate re-sampling one call may still move that one call.
gen_overrides = {
# The configured default underneath, the session's pin over it,
# a hook's rollback override on top. The default is here rather
# than left to the provider because the provider's copy is
# frozen at construction (see ``default_reasoning_effort``).
**({"reasoning_effort": self.default_reasoning_effort} if self.default_reasoning_effort else {}),
**({"reasoning_effort": policy.reasoning_effort} if policy.reasoning_effort else {}),
**(pending_gen_overrides or {}),
}
Expand Down Expand Up @@ -1856,13 +1861,13 @@ async def _process_message( # noqa: C901 (cc 47: pre-existing, above the ceilin
if not self.harness.memory.owns_compaction:
await self.memory_consolidator.maybe_consolidate_by_tokens(session)

# ── Personalization flow (global switch: self.enable_personalization) ──
# ── Personalization flow (global switch: agents.defaults.enablePersonalization) ──
# Skip for a subagent result re-injection: its content is a system-generated
# announce, not user input — personalizing it would pollute the profile or
# fire a clarification on the announce. Only SUBAGENT skips here (not the
# wider after-send / user-inbound sets): a Sentinel notice and cron/heartbeat
# reach this flow today and keep it.
if self.enable_personalization and origin is not Origin.SUBAGENT:
if self.personalization_enabled and origin is not Origin.SUBAGENT:
from datetime import datetime as _dt

from raven.agent.personalizer import Personalizer
Expand Down Expand Up @@ -2311,7 +2316,7 @@ def _rerun_pending(final_content: str | None, msgs: list[dict], status: str) ->
# ── Step 4: post-action learning (background, non-blocking) ─────────────
# Skip for a subagent result re-injection (see the pre-turn flow above):
# its content is a system-generated announce, not user input to learn from.
if self.enable_personalization and origin is not Origin.SUBAGENT:
if self.personalization_enabled and origin is not Origin.SUBAGENT:
from raven.agent.personalizer import Personalizer

_p4 = Personalizer(MemoryStore(self.workspace), self.provider, self.model)
Expand Down
96 changes: 85 additions & 11 deletions raven/agent/loop/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
AskUserTool,
Callable,
DeepResearchManager,
DeepResearchOfferTool,
EditFileTool,
ExecTool,
FindTool,
Expand Down Expand Up @@ -61,6 +60,13 @@

#: What the iteration cap is held under for the length of a turn.
_CAP_KEY = "agents.defaults.maxToolIterations"
#: Same, for the effort every model call of the turn is sent at.
_EFFORT_KEY = "agents.defaults.reasoningEffort"
#: Same, for the switch both personalization gates of a turn read.
_PERSONALIZATION_KEY = "agents.defaults.enablePersonalization"
#: Both web vendors under one key: they come out of one section, and a turn
#: that searches and fetches must not straddle an edit between the two.
_WEB_VENDORS_KEY = "tools.web.providers"


class WiringMixin:
Expand Down Expand Up @@ -405,9 +411,14 @@ def _turn_scope(self):
reason: one turn, one answer. The cap is resolved here rather than at
its first read, which happens after context assembly.
"""
from raven.config.live import hold_for_this_turn, max_tool_iterations
from raven.config.live import hold_for_this_turn, max_tool_iterations, reasoning_effort

return hold_for_this_turn(**{_CAP_KEY: max_tool_iterations(self._live_config)})
return hold_for_this_turn(
**{
_CAP_KEY: max_tool_iterations(self._live_config),
_EFFORT_KEY: reasoning_effort(self._live_config),
}
)

def _with_live_window(self, binding: ModelBinding) -> ModelBinding:
"""This turn's binding, carrying the window the config has right now.
Expand All @@ -432,6 +443,36 @@ def _with_live_window(self, binding: ModelBinding) -> ModelBinding:
return binding
return replace(binding, configured_window=configured)

@property
def default_reasoning_effort(self) -> str | None:
"""The effort a call runs at when the session pinned none.

The provider holds a configured default too, but it is frozen at
construction on purpose (see ``ResolvingProvider``: a credentials
refresh must not import a live ``agents`` section), so an edit reached
it only at a restart. Sent as an explicit argument instead, which is
the path a session's pinned effort already takes.

None passes nothing, which leaves the provider's own default standing
-- an explicit None would switch it off instead.
"""
from raven.config.live import held, reasoning_effort

return held(_EFFORT_KEY, lambda: reasoning_effort(self._live_config))

@property
def personalization_enabled(self) -> bool:
"""Whether the personalization flow runs, as the file has it now.

``configure_personalization`` still sets what a process was built with;
the file answers when it has an opinion, so a switch on a settings
surface reaches the next turn.
"""
from raven.config.live import held, personalization_enabled

configured = held(_PERSONALIZATION_KEY, lambda: personalization_enabled(self._live_config))
return self.enable_personalization if configured is None else configured

@property
def max_iterations(self) -> int:
"""The ReAct cap a turn runs under when its session pinned none.
Expand Down Expand Up @@ -836,6 +877,28 @@ def configure_personalization(self, enable: bool) -> None:
self.enable_personalization = enable
logger.info("Personalization flow: {}", "enabled" if enable else "disabled")

@property
def web_search_provider(self) -> str:
"""The search vendor a call runs on, as the file has it now.

The keys have been read live since they landed and resolve against this
selection (``_live_web_search_key``), so a vendor frozen at startup was
the half that kept the pair on the old endpoint. Held for the turn, like
the rest of them.
"""
from raven.config.live import default_live, held, web_providers

configured = held(_WEB_VENDORS_KEY, lambda: web_providers(default_live()))
return configured[0] or self._boot_web_search_provider

@property
def web_fetch_provider(self) -> str:
"""The fetch vendor a call runs on. See :attr:`web_search_provider`."""
from raven.config.live import default_live, held, web_providers

configured = held(_WEB_VENDORS_KEY, lambda: web_providers(default_live()))
return configured[1] or self._boot_web_fetch_provider

def _web_key(self, vendor: str) -> str | None:
return resolve_vendor_key(vendor, self.web_provider_keys, self.search_api_key, self.jina_api_key)

Expand Down Expand Up @@ -878,7 +941,9 @@ def _register_default_tools(self) -> None:
# says nothing about the replacement's credential story.
self._config_gated_tools: dict[str, Any] = {}
web_search = WebSearchTool(
api_key=self._live_web_search_key, proxy=self.web_proxy, provider=self.web_search_provider
api_key=self._live_web_search_key,
proxy=self.web_proxy,
provider=lambda: self.web_search_provider,
)
self.tools.register(web_search)
self._config_gated_tools[web_search.name] = web_search
Expand All @@ -887,20 +952,29 @@ def _register_default_tools(self) -> None:
# only where `tools.web.search.images` asks for the tool at all, so a lane
# that never places a picture keeps the tool face it had.
if self.image_search:
picture_vendor = image_search_vendor(self.web_search_provider, self._web_key)
# Both halves follow the selection: the picture vendor is derived
# from it, so freezing either one pins the pair to the boot choice.
def picture_vendor() -> str:
return image_search_vendor(self.web_search_provider, self._web_key)

image_search = ImageSearchTool(
api_key=lambda: self._live_vendor_key(picture_vendor), proxy=self.web_proxy, provider=picture_vendor
api_key=lambda: self._live_vendor_key(picture_vendor()),
proxy=self.web_proxy,
provider=picture_vendor,
)
self.tools.register(image_search)
self._config_gated_tools[image_search.name] = image_search
# web_fetch registers the same way and is never withheld: Jina needs no
# key, so a keyed backend selected without one is replaced by Jina
# rather than left to fail.
fetch_provider = WebFetchTool.effective_provider(
self.web_fetch_provider, self._web_key(self.web_fetch_provider)
)
# The substitution moved into the tool, which asks it per call: decided
# here it outlived the key that would have stopped it.
self.tools.register(
WebFetchTool(api_key=self._web_key(fetch_provider), proxy=self.web_proxy, provider=fetch_provider)
WebFetchTool(
api_key=lambda: self._live_vendor_key(self.web_fetch_provider),
proxy=self.web_proxy,
provider=lambda: self.web_fetch_provider,
)
)
# 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 Expand Up @@ -939,7 +1013,7 @@ def _register_default_tools(self) -> None:
if deep_research_mode(self.deep_research_config) == "real":
self._register_real_deep_research(self.deep_research_config)
else:
self.tools.register(DeepResearchOfferTool())
self._register_deep_research_offer()
self.tools.register(MessageTool())
# Not registered at all for a sub-agent, rather than hidden from the
# schema: hiding leaves the tool in the registry, which is exactly how the
Expand Down
Loading
Loading