feat(prompt): expose SystemPromptBuilder.sections() inspection API (#457)

This commit is contained in:
seilk
2026-06-02 19:31:02 -07:00
committed by GitHub
parent 98b79eca72
commit 097795567b
2 changed files with 176 additions and 8 deletions
+125 -8
View File
@@ -1,10 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from typing import List, Literal, Optional, Tuple
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
PromptCacheSegment = Literal["frozen_prefix", "dynamic_suffix"]
@dataclass(frozen=True, slots=True)
class PromptSection:
"""Inspectable prompt section emitted by SystemPromptBuilder."""
name: str
content: str
source: str
cache_segment: PromptCacheSegment
class SystemPromptBuilder:
"""Assembles system prompts with frozen prefix for cache stability."""
@@ -34,6 +47,7 @@ class SystemPromptBuilder:
else:
self._skill_few_shot = list(skill_few_shot or [])
self._frozen_prefix: Optional[str] = None
self._frozen_sections: Optional[list[PromptSection]] = None
def build(self) -> str:
if self._frozen_prefix is None:
@@ -45,6 +59,34 @@ class SystemPromptBuilder:
parts.append(f"\n\n## Previous State\n\n{self._previous_state}")
return "".join(parts)
def sections(self) -> list[PromptSection]:
"""Return prompt sections with lightweight cache/debug metadata."""
sections = [*self._get_frozen_sections()]
if self._session_context:
sections.append(
PromptSection(
name="session_context",
content=f"## Session Context\n\n{self._session_context}",
source="session_context",
cache_segment="dynamic_suffix",
)
)
if self._previous_state:
sections.append(
PromptSection(
name="previous_state",
content=f"## Previous State\n\n{self._previous_state}",
source="previous_state",
cache_segment="dynamic_suffix",
)
)
return sections
def _get_frozen_sections(self) -> list[PromptSection]:
if self._frozen_sections is None:
self._frozen_sections = self._build_frozen_sections()
return self._frozen_sections
def _persona_sections(self) -> list[str]:
"""The SOUL / MEMORY / USER sections (no agent template, no skills)."""
sections: list[str] = []
@@ -79,12 +121,29 @@ class SystemPromptBuilder:
return "\n\n".join(self._persona_sections())
def _build_frozen_prefix(self) -> str:
sections: list[str] = []
sections.append(self._agent_template)
sections.extend(self._persona_sections())
return "\n\n".join(section.content for section in self._get_frozen_sections())
def _build_frozen_sections(self) -> list[PromptSection]:
sections: list[PromptSection] = []
sections.append(
PromptSection(
name="agent_template",
content=self._agent_template,
source="agent_template",
cache_segment="frozen_prefix",
)
)
sections.extend(self._persona_prompt_sections())
# XML skill catalog (preferred over legacy markdown list)
if self._skill_catalog_xml:
sections.append("## Available Skills\n\n" + self._skill_catalog_xml)
sections.append(
PromptSection(
name="skill_catalog",
content="## Available Skills\n\n" + self._skill_catalog_xml,
source="skill_catalog_xml",
cache_segment="frozen_prefix",
)
)
elif self._skill_index:
skill_lines = []
for name, desc in self._skill_index:
@@ -92,11 +151,69 @@ class SystemPromptBuilder:
if len(desc) > self._sp_config.skill_desc_max_chars:
truncated = truncated[:-3] + "..."
skill_lines.append(f"- **{name}**: {truncated}")
sections.append("## Available Skills\n\n" + "\n".join(skill_lines))
sections.append(
PromptSection(
name="skill_index",
content="## Available Skills\n\n" + "\n".join(skill_lines),
source="skill_index",
cache_segment="frozen_prefix",
)
)
if self._skill_few_shot:
examples = "\n\n".join(self._skill_few_shot)
sections.append("## Skill Examples\n\n" + examples)
return "\n\n".join(sections)
sections.append(
PromptSection(
name="skill_examples",
content="## Skill Examples\n\n" + examples,
source="skill_few_shot_examples",
cache_segment="frozen_prefix",
)
)
return sections
def _persona_prompt_sections(self) -> list[PromptSection]:
sections: list[PromptSection] = []
self._append_file_section(
sections=sections,
name="soul",
heading="Agent Persona",
path_str=self._mf_config.soul_path,
max_chars=self._sp_config.soul_max_chars,
)
self._append_file_section(
sections=sections,
name="memory",
heading="Agent Memory",
path_str=self._mf_config.memory_path,
max_chars=self._sp_config.memory_max_chars,
)
self._append_file_section(
sections=sections,
name="user",
heading="User Profile",
path_str=self._mf_config.user_path,
max_chars=self._sp_config.user_max_chars,
)
return sections
def _append_file_section(
self,
sections: list[PromptSection],
name: str,
heading: str,
path_str: str,
max_chars: int,
) -> None:
content = self._load_file(path_str, max_chars)
if content:
sections.append(
PromptSection(
name=name,
content=f"## {heading}\n\n{content}",
source=str(Path(path_str).expanduser()),
cache_segment="frozen_prefix",
)
)
def _load_file(self, path_str: str, max_chars: int) -> str:
path = Path(path_str).expanduser()
+51
View File
@@ -109,6 +109,57 @@ def test_dynamic_section_appended(memory_dir: Path):
assert "Platform: CLI" in prompt
def test_sections_expose_prompt_metadata(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
session_context="Platform: CLI | Session: abc123",
previous_state="Last task: summarize telemetry.",
)
sections = builder.sections()
assert [section.name for section in sections] == [
"agent_template",
"soul",
"memory",
"user",
"session_context",
"previous_state",
]
assert sections[1].source == str(memory_dir / "SOUL.md")
assert sections[1].cache_segment == "frozen_prefix"
assert sections[-1].cache_segment == "dynamic_suffix"
assert builder.build() == "\n\n".join(section.content for section in sections)
def test_sections_keep_frozen_file_content_stable(memory_dir: Path):
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template="You are Jarvis.",
memory_files_config=MemoryFilesConfig(
soul_path=str(memory_dir / "SOUL.md"),
memory_path=str(memory_dir / "MEMORY.md"),
user_path=str(memory_dir / "USER.md"),
),
system_prompt_config=SystemPromptConfig(),
)
first = builder.sections()
(memory_dir / "MEMORY.md").write_text("- CHANGED CONTENT")
second = builder.sections()
assert first == second
def test_missing_files_handled(tmp_path: Path):
from openjarvis.prompt.builder import SystemPromptBuilder