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
4 changes: 3 additions & 1 deletion raven/agent/context/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ def __init__(
*,
start_watcher: bool = True,
blocklist_reader: Callable[[], frozenset[str]] | None = None,
has_memory_backend: bool = False,
):
self.workspace = workspace
self._has_memory_backend = has_memory_backend
self.memory = MemoryStore(workspace)
self.skills = LocalSkillCatalog(
workspace,
Expand Down Expand Up @@ -180,7 +182,7 @@ def _get_identity(self) -> str:
"""
from raven.context_engine.segments import render

return render.identity_text(self.workspace)
return render.identity_text(self.workspace, has_memory_backend=self._has_memory_backend)

def _build_runtime_context(self, channel: str | None, chat_id: str | None) -> str:
"""Build untrusted runtime metadata block for injection before the user message."""
Expand Down
1 change: 1 addition & 0 deletions raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ def __init__(
skill_forge_config=skill_forge_config,
now_fn=now_fn,
blocklist_reader=lambda: skill_blocklist(self._live_config),
has_memory_backend=backend is not None,
Comment thread
gloryfromca marked this conversation as resolved.
Resolved
)
self.sessions = session_manager or SessionManager(workspace)
# Off switches with no config file behind them: an eval harness that
Expand Down
7 changes: 6 additions & 1 deletion raven/context_engine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@ def build_context_engine(
rewriter, gate = None, None

builders = [
IdentitySegmentBuilder(workspace, list_subagents=list_subagents, get_tool_definitions=get_tool_definitions),
IdentitySegmentBuilder(
workspace,
list_subagents=list_subagents,
get_tool_definitions=get_tool_definitions,
has_memory_backend=backend is not None,
),
BootstrapSegmentBuilder(workspace),
MemorySegmentBuilder(
builder.memory,
Expand Down
4 changes: 4 additions & 0 deletions raven/context_engine/segments/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,13 @@ def __init__(
workspace: Path,
list_subagents: Any | None = None,
get_tool_definitions: Any | None = None,
*,
has_memory_backend: bool = False,
) -> None:
self._workspace = workspace
self._list_subagents = list_subagents
self._get_tool_definitions = get_tool_definitions
self._has_memory_backend = has_memory_backend

def _specialists(self) -> list[tuple[str, str]]:
"""``(name, owns)`` for every agent that declares what it owns.
Expand Down Expand Up @@ -83,5 +86,6 @@ async def build(self, ctx: AssemblyContext) -> Segment | None:
self._workspace,
specialists=self._specialists(),
dispatch_tools=render.live_dispatch_tools(self._get_tool_definitions),
has_memory_backend=self._has_memory_backend,
)
return Segment(text=text + _task_block(ctx))
34 changes: 31 additions & 3 deletions raven/context_engine/segments/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,8 @@ def identity_text(
model: str | None = None,
specialists: Sequence[tuple[str, str]] = (),
dispatch_tools: Sequence[str] = DISPATCH_TOOLS,
*,
has_memory_backend: bool = False,
) -> str:
"""Segment 1 - the core identity / runtime block.

Expand All @@ -385,6 +387,11 @@ def identity_text(
form) told to the model so it never guesses its own identity from
pretraining. ``None`` (the default) resolves it from the running turn's
binding, else lazily from config (see ``_resolved_model_id``).

``has_memory_backend`` reflects whether a ``MemoryBackend`` plugin is
wired. A backend keeps memory in its own store and surfaces it through
per-turn recall into ``# Memory``, so naming a profile file there would
hand the agent a write target that is not where its memory lives.
"""
home_path = str(agent_home.expanduser().resolve())
bound = work_dir or workdir.current()
Expand All @@ -395,6 +402,29 @@ def identity_text(
delegation, delegation_rule = _delegation_block(specialists, dispatch_tools)
model_line = f"\nYou are running on model: {resolved_model}." if resolved_model else ""

# A wired backend keeps memory in the plugin's own store and hands the
# turn what is relevant as ``# Memory``, so the two files under agent home
# are not where that memory lives. Naming them there left the agent a write
# target it does not own. The last clause is the other half of the same
# bug: the store is dispatched after the turn ends, so a claim either way
# is one the agent cannot have checked -- and whatever it claims is itself
# extracted into memory next turn. Without a backend the text is unchanged:
# whatever those files are worth is the host's business, not this segment's.
if has_memory_backend:
home_memory = (
" — your own skills, not a place for user artifacts.\n"
" - Memory is not yours to write: what is relevant is recalled into the `# Memory`\n"
" section each turn, and the host persists the conversation after it ends. You\n"
" cannot observe that write, so never report it to the user as done or as failed."
)
else:
home_memory = (
" — your own memory and skills, not a place for user artifacts.\n"
f" - User profile: {home_path}/user_memory/profile/user.md (preferences, identity, project context)\n"
f" - Episodic log: {home_path}/user_memory/episodic/episodes.md (grep-searchable). "
"Each entry starts with [YYYY-MM-DD HH:MM]."
)

# One sentence for both policies. A package installed into the interpreter
# Raven itself runs on, or into a user-wide prefix, changes every later run.
package_install = (
Expand Down Expand Up @@ -424,9 +454,7 @@ def identity_text(

## Directories
- Working directory: {work_path} — files you produce go here; relative paths resolve here.
- Agent home: {home_path} — your own memory and skills, not a place for user artifacts.
- User profile: {home_path}/user_memory/profile/user.md (preferences, identity, project context)
- Episodic log: {home_path}/user_memory/episodic/episodes.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
- Agent home: {home_path}{home_memory}
- Custom skills: {home_path}/skills/{{skill-name}}/SKILL.md

{platform_policy}{delegation}{_subagent_note()}## Raven Guidelines
Expand Down
14 changes: 14 additions & 0 deletions tests/test_agent_loop_backend_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ def test_explicit_backend_stored(self, tmp_path: Path) -> None:
agent = _make_loop(tmp_path, backend=b)
assert agent.backend is b

def test_the_loop_tells_its_estimation_renderer_a_backend_is_wired(self, tmp_path: Path) -> None:
"""Asserted on what the loop renders, not on the argument it passes.

The flag reaches two renderers from two places, and a test that hands
each of them the flag by hand pins that they agree once told -- not
that the loop tells them. This one holds the loop's half: drop the
argument at its call site and this reddens.
"""
plain = _make_loop(tmp_path, backend=None).context._get_identity()
assert "user_memory" in plain

wired = _make_loop(tmp_path, backend=_FakeBackend()).context._get_identity()
assert "user_memory" not in wired


# ---------------------------------------------------------------------------
# _dispatch_backend_store
Expand Down
25 changes: 25 additions & 0 deletions tests/test_context_engine_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,31 @@ def test_push_config_restores_the_legacy_pipeline(tmp_path):
# ---------------------------------------------------------------------------


class TestTheEngineTellsItsIdentityWhoOwnsMemory:
"""The turn's half of the flag, held where the engine decides it.

Handing ``IdentitySegmentBuilder`` the flag directly pins how it renders,
not who told it. This builds the engine the way the loop does and reads
the builder it chose, so deleting the argument at the factory's call site
reddens here -- the suite was otherwise green with the request path naming
both memory files again.
"""

@staticmethod
async def _identity_text(engine) -> str:
builder = next(b for b in engine._builders if isinstance(b, IdentitySegmentBuilder))
return (await builder.build(_assembly_ctx())).text

async def test_without_a_backend_the_turn_still_names_the_memory_files(self, tmp_path: Path) -> None:
text = await self._identity_text(_build_engine(tmp_path))
assert f"{tmp_path}/user_memory/profile/user.md" in text

async def test_with_a_backend_the_turn_names_no_memory_file(self, tmp_path: Path) -> None:
text = await self._identity_text(_build_engine(tmp_path, backend=_FakeBackend()))
assert "user_memory" not in text
assert "# Memory" in text


def _assembly_ctx() -> AssemblyContext:
return AssemblyContext(
session_key="s",
Expand Down
36 changes: 36 additions & 0 deletions tests/test_segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,42 @@ async def test_identity_names_both_directories(self, tmp_path: Path) -> None:
assert f"{home}/user_memory/profile/user.md" in seg.text
assert str(project / "user_memory") not in seg.text

async def test_identity_without_a_backend_still_names_both_memory_files(self, tmp_path: Path) -> None:
"""No backend, no change.

Whether an episodic log nothing writes earns a line in the prompt is a
separate question from where memory lives once a backend owns it, and
deciding it here would tie the two together.
"""
seg = await IdentitySegmentBuilder(tmp_path).build(_ctx(tmp_path))
assert f"{tmp_path}/user_memory/profile/user.md" in seg.text
assert f"{tmp_path}/user_memory/episodic/episodes.md" in seg.text

async def test_identity_with_a_backend_names_no_memory_file(self, tmp_path: Path) -> None:
"""A wired backend owns memory, so agent home holds no file for it.

Told otherwise, the agent treats ``user.md`` as the place a
"remember this" goes. On a non-interactive turn that ``edit_file``
is refused at the ask tier, and the agent reports the refusal as the
memory system having failed -- while the backend has already stored
the turn and recalls it in the next session.

The same block forbids the claim in either direction. ``backend.store``
is dispatched after the turn ends, so neither "saved" nor "not saved"
is something this side watched happen -- and the claim is extracted
into memory next turn as if it were an observation of the system.
"""
seg = await IdentitySegmentBuilder(tmp_path, has_memory_backend=True).build(_ctx(tmp_path))
assert "user_memory" not in seg.text
assert "# Memory" in seg.text
assert "never report it to the user as done or as failed" in seg.text

async def test_identity_with_a_backend_matches_the_estimation_prompt(self, tmp_path: Path) -> None:
"""``_get_identity`` renders what the turn renders, backend included."""
seg = await IdentitySegmentBuilder(tmp_path, has_memory_backend=True).build(_ctx(tmp_path))
legacy = ContextBuilder(workspace=tmp_path, has_memory_backend=True)._get_identity()
assert seg.text == legacy

async def test_the_identity_renders_the_task_it_is_handed(self, tmp_path: Path) -> None:
"""The segment owns how the identity reads and nothing about deciding it.

Expand Down
Loading