mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
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
This commit is contained in:
@@ -155,6 +155,9 @@ class BaseAgent(ABC):
|
|||||||
conversation messages, and finally the user input.
|
conversation messages, and finally the user input.
|
||||||
"""
|
"""
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
|
context_messages = (
|
||||||
|
list(context.conversation.messages) if context is not None else []
|
||||||
|
)
|
||||||
# Check if the context already supplies a system message
|
# Check if the context already supplies a system message
|
||||||
_context_has_system = (
|
_context_has_system = (
|
||||||
context
|
context
|
||||||
@@ -176,9 +179,28 @@ class BaseAgent(ABC):
|
|||||||
except Exception:
|
except Exception:
|
||||||
effective_system_prompt = None
|
effective_system_prompt = None
|
||||||
if effective_system_prompt:
|
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))
|
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
|
||||||
if context and context.conversation.messages:
|
if context_messages:
|
||||||
messages.extend(context.conversation.messages)
|
messages.extend(context_messages)
|
||||||
messages.append(Message(role=Role.USER, content=input))
|
messages.append(Message(role=Role.USER, content=input))
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,17 @@ def _get_memory_backend(config):
|
|||||||
return None
|
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(
|
_MEMORY_TOOLS = frozenset(
|
||||||
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
|
{"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
|
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||||
|
|
||||||
backend = _get_memory_backend(config)
|
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(
|
ctx_cfg = ContextConfig(
|
||||||
top_k=config.memory.context_top_k,
|
top_k=config.memory.context_top_k,
|
||||||
min_score=config.memory.context_min_score,
|
min_score=config.memory.context_min_score,
|
||||||
@@ -427,6 +439,7 @@ def _run_agent(
|
|||||||
[],
|
[],
|
||||||
backend,
|
backend,
|
||||||
config=ctx_cfg,
|
config=ctx_cfg,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
for msg in context_messages:
|
for msg in context_messages:
|
||||||
ctx.conversation.add(msg)
|
ctx.conversation.add(msg)
|
||||||
@@ -963,7 +976,8 @@ def ask(
|
|||||||
)
|
)
|
||||||
|
|
||||||
backend = _get_memory_backend(config)
|
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(
|
ctx_cfg = ContextConfig(
|
||||||
top_k=config.memory.context_top_k,
|
top_k=config.memory.context_top_k,
|
||||||
min_score=config.memory.context_min_score,
|
min_score=config.memory.context_min_score,
|
||||||
@@ -974,6 +988,7 @@ def ask(
|
|||||||
messages,
|
messages,
|
||||||
backend,
|
backend,
|
||||||
config=ctx_cfg,
|
config=ctx_cfg,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to inject memory context: %s", exc)
|
logger.debug("Failed to inject memory context: %s", exc)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ from openjarvis.core.events import EventBus
|
|||||||
from openjarvis.core.types import Message, Role
|
from openjarvis.core.types import Message, Role
|
||||||
from openjarvis.memory import publish_completed_exchange
|
from openjarvis.memory import publish_completed_exchange
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||||
"""Read user input with graceful EOF handling."""
|
"""Read user input with graceful EOF handling."""
|
||||||
@@ -194,6 +197,15 @@ def chat(
|
|||||||
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
|
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
|
||||||
memory_service = None
|
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
|
# Conversation state
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||||
@@ -262,15 +274,55 @@ def chat(
|
|||||||
# Add user message
|
# Add user message
|
||||||
history.append(Message(role=Role.USER, content=user_input))
|
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:
|
try:
|
||||||
if agent is not None:
|
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 = (
|
content = (
|
||||||
response.content if hasattr(response, "content") else str(response)
|
response.content if hasattr(response, "content") else str(response)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = engine.generate(history, model=model)
|
result = engine.generate(generation_history, model=model)
|
||||||
content = (
|
content = (
|
||||||
result.get("content", "")
|
result.get("content", "")
|
||||||
if isinstance(result, dict)
|
if isinstance(result, dict)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from openjarvis.memory.store import (
|
|||||||
FactStore,
|
FactStore,
|
||||||
LocalFactStore,
|
LocalFactStore,
|
||||||
create_fact_store,
|
create_fact_store,
|
||||||
|
load_configured_facts,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -29,5 +30,6 @@ __all__ = [
|
|||||||
"MemoryService",
|
"MemoryService",
|
||||||
"build_memory_service",
|
"build_memory_service",
|
||||||
"create_fact_store",
|
"create_fact_store",
|
||||||
|
"load_configured_facts",
|
||||||
"publish_completed_exchange",
|
"publish_completed_exchange",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import time
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
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.paths import get_config_dir
|
||||||
from openjarvis.core.registry import FactStoreRegistry
|
from openjarvis.core.registry import FactStoreRegistry
|
||||||
@@ -205,4 +205,30 @@ def create_fact_store(
|
|||||||
return FactStoreRegistry.create(key, path, max_facts=max_facts)
|
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
@@ -522,14 +522,15 @@ class Jarvis:
|
|||||||
# Context injection
|
# Context injection
|
||||||
if context and self._config.agent.context_from_memory:
|
if context and self._config.agent.context_from_memory:
|
||||||
try:
|
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 (
|
from openjarvis.tools.storage.context import (
|
||||||
ContextConfig,
|
ContextConfig,
|
||||||
inject_context,
|
inject_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
backend = _get_memory_backend(self._config)
|
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(
|
ctx_cfg = ContextConfig(
|
||||||
top_k=self._config.memory.context_top_k,
|
top_k=self._config.memory.context_top_k,
|
||||||
min_score=self._config.memory.context_min_score,
|
min_score=self._config.memory.context_min_score,
|
||||||
@@ -540,6 +541,7 @@ class Jarvis:
|
|||||||
[],
|
[],
|
||||||
backend,
|
backend,
|
||||||
config=ctx_cfg,
|
config=ctx_cfg,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
for msg in context_messages:
|
for msg in context_messages:
|
||||||
ctx.conversation.add(msg)
|
ctx.conversation.add(msg)
|
||||||
@@ -570,17 +572,24 @@ class Jarvis:
|
|||||||
) -> List[Message]:
|
) -> List[Message]:
|
||||||
"""Inject memory context into messages."""
|
"""Inject memory context into messages."""
|
||||||
try:
|
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
|
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||||
|
|
||||||
backend = _get_memory_backend(self._config)
|
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(
|
ctx_cfg = ContextConfig(
|
||||||
top_k=self._config.memory.context_top_k,
|
top_k=self._config.memory.context_top_k,
|
||||||
min_score=self._config.memory.context_min_score,
|
min_score=self._config.memory.context_min_score,
|
||||||
max_context_tokens=self._config.memory.context_max_tokens,
|
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:
|
except Exception as exc:
|
||||||
logger.warning("Failed to inject memory context: %s", exc)
|
logger.warning("Failed to inject memory context: %s", exc)
|
||||||
return messages
|
return messages
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from openjarvis.core.paths import get_config_dir
|
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.model_capabilities import is_embed_only_model
|
||||||
from openjarvis.server.models import (
|
from openjarvis.server.models import (
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
@@ -40,6 +40,15 @@ def _to_messages(chat_messages) -> list[Message]:
|
|||||||
role=role,
|
role=role,
|
||||||
content=m.content or "",
|
content=m.content or "",
|
||||||
name=m.name,
|
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,
|
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)
|
memory_backend = getattr(request.app.state, "memory_backend", None)
|
||||||
if (
|
if (
|
||||||
config is not None
|
config is not None
|
||||||
and memory_backend is not None
|
|
||||||
and config.agent.context_from_memory
|
and config.agent.context_from_memory
|
||||||
and request_body.messages
|
and request_body.messages
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
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
|
# Extract query from the last user message
|
||||||
query_text = ""
|
query_text = ""
|
||||||
for m in reversed(request_body.messages):
|
for m in reversed(request_body.messages):
|
||||||
@@ -130,6 +141,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
|||||||
|
|
||||||
if query_text:
|
if query_text:
|
||||||
messages = _to_messages(request_body.messages)
|
messages = _to_messages(request_body.messages)
|
||||||
|
messages = _ensure_identity_prompt(messages, config)
|
||||||
ctx_cfg = ContextConfig(
|
ctx_cfg = ContextConfig(
|
||||||
top_k=config.memory.context_top_k,
|
top_k=config.memory.context_top_k,
|
||||||
min_score=config.memory.context_min_score,
|
min_score=config.memory.context_min_score,
|
||||||
@@ -140,22 +152,35 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
|||||||
messages,
|
messages,
|
||||||
memory_backend,
|
memory_backend,
|
||||||
config=ctx_cfg,
|
config=ctx_cfg,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
# Rebuild request messages from enriched Message objects
|
# Rebuild after identity/context merging so downstream engine
|
||||||
if len(enriched) > len(messages):
|
# adapters always receive exactly one system message.
|
||||||
from openjarvis.server.models import ChatMessage
|
from openjarvis.server.models import ChatMessage
|
||||||
|
|
||||||
new_msgs = []
|
new_msgs = []
|
||||||
for msg in enriched:
|
for msg in enriched:
|
||||||
new_msgs.append(
|
new_msgs.append(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role=msg.role.value,
|
role=msg.role.value,
|
||||||
content=msg.content,
|
content=msg.content,
|
||||||
name=msg.name,
|
name=msg.name,
|
||||||
tool_call_id=getattr(msg, "tool_call_id", None),
|
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:
|
except Exception:
|
||||||
logging.getLogger("openjarvis.server").debug(
|
logging.getLogger("openjarvis.server").debug(
|
||||||
"Memory context injection failed",
|
"Memory context injection failed",
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class QueryOrchestrator:
|
|||||||
|
|
||||||
messages = [Message(role=Role.USER, content=query)]
|
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:
|
try:
|
||||||
|
from openjarvis.memory import load_configured_facts
|
||||||
from openjarvis.tools.storage.context import (
|
from openjarvis.tools.storage.context import (
|
||||||
ContextConfig,
|
ContextConfig,
|
||||||
inject_context,
|
inject_context,
|
||||||
@@ -52,11 +53,13 @@ class QueryOrchestrator:
|
|||||||
min_score=s.config.memory.context_min_score,
|
min_score=s.config.memory.context_min_score,
|
||||||
max_context_tokens=s.config.memory.context_max_tokens,
|
max_context_tokens=s.config.memory.context_max_tokens,
|
||||||
)
|
)
|
||||||
|
facts = load_configured_facts(s.config)
|
||||||
messages = inject_context(
|
messages = inject_context(
|
||||||
query,
|
query,
|
||||||
messages,
|
messages,
|
||||||
s.memory_backend,
|
s.memory_backend,
|
||||||
config=ctx_cfg,
|
config=ctx_cfg,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to inject memory context: %s", exc)
|
logger.warning("Failed to inject memory context: %s", exc)
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from typing import List, Optional
|
from typing import TYPE_CHECKING, List, Optional, Sequence
|
||||||
|
|
||||||
from openjarvis.core.events import EventType, get_event_bus
|
from openjarvis.core.events import EventType, get_event_bus
|
||||||
from openjarvis.core.types import Message, Role
|
from openjarvis.core.types import Message, Role
|
||||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from openjarvis.memory.store import Fact
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ContextConfig:
|
class ContextConfig:
|
||||||
@@ -46,28 +49,75 @@ def format_context(results: List[RetrievalResult]) -> str:
|
|||||||
|
|
||||||
def build_context_message(
|
def build_context_message(
|
||||||
results: List[RetrievalResult],
|
results: List[RetrievalResult],
|
||||||
|
facts: Sequence[Fact] = (),
|
||||||
) -> Message:
|
) -> Message:
|
||||||
"""Create a system message with formatted context."""
|
"""Create a system message with formatted context."""
|
||||||
context_text = format_context(results)
|
sections = []
|
||||||
content = (
|
if facts:
|
||||||
"The following context was retrieved from the knowledge"
|
fact_text = "\n".join(f"- {fact.text}" for fact in facts)
|
||||||
" base. Use it to inform your response, citing sources"
|
sections.append(
|
||||||
" where applicable:\n\n" + context_text
|
"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(
|
def inject_context(
|
||||||
query: str,
|
query: str,
|
||||||
messages: List[Message],
|
messages: List[Message],
|
||||||
backend: MemoryBackend,
|
backend: Optional[MemoryBackend],
|
||||||
*,
|
*,
|
||||||
config: Optional[ContextConfig] = None,
|
config: Optional[ContextConfig] = None,
|
||||||
|
facts: Sequence[Fact] = (),
|
||||||
) -> List[Message]:
|
) -> List[Message]:
|
||||||
"""Retrieve relevant context and prepend it to *messages*.
|
"""Retrieve relevant context and prepend it to *messages*.
|
||||||
|
|
||||||
Returns a **new** list — the original list is not mutated.
|
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.
|
messages unchanged.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
@@ -77,33 +127,55 @@ def inject_context(
|
|||||||
messages:
|
messages:
|
||||||
The existing message list.
|
The existing message list.
|
||||||
backend:
|
backend:
|
||||||
The memory backend to search.
|
The memory backend to search, or ``None`` when only facts are available.
|
||||||
config:
|
config:
|
||||||
Context injection settings (uses defaults if ``None``).
|
Context injection settings (uses defaults if ``None``).
|
||||||
|
facts:
|
||||||
|
Durable facts captured by the automatic memory service.
|
||||||
"""
|
"""
|
||||||
cfg = config or ContextConfig()
|
cfg = config or ContextConfig()
|
||||||
if not cfg.enabled:
|
if not cfg.enabled:
|
||||||
return messages
|
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
|
# Filter by minimum score
|
||||||
results = [r for r in results if r.score >= cfg.min_score]
|
results = [r for r in results if r.score >= cfg.min_score]
|
||||||
|
|
||||||
if not results:
|
# When both sources have data, cap facts at half the total budget so they
|
||||||
return messages
|
# cannot starve query-specific document retrieval. Unused fact budget is
|
||||||
|
# still available to documents. Newest facts win within the fact budget.
|
||||||
# Truncate to max_context_tokens
|
fact_budget = cfg.max_context_tokens
|
||||||
truncated: List[RetrievalResult] = []
|
if results:
|
||||||
|
fact_budget //= 2
|
||||||
|
selected_facts: List[Fact] = []
|
||||||
total_tokens = 0
|
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:
|
for r in results:
|
||||||
tokens = _count_tokens(r.content)
|
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:
|
if total_tokens + tokens > cfg.max_context_tokens:
|
||||||
break
|
break
|
||||||
truncated.append(r)
|
truncated.append(r)
|
||||||
total_tokens += tokens
|
total_tokens += tokens
|
||||||
|
|
||||||
if not truncated:
|
if not selected_facts and not truncated:
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
# Publish event
|
# Publish event
|
||||||
@@ -114,13 +186,14 @@ def inject_context(
|
|||||||
"context_injection": True,
|
"context_injection": True,
|
||||||
"query": query,
|
"query": query,
|
||||||
"num_results": len(truncated),
|
"num_results": len(truncated),
|
||||||
|
"num_facts": len(selected_facts),
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build context message and prepend
|
# Build context message and prepend
|
||||||
ctx_msg = build_context_message(truncated)
|
ctx_msg = build_context_message(truncated, selected_facts)
|
||||||
return [ctx_msg] + list(messages)
|
return _merge_context_message(messages, ctx_msg)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -205,6 +205,40 @@ class TestBuildMessages:
|
|||||||
assert messages[1].content == "prev"
|
assert messages[1].content == "prev"
|
||||||
assert messages[2].content == "new"
|
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:
|
class TestGenerate:
|
||||||
def test_delegates_to_engine(self):
|
def test_delegates_to_engine(self):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from openjarvis.core.config import JarvisConfig
|
|||||||
from openjarvis.core.events import Event, EventBus, EventType
|
from openjarvis.core.events import Event, EventBus, EventType
|
||||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||||
from openjarvis.core.types import ToolCall, ToolResult
|
from openjarvis.core.types import ToolCall, ToolResult
|
||||||
|
from openjarvis.memory.store import LocalFactStore
|
||||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,79 @@ class TestReadInput:
|
|||||||
|
|
||||||
|
|
||||||
class TestChatAgents:
|
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:
|
def test_simple_agent_does_not_receive_tool_only_kwargs(self) -> None:
|
||||||
engine = MagicMock()
|
engine = MagicMock()
|
||||||
engine.engine_id = "mock"
|
engine.engine_id = "mock"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from openjarvis.core.events import EventBus, EventType
|
from openjarvis.core.events import EventBus, EventType
|
||||||
from openjarvis.core.types import Message, Role
|
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._stubs import MemoryBackend, RetrievalResult
|
||||||
from openjarvis.tools.storage.context import (
|
from openjarvis.tools.storage.context import (
|
||||||
ContextConfig,
|
ContextConfig,
|
||||||
@@ -167,6 +168,113 @@ def test_inject_context_no_results_returns_original():
|
|||||||
assert augmented is messages
|
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():
|
def test_inject_context_publishes_event():
|
||||||
bus = EventBus(record_history=True)
|
bus = EventBus(record_history=True)
|
||||||
results = [
|
results = [
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from openjarvis.core.registry import FactStoreRegistry
|
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):
|
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):
|
def test_create_fact_store_unknown_backend(tmp_path):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
create_fact_store("cloud", path=tmp_path / "f.jsonl")
|
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) == []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fastapi = pytest.importorskip("fastapi")
|
|||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
from openjarvis.core.events import EventBus, EventType # 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
|
from openjarvis.server.app import create_app # noqa: E402
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -886,6 +887,101 @@ class TestIdentityPromptInjection:
|
|||||||
assert len(system_msgs) == 1
|
assert len(system_msgs) == 1
|
||||||
assert system_msgs[0].content == "Be terse."
|
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):
|
def test_direct_injects_soul_persona_when_present(self, tmp_path):
|
||||||
"""Regression: /v1/chat/completions previously injected only the bare
|
"""Regression: /v1/chat/completions previously injected only the bare
|
||||||
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
|
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
|
||||||
|
|||||||
Reference in New Issue
Block a user