diff --git a/src/openjarvis/agents/_stubs.py b/src/openjarvis/agents/_stubs.py index 3cbf8dbd..3c4ce9e4 100644 --- a/src/openjarvis/agents/_stubs.py +++ b/src/openjarvis/agents/_stubs.py @@ -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 diff --git a/src/openjarvis/cli/ask.py b/src/openjarvis/cli/ask.py index b10fc88a..3f6152cd 100644 --- a/src/openjarvis/cli/ask.py +++ b/src/openjarvis/cli/ask.py @@ -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) diff --git a/src/openjarvis/cli/chat_cmd.py b/src/openjarvis/cli/chat_cmd.py index 796b8c93..c13943bc 100644 --- a/src/openjarvis/cli/chat_cmd.py +++ b/src/openjarvis/cli/chat_cmd.py @@ -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) diff --git a/src/openjarvis/memory/__init__.py b/src/openjarvis/memory/__init__.py index 8e978e89..a52b9881 100644 --- a/src/openjarvis/memory/__init__.py +++ b/src/openjarvis/memory/__init__.py @@ -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", ] diff --git a/src/openjarvis/memory/store.py b/src/openjarvis/memory/store.py index a8a63ce7..2841043a 100644 --- a/src/openjarvis/memory/store.py +++ b/src/openjarvis/memory/store.py @@ -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", +] diff --git a/src/openjarvis/sdk.py b/src/openjarvis/sdk.py index 81d58a1f..f9f7400b 100644 --- a/src/openjarvis/sdk.py +++ b/src/openjarvis/sdk.py @@ -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 diff --git a/src/openjarvis/server/routes.py b/src/openjarvis/server/routes.py index 0275dcef..59177e62 100644 --- a/src/openjarvis/server/routes.py +++ b/src/openjarvis/server/routes.py @@ -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", diff --git a/src/openjarvis/system/orchestrator.py b/src/openjarvis/system/orchestrator.py index 8570871c..6b7073f5 100644 --- a/src/openjarvis/system/orchestrator.py +++ b/src/openjarvis/system/orchestrator.py @@ -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) diff --git a/src/openjarvis/tools/storage/context.py b/src/openjarvis/tools/storage/context.py index c8091da6..16515e04 100644 --- a/src/openjarvis/tools/storage/context.py +++ b/src/openjarvis/tools/storage/context.py @@ -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__ = [ diff --git a/tests/agents/test_base_agent.py b/tests/agents/test_base_agent.py index 4235959c..7c04289c 100644 --- a/tests/agents/test_base_agent.py +++ b/tests/agents/test_base_agent.py @@ -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): diff --git a/tests/cli/test_chat_cmd.py b/tests/cli/test_chat_cmd.py index 648fb833..2989e91d 100644 --- a/tests/cli/test_chat_cmd.py +++ b/tests/cli/test_chat_cmd.py @@ -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" diff --git a/tests/memory/test_context.py b/tests/memory/test_context.py index 8aa036ad..fe018c99 100644 --- a/tests/memory/test_context.py +++ b/tests/memory/test_context.py @@ -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 = [ diff --git a/tests/memory/test_fact_store.py b/tests/memory/test_fact_store.py index 66066c1c..149af13e 100644 --- a/tests/memory/test_fact_store.py +++ b/tests/memory/test_fact_store.py @@ -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) == [] diff --git a/tests/server/test_routes.py b/tests/server/test_routes.py index 4d182e73..cd3a0acc 100644 --- a/tests/server/test_routes.py +++ b/tests/server/test_routes.py @@ -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