Compare commits

...
1 Commits
Author SHA1 Message Date
Elliot Slusky c3a7ffebff fix(memory): recall auto-captured facts across sessions (#740)
* fix(memory): recall auto-captured facts

* fix(memory): harden recalled context injection

* fix(memory): preserve mixed context history

* fix(agents): preserve caller system context
2026-08-13 17:07:10 -07:00
14 changed files with 619 additions and 51 deletions
+24 -2
View File
@@ -155,6 +155,9 @@ class BaseAgent(ABC):
conversation messages, and finally the user input.
"""
messages: list[Message] = []
context_messages = (
list(context.conversation.messages) if context is not None else []
)
# Check if the context already supplies a system message
_context_has_system = (
context
@@ -176,9 +179,28 @@ class BaseAgent(ABC):
except Exception:
effective_system_prompt = None
if effective_system_prompt:
context_system_text = "\n\n".join(
message.text
for message in context_messages
if message.role == Role.SYSTEM
and message.metadata.get("memory_context")
and message.text
)
if context_system_text:
effective_system_prompt = (
f"{effective_system_prompt}\n\n{context_system_text}"
)
context_messages = [
message
for message in context_messages
if not (
message.role == Role.SYSTEM
and message.metadata.get("memory_context")
)
]
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
if context_messages:
messages.extend(context_messages)
messages.append(Message(role=Role.USER, content=input))
return messages
+17 -2
View File
@@ -248,6 +248,17 @@ def _get_memory_backend(config):
return None
def _get_memory_facts(config):
"""Load facts captured by the automatic memory service."""
try:
from openjarvis.memory import load_configured_facts
return load_configured_facts(config)
except Exception as exc:
logger.debug("Automatic memory facts unavailable (optional): %s", exc)
return []
_MEMORY_TOOLS = frozenset(
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
)
@@ -416,7 +427,8 @@ def _run_agent(
from openjarvis.tools.storage.context import ContextConfig, inject_context
backend = _get_memory_backend(config)
if backend is not None:
facts = _get_memory_facts(config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
@@ -427,6 +439,7 @@ def _run_agent(
[],
backend,
config=ctx_cfg,
facts=facts,
)
for msg in context_messages:
ctx.conversation.add(msg)
@@ -963,7 +976,8 @@ def ask(
)
backend = _get_memory_backend(config)
if backend is not None:
facts = _get_memory_facts(config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
@@ -974,6 +988,7 @@ def ask(
messages,
backend,
config=ctx_cfg,
facts=facts,
)
except Exception as exc:
logger.debug("Failed to inject memory context: %s", exc)
+55 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import sys
from typing import List, Optional
@@ -15,6 +16,8 @@ from openjarvis.core.events import EventBus
from openjarvis.core.types import Message, Role
from openjarvis.memory import publish_completed_exchange
logger = logging.getLogger(__name__)
def _read_input(prompt: str = "You> ") -> Optional[str]:
"""Read user input with graceful EOF handling."""
@@ -194,6 +197,15 @@ def chat(
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
memory_service = None
# The document backend and automatic fact store are separate persistence
# mechanisms. Context injection combines both at read time so facts from
# previous sessions are immediately available without a manual index step.
memory_backend = None
if config.agent.context_from_memory:
from openjarvis.cli.ask import _get_memory_backend
memory_backend = _get_memory_backend(config)
# Conversation state
if not system_prompt:
from openjarvis.prompt.builder import SystemPromptBuilder
@@ -262,15 +274,55 @@ def chat(
# Add user message
history.append(Message(role=Role.USER, content=user_input))
# Generate response
generation_history = history
agent_context_message = None
if config.agent.context_from_memory:
try:
from openjarvis.memory import load_configured_facts
from openjarvis.tools.storage.context import (
ContextConfig,
inject_context,
)
if memory_service is not None and hasattr(memory_service, "list_facts"):
facts = memory_service.list_facts()
else:
facts = load_configured_facts(config)
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
max_context_tokens=config.memory.context_max_tokens,
)
context_messages = inject_context(
user_input,
[] if agent is not None else history,
memory_backend,
config=ctx_cfg,
facts=facts,
)
if agent is not None:
if context_messages:
agent_context_message = context_messages[0]
else:
generation_history = context_messages
except Exception:
logger.debug("Failed to inject memory context", exc_info=True)
# Generate response even when optional memory context is unavailable.
try:
if agent is not None:
response = agent.run(user_input)
agent_context = None
if agent_context_message is not None:
from openjarvis.agents._stubs import AgentContext
agent_context = AgentContext()
agent_context.conversation.add(agent_context_message)
response = agent.run(user_input, context=agent_context)
content = (
response.content if hasattr(response, "content") else str(response)
)
else:
result = engine.generate(history, model=model)
result = engine.generate(generation_history, model=model)
content = (
result.get("content", "")
if isinstance(result, dict)
+2
View File
@@ -19,6 +19,7 @@ from openjarvis.memory.store import (
FactStore,
LocalFactStore,
create_fact_store,
load_configured_facts,
)
__all__ = [
@@ -29,5 +30,6 @@ __all__ = [
"MemoryService",
"build_memory_service",
"create_fact_store",
"load_configured_facts",
"publish_completed_exchange",
]
+28 -2
View File
@@ -16,7 +16,7 @@ import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, List
from typing import Any, Iterable, List
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import FactStoreRegistry
@@ -205,4 +205,30 @@ def create_fact_store(
return FactStoreRegistry.create(key, path, max_facts=max_facts)
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
def load_configured_facts(config: Any) -> List[Fact]:
"""Load automatic-memory facts from *config* when the service is enabled.
Context injection is also used by short-lived commands such as
``jarvis ask``, where no :class:`MemoryService` instance exists. This
helper gives those callers the same configured fact-store view without
coupling them to the service lifecycle.
"""
memory = getattr(config, "memory", None)
if memory is None or not getattr(memory, "enabled", False):
return []
store = create_fact_store(
getattr(memory, "backend", "local"),
path=getattr(memory, "facts_path", None),
max_facts=getattr(memory, "max_facts", 1000),
)
return store.list()
__all__ = [
"Fact",
"FactStore",
"LocalFactStore",
"create_fact_store",
"load_configured_facts",
]
+14 -5
View File
@@ -522,14 +522,15 @@ class Jarvis:
# Context injection
if context and self._config.agent.context_from_memory:
try:
from openjarvis.cli.ask import _get_memory_backend
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
from openjarvis.tools.storage.context import (
ContextConfig,
inject_context,
)
backend = _get_memory_backend(self._config)
if backend is not None:
facts = _get_memory_facts(self._config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=self._config.memory.context_top_k,
min_score=self._config.memory.context_min_score,
@@ -540,6 +541,7 @@ class Jarvis:
[],
backend,
config=ctx_cfg,
facts=facts,
)
for msg in context_messages:
ctx.conversation.add(msg)
@@ -570,17 +572,24 @@ class Jarvis:
) -> List[Message]:
"""Inject memory context into messages."""
try:
from openjarvis.cli.ask import _get_memory_backend
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
from openjarvis.tools.storage.context import ContextConfig, inject_context
backend = _get_memory_backend(self._config)
if backend is not None:
facts = _get_memory_facts(self._config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=self._config.memory.context_top_k,
min_score=self._config.memory.context_min_score,
max_context_tokens=self._config.memory.context_max_tokens,
)
return inject_context(query, messages, backend, config=ctx_cfg)
return inject_context(
query,
messages,
backend,
config=ctx_cfg,
facts=facts,
)
except Exception as exc:
logger.warning("Failed to inject memory context: %s", exc)
return messages
+40 -15
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message, Role
from openjarvis.core.types import Message, Role, ToolCall
from openjarvis.server.model_capabilities import is_embed_only_model
from openjarvis.server.models import (
ChatCompletionChunk,
@@ -40,6 +40,15 @@ def _to_messages(chat_messages) -> list[Message]:
role=role,
content=m.content or "",
name=m.name,
tool_calls=[
ToolCall(
id=tool_call.get("id", ""),
name=tool_call.get("function", {}).get("name", ""),
arguments=tool_call.get("function", {}).get("arguments", "{}"),
)
for tool_call in (m.tool_calls or [])
]
or None,
tool_call_id=m.tool_call_id,
)
)
@@ -114,13 +123,15 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
memory_backend = getattr(request.app.state, "memory_backend", None)
if (
config is not None
and memory_backend is not None
and config.agent.context_from_memory
and request_body.messages
):
try:
from openjarvis.tools.storage.context import ContextConfig, inject_context
memory_service = getattr(request.app.state, "memory_service", None)
facts = memory_service.list_facts() if memory_service is not None else []
# Extract query from the last user message
query_text = ""
for m in reversed(request_body.messages):
@@ -130,6 +141,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
if query_text:
messages = _to_messages(request_body.messages)
messages = _ensure_identity_prompt(messages, config)
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
@@ -140,22 +152,35 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
messages,
memory_backend,
config=ctx_cfg,
facts=facts,
)
# Rebuild request messages from enriched Message objects
if len(enriched) > len(messages):
from openjarvis.server.models import ChatMessage
# Rebuild after identity/context merging so downstream engine
# adapters always receive exactly one system message.
from openjarvis.server.models import ChatMessage
new_msgs = []
for msg in enriched:
new_msgs.append(
ChatMessage(
role=msg.role.value,
content=msg.content,
name=msg.name,
tool_call_id=getattr(msg, "tool_call_id", None),
)
new_msgs = []
for msg in enriched:
new_msgs.append(
ChatMessage(
role=msg.role.value,
content=msg.content,
name=msg.name,
tool_calls=[
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": tool_call.arguments,
},
}
for tool_call in (msg.tool_calls or [])
]
or None,
tool_call_id=getattr(msg, "tool_call_id", None),
)
request_body.messages = new_msgs
)
request_body.messages = new_msgs
except Exception:
logging.getLogger("openjarvis.server").debug(
"Memory context injection failed",
+4 -1
View File
@@ -40,8 +40,9 @@ class QueryOrchestrator:
messages = [Message(role=Role.USER, content=query)]
if context and s.memory_backend and s.config.agent.context_from_memory:
if context and s.config.agent.context_from_memory:
try:
from openjarvis.memory import load_configured_facts
from openjarvis.tools.storage.context import (
ContextConfig,
inject_context,
@@ -52,11 +53,13 @@ class QueryOrchestrator:
min_score=s.config.memory.context_min_score,
max_context_tokens=s.config.memory.context_max_tokens,
)
facts = load_configured_facts(s.config)
messages = inject_context(
query,
messages,
s.memory_backend,
config=ctx_cfg,
facts=facts,
)
except Exception as exc:
logger.warning("Failed to inject memory context: %s", exc)
+93 -20
View File
@@ -2,13 +2,16 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, List, Optional, Sequence
from openjarvis.core.events import EventType, get_event_bus
from openjarvis.core.types import Message, Role
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
if TYPE_CHECKING:
from openjarvis.memory.store import Fact
@dataclass(slots=True)
class ContextConfig:
@@ -46,28 +49,75 @@ def format_context(results: List[RetrievalResult]) -> str:
def build_context_message(
results: List[RetrievalResult],
facts: Sequence[Fact] = (),
) -> Message:
"""Create a system message with formatted context."""
context_text = format_context(results)
content = (
"The following context was retrieved from the knowledge"
" base. Use it to inform your response, citing sources"
" where applicable:\n\n" + context_text
sections = []
if facts:
fact_text = "\n".join(f"- {fact.text}" for fact in facts)
sections.append(
"The following durable facts were remembered from prior "
"conversations. Use them when relevant to the user's request:\n\n"
+ fact_text
)
if results:
sections.append(
"The following context was retrieved from the knowledge"
" base. Use it to inform your response, citing sources"
" where applicable:\n\n" + format_context(results)
)
content = "\n\n".join(sections)
return Message(
role=Role.SYSTEM,
content=content,
metadata={"memory_context": True},
)
return Message(role=Role.SYSTEM, content=content)
def _merge_context_message(
messages: List[Message],
context_message: Message,
) -> List[Message]:
"""Return a copy with context folded into the existing system prompt."""
system_messages = [message for message in messages if message.role == Role.SYSTEM]
if not system_messages:
return [context_message, *messages]
content = "\n\n".join(
part
for part in (
*(message.text for message in system_messages),
context_message.text,
)
if part
)
combined = replace(system_messages[0], content=content)
merged: List[Message] = []
inserted = False
for message in messages:
if message.role == Role.SYSTEM:
if not inserted:
merged.append(combined)
inserted = True
continue
merged.append(message)
return merged
def inject_context(
query: str,
messages: List[Message],
backend: MemoryBackend,
backend: Optional[MemoryBackend],
*,
config: Optional[ContextConfig] = None,
facts: Sequence[Fact] = (),
) -> List[Message]:
"""Retrieve relevant context and prepend it to *messages*.
Returns a **new** list — the original list is not mutated.
If no results pass the score threshold, returns the original
Automatic-memory facts are included independently of the retrieval
backend, so persisted facts remain recallable even when the document
store is empty. If no facts or results are available, returns the original
messages unchanged.
Parameters
@@ -77,33 +127,55 @@ def inject_context(
messages:
The existing message list.
backend:
The memory backend to search.
The memory backend to search, or ``None`` when only facts are available.
config:
Context injection settings (uses defaults if ``None``).
facts:
Durable facts captured by the automatic memory service.
"""
cfg = config or ContextConfig()
if not cfg.enabled:
return messages
results = backend.retrieve(query, top_k=cfg.top_k)
results = backend.retrieve(query, top_k=cfg.top_k) if backend is not None else []
# Filter by minimum score
results = [r for r in results if r.score >= cfg.min_score]
if not results:
return messages
# Truncate to max_context_tokens
truncated: List[RetrievalResult] = []
# When both sources have data, cap facts at half the total budget so they
# cannot starve query-specific document retrieval. Unused fact budget is
# still available to documents. Newest facts win within the fact budget.
fact_budget = cfg.max_context_tokens
if results:
fact_budget //= 2
selected_facts: List[Fact] = []
total_tokens = 0
for fact in reversed(facts):
tokens = _count_tokens(fact.text)
if total_tokens + tokens > fact_budget:
continue
selected_facts.append(fact)
total_tokens += tokens
# Fill the remaining context budget with retrieved documents.
truncated: List[RetrievalResult] = []
for r in results:
tokens = _count_tokens(r.content)
if total_tokens + tokens > cfg.max_context_tokens:
# A large top result should not disappear solely because facts
# consumed their reserved share. Prefer that result when it fits
# the total budget on its own.
if not truncated and selected_facts and tokens <= cfg.max_context_tokens:
selected_facts = []
total_tokens = 0
else:
break
if total_tokens + tokens > cfg.max_context_tokens:
break
truncated.append(r)
total_tokens += tokens
if not truncated:
if not selected_facts and not truncated:
return messages
# Publish event
@@ -114,13 +186,14 @@ def inject_context(
"context_injection": True,
"query": query,
"num_results": len(truncated),
"num_facts": len(selected_facts),
"total_tokens": total_tokens,
},
)
# Build context message and prepend
ctx_msg = build_context_message(truncated)
return [ctx_msg] + list(messages)
ctx_msg = build_context_message(truncated, selected_facts)
return _merge_context_message(messages, ctx_msg)
__all__ = [
+34
View File
@@ -205,6 +205,40 @@ class TestBuildMessages:
assert messages[1].content == "prev"
assert messages[2].content == "new"
def test_prompt_builder_merges_context_system_message(self):
engine = MagicMock()
prompt_builder = MagicMock()
prompt_builder.build.return_value = "You are OpenJarvis."
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
conv = Conversation()
conv.add(
Message(
role=Role.SYSTEM,
content="Remember: user likes jazz.",
metadata={"memory_context": True},
)
)
ctx = AgentContext(conversation=conv)
messages = agent._build_messages("new", ctx)
system_messages = [m for m in messages if m.role == Role.SYSTEM]
assert len(system_messages) == 1
assert "You are OpenJarvis." in system_messages[0].content
assert "user likes jazz" in system_messages[0].content
def test_prompt_builder_preserves_caller_system_context(self):
engine = MagicMock()
prompt_builder = MagicMock()
prompt_builder.build.return_value = "Agent instructions."
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
conv = Conversation()
conv.add(Message(role=Role.SYSTEM, content="You are helpful."))
messages = agent._build_messages("new", AgentContext(conversation=conv))
assert any(message.content == "You are helpful." for message in messages)
class TestGenerate:
def test_delegates_to_engine(self):
+74
View File
@@ -18,6 +18,7 @@ from openjarvis.core.config import JarvisConfig
from openjarvis.core.events import Event, EventBus, EventType
from openjarvis.core.registry import AgentRegistry, ToolRegistry
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.memory.store import LocalFactStore
from openjarvis.tools._stubs import BaseTool, ToolSpec
@@ -97,6 +98,79 @@ class TestReadInput:
class TestChatAgents:
def test_direct_chat_injects_auto_memory_facts(self, tmp_path) -> None:
facts_path = tmp_path / "facts.jsonl"
LocalFactStore(facts_path).add(
"The user's favorite color is blue",
source="auto",
)
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = {"content": "Blue."}
config = JarvisConfig()
config.intelligence.default_model = "test-model"
config.memory.enabled = True
config.memory.facts_path = str(facts_path)
config.agent.context_from_memory = True
with (
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
patch("openjarvis.intelligence.register_builtin_models"),
patch("openjarvis.memory.build_memory_service", return_value=None),
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
):
result = CliRunner().invoke(
chat,
["--model", "test-model"],
input="What is my favorite color?\n/quit\n",
)
assert result.exit_code == 0
messages = engine.generate.call_args.args[0]
assert messages[0].role.value == "system"
assert "favorite color is blue" in messages[0].content
def test_chat_generation_survives_fact_store_failure(self) -> None:
class _FailingMemoryService:
def start(self) -> None:
pass
def stop(self, timeout: float = 2.0) -> None:
pass
def list_facts(self):
raise OSError("fact store unavailable")
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = {"content": "Still working."}
config = JarvisConfig()
config.intelligence.default_model = "test-model"
config.memory.enabled = True
config.agent.context_from_memory = True
with (
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
patch("openjarvis.intelligence.register_builtin_models"),
patch(
"openjarvis.memory.build_memory_service",
return_value=_FailingMemoryService(),
),
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
):
result = CliRunner().invoke(
chat,
["--model", "test-model"],
input="hello\n/quit\n",
)
assert result.exit_code == 0
assert "Still working." in result.output
engine.generate.assert_called_once()
def test_simple_agent_does_not_receive_tool_only_kwargs(self) -> None:
engine = MagicMock()
engine.engine_id = "mock"
+108
View File
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Message, Role
from openjarvis.memory.store import Fact
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
from openjarvis.tools.storage.context import (
ContextConfig,
@@ -167,6 +168,113 @@ def test_inject_context_no_results_returns_original():
assert augmented is messages
def test_inject_context_adds_auto_memory_facts_without_backend():
messages = [Message(role=Role.USER, content="What is my favorite color?")]
facts = [Fact(text="The user's favorite color is blue", source="auto")]
augmented = inject_context("favorite color", messages, None, facts=facts)
assert len(augmented) == 2
assert augmented[0].role == Role.SYSTEM
assert "remembered from prior conversations" in augmented[0].content
assert "favorite color is blue" in augmented[0].content
def test_inject_context_prioritizes_newest_facts_within_token_budget():
messages = [Message(role=Role.USER, content="What do you remember?")]
facts = [
Fact(text="old fact uses four tokens"),
Fact(text="new fact uses four tokens"),
]
augmented = inject_context(
"remember",
messages,
None,
config=ContextConfig(max_context_tokens=5),
facts=facts,
)
assert "new fact uses four tokens" in augmented[0].content
assert "old fact uses four tokens" not in augmented[0].content
def test_inject_context_merges_with_existing_system_message():
messages = [
Message(role=Role.SYSTEM, content="You are OpenJarvis."),
Message(role=Role.USER, content="What is my favorite color?"),
]
facts = [Fact(text="The user's favorite color is blue")]
augmented = inject_context("favorite color", messages, None, facts=facts)
system_messages = [m for m in augmented if m.role == Role.SYSTEM]
assert len(system_messages) == 1
assert "You are OpenJarvis." in system_messages[0].content
assert "favorite color is blue" in system_messages[0].content
assert messages[0].content == "You are OpenJarvis."
def test_inject_context_collapses_multiple_system_messages():
messages = [
Message(role=Role.SYSTEM, content="Identity."),
Message(role=Role.SYSTEM, content="Persona."),
Message(role=Role.USER, content="What do you remember?"),
]
augmented = inject_context(
"remember",
messages,
None,
facts=[Fact(text="User likes jazz")],
)
system_messages = [m for m in augmented if m.role == Role.SYSTEM]
assert len(system_messages) == 1
assert "Identity." in system_messages[0].content
assert "Persona." in system_messages[0].content
assert "User likes jazz" in system_messages[0].content
def test_inject_context_reserves_budget_for_retrieved_documents():
backend = _FakeMemory(
[RetrievalResult(content="d1 d2 d3 d4 d5", score=1.0, source="doc")]
)
facts = [
Fact(text="old1 old2 old3 old4 old5"),
Fact(text="new1 new2 new3 new4 new5"),
]
augmented = inject_context(
"query",
[Message(role=Role.USER, content="query")],
backend,
config=ContextConfig(max_context_tokens=10),
facts=facts,
)
assert "new1 new2 new3 new4 new5" in augmented[0].content
assert "d1 d2 d3 d4 d5" in augmented[0].content
assert "old1 old2 old3 old4 old5" not in augmented[0].content
def test_inject_context_prefers_large_document_that_fits_total_budget():
backend = _FakeMemory(
[RetrievalResult(content="d1 d2 d3 d4 d5 d6 d7 d8", score=1.0)]
)
augmented = inject_context(
"query",
[Message(role=Role.USER, content="query")],
backend,
config=ContextConfig(max_context_tokens=10),
facts=[Fact(text="f1 f2 f3 f4 f5")],
)
assert "d1 d2 d3 d4 d5 d6 d7 d8" in augmented[0].content
assert "f1 f2 f3 f4 f5" not in augmented[0].content
def test_inject_context_publishes_event():
bus = EventBus(record_history=True)
results = [
+30 -1
View File
@@ -7,7 +7,11 @@ import json
import pytest
from openjarvis.core.registry import FactStoreRegistry
from openjarvis.memory.store import LocalFactStore, create_fact_store
from openjarvis.memory.store import (
LocalFactStore,
create_fact_store,
load_configured_facts,
)
def test_add_and_list(tmp_path):
@@ -145,3 +149,28 @@ def test_create_fact_store_default_path_uses_openjarvis_home(tmp_path, monkeypat
def test_create_fact_store_unknown_backend(tmp_path):
with pytest.raises(ValueError):
create_fact_store("cloud", path=tmp_path / "f.jsonl")
def test_load_configured_facts_reads_enabled_store(tmp_path):
from types import SimpleNamespace
path = tmp_path / "facts.jsonl"
LocalFactStore(path).add("User likes jazz", source="auto")
config = SimpleNamespace(
memory=SimpleNamespace(
enabled=True,
backend="local",
facts_path=str(path),
max_facts=1000,
)
)
assert [fact.text for fact in load_configured_facts(config)] == ["User likes jazz"]
def test_load_configured_facts_skips_disabled_memory():
from types import SimpleNamespace
config = SimpleNamespace(memory=SimpleNamespace(enabled=False))
assert load_configured_facts(config) == []
+96
View File
@@ -11,6 +11,7 @@ fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from openjarvis.core.events import EventBus, EventType # noqa: E402
from openjarvis.core.types import Role # noqa: E402
from openjarvis.server.app import create_app # noqa: E402
# ---------------------------------------------------------------------------
@@ -886,6 +887,101 @@ class TestIdentityPromptInjection:
assert len(system_msgs) == 1
assert system_msgs[0].content == "Be terse."
def test_direct_merges_identity_and_auto_memory_into_one_system_message(self):
from openjarvis.memory.store import Fact
class _MemoryService:
def list_facts(self):
return [Fact(text="The user's favorite color is blue")]
captured: list = []
engine = _make_capturing_engine(captured)
cfg = _identity_config()
cfg.agent.context_from_memory = True
client = TestClient(
create_app(
engine,
"test-model",
config=cfg,
memory_service=_MemoryService(),
)
)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "What is my favorite color?"}],
},
)
assert resp.status_code == 200
messages = engine.generate.call_args.args[0]
system_messages = [m for m in messages if m.role == Role.SYSTEM]
assert len(system_messages) == 1
assert "OpenJarvis" in system_messages[0].content
assert "favorite color is blue" in system_messages[0].content
def test_memory_context_preserves_assistant_tool_calls(self):
from openjarvis.memory.store import Fact
class _MemoryService:
def list_facts(self):
return [Fact(text="User likes jazz")]
captured: list = []
engine = _make_capturing_engine(captured)
cfg = _identity_config()
cfg.agent.context_from_memory = True
client = TestClient(
create_app(
engine,
"test-model",
config=cfg,
memory_service=_MemoryService(),
)
)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [
{"role": "user", "content": "Run the lookup"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": '{"query":"jazz"}',
},
}
],
},
{
"role": "tool",
"content": "result",
"tool_call_id": "call_1",
},
{"role": "user", "content": "What did it find?"},
],
},
)
assert resp.status_code == 200
messages = engine.generate.call_args.args[0]
assistant = next(
message for message in messages if message.role == Role.ASSISTANT
)
assert assistant.tool_calls is not None
assert assistant.tool_calls[0].id == "call_1"
assert assistant.tool_calls[0].name == "lookup"
assert assistant.tool_calls[0].arguments == '{"query":"jazz"}'
def test_direct_injects_soul_persona_when_present(self, tmp_path):
"""Regression: /v1/chat/completions previously injected only the bare
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing