feat(prompt): per-invocation persona scope (#380) (#493)

Adds --persona NAME / --persona none and a [memory_files].persona_name config field, resolving ~/.openjarvis/personas/<name>/{SOUL,MEMORY,USER}.md. Default (empty) preserves today's global-persona behavior exactly. Includes a path-traversal guard on persona names. Resolution lives in SystemPromptBuilder._resolve_persona so all callers benefit. Squad-derived: Ada (Qwen3.6-27B) authored the spec independently from the issue+code; Lucy (Qwen3-Coder-Next, 80B-A3B / ~3B active) implemented it; cross-function param threading completed in the test phase. 21 existing tests pass; +8 new persona-scope tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gilbert Barajas
2026-06-03 16:59:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent de3e86c544
commit 739bff417c
4 changed files with 90 additions and 5 deletions
+23 -4
View File
@@ -151,9 +151,7 @@ def _run_research(
trace.print(f" [dim]↳ Found {n} {label}[/dim]")
elif etype == "clarify_call":
q = event.get("question", "") or ""
trace.print(
f" [dim]↳ Clarifying:[/dim] [dim italic]{q}[/dim italic]"
)
trace.print(f" [dim]↳ Clarifying:[/dim] [dim italic]{q}[/dim italic]")
# final_answer and clarify_response are handled outside the loop.
agent = ResearchAgent(
@@ -326,6 +324,7 @@ def _run_agent(
temperature: float,
max_tokens: int,
capability_policy=None,
memory_files_config=None,
):
"""Instantiate and run an agent, returning the AgentResult."""
# Import agents to trigger registration
@@ -374,7 +373,7 @@ def _run_agent(
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=config.memory_files,
memory_files_config=memory_files_config or config.memory_files,
system_prompt_config=config.system_prompt,
)
@@ -592,6 +591,15 @@ def _print_profile(
"(default: ~/.openjarvis/knowledge.db)."
),
)
@click.option(
"--persona",
"persona_name",
default=None,
help=(
"Named persona dir under ~/.openjarvis/personas/<name>/ "
"(overrides config). Pass 'none' to disable all persona files."
),
)
@click.pass_context
def ask(
ctx: click.Context,
@@ -608,6 +616,7 @@ def ask(
enable_profile: bool,
research_mode: bool,
knowledge_db: str | None,
persona_name: str | None,
) -> None:
"""Ask Jarvis a question."""
quiet = (ctx.obj or {}).get("quiet", False) or output_json
@@ -620,6 +629,15 @@ def ask(
# Load config
config = load_config()
# Resolve effective MemoryFilesConfig with --persona override
import dataclasses as _dc
effective_mf = (
_dc.replace(config.memory_files, persona_name=persona_name)
if persona_name is not None
else config.memory_files
)
# Honor `agent.default_agent` from config when --agent was not explicitly
# passed. Pass `--agent ""` to opt out and use direct-to-engine mode.
# Without this fallback, `[agent].default_system_prompt` and the
@@ -773,6 +791,7 @@ def ask(
temperature,
max_tokens,
capability_policy=sec.capability_policy,
memory_files_config=effective_mf,
)
except EngineConnectionError as exc:
console.print(f"[red]Engine error:[/red] {exc}")
+1
View File
@@ -1428,6 +1428,7 @@ class MemoryFilesConfig:
memory_path: str = "~/.openjarvis/MEMORY.md"
user_path: str = "~/.openjarvis/USER.md"
nudge_interval: int = 10
persona_name: str = "" # named persona dir under ~/.openjarvis/personas/<name>/
@dataclass(slots=True)
+33 -1
View File
@@ -35,7 +35,8 @@ class SystemPromptBuilder:
skill_few_shot_examples: Optional[List[str]] = None,
) -> None:
self._agent_template = agent_template
self._mf_config = memory_files_config or MemoryFilesConfig()
_mf = memory_files_config or MemoryFilesConfig()
self._mf_config = self._resolve_persona(_mf)
self._sp_config = system_prompt_config or SystemPromptConfig()
self._skill_index = skill_index or []
self._session_context = session_context
@@ -250,3 +251,34 @@ class SystemPromptBuilder:
+ text[-tail_size:]
)
return text[:max_chars] + "\n[...truncated...]"
@staticmethod
def _resolve_persona(mf: MemoryFilesConfig) -> MemoryFilesConfig:
"""Resolve persona_name to effective file paths.
- "" (empty) -> use mf's existing paths (global default, unchanged)
- "none" -> empty paths (opt-out, no persona injected)
- "<name>" -> ~/.openjarvis/personas/<name>/{SOUL,MEMORY,USER}.md
"""
if not mf.persona_name:
return mf
if mf.persona_name == "none":
return MemoryFilesConfig(
soul_path="",
memory_path="",
user_path="",
nudge_interval=mf.nudge_interval,
)
name = mf.persona_name
if ".." in name or "/" in name or "\\" in name or name.startswith("/"):
raise ValueError(
f"Invalid persona name {name!r}: must be a simple "
"identifier (no path separators or '..')."
)
base = Path.home() / ".openjarvis" / "personas" / name
return MemoryFilesConfig(
soul_path=str(base / "SOUL.md"),
memory_path=str(base / "MEMORY.md"),
user_path=str(base / "USER.md"),
nudge_interval=mf.nudge_interval,
persona_name=name,
)
+33
View File
@@ -0,0 +1,33 @@
"""Tests for #380 per-invocation persona scope (_resolve_persona)."""
from pathlib import Path
import pytest
from openjarvis.core.config import MemoryFilesConfig
from openjarvis.prompt.builder import SystemPromptBuilder
def test_empty_persona_passes_through_global_defaults():
mf = MemoryFilesConfig()
out = SystemPromptBuilder._resolve_persona(mf)
assert out.soul_path == mf.soul_path # unchanged = backward compatible
def test_none_persona_disables_all_files():
out = SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name="none"))
assert out.soul_path == "" and out.memory_path == "" and out.user_path == ""
def test_named_persona_resolves_to_personas_dir():
out = SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name="coder"))
base = str(Path.home() / ".openjarvis" / "personas" / "coder")
assert out.soul_path == f"{base}/SOUL.md"
assert out.memory_path == f"{base}/MEMORY.md"
assert out.user_path == f"{base}/USER.md"
@pytest.mark.parametrize("bad", ["../etc", "a/b", "..\\win", "/abs", "x/../y"])
def test_path_traversal_rejected(bad):
with pytest.raises(ValueError):
SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))