mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a7ffebff |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user