Skip to content
43 changes: 43 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,49 @@ The bare `chat_id` value shown to and accepted from users (the channel prefix is
stripped for display, re-prepended to form the session key). Presentation term; in
code the value lives in the `chat_id` field and the composite is the `session_key`.

**Model binding**:
A model id together with the provider whose credential serves it, as one value
(`raven/providers/binding.py`). The pairing is the point: a model id alone does
not say which key reaches it, and updating one half is how one vendor's key ends
up on another vendor's endpoint. A turn resolves its binding once at `run_turn`
entry and holds it in a context var for the whole turn tree, so everything under
that turn -- the loop, the context engine's LLM-backed segments, the skill gate
and rewriter, the consolidator, and any task the turn detaches -- reads the same
pair. _Avoid_: "the current model" / "the active provider" for this; both name
one half.

**Session binding**:
The model binding one conversation runs on. Sessions that never switched have no
entry and resolve to the **default binding**; a switch writes only that
session's entry, so it moves no other conversation and does not change what a
new one starts on. Stored on the session record so it survives a restart.

**Default binding**:
What a session with no binding of its own runs on: `agents.defaults` from config,
verbatim. Changed by a `scope="default"` switch, which leaves sessions that
already chose their own model where they are.

**Provider pool**:
The one place a model id is resolved to the credential that serves it
(`raven/providers/pool.py`), caching a provider per (vendor, model) and dropping
the cache when the credentials behind it change. Also what turns a **subsystem
pin** into a pair. Constructing a `ModelBinding` from an already-resolved pair
happens in several places; deciding *which* provider a model id pairs with
happens only here.

**Subsystem pin**:
A model configured for one subsystem rather than for the conversation, as a
model and the provider serving it (`context.curator_model` +
`curator_provider`, `skill_forge.llm_gate_model` + `llm_gate_provider`). Both
halves because an id alone is ambiguous the moment a gateway is configured:
`openrouter` + `anthropic/claude-haiku-4-5` and `anthropic` +
`claude-haiku-4-5` are both valid and name different credentials. With the
provider set nothing is derived; with it unset the vendor is guessed from the
id, which is what configs predating the field get. A pin that cannot be paired
is reported and dropped, and the subsystem follows the conversation's model --
a bare pinned id sent on the conversation's key is exactly the mis-pairing
above. Unset out of the box -- no subsystem ships a vendor default.

**Turn**:
One complete agent reaction: from an inbound message entering the agent loop to the
agent's final response, including every LLM call and tool execution in between.
Expand Down
2 changes: 1 addition & 1 deletion docs/Raven-vs-OpenClaw-Hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

**Hermes 的做法**:`ContextCompressor` — 一套固定的 4 阶段(裁剪工具输出 → 保护边界 → 中段摘要 → 增量更新)。触发式、被动、不可恢复。

**我们的做法**:Curator 是一个**独立的小模型 Agent**(默认 gemini-2.5-flash),有自己的 11 个内部工具:
**我们的做法**:Curator 是一个**独立的小模型 Agent**(默认跟随对话所用的模型;可通过 context.curator_model 单独指定一个更小更快的模型),有自己的 11 个内部工具:

```
curator_check_budget — 理解当前 token 压力
Expand Down
169 changes: 162 additions & 7 deletions raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from raven.memory_engine.base import TokenBudget
from raven.memory_engine.consolidate.consolidator import MemoryConsolidator, MemoryStore
from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from raven.providers.binding import ModelBinding, active_binding, use_binding
from raven.providers.capabilities import image_placeholder_text, supports_image_tool_result
from raven.sandbox import SandboxConfig, SandboxExecutor, SandboxInitError, build_executor
from raven.session.manager import Session, SessionManager
Expand Down Expand Up @@ -79,6 +80,7 @@
from raven.context_engine import ContextEngine
from raven.memory_engine.backend import MemoryBackend
from raven.proactive_engine.schedulers.cron.service import CronService
from raven.providers.pool import ProviderPool
from raven.routing.router import ModelRouter
from raven.sandbox.debug_server import SandboxDebugServer
from raven.skill_hub import SkillHubClient
Expand Down Expand Up @@ -310,6 +312,7 @@ def __init__(
now_fn: Callable | None = None,
context_config: "ContextConfig | None" = None,
runtime_config: "RuntimeConfig | None" = None,
provider_pool: "ProviderPool | None" = None,
interactive: bool = True,
jina_api_key: str | None = None,
max_concurrent_subagents: int = 4,
Expand Down Expand Up @@ -365,9 +368,14 @@ def __init__(
# Returning None means "fall through to normal flow".
self.decision_consumer = decision_consumer
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
# The model a turn runs on is per session, so it cannot live in two
# attributes on a process-wide loop. ``_default_binding`` is what a
# session starts on; ``provider``/``model`` below read whichever
# binding the running turn entered.
self._provider_pool = provider_pool
self._default_binding = ModelBinding(provider, model or provider.get_default_model())
self._session_bindings: dict[str, ModelBinding] = {}
# Resolved lazily on the first tool result that carries an image. Keyed
# by model, not a single flag: the loop is a long-lived singleton and
# takes a per-call model (strategies rewrite it, and the model chain
Expand Down Expand Up @@ -462,7 +470,7 @@ def __init__(
config=context_config,
builder=self.context,
provider=provider,
model=self.model,
model=self._default_binding.model,
context_window_tokens=context_window_tokens,
get_tool_definitions=self.tools.get_definitions,
now_fn=now_fn,
Expand All @@ -473,6 +481,7 @@ def __init__(
skill_forge_router_config=skill_forge_router_config,
skill_forge_config=skill_forge_config,
skill_hub_client=self._skill_hub_client,
provider_pool=provider_pool,
)

# Runtime discipline (5th pillar). Bug2 uses ``runtime.checkpoint``;
Expand Down Expand Up @@ -508,7 +517,7 @@ def __init__(
self.subagents = SubagentManager(
provider=provider,
workspace=workspace,
model=self.model,
model=self._default_binding.model,
brave_api_key=brave_api_key,
jina_api_key=jina_api_key,
web_proxy=web_proxy,
Expand Down Expand Up @@ -543,7 +552,7 @@ def __init__(
self.memory_consolidator = MemoryConsolidator(
workspace=workspace,
provider=provider,
model=self.model,
model=self._default_binding.model,
sessions=self.sessions,
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
Expand All @@ -553,6 +562,13 @@ def __init__(

self._consolidation_tasks: set[asyncio.Task] = set()

# ``self.subagents``, ``self.context_engine`` and
# ``self.memory_consolidator`` were each handed ``provider`` earlier in
# this constructor. Inside a turn they read the turn's binding; the
# reference they hold is only the fallback for work that runs outside
# one, and ``set_default_binding`` is what keeps that fallback current.
# Add the call there when adding another holder.

# Phase B-3: the L4 facade (``DefaultMemoryEngine`` /
# ``MemoryEngine`` ABC) has been retired. AgentLoop now holds
# the underlying subsystems directly:
Expand Down Expand Up @@ -607,6 +623,107 @@ def _apply_disabled_tools(self) -> None:
if self.tools.has(name):
self.tools.unregister(name)

@property
def provider(self) -> LLMProvider:
"""The provider of the binding the running turn entered.

A property, not an attribute: the model is per session now, so there
is no single answer to cache on the loop. Outside a turn (startup, a
one-shot CLI call) this is the configured default.
"""
binding = active_binding()
return binding.provider if binding is not None else self._default_binding.provider

@property
def model(self) -> str:
"""The model id of the binding the running turn entered."""
binding = active_binding()
return binding.model if binding is not None else self._default_binding.model

@property
def provider_pool(self) -> "ProviderPool | None":
"""Where a model id becomes a model id plus the credential for it."""
return self._provider_pool

@property
def default_binding(self) -> ModelBinding:
"""What a session with no switch of its own runs on."""
return self._default_binding

def binding_for_session(self, session_key: str) -> ModelBinding:
"""The binding this session runs on: its own switch, else the default.

A new session has no entry, so it starts on the configured default
rather than on whatever the last session switched to.
"""
return self._session_bindings.get(session_key, self._default_binding)

def session_model(self, session_key: str) -> str:
"""What to show this session's user, which is not the global default."""
return self.binding_for_session(session_key).model

def has_session_binding(self, session_key: str) -> bool:
"""Did this session switch, or is it just following the default?

``session_model`` cannot answer that -- it falls back to the default,
so it never returns None. Callers that must distinguish "chose this"
from "inherited this" ask here.
"""
return session_key in self._session_bindings

def restore_session_model(self, session_key: str, model: str, provider_name: str | None = None) -> None:
"""Put a resumed session back on the model it was last switched to.

Session overrides live in memory, so without this a restart moves every
switched session back to the default and the user's choice lasts
exactly as long as the process. The model comes from the session
record. A model that can no longer be built (a credential since
removed) leaves the session on the default rather than failing the
resume.
"""
pool = self._provider_pool
if pool is None or not model:
return
try:
self.set_session_binding(session_key, pool.bind(model, provider_name))
except (SystemExit, RuntimeError, ValueError) as exc:
logger.warning("session {!r} cannot resume on {!r} ({}); using the default", session_key, model, exc)

def set_session_binding(self, session_key: str, binding: ModelBinding) -> None:
"""Switch one session, leaving every other session where it was.

Applied immediately and still safe mid-turn: a turn resolves its
binding once at ``run_turn`` entry and holds it in a context var for
its whole tree, including anything it detaches. So a switch during a
turn cannot move that turn -- it lands on the next one -- and no
parking is needed to arrange that.
"""
self._session_bindings[session_key] = binding

def clear_session_binding(self, session_key: str) -> None:
"""Drop a session's override so it follows the default again."""
self._session_bindings.pop(session_key, None)

def set_default_binding(self, binding: ModelBinding) -> None:
"""Change what new sessions start on.

Sessions that already switched keep their own binding; sessions that
never did pick this up on their next turn. Subsystem fallbacks are
re-pointed too, for the paths that run outside a turn and therefore
have no binding to read.
"""
self._default_binding = binding
self.subagents.set_provider(binding.provider, binding.model)
self.context_engine.set_provider(binding.provider, binding.model)
self.memory_consolidator.set_provider(binding.provider, binding.model)

def set_provider(self, provider: LLMProvider, model: str) -> None:
"""Change the default binding. Kept for callers that are not
session-aware (the gateway, a CLI one-shot); the session-scoped path
is ``set_session_binding``.
"""
self.set_default_binding(ModelBinding(provider, model))

def configure_personalization(self, enable: bool) -> None:
"""Global switch for the 4-step personalization flow (PAHF-inspired).

Expand Down Expand Up @@ -2558,14 +2675,52 @@ async def run_turn(
inline_tool_stream: bool = False,
usage_sink: dict[str, Any] | None = None,
text_sink: dict[str, Any] | None = None,
) -> TurnOutcome:
"""Bind the turn to its session's model; see ``_run_turn`` for the turn.

This is where a session's model becomes the one thing everything under
the turn reads: the loop's own ``provider``/``model``, the context
engine's LLM-backed segments, the skill gate and rewriter, the
consolidator, and anything the turn detaches (a subagent inherits the
context it was created in).

Resolving once here is also what makes a mid-turn switch harmless
without any parking. The binding is captured before the first read and
held for the tree, so a switch that lands while this turn runs is
simply not visible to it -- it takes effect on the session's next
turn. Turns from other sessions run under their own binding
concurrently, which is the point.
"""
session_key = req.conversation or f"{req.source.channel}:{req.source.chat_id}"
with use_binding(self.binding_for_session(session_key)):
return await self._run_turn(
req,
emit,
drain,
stream=stream,
inline_tool_stream=inline_tool_stream,
usage_sink=usage_sink,
text_sink=text_sink,
)

async def _run_turn(
self,
req: TurnRequest,
emit: Emit,
drain: Drain,
*,
stream: bool = True,
inline_tool_stream: bool = False,
usage_sink: dict[str, Any] | None = None,
text_sink: dict[str, Any] | None = None,
) -> TurnOutcome:
"""Spine-native turn entry: consume a TurnRequest, fan the agent's output
onto the single ``emit``, return a TurnOutcome. Collapses the legacy
output paths (a str return + the five callbacks) onto one boundary.

Named ``run_turn`` rather than ``run``: ``run`` is the runtime keep-alive
(executor / debug server / MCP up, then idle). A spine runner wraps this
method to satisfy the TurnRunner protocol.
(executor / debug server / MCP up, then idle). A spine runner calls the
public ``run_turn`` to satisfy the TurnRunner protocol.

``stream`` is the canon Q2-D assembly switch: a streaming outlet (TUI)
wires it True so the reply goes out as StreamDelta and dissolves (b2 — no
Expand Down
Loading
Loading